diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..be32af4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,44 @@ +--- +name: Bug report +about: Report a bug or an issue with existing features +title: "[\U0001F41E] " +labels: bug, triage +assignees: Nek-12 + +--- + +### Description (required): + +describe the difference between expected and actual behavior, if not evident + +### Steps to reproduce (required) + +Steps to reproduce the behavior: + +1. ... + +--- + +
+ Stacktrace (if applicable): + +```plaintext + +``` + +
+ +--- + +
+ Relevant code: + +```kotlin + +``` + +
+ +--- + +- [ ] This issue hasn't been reported already diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..b313b32 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,27 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[\U0001F680] " +labels: feature, triage +assignees: Nek-12 + +--- + +### Description (required) + +describe what you are trying to solve and what is missing + +--- + +
+ Relevant code (if applicable): + +```kotlin + +``` + +
+ +--- + +- [ ] This issue hasn't been reported already diff --git a/.github/ISSUE_TEMPLATE/something-else.md b/.github/ISSUE_TEMPLATE/something-else.md new file mode 100644 index 0000000..e43c9a5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/something-else.md @@ -0,0 +1,13 @@ +--- +name: Something else +about: Ask a question, request documentation, or something else +title: "[โ”] " +labels: question, triage +assignees: Nek-12 + +--- + +--- + +- [ ] This question or issue hasn't been asked or reported already via Discussions or Issues +- [ ] I have read the [documentation and FAQ](https://opensource.respawn.pro/FlowMVI/#/faq) diff --git a/.github/changelog_config.json b/.github/changelog_config.json new file mode 100644 index 0000000..ddc8bde --- /dev/null +++ b/.github/changelog_config.json @@ -0,0 +1,57 @@ +{ + "template" : "#{{CHANGELOG}}", + "pr_template" : "- #{{TITLE_ONLY}}", + "trim_values" : true, + "categories" : [ + { + "title" : "## ๐Ÿš€ New Features", + "labels" : [ + "feat", + "feature" + ] + }, + { + "title" : "## ๐Ÿงจ Api Changes", + "labels" : [ + "feat!", + "breaking", + "api" + ] + }, + { + "title" : "## ๐Ÿž Bug Fixes", + "labels" : [ + "fix", + "bug" + ] + }, + { + "title" : "## โ” Other", + "labels" : [] + }, + { + "title" : "## ๐Ÿ“š Docs", + "labels" : [ + "doc", + "docs" + ] + } + ], + "custom_placeholders" : [ + { + "name" : "TITLE_ONLY", + "source" : "TITLE", + "transformer" : { + "method" : "regexr", + "pattern" : "(\\w+(\\(.+\\))?: ?)?(.+)", + "target" : "$2" + } + } + ], + "label_extractor" : [ + { + "pattern" : "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\\([\\w\\-\\.]+\\))?(!)?: ([\\w ])+([\\s\\S]*)", + "target" : "$1" + } + ] +} diff --git a/.github/ci-gradle.properties b/.github/ci-gradle.properties index 2cbbe0c..dce30b2 100644 --- a/.github/ci-gradle.properties +++ b/.github/ci-gradle.properties @@ -1,6 +1,9 @@ -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -XX:+UseParallelGC +org.gradle.jvmargs=-Xmx3g -Xms1g -XX:+UseParallelGC -XX:+UseStringDeduplication -Dfile.encoding=UTF-8 +kotlin.daemon.jvmargs=-Xmx3g -Xms1g -XX:+UseParallelGC -XX:+UseStringDeduplication -XX:MaxMetaspaceSize=1g android.useAndroidX=true kotlin.code.style=official +org.gradle.caching=true +org.gradle.parallel=true android.enableR8.fullMode=true org.gradle.configureondemand=true android.enableJetifier=false @@ -10,15 +13,16 @@ android.experimental.enableSourceSetPathsMap=true android.experimental.cacheCompileLibResources=true kotlin.mpp.enableCInteropCommonization=true kotlin.mpp.stability.nowarn=true -kotlin.mpp.androidSourceSetLayoutVersion=2 -android.nonFinalResIds=true kotlin.mpp.androidGradlePluginCompatibility.nowarn=true -# changed -org.gradle.caching=true -org.gradle.parallel=true -warningsAsErrors=true -org.gradle.daemon=false -org.gradle.workers.max=2 -org.gradle.unsafe.configuration-cache=false +org.gradle.unsafe.configuration-cache=true +kotlin.mpp.androidSourceSetLayoutVersion=2 android.disableResourceValidation=false +org.gradle.daemon=true +android.nonFinalResIds=true +kotlin.native.ignoreIncorrectDependencies=true kotlinx.atomicfu.enableJvmIrTransformation=true +org.jetbrains.compose.experimental.macos.enabled=true +org.gradle.configuration-cache.problems=warn +nl.littlerobots.vcu.resolver=true +org.gradle.console=plain +CI=true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b552cbd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/assign_self.yml b/.github/workflows/assign_self.yml new file mode 100644 index 0000000..b4d9afe --- /dev/null +++ b/.github/workflows/assign_self.yml @@ -0,0 +1,16 @@ +name: Assign self to PR + +on: + pull_request: + types: [ opened ] + branches: + - master + - main + +jobs: + assign_author: + runs-on: ubuntu-latest + steps: + - uses: samspills/assign-pr-to-author@v1.0.2 + with: + repo-token: '${{ secrets.GITHUB_TOKEN }}' diff --git a/.github/workflows/autotag.yml b/.github/workflows/autotag.yml new file mode 100644 index 0000000..e990f14 --- /dev/null +++ b/.github/workflows/autotag.yml @@ -0,0 +1,27 @@ +name: Auto-tag +on: + pull_request: + types: + - closed + branches: + - master + +jobs: + build: + if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'autorelease') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + fetch-depth: '0' + + - name: Bump version and push tag + uses: anothrNick/github-tag-action@v1 + env: + GITHUB_TOKEN: ${{ secrets.OPENSOURCE_PAT }} # Use PAT to trigger workflows + CUSTOM_TAG: ${{ github.event.pull_request.title }} + WITH_V: false + PRERELEASE: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dde4cf0..3989a38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,18 +15,13 @@ jobs: runs-on: macos-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - name: Create local properties - env: - LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }} - run: echo "$LOCAL_PROPERTIES" > local.properties - - name: set up JDK - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: distribution: 'zulu' check-latest: true @@ -40,11 +35,16 @@ jobs: with: xcode-version: latest + - name: Create local properties + env: + LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }} + run: echo "$LOCAL_PROPERTIES" > local.properties + - name: Run detekt run: ./gradlew detektAll - name: Build - run: ./gradlew assemble --stacktrace + run: ./gradlew assemble --stacktrace --no-configuration-cache - name: Unit tests - run: ./gradlew allTests --stacktrace + run: ./gradlew allTests --stacktrace --no-configuration-cache diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index bddb92a..ae3e3dd 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -19,13 +19,13 @@ jobs: runs-on: macos-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: set up JDK - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: distribution: 'zulu' check-latest: true @@ -44,19 +44,19 @@ jobs: run: cp ./README.md ./docs/README.md - name: Generate docs - run: ./gradlew dokkaHtmlMultiModule --no-configuration-cache + run: ./gradlew :dokkaHtmlMultiModule --no-configuration-cache - name: Move docs to the parent docs dir - run: cp -r ./build/dokka ./docs/javadocs/ + run: cp -r ./build/dokka/htmlMultiModule/ ./docs/javadocs/ - name: Setup Pages - uses: actions/configure-pages@v3 + uses: actions/configure-pages@v5 - name: Upload pages - uses: actions/upload-pages-artifact@v1 + uses: actions/upload-pages-artifact@v3 with: path: './docs/' - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v1 + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5b0b2f1..bc15f23 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,9 +1,15 @@ -name: publish +name: Publish a new Release on: push: tags: - '1.*' + workflow_dispatch: + inputs: + tag: + required: true + type: string + description: 'Tag to use for the release and changelog' concurrency: group: "publish" @@ -12,15 +18,16 @@ concurrency: jobs: publish: runs-on: macos-latest + environment: publishing steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: set up JDK - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: distribution: 'zulu' check-latest: true @@ -43,4 +50,27 @@ jobs: env: ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.SIGNING_PASSWORD }} ORG_GRADLE_PROJECT_signingKey: ${{ secrets.SIGNING_KEY }} - run: ./gradlew publishAllPublicationsToSonatypeRepository --stacktrace -Dorg.gradle.workers.max=1 + SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} + SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} + # It's important to not upload in parallel or duplicate repos will be created + # repository creds are broken with gradle 8.6 https://github.com/gradle/gradle/issues/24040 + run: ./gradlew publishAllPublicationsToSonatypeRepository -Dorg.gradle.parallel=false --stacktrace --no-configuration-cache + + - name: Generate Changelog + uses: mikepenz/release-changelog-builder-action@v4 + id: build_changelog + with: + commitMode: true + configuration: ./github/changelog_config.json + + - name: Create GH release + uses: ncipollo/release-action@v1.14.0 + id: create_release + with: + draft: true + artifactErrorsFailBuild: false + prerelease: false + body: ${{steps.build_changelog.outputs.changelog}} + tag: ${{ inputs.tag != '' && inputs.tag || github.ref_name }} + env: + GITHUB_TOKEN: ${{ github.token }} diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 0d47e48..02f4f5a 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -2,6 +2,45 @@ \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 01923f5..06b7eb4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,9 @@ import nl.littlerobots.vcu.plugin.versionCatalogUpdate import nl.littlerobots.vcu.plugin.versionSelector +import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension +import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradleSubplugin import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnLockMismatchReport +import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnPlugin import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnRootExtension import org.jetbrains.kotlin.gradle.tasks.KotlinCompile @@ -11,7 +14,13 @@ plugins { alias(libs.plugins.dokka) alias(libs.plugins.dependencyAnalysis) alias(libs.plugins.atomicfu) - kotlin("plugin.serialization") version libs.versions.kotlin.get() apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.compose) apply false + alias(libs.plugins.serialization) apply false + // plugins already on a classpath (conventions) + // alias(libs.plugins.androidApplication) apply false + // alias(libs.plugins.androidLibrary) apply false + // alias(libs.plugins.kotlinMultiplatform) apply false } buildscript { @@ -25,10 +34,22 @@ buildscript { allprojects { group = Config.artifactId version = Config.versionName + plugins.withType().configureEach { + the().apply { + enableIntrinsicRemember = true + enableNonSkippingGroupOptimization = true + enableStrongSkippingMode = true + stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_definitions.txt") + if (properties["enableComposeCompilerReports"] == "true") { + val metricsDir = layout.buildDirectory.dir("compose_metrics") + metricsDestination = metricsDir + reportsDestination = metricsDir + } + } + } tasks.withType().configureEach { compilerOptions { jvmTarget.set(Config.jvmTarget) - languageVersion.set(Config.kotlinVersion) freeCompilerArgs.addAll(Config.jvmCompilerArgs) optIn.addAll(Config.optIns) } @@ -133,8 +154,10 @@ tasks { distributionType = Wrapper.DistributionType.BIN } } -extensions.findByType()?.run { - yarnLockMismatchReport = YarnLockMismatchReport.WARNING - reportNewYarnLock = true - yarnLockAutoReplace = false +rootProject.plugins.withType().configureEach { + rootProject.the().apply { + yarnLockMismatchReport = YarnLockMismatchReport.WARNING // NONE | FAIL | FAIL_AFTER_BUILD + reportNewYarnLock = true + yarnLockAutoReplace = true + } } diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts index 5f0643d..ab3dafa 100644 --- a/buildSrc/settings.gradle.kts +++ b/buildSrc/settings.gradle.kts @@ -9,3 +9,4 @@ dependencyResolutionManagement { } } } +rootProject.name = "buildSrc" diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index da19709..963ce70 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -7,7 +7,6 @@ import org.gradle.api.JavaVersion import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.dsl.KotlinVersion object Config { @@ -17,15 +16,20 @@ object Config { const val artifactId = "$group.$artifact" const val majorRelease = 1 - const val minorRelease = 3 - const val patch = 2 + const val minorRelease = 4 + const val patch = 0 const val postfix = "" const val versionName = "$majorRelease.$minorRelease.$patch$postfix" + const val supportEmail = "hello@respawn.pro" + const val vendorName = "Respawn Open Source Team" + const val vendorId = "respawn-app" const val url = "https://github.com/respawn-app/kmputils" + const val developerUrl = "https://respawn.pro" + const val licenseFile = "LICENSE.txt" const val licenseName = "The Apache Software License, Version 2.0" - const val licenseUrl = "http://www.apache.org/licenses/LICENSE-2.0.txt" - const val scmUrl = "https://github.com/respawn-app/kmmutils.git" + const val licenseUrl = "https://www.apache.org/licenses/LICENSE-2.0.txt" + const val scmUrl = "https://github.com/respawn-app/KMPUtils.git" const val description = """A collection of Kotlin Multiplatform essentials""" // kotlin @@ -49,10 +53,9 @@ object Config { val jvmTarget = JvmTarget.JVM_11 val javaVersion = JavaVersion.VERSION_11 - val kotlinVersion = KotlinVersion.KOTLIN_1_9 const val compileSdk = 34 const val targetSdk = compileSdk - const val minSdk = 21 + const val minSdk = 24 const val appMinSdk = 26 const val publishingVariant = "release" diff --git a/buildSrc/src/main/kotlin/ConfigureAndroid.kt b/buildSrc/src/main/kotlin/ConfigureAndroid.kt index 2a59b72..f99ada6 100644 --- a/buildSrc/src/main/kotlin/ConfigureAndroid.kt +++ b/buildSrc/src/main/kotlin/ConfigureAndroid.kt @@ -4,11 +4,8 @@ import com.android.build.api.dsl.CommonExtension import com.android.build.gradle.LibraryExtension import org.gradle.api.Project -fun Project.configureAndroid( - commonExtension: CommonExtension<*, *, *, *, *, *>, -) = commonExtension.apply { +fun CommonExtension<*, *, *, *, *, *>.configureAndroid() = apply { compileSdk = Config.compileSdk - // val libs by versionCatalog defaultConfig { minSdk = Config.minSdk @@ -66,15 +63,10 @@ fun Project.configureAndroid( } } } - - // composeOptions { - // kotlinCompilerExtensionVersion = libs.requireVersion("compose-compiler") - // useLiveLiterals = true - // } } fun Project.configureAndroidLibrary(variant: LibraryExtension) = variant.apply { - configureAndroid(this) + configureAndroid() testFixtures { enable = true diff --git a/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt b/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt index fa6bab7..f4eb6ff 100644 --- a/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt +++ b/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt @@ -3,11 +3,12 @@ import org.gradle.api.Project import org.gradle.kotlin.dsl.getValue import org.gradle.kotlin.dsl.getting +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.plugin.KotlinHierarchyBuilder import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl -@OptIn(ExperimentalWasmDsl::class) -@Suppress("LongParameterList", "CyclomaticComplexMethod") +@OptIn(ExperimentalWasmDsl::class, ExperimentalKotlinGradlePluginApi::class) fun Project.configureMultiplatform( ext: KotlinMultiplatformExtension, jvm: Boolean = true, @@ -18,33 +19,29 @@ fun Project.configureMultiplatform( tvOs: Boolean = true, macOs: Boolean = true, watchOs: Boolean = true, + windows: Boolean = true, wasmJs: Boolean = true, + wasmWasi: Boolean = false, // TODO: Coroutines do not support wasmWasi yet + configure: KotlinHierarchyBuilder.Root.() -> Unit = {}, ) = ext.apply { val libs by versionCatalog explicitApi() - applyDefaultHierarchyTemplate() + applyDefaultHierarchyTemplate(configure) withSourcesJar(true) if (linux) { linuxX64() linuxArm64() - mingwX64() } - if (js) { - js(IR) { - browser() - nodejs() - binaries.library() - } - } + if (windows) mingwX64() - if (android) androidTarget { - publishLibraryVariants("release") + if (js) js(IR) { + browser() + nodejs() + binaries.library() } - if (jvm) jvm() - if (wasmJs) wasmJs { moduleName = this@configureMultiplatform.name nodejs() @@ -52,6 +49,14 @@ fun Project.configureMultiplatform( binaries.library() } + if (wasmWasi) wasmWasi() + + if (android) androidTarget { + publishLibraryVariants("release") + } + + if (jvm) jvm() + sequence { if (iOs) { yield(iosX64()) @@ -73,13 +78,7 @@ fun Project.configureMultiplatform( yield(watchosDeviceArm64()) yield(watchosSimulatorArm64()) } - }.forEach { - it.binaries.framework { - binaryOption("bundleId", Config.artifactId) - binaryOption("bundleVersion", Config.versionName) - baseName = Config.artifactId - } - } + }.toList() // for now, do nothing, but iterate the lazy sequence sourceSets.apply { if (jvm) { @@ -92,7 +91,6 @@ fun Project.configureMultiplatform( all { languageSettings { progressiveMode = true - languageVersion = Config.kotlinVersion.version Config.optIns.forEach { optIn(it) } } } diff --git a/buildSrc/src/main/kotlin/ConfigurePublication.kt b/buildSrc/src/main/kotlin/ConfigurePublication.kt index 8056f67..099fd97 100644 --- a/buildSrc/src/main/kotlin/ConfigurePublication.kt +++ b/buildSrc/src/main/kotlin/ConfigurePublication.kt @@ -16,27 +16,25 @@ import org.gradle.plugins.signing.Sign * Configures Maven publishing to sonatype for this project */ fun Project.publishMultiplatform() { - val properties by localProperties + val properties = localProperties() val isReleaseBuild = properties["release"]?.toString().toBoolean() - val javadocTask = tasks.named("emptyJavadocJar") // TODO: dokka does not support kmp javadocs yet afterEvaluate { requireNotNull(extensions.findByType()).apply { - sonatypeRepository(isReleaseBuild, properties) - publications.withType().configureEach { + groupId = rootProject.group.toString() artifact(javadocTask) configurePom() configureVersion(isReleaseBuild) } + sonatypeRepository(isReleaseBuild, properties) } signPublications(isReleaseBuild, properties) } tasks.withType().configureEach { dependsOn(javadocTask) - dependsOn(tasks.withType()) } } @@ -44,6 +42,8 @@ fun Project.publishMultiplatform() { * Publish the android artifact */ fun Project.publishAndroid(ext: LibraryExtension) = with(ext) { + val properties = localProperties() + val isReleaseBuild = requireNotNull(properties["release"]).toString().toBooleanStrict() publishing { singleVariant(Config.publishingVariant) { withSourcesJar() @@ -52,27 +52,21 @@ fun Project.publishAndroid(ext: LibraryExtension) = with(ext) { } afterEvaluate { - val properties by localProperties - val isReleaseBuild = properties["release"]?.toString().toBoolean() - requireNotNull(extensions.findByType()).apply { - sonatypeRepository(isReleaseBuild, properties) - - publications { - maybeCreate(Config.publishingVariant, MavenPublication::class).apply { - from(components[Config.publishingVariant]) - suppressPomMetadataWarningsFor(Config.publishingVariant) - groupId = rootProject.group.toString() - artifactId = project.name - - configurePom() - configureVersion(isReleaseBuild) - } + publications.maybeCreate(Config.publishingVariant, MavenPublication::class).apply { + from(components[Config.publishingVariant]) + groupId = rootProject.group.toString() + configurePom() + configureVersion(isReleaseBuild) } + sonatypeRepository(isReleaseBuild, properties) } signPublications(isReleaseBuild, properties) } + tasks.withType().configureEach { + dependsOn(tasks.withType()) + } tasks.withType().configureEach { dependsOn(tasks.withType()) } diff --git a/buildSrc/src/main/kotlin/PublishingExt.kt b/buildSrc/src/main/kotlin/PublishingExt.kt index ebabbe6..7d2ce27 100644 --- a/buildSrc/src/main/kotlin/PublishingExt.kt +++ b/buildSrc/src/main/kotlin/PublishingExt.kt @@ -32,18 +32,22 @@ internal fun MavenPublication.configurePom() = pom { } developers { developer { - id.set("respawn-app") - name.set("Respawn") - email.set("hello@respawn.pro") - url.set("https://respawn.pro") - organization.set("Respawn") + id.set(Config.vendorId) + name.set(Config.vendorName) + email.set(Config.supportEmail) + url.set(Config.developerUrl) + organization.set(Config.vendorName) organizationUrl.set(url) } } - scm { url.set(Config.scmUrl) } + scm { + url.set(Config.scmUrl) + } } internal fun PublishingExtension.sonatypeRepository(release: Boolean, localProps: Properties) = repositories { + val username = localProps["sonatypeUsername"]?.toString() ?: System.getenv("SONATYPE_USERNAME") + val password = localProps["sonatypePassword"]?.toString() ?: System.getenv("SONATYPE_PASSWORD") maven { name = "sonatype" url = URI( @@ -54,16 +58,15 @@ internal fun PublishingExtension.sonatypeRepository(release: Boolean, localProps } ) credentials { - username = localProps["sonatypeUsername"]?.toString() - password = localProps["sonatypePassword"]?.toString() + this.username = username.takeIf { !it.isNullOrBlank() } + this.password = password.takeIf { !it.isNullOrBlank() } } } } -internal fun Project.signPublications(isRelease: Boolean, localProps: Properties) = +internal fun Project.signPublications(isRelease: Boolean, localProps: Properties) { requireNotNull(extensions.findByType()).apply { val publishing = requireNotNull(extensions.findByType()) - val signingKey: String? = localProps["signing.key"]?.toString() val signingPassword: String? = localProps["signing.password"]?.toString() @@ -85,3 +88,4 @@ internal fun Project.signPublications(isRelease: Boolean, localProps: Properties } } } +} diff --git a/buildSrc/src/main/kotlin/Util.kt b/buildSrc/src/main/kotlin/Util.kt index 32d89d8..7461b4b 100644 --- a/buildSrc/src/main/kotlin/Util.kt +++ b/buildSrc/src/main/kotlin/Util.kt @@ -1,9 +1,4 @@ -@file:Suppress( - "MemberVisibilityCanBePrivate", - "MissingPackageDeclaration", - "UndocumentedPublicProperty", - "UndocumentedPublicFunction" -) +@file:Suppress("MissingPackageDeclaration", "UndocumentedPublicFunction", "UndocumentedPublicProperty") import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalog @@ -20,7 +15,7 @@ import java.util.Properties * to obtain a version/lib use: * ``` * val libs by versionCatalog - * libs.requireVersion("androidxCompose") + * libs.findVersion("androidxCompose").get().toString() * libs.requireLib("androidx.core.ktx") * ``` */ @@ -54,12 +49,11 @@ fun List.toJavaArrayString() = buildString { fun String.toBase64() = Base64.getEncoder().encodeToString(toByteArray()) -val Project.localProperties - get() = lazy { - Properties().apply { - load(FileInputStream(File(rootProject.rootDir, "local.properties"))) - } - } +fun Project.localProperties() = Properties().apply { + val file = File(rootProject.rootDir.absolutePath, "local.properties") + require(file.exists()) { "Please create root local.properties file" } + load(FileInputStream(file)) +} fun stabilityLevel(version: String): Int { Config.stabilityLevels.forEachIndexed { index, postfix -> diff --git a/buildSrc/src/main/kotlin/android-library.gradle.kts b/buildSrc/src/main/kotlin/android-library.gradle.kts deleted file mode 100644 index ad950de..0000000 --- a/buildSrc/src/main/kotlin/android-library.gradle.kts +++ /dev/null @@ -1,10 +0,0 @@ - - -plugins { - id("com.android.library") - kotlin("multiplatform") -} - -android { - configureAndroidLibrary(this) -} diff --git a/buildSrc/src/main/kotlin/pro.respawn.android-library.gradle.kts b/buildSrc/src/main/kotlin/pro.respawn.android-library.gradle.kts deleted file mode 100644 index 7d34e31..0000000 --- a/buildSrc/src/main/kotlin/pro.respawn.android-library.gradle.kts +++ /dev/null @@ -1,20 +0,0 @@ -plugins { - kotlin("android") - id("com.android.library") - id("maven-publish") - signing -} - -kotlin { - explicitApi() -} - -android { - configureAndroidLibrary(this) - publishAndroid(this) - - kotlinOptions { - jvmTarget = Config.jvmTarget.target - languageVersion = Config.kotlinVersion.version - } -} diff --git a/common/src/commonMain/kotlin/pro/respawn/kmmutils/common/CommonExt.kt b/common/src/commonMain/kotlin/pro/respawn/kmmutils/common/CommonExt.kt index a21904d..674a43e 100644 --- a/common/src/commonMain/kotlin/pro/respawn/kmmutils/common/CommonExt.kt +++ b/common/src/commonMain/kotlin/pro/respawn/kmmutils/common/CommonExt.kt @@ -4,6 +4,8 @@ package pro.respawn.kmmutils.common import kotlin.contracts.contract import kotlin.enums.enumEntries +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.jvm.JvmName /** @@ -120,3 +122,34 @@ public fun Throwable?.rethrowErrors(): Exception? = this?.let { it as? Exception */ @JvmName("rethrowErrorsNotNull") public fun Throwable.rethrowErrors(): Exception = this as? Exception? ?: throw this + +/** + * Encode this string to Base64 + */ +public fun String.toBase64(): String = encodeToByteArray().toBase64() + +/** + * Encode this byte array to base 64 + */ +@OptIn(ExperimentalEncodingApi::class) +public fun ByteArray.toBase64(): String = Base64.Default.encode(this) + +/** + * Add quotes to this string, if not present. Will do nothing to `null`s. + * + * Will turn : + * * `foo` -> `"foo"`, + * * `"foo"` -> `"foo"` (no change) + * * `null` -> `null` (no change) + */ +@get:JvmName("quotedOrNull") +public inline val String?.quoted: String? get() = this?.quoted + +/** + * Add quotes to this string, if not present. + * + * Will turn : + * * `foo` -> `"foo"`, + * * `"foo"` -> `"foo"` (no change) + */ +public inline val String.quoted: String get() = """"${removeSurrounding("\"")}"""" diff --git a/compose/build.gradle.kts b/compose/build.gradle.kts new file mode 100644 index 0000000..c31a171 --- /dev/null +++ b/compose/build.gradle.kts @@ -0,0 +1,64 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi + +plugins { + id(libs.plugins.kotlin.multiplatform.id) + id(libs.plugins.androidLibrary.id) + alias(libs.plugins.compose) + alias(libs.plugins.compose.compiler) + id("maven-publish") + signing +} + +android { + configureAndroidLibrary(this) + namespace = "${Config.namespace}.compose" + + buildFeatures { + compose = true + } +} + +kotlin { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + configureMultiplatform( + ext = this, + jvm = true, + android = true, + iOs = true, + macOs = true, + watchOs = false, + tvOs = false, + linux = false, + js = true, + wasmJs = true, + windows = false, + ) { + common { + group("web") { + withJs() + withWasmJs() + } + } + } + sourceSets { + commonMain.dependencies { + api(compose.components.resources) + + api(libs.lifecycle.runtime) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.animationGraphics) + implementation(compose.animation) + } + jvmMain.dependencies { + implementation(compose.desktop.common) + } + androidMain.dependencies { + api(libs.androidx.lifecycle.viewmodel) + api(libs.androidx.activity.compose) + implementation(projects.system) + } + } +} + +publishMultiplatform() diff --git a/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/ActivityComposeExt.kt b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/ActivityComposeExt.kt new file mode 100644 index 0000000..af7a82d --- /dev/null +++ b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/ActivityComposeExt.kt @@ -0,0 +1,121 @@ +package pro.respawn.kmmutils.compose + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.os.Build +import android.view.WindowManager +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.result.ActivityResult +import androidx.annotation.RequiresPermission +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalContext +import pro.respawn.kmmutils.system.android.launchCatching +import pro.respawn.kmmutils.system.android.withApiLevel + +/** + * Possible values for [orientation]: + * + * * SCREEN_ORIENTATION_UNSPECIFIED, + * * SCREEN_ORIENTATION_LANDSCAPE, + * * SCREEN_ORIENTATION_PORTRAIT, + * * SCREEN_ORIENTATION_USER, + * * SCREEN_ORIENTATION_BEHIND, + * * SCREEN_ORIENTATION_SENSOR, + * * SCREEN_ORIENTATION_NOSENSOR, + * * SCREEN_ORIENTATION_SENSOR_LANDSCAPE, + * * SCREEN_ORIENTATION_SENSOR_PORTRAIT, + * * SCREEN_ORIENTATION_REVERSE_LANDSCAPE, + * * SCREEN_ORIENTATION_REVERSE_PORTRAIT, + * * SCREEN_ORIENTATION_FULL_SENSOR, + * * SCREEN_ORIENTATION_USER_LANDSCAPE, + * * SCREEN_ORIENTATION_USER_PORTRAIT, + * * SCREEN_ORIENTATION_FULL_USER, + * * SCREEN_ORIENTATION_LOCKED. + * + * @param orientation the int constant of [Activity.getRequestedOrientation] + */ +@Composable +public fun LockScreenOrientation(orientation: Int) { + val context = LocalContext.current + DisposableEffect(orientation) { + val activity = context.findActivity() ?: return@DisposableEffect onDispose {} + val originalOrientation = activity.requestedOrientation + activity.requestedOrientation = orientation + onDispose { + // restore original orientation when view disappears + activity.requestedOrientation = originalOrientation + } + } +} + +/** + * Traverses the context hierarchy until an activity is found, or null if not present. + */ +public fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} + +/** + * Disables overlays over the content of current activity for as long as this composable is in the composition + * Works only on API 31 (S) and above, otherwise does nothing. + * + * Requires permission [Manifest.permission.HIDE_OVERLAY_WINDOWS] + * @see android.view.Window.setHideOverlayWindows + */ +@Composable +@RequiresPermission(Manifest.permission.HIDE_OVERLAY_WINDOWS) +public fun DisallowOverlays(): Unit = withApiLevel(Build.VERSION_CODES.S) { + val context = LocalContext.current + DisposableEffect(Unit) { + val window = context.findActivity()?.window + window?.setHideOverlayWindows(true) + onDispose { window?.setHideOverlayWindows(false) } + } +} + +/** + * Sets flags to the parent activity, if exists, temporarily, until the composition is left. + * The flags are added and then removed, other flags are not changed + * Possible values of [flags] are from [WindowManager.LayoutParams] + * + * @see [android.view.Window.setFlags] + */ +@Composable +public fun SetWindowFlags(flags: Int) { + val context = LocalContext.current + DisposableEffect(flags) { + val window = context.findActivity()?.window + window?.addFlags(flags) + onDispose { window?.clearFlags(flags) } + } +} + +/** + * A shortcut for sending messages to any social media app installed in the + * user's device. This will optionally return an activity result as it's why an + * extension for [ManagedActivityResultLauncher]. + * + * @param text the message to send. + * @param onAppNotFound a callback for error, if ever no apps are installed that can handle the request. + */ + +public inline fun ManagedActivityResultLauncher.shareAsText( + text: String, + onAppNotFound: (e: Exception) -> Unit +) { + val intent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_TEXT, text) + type = "text/plain" + } + val shareIntent = Intent.createChooser(intent, null) + launchCatching( + input = shareIntent, + onNotFound = onAppNotFound, + ) +} diff --git a/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/RememberRetainedValue.kt b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/RememberRetainedValue.kt new file mode 100644 index 0000000..82c5727 --- /dev/null +++ b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/RememberRetainedValue.kt @@ -0,0 +1,37 @@ +package pro.respawn.kmmutils.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.currentCompositeKeyHash +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel + +/** + * Remember the value produced by [calculation]. + * It behaves similarly to [rememberSaveable], but uses [ViewModel] to store the value. + * + * You **must** have a [ViewModelStoreOwner] provided via a composition local to use this function + */ +@Composable +@Suppress("ComposableParametersOrdering") +public fun rememberRetainedValue( + key: String? = null, + viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" + }, + onDispose: (T.() -> Unit)? = null, + calculation: () -> T, +): T { + val finalKey = if (!key.isNullOrBlank()) key else currentCompositeKeyHash.toString() + return viewModel( + key = finalKey, viewModelStoreOwner = viewModelStoreOwner + ) { CacheViewModel(calculation(), onDispose) }.value +} + +private class CacheViewModel(val value: T, private val dispose: (T.() -> Unit)?) : ViewModel() { + + override fun onCleared() { + dispose?.invoke(value) + } +} diff --git a/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.android.kt b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.android.kt new file mode 100644 index 0000000..7ce1e54 --- /dev/null +++ b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.android.kt @@ -0,0 +1,25 @@ +package pro.respawn.kmmutils.compose + +import android.view.WindowManager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalContext + +/** + * Keeps the screen of the current activity on while this composable is in composition. + */ +@Composable +public actual fun KeepScreenOn(enabled: Boolean) { + val context = LocalContext.current + DisposableEffect(enabled) { + val window = context.findActivity()?.window + if (enabled) { + window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + onDispose { + window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } else { + onDispose { } + } + } +} diff --git a/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/SdkIntExt.android.kt b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/SdkIntExt.android.kt new file mode 100644 index 0000000..0a7a472 --- /dev/null +++ b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/SdkIntExt.android.kt @@ -0,0 +1,23 @@ +package pro.respawn.kmmutils.compose + +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast + +/** + * @returns whether Blur composition is currently supported. + * Currently returns `false` on all platforms except android as this is not yet implemented in Compose. + */ +@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) +public val supportsBlur: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + +/** + * @return whether dynamic colors are supported. Only supported on Android since S, all other platforms return false + */ +@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) +public val supportsDynamicColors: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + +/** + * @return whether dynamic colors are supported. Only supported on Android since S, all other platforms return false + */ +@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.TIRAMISU) +public val supportsShaders: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU diff --git a/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/Service.kt b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/Service.kt new file mode 100644 index 0000000..76c5658 --- /dev/null +++ b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/Service.kt @@ -0,0 +1,52 @@ +package pro.respawn.kmmutils.compose + +import android.app.Service +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Binder +import android.os.IBinder +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisallowComposableCalls +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext + +/** + * Remembers a reference to a [Service] [BoundService]'s [Binder] [BoundServiceBinder] in composition. + * The function will bind to a service while it is in the composition and unbind when it has left the composition. + * The function will maintain a [ServiceConnection] and return a [State] with a nullable value of service. + * + * Binding to the service takes some time and connection breakages can occur, + * so the return state value may be null at times. + */ +@Composable +@Suppress("ComposableParametersOrdering") +public inline fun rememberBoundLocalService( + flags: Int = Context.BIND_AUTO_CREATE, + noinline getService: @DisallowComposableCalls BoundServiceBinder.() -> BoundService, +): State { + val context: Context = LocalContext.current + val boundService = remember(context) { mutableStateOf(null) } + + val serviceConnection: ServiceConnection = remember(context, getService) { + object : ServiceConnection { + override fun onServiceConnected(className: ComponentName, service: IBinder) { + boundService.value = (service as BoundServiceBinder).getService() + } + + override fun onServiceDisconnected(arg0: ComponentName) { + boundService.value = null + } + } + } + DisposableEffect(context, serviceConnection, flags) { + context.bindService(Intent(context, BoundService::class.java), serviceConnection, flags) + + onDispose { context.unbindService(serviceConnection) } + } + return boundService +} diff --git a/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/resources/ResourcesExt.android.kt b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/resources/ResourcesExt.android.kt new file mode 100644 index 0000000..5ddf862 --- /dev/null +++ b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/resources/ResourcesExt.android.kt @@ -0,0 +1,91 @@ +package pro.respawn.kmmutils.compose.resources + +import android.text.format.DateFormat +import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi +import androidx.compose.animation.graphics.res.animatedVectorResource +import androidx.compose.animation.graphics.vector.AnimatedImageVector +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.booleanResource +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.integerArrayResource +import androidx.compose.ui.res.integerResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.Dp + +@Composable +public fun Int.plural( + quantity: Int, + vararg args: Any, +): String = pluralStringResource(this, quantity, formatArgs = args) + +@Composable +public fun Int?.plural(quantity: Int, vararg args: Any): String? = this?.plural(quantity, args) + +@Composable +public fun Int.vector(): ImageVector = ImageVector.vectorResource(this) + +@Composable +public fun Int?.vector(): ImageVector? = this?.vector() + +@ExperimentalAnimationGraphicsApi +@Composable +public fun Int.animatedVector(): AnimatedImageVector = AnimatedImageVector.animatedVectorResource(id = this) + +@ExperimentalAnimationGraphicsApi +@Composable +public fun Int?.animatedVector(): AnimatedImageVector? = this?.animatedVector() + +@Composable +public fun Int.string(vararg args: Any): String = stringResource(id = this, formatArgs = args) + +@Composable +public fun Int?.string(vararg args: Any): String? = this?.string(args) + +@Composable +public fun Int.painter(): Painter = painterResource(this) + +@Composable +public fun Int?.painter(): Painter? = this?.painter() + +@Composable +public fun Int.integerRes(): Int = integerResource(this) + +@Composable +public fun Int?.integerRes(): Int? = this?.integerRes() + +@Composable +public fun Int.integerArrayRes(): IntArray = integerArrayResource(this) + +@Composable +public fun Int?.integerArrayRes(): IntArray? = this?.integerArrayRes() + +@Composable +public fun Int.booleanRes(): Boolean = booleanResource(this) + +@Composable +public fun Int?.booleanRes(): Boolean? = this?.booleanRes() + +@Composable +public fun Int.color(): Color = colorResource(this) + +@Composable +public fun Int?.color(): Color? = this?.color() + +@Composable +public fun Int.dimen(): Dp = dimensionResource(this) + +@Composable +public fun Int?.dimen(): Dp? = this?.dimen() + +public val isSystem24Hour: Boolean @Composable get() = DateFormat.is24HourFormat(LocalContext.current) + +public val displayDensity: Int @Composable get() = LocalConfiguration.current.densityDpi diff --git a/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.android.kt b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.android.kt new file mode 100644 index 0000000..a25f4cd --- /dev/null +++ b/compose/src/androidMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.android.kt @@ -0,0 +1,22 @@ +package pro.respawn.kmmutils.compose.windowsize + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp + +/** + * Get the window size of the current window in pixels. + * This matches closely width screen size on mobile devices most of the time, but windows can be resized by the user. + */ +@ExperimentalComposeUiApi +public actual val windowSizePx: IntSize + @ReadOnlyComposable + @Composable get() = with(LocalDensity.current) { + with(LocalConfiguration.current) { + IntSize(screenWidthDp.dp.roundToPx(), screenHeightDp.dp.roundToPx()) + } + } diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Annotations.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Annotations.kt new file mode 100644 index 0000000..ac39d53 --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Annotations.kt @@ -0,0 +1,140 @@ +package pro.respawn.kmmutils.compose + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.TextUnit + +/** + * Annotates this string with the [spanStyle] provided. The whole string is annotated. + * + * @return the [AnnotatedString] created + */ +public fun String.annotate(spanStyle: SpanStyle): AnnotatedString = buildAnnotatedString { + withStyle(spanStyle) { + append(this@annotate) + } +} + +/** + * Produces an annotated string identical to the original string + * For those times when an api requires AnnotatedString but you don't want to build one + */ +public fun String.annotate(): AnnotatedString = AnnotatedString(this) + +/** + * Annotates this string using the [builder] provided. The string is the argument of the lambda. + * + * @return the [AnnotatedString] created + */ +public inline fun String.annotate( + builder: AnnotatedString.Builder.(String) -> Unit +): AnnotatedString = buildAnnotatedString { builder(this@annotate) } + +/** + * Applies [FontWeight.Bold] to `this` string + * + * @return the [AnnotatedString] created + */ +public fun String.bold(): AnnotatedString = weight(FontWeight.Bold) + +/** + * Applies [FontStyle.Italic] to `this` string + * + * @return the [AnnotatedString] created + */ +public fun String.italic(): AnnotatedString = style(FontStyle.Italic) + +/** + * Strikes through this string + * + * @return the [AnnotatedString] created + */ +public fun String.strike(): AnnotatedString = annotate(SpanStyle(textDecoration = TextDecoration.LineThrough)) + +/** + * Adds an underline to this string + * + * @return the [AnnotatedString] created + */ +public fun String.underline(): AnnotatedString = annotate(SpanStyle(textDecoration = TextDecoration.Underline)) + +/** + * Adds text [decorations] provided to this string + * + * @return the [AnnotatedString] created + */ +public fun String.decorate( + vararg decorations: TextDecoration +): AnnotatedString = annotate(SpanStyle(textDecoration = TextDecoration.combine(decorations.asList()))) + +/** + * Applies absolute [size] to the text + * + * @return the [AnnotatedString] created + */ +public fun String.size(size: TextUnit): AnnotatedString = annotate(SpanStyle(fontSize = size)) + +/** + * Sets the background [color] to this string + * + * @return the [AnnotatedString] created + */ +public fun String.background(color: Color): AnnotatedString = annotate(SpanStyle(background = color)) + +/** + * Sets the foreground [color] of this string + * + * @return the [AnnotatedString] created + */ +public fun String.color(color: Color): AnnotatedString = annotate(SpanStyle(color = color)) + +/** + * Sets the font [weight] for this string + * + * @return the [AnnotatedString] created + */ +public fun String.weight(weight: FontWeight): AnnotatedString = annotate(SpanStyle(fontWeight = weight)) + +/** + * Applies a [style] [FontStyle] to this string + * + * @return the [AnnotatedString] created + */ +public fun String.style(style: FontStyle): AnnotatedString = annotate(SpanStyle(fontStyle = style)) + +/** + * Adds a shadow to `this` string. The shadow has a [color], an [offset] and a [blurRadius] + * + * @return the [AnnotatedString] created + */ +public fun String.shadow( + color: Color, + offset: Offset = Offset.Zero, + blurRadius: Float = 0.0f +): AnnotatedString = annotate(SpanStyle(shadow = Shadow(color, offset, blurRadius))) + +/** + * Changes the [fontFamily] of this string + * + * @return the [AnnotatedString] created + */ +public fun String.font(fontFamily: FontFamily): AnnotatedString = annotate(SpanStyle(fontFamily = fontFamily)) + +// TODO: Waiting for compose update + +// public fun String.clickable(onClick: () -> Unit): AnnotatedString = annotate { +// pushLink(LinkAnnotation.Clickable("clickable") { onClick() }) +// pushStyle(SpanStyle(textDecoration = TextDecoration.Underline)) +// append(this@clickable) +// pop() +// pop() +// } diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/GraphicsLayerExt.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/GraphicsLayerExt.kt new file mode 100644 index 0000000..0dac98f --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/GraphicsLayerExt.kt @@ -0,0 +1,54 @@ +package pro.respawn.kmmutils.compose + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.GraphicsLayerScope + +/** + * Scale the element uniformly. Alias for [GraphicsLayerScope.scaleX] and [GraphicsLayerScope.scaleY]. + * Getter returns the average value of the scale + */ +public var GraphicsLayerScope.scale: Float + get() = (scaleX + scaleY) / 2f + set(value) { + scaleX = value + scaleY = value + } + +/** + * Inverts the X and Y coordinates of a given offset + */ +public inline val Offset.inverted: Offset get() = Offset(-x, -y) + +/** + * Inverts the X coordinate of a given offset + */ +public inline val Offset.invertedX: Offset get() = copy(x = -x) + +/** + * Inverts the Y coordinate of a given offset + */ +public inline val Offset.invertedY: Offset get() = copy(x = -x) + +/** + * Offset expressed as a fraction of the size of the widget. + */ +public typealias FractionalOffset = Offset + +/** + * Converts fractional offset to absolute offset + */ +public fun FractionalOffset.asAbsolute(size: Size): Offset = Offset(x * size.width, y * size.height) + +/** + * Converts absolute offset to fractional offset + */ +public fun Offset.asFractional(size: Size): FractionalOffset = Offset(x / size.width, y / size.height) + +/** + * Applies an [offset] to this [GraphicsLayerScope] + */ +public fun GraphicsLayerScope.offset(offset: Offset) { + translationX = offset.x + translationY = offset.y +} diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Haptics.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Haptics.kt new file mode 100644 index 0000000..bbb36c9 --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Haptics.kt @@ -0,0 +1,24 @@ +@file:Suppress("unused") + +package pro.respawn.kmmutils.compose + +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType + +/** + * Plays a medium haptic feedback, similar to force-touch or long-tap effect. + * + * The effect can vary between platforms or be unavailable (e.g. desktop, browser). + * + * @see HapticFeedbackType.LongPress + */ +public fun HapticFeedback.medium(): Unit = performHapticFeedback(HapticFeedbackType.LongPress) + +/** + * Plays a short haptic feedback, similar to a software keyboard typing vibration. + * + * The effect can vary between platforms or be unavailable (e.g. desktop, browser). + * + * @see HapticFeedbackType.TextHandleMove + */ +public fun HapticFeedback.short(): Unit = performHapticFeedback(HapticFeedbackType.TextHandleMove) diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/InputExt.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/InputExt.kt new file mode 100644 index 0000000..5d60391 --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/InputExt.kt @@ -0,0 +1,103 @@ +package pro.respawn.kmmutils.compose + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.text.KeyboardActionScope +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.AutofillNode +import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalAutofill +import androidx.compose.ui.platform.LocalAutofillTree +import androidx.compose.ui.platform.LocalFocusManager +import kotlinx.coroutines.launch + +/** + * Mark this element as autofillable. + * + * @param autofillType required first autofill type + * @param autofillTypes additional autofill types + * @param onFill callback to execute when the user agrees to autofill the form. Most often used to update the value + * of the form + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +public fun Modifier.autofill( + autofillType: AutofillType, + vararg autofillTypes: AutofillType, + onFill: (String) -> Unit, +): Modifier { + val autofill = LocalAutofill.current + val types = remember(autofillType, *autofillTypes) { + buildList { + add(autofillType) + addAll(autofillTypes) + } + } + val autofillNode = AutofillNode(onFill = onFill, autofillTypes = types) + LocalAutofillTree.current += autofillNode + + return onGloballyPositioned { + autofillNode.boundingBox = it.boundsInWindow() + }.onFocusChanged { focusState -> + autofill?.run { + if (focusState.isFocused) { + requestAutofillForNode(autofillNode) + } else { + cancelAutofillForNode(autofillNode) + } + } + } +} + +/** + * Scrolls to this widget when an element is focused (i.e. user taps the input field). + * Use on text input fields to keep them visible while editing. + * + * For this to work, wrap your scrollable container in another and add the .imePadding() modifier + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +public fun Modifier.bringIntoViewOnFocus(): Modifier { + val coroutineScope = rememberCoroutineScope() + val requester = remember { BringIntoViewRequester() } + + return bringIntoViewRequester(requester) + .onFocusEvent { + if (it.isFocused) { + coroutineScope.launch { + requester.bringIntoView() + } + } + } +} + +/** + * @param onOther an actions to be run when you specify one that is not the one that can be handled by focusManager + * e.g. Go, Search, and Send. By default does nothing. + */ +@Composable +public fun KeyboardActions.Companion.default( + onProceed: (KeyboardActionScope.() -> Unit)? = null +): KeyboardActions = LocalFocusManager.current.run { + remember { + KeyboardActions( + onDone = { clearFocus() }, + onNext = { moveFocus(FocusDirection.Next) }, + onPrevious = { moveFocus(FocusDirection.Previous) }, + onGo = onProceed, + onSearch = onProceed, + onSend = onProceed, + ) + } +} diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.kt new file mode 100644 index 0000000..946c8c0 --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.kt @@ -0,0 +1,36 @@ +package pro.respawn.kmmutils.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver + +/** + * Registers a new lifecycle observer for the lifetime of the composition of this function, then clears it. + */ +@Composable +public fun ObserveLifecycle(onEvent: (event: Lifecycle.Event) -> Unit) { + val lifecycle = LocalLifecycleOwner.current + val action by rememberUpdatedState(onEvent) + DisposableEffect(lifecycle) { + val observer = LifecycleEventObserver { _, event -> action(event) } + + lifecycle.lifecycle.addObserver(observer) + + onDispose { + lifecycle.lifecycle.removeObserver(observer) + } + } +} + +/** + * Keeps the device screen on while the current composable is in the composition. + * + * Currently supported only on iOS and Android. + * No-op on other platforms + */ +@Composable +public expect fun KeepScreenOn(enabled: Boolean = true) diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Transitions.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Transitions.kt new file mode 100644 index 0000000..c7b92e0 --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Transitions.kt @@ -0,0 +1,120 @@ +package pro.respawn.kmmutils.compose + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.runtime.Stable +import androidx.compose.ui.unit.IntOffset +import kotlin.math.roundToInt + +private val DefaultSpec = spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = IntOffset.VisibilityThreshold +) + +/** + * Positive [itemHeightOffsetFraction] means the item is moving **up**. + */ +@Stable +public fun slideInVertically( + itemHeightOffsetFraction: Float, + spec: FiniteAnimationSpec = DefaultSpec +): EnterTransition = slideInVertically(spec) { (it * itemHeightOffsetFraction).roundToInt() } + +/** + * Positive [itemHeightOffsetFraction] means the item is moving **down**. + */ +@Stable +public fun slideOutVertically( + itemHeightOffsetFraction: Float, + spec: FiniteAnimationSpec = DefaultSpec +): ExitTransition = slideOutVertically(spec) { (it * itemHeightOffsetFraction).roundToInt() } + +/** + * Returns an exit transition that slides the element to the bottom fully + */ +@Stable +public fun slideToBottom( + spec: FiniteAnimationSpec = DefaultSpec +): ExitTransition = slideOutVertically(1f, spec) + +/** + * Returns an exit transition that slides the element to the top fully + */ +@Stable +public fun slideToTop( + spec: FiniteAnimationSpec = DefaultSpec +): ExitTransition = slideOutVertically(-1f, spec) + +/** + * Returns an enter transition that slides the element from the bottom fully + */ +@Stable +public fun slideFromBottom( + spec: FiniteAnimationSpec = DefaultSpec +): EnterTransition = slideInVertically(1f, spec) + +/** + * Returns an enter transition that slides the element from the top fully + */ +@Stable +public fun slideFromTop( + spec: FiniteAnimationSpec = DefaultSpec +): EnterTransition = slideInVertically(-1f, spec) + +/** + * A positive value means sliding from right to left, whereas a negative value would slide the content from left to right. + */ +@Stable +public fun slideInHorizontally( + widthOffsetFraction: Float, + spec: FiniteAnimationSpec = DefaultSpec +): EnterTransition = slideInHorizontally(spec) { (it * widthOffsetFraction).roundToInt() } + +/** + * A positive value means sliding to the right, whereas a negative value would slide the content towards the left. + */ +@Stable +public fun slideOutHorizontally( + widthOffsetFraction: Float, + spec: FiniteAnimationSpec = DefaultSpec +): ExitTransition = slideOutHorizontally(spec) { (it * widthOffsetFraction).roundToInt() } + +/** + * Returns an enter transition that slides the element from the left fully + */ +@Stable +public fun slideFromLeft( + spec: FiniteAnimationSpec = DefaultSpec +): EnterTransition = slideInHorizontally(-1f, spec) + +/** + * Returns an enter transition that slides the element from the right fully + */ +@Stable +public fun slideFromRight( + spec: FiniteAnimationSpec = DefaultSpec +): EnterTransition = slideInHorizontally(1f, spec) + +/** + * Returns an exit transition that slides the element to the left fully + */ +@Stable +public fun slideToLeft( + spec: FiniteAnimationSpec = DefaultSpec +): ExitTransition = slideOutHorizontally(-1f, spec) + +/** + * Returns an exit transition that slides the element to the right fully + */ +@Stable +public fun slideToRight( + spec: FiniteAnimationSpec = DefaultSpec +): EnterTransition = slideInHorizontally(1f, spec) diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/TypeConverters.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/TypeConverters.kt new file mode 100644 index 0000000..5286444 --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/TypeConverters.kt @@ -0,0 +1,22 @@ +package pro.respawn.kmmutils.compose + +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.TwoWayConverter +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp + +private val TextUnitVectorConverter = TwoWayConverter({ AnimationVector1D(it.value) }, { it.value.sp }) + +/** + * A vector converter that converts this [TextUnit] value to a [Float] to allow you to animate it. + */ +public val TextUnit.Companion.VectorConverter: TwoWayConverter + get() = TextUnitVectorConverter + +private val LongVectorConverter = TwoWayConverter({ AnimationVector1D(it.toFloat()) }, { it.value.toLong() }) + +/** + * A vector converter that converts this [Long] value to a [Float] to allow you to animate it. + */ +public val Long.Companion.VectorConverter: TwoWayConverter + get() = LongVectorConverter diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/TypeCrossfade.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/TypeCrossfade.kt new file mode 100644 index 0000000..55b80bb --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/TypeCrossfade.kt @@ -0,0 +1,103 @@ +package pro.respawn.kmmutils.compose + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.util.fastForEach + +/** + * TypeCrossfade is a [Crossfade] variation that runs a fade-through animation when the type of the [state] [T] changes. + * It will not run the animation when the object itself changes. + * + * This should be used for any page where multiple states are defined to transition between them. + * This can have a small performance impact though, so avoid using this where the type changes very frequently. + */ +@Composable +public inline fun TypeCrossfade( + state: T, + modifier: Modifier = Modifier, + fill: Boolean = true, + alignment: Alignment = Alignment.Center, + animationSpec: FiniteAnimationSpec = tween(), + crossinline content: @Composable T.() -> Unit +) { + val transition = updateTransition(targetState = state, label = "TypeCrossfade") + transition.Crossfade( + contentKey = { it::class }, + animationSpec = animationSpec, + contentAlignment = alignment, + modifier = modifier.then(if (fill) Modifier.fillMaxSize() else Modifier), + ) { + content(it) + } +} + +/** + * Fork of [androidx.compose.animation.Crossfade] that accepts [contentAlignment]. + */ +@Composable +@PublishedApi +internal fun Transition.Crossfade( + modifier: Modifier = Modifier, + contentAlignment: Alignment, + animationSpec: FiniteAnimationSpec = tween(), + contentKey: (targetState: T) -> Any? = { it }, + content: @Composable BoxScope.(targetState: T) -> Unit +) { + val currentlyVisible = remember { mutableStateListOf().apply { add(currentState) } } + val contentMap = remember { + mutableMapOf Unit>() + } + if (currentState == targetState) { + // If not animating, just display the current state + if (currentlyVisible.size != 1 || currentlyVisible[0] != targetState) { + // Remove all the intermediate items from the list once the animation is finished. + currentlyVisible.removeAll { it != targetState } + contentMap.clear() + } + } + if (!contentMap.contains(targetState)) { + // Replace target with the same key if any + val replacementId = currentlyVisible.indexOfFirst { + contentKey(it) == contentKey(targetState) + } + if (replacementId == -1) { + currentlyVisible.add(targetState) + } else { + currentlyVisible[replacementId] = targetState + } + contentMap.clear() + currentlyVisible.fastForEach { stateForContent -> + contentMap[stateForContent] = { + val alpha by animateFloat( + transitionSpec = { animationSpec } + ) { if (it == stateForContent) 1f else 0f } + Box(Modifier.graphicsLayer { this.alpha = alpha }) { + content(stateForContent) + } + } + } + } + + Box(modifier, contentAlignment = contentAlignment) { + currentlyVisible.fastForEach { + key(contentKey(it)) { + contentMap[it]?.invoke() + } + } + } +} diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Utils.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Utils.kt new file mode 100644 index 0000000..65a80e5 --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/Utils.kt @@ -0,0 +1 @@ +package pro.respawn.kmmutils.compose diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/GridExt.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/GridExt.kt new file mode 100644 index 0000000..fac62de --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/GridExt.kt @@ -0,0 +1,13 @@ +package pro.respawn.kmmutils.compose.lazy + +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridItemScope +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.runtime.Composable + +/** + * Adds an item that spans the whole row of this grid. + */ +public fun LazyGridScope.fullWidthItem( + content: @Composable LazyGridItemScope.() -> Unit, +): Unit = item(span = { GridItemSpan(maxLineSpan) }, content = content) diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/PagerEffects.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/PagerEffects.kt new file mode 100644 index 0000000..f39dc85 --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/PagerEffects.kt @@ -0,0 +1,69 @@ +@file:OptIn(ExperimentalFoundationApi::class) + +package pro.respawn.kmmutils.compose.lazy + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.pager.PagerState +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ScaleFactor +import androidx.compose.ui.layout.lerp +import androidx.compose.ui.util.lerp + +private const val DefaultPagerScaleFactor = 0.85f +private const val MaxRotationDegrees = 30f + +/** + * Calculate absolute offset for current page [page]. + * * For previous page, this will be -1 + * * For current, 0 + * * For next, 1 + * * For one after the next - 2 + * + * and so on... + */ +@Stable +public fun PagerState.offsetForPage(page: Int): Float = (currentPage - page + currentPageOffsetFraction) * -1 + +/** + * Applies a scale effect when scrolling through pages. + * + * The highest scale change factor possible is determined by [scaleFactor], achieved when the item is fully swiped away. + */ +public fun Modifier.scalePagerEffect( + state: PagerState, + index: Int, + scaleFactor: Float = DefaultPagerScaleFactor +): Modifier = graphicsLayer { + // Calculate the absolute offset for the current page from the + // scroll position. We use the absolute value which allows us to mirror + // any effects for both directions + val pageOffset = state.offsetForPage(index) + + lerp( + start = ScaleFactor(scaleFactor, scaleFactor), + stop = ScaleFactor(1f, 1f), + fraction = 1f - pageOffset.coerceIn(0f, 1f) + ).also { scale -> + scaleX = scale.scaleX + scaleY = scale.scaleY + } +} + +/** + * Applies a "spin" (rotate) effect when scrolling through pages. + * + * The highest angle possible is determined by [maxRotationDeg], achieved when the item is fully swiped away. + */ +public fun Modifier.spinPagerEffect( + state: PagerState, + index: Int, + maxRotationDeg: Float = MaxRotationDegrees +): Modifier = graphicsLayer { + rotationZ = lerp( + start = 0f, + stop = maxRotationDeg, + fraction = state.offsetForPage(index).coerceIn(-1f, 1f) + ) +} diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/PagerExt.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/PagerExt.kt new file mode 100644 index 0000000..38c8acd --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/PagerExt.kt @@ -0,0 +1,22 @@ +package pro.respawn.kmmutils.compose.lazy + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.pager.PagerState + +/** + * Same behavior as [LazyListState.firstVisibleItemIndex]. + */ +@OptIn(ExperimentalFoundationApi::class) +public inline val PagerState.firstVisiblePage: Int + get() = when { + currentPageOffsetFraction >= 0 -> currentPage + else -> currentPage - 1 + }.coerceIn(0..pageCount) + +/** + * Same behavior as [LazyListState.firstVisibleItemScrollOffset]. + */ +@OptIn(ExperimentalFoundationApi::class) +public inline val PagerState.firstVisiblePageOffsetFraction: Float + get() = getOffsetFractionForPage(firstVisiblePage.coerceIn(0..pageCount)) diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/PagesPerScreen.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/PagesPerScreen.kt new file mode 100644 index 0000000..933e5db --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/lazy/PagesPerScreen.kt @@ -0,0 +1,20 @@ +package pro.respawn.kmmutils.compose.lazy + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.pager.PageSize +import androidx.compose.ui.unit.Density + +/** + * Defines a [PageSize] that shows [amount] pages on the screen at all times. The size of pages will be changed + * to always show [amount] pages. + */ +@OptIn(ExperimentalFoundationApi::class) +public class PagesPerScreen( + public val amount: Int +) : PageSize { + + override fun Density.calculateMainAxisPageSize( + availableSpace: Int, + pageSpacing: Int + ): Int = (availableSpace - 2 * pageSpacing) / amount +} diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/Clickable.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/Clickable.kt new file mode 100644 index 0000000..b458bde --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/Clickable.kt @@ -0,0 +1,17 @@ +package pro.respawn.kmmutils.compose.modifier + +// TODO: Waiting for compose update + +// public fun Modifier.noIndicationClickable( +// enabled: Boolean = true, +// onClickLabel: String? = null, +// role: Role? = null, +// onClick: () -> Unit, +// ): Modifier = clickable( +// interactionSource = null, +// indication = null, +// enabled = enabled, +// onClickLabel = onClickLabel, +// role = role, +// onClick = onClick +// ) diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/FadingEdge.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/FadingEdge.kt new file mode 100644 index 0000000..55a5f4a --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/FadingEdge.kt @@ -0,0 +1,67 @@ +package pro.respawn.kmmutils.compose.modifier + +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection + +/** + * The edge to use with [fadingEdge] modifier. + */ +public enum class FadingEdge { + Start, End, Top, Bottom +} + +private const val EdgeAlpha = 0.99f + +/** + * Makes this widget "fade out" into transparency over specified [size]. + * The edge specified by [fadingEdge] will be faded only. + * + * For RTL layouts, if [rtlAware] is true, automatically inverts the fading edge direction. + */ +public fun Modifier.fadingEdge( + fadingEdge: FadingEdge, + size: Dp, + rtlAware: Boolean = false, +): Modifier = composed { + val direction = LocalLayoutDirection.current + val invert = direction == LayoutDirection.Rtl && rtlAware + val edge = when (fadingEdge) { + FadingEdge.Top, FadingEdge.Bottom -> fadingEdge + FadingEdge.Start -> if (invert) FadingEdge.End else FadingEdge.Start + FadingEdge.End -> if (invert) FadingEdge.Start else FadingEdge.End + } + graphicsLayer { alpha = EdgeAlpha }.drawWithCache { + val colors = listOf(Color.Transparent, Color.Black) + val sizePx = size.toPx() + val brush = when (edge) { + FadingEdge.Start -> Brush.horizontalGradient(colors, startX = 0f, endX = sizePx) + FadingEdge.End -> Brush.horizontalGradient( + colors.reversed(), + startX = this.size.width - sizePx, + endX = this.size.width + ) + + FadingEdge.Top -> Brush.verticalGradient(colors, startY = 0f, endY = sizePx) + FadingEdge.Bottom -> Brush.verticalGradient( + colors.reversed(), + startY = this.size.height - sizePx, + endY = this.size.height + ) + } + onDrawWithContent { + drawContent() + drawRect( + brush = brush, + blendMode = BlendMode.DstIn + ) + } + } +} diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/Flip.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/Flip.kt new file mode 100644 index 0000000..f697404 --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/Flip.kt @@ -0,0 +1,40 @@ +package pro.respawn.kmmutils.compose.modifier + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.DrawTransform +import androidx.compose.ui.graphics.drawscope.withTransform + +/** + * Defines available modes for the [flip] modifier + */ +public enum class FlipDirection(internal val x: Float, internal val y: Float) { + Vertical(1f, -1f), + Horizontal(-1f, 1f) +} + +/** + * Flip this composable using [direction] + */ +public fun Modifier.flip(direction: FlipDirection): Modifier = scale(direction.x, direction.y) + +/** + * Flip this canvas using [direction] and [pivot] point + */ +public fun DrawTransform.flip(direction: FlipDirection?, pivot: Offset = center) { + direction?.run { scale(x, y, pivot) } +} + +/** + * Create a new draw transform scope for [block], flipping the contents using [direction]] + */ +public inline fun DrawScope.flip( + direction: FlipDirection?, + pivot: Offset = center, + block: DrawScope.() -> Unit +): Unit = withTransform( + transformBlock = { flip(direction, pivot) }, + drawBlock = block +) diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/GradientTint.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/GradientTint.kt new file mode 100644 index 0000000..04973c3 --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/GradientTint.kt @@ -0,0 +1,33 @@ +package pro.respawn.kmmutils.compose.modifier + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer + +/** + * Tint the composable with the provided [brush]. This will draw over any pixels that are not completely transparent in + * the source composable. For example, this can be used to tint over icons or any other complex shapes. + */ +// srcAtop only works when alpha != 1f +public fun Modifier.tint(brush: Brush): Modifier = graphicsLayer(alpha = 0.99f) + .drawWithCache { + onDrawWithContent { + drawContent() + drawRect(brush, size = size, blendMode = BlendMode.SrcAtop) + } + } + +/** + * Tint the composable with the provided [color]. This will draw over any pixels that are not completely transparent in + * the source composable. For example, this can be used to tint over icons or any other complex shapes. + */ +public fun Modifier.tint(color: Color): Modifier = graphicsLayer(alpha = 0.99f) + .drawWithCache { + onDrawWithContent { + drawContent() + drawRect(color = color, size = size, blendMode = BlendMode.SrcAtop) + } + } diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/GrayScale.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/GrayScale.kt new file mode 100644 index 0000000..842ce05 --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/GrayScale.kt @@ -0,0 +1,27 @@ +package pro.respawn.kmmutils.compose.modifier + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas + +/** + * Apply 0-saturation color transformation to this composable, rendering it grayscale. + */ +public fun Modifier.grayScale(): Modifier = drawWithCache { + val saturationMatrix = ColorMatrix().apply { setToSaturation(0f) } + val saturationFilter = ColorFilter.colorMatrix(saturationMatrix) + val paint = Paint().apply { colorFilter = saturationFilter } + val canvasBounds = Rect(Offset.Zero, size) + onDrawWithContent { + drawIntoCanvas { + it.saveLayer(canvasBounds, paint) + drawContent() + it.restore() + } + } +} diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/ThenIf.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/ThenIf.kt new file mode 100644 index 0000000..3e067a1 --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/modifier/ThenIf.kt @@ -0,0 +1,27 @@ +package pro.respawn.kmmutils.compose.modifier + +import androidx.compose.ui.Modifier + +/** + * Apply the [ifTrue] block to the modifier chain only if the [condition] is `true`, otherwise apply [ifFalse] + * (nothing additional by default) + * + * @return the continued modifier chain with changes applied, if any. + */ +public inline fun Modifier.thenIf( + condition: Boolean, + ifFalse: Modifier.() -> Modifier = { this }, + ifTrue: Modifier.() -> Modifier +): Modifier = then(Modifier.let { if (condition) it.ifTrue() else it.ifFalse() }) + +/** + * Apply the [block] to the modifier chain only if the [value] is not null, otherwise apply [ifNull] + * (nothing additional by default) + * + * @return the continued modifier chain with changes applied, if any. + */ +public inline fun Modifier.thenIfNotNull( + value: T, + ifNull: Modifier.() -> Modifier = { this }, + block: Modifier.(T & Any) -> Modifier +): Modifier = then(Modifier.let { if (value != null) it.block(value) else it.ifNull() }) diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/resources/ResourcesExt.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/resources/ResourcesExt.kt new file mode 100644 index 0000000..2cb7894 --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/resources/ResourcesExt.kt @@ -0,0 +1,54 @@ +package pro.respawn.kmmutils.compose.resources + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonSkippableComposable +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.PluralStringResource +import org.jetbrains.compose.resources.StringArrayResource +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.imageResource +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringArrayResource +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.resources.vectorResource + +@Composable +@NonSkippableComposable +public fun DrawableResource.vector(): ImageVector = vectorResource(this) + +@Composable +@NonSkippableComposable +public fun DrawableResource.painter(): Painter = painterResource(this) + +@Composable +@NonSkippableComposable +public fun DrawableResource.image(): ImageBitmap = imageResource(this) + +@Composable +@NonSkippableComposable +public fun StringArrayResource.strings(): List = stringArrayResource(this) + +@Composable +@NonSkippableComposable +public fun StringResource.string(vararg args: Any): String = stringResource(this, formatArgs = args) + +@Composable +@NonSkippableComposable +public fun PluralStringResource.plural( + quantity: Int, + vararg args: Any +): String = pluralStringResource(this, quantity, formatArgs = args) + +@Composable +public fun Text.string(): String = when (this) { + is Text.Dynamic -> value + is Text.Resource -> stringResource(id, formatArgs = args) +} + +public fun String.text(): Text.Dynamic = Text.Dynamic(this) + +public fun StringResource.text(vararg args: Any): Text.Resource = Text.Resource(this, args = args) diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/resources/Text.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/resources/Text.kt new file mode 100644 index 0000000..5b932aa --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/resources/Text.kt @@ -0,0 +1,40 @@ +package pro.respawn.kmmutils.compose.resources + +import androidx.compose.runtime.Immutable +import org.jetbrains.compose.resources.StringResource +import kotlin.jvm.JvmInline + +@Immutable +public sealed interface Text { + + @JvmInline + public value class Dynamic(public val value: String) : Text + + public class Resource( + internal val id: StringResource, + internal vararg val args: Any + ) : Text { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Resource) return false + + if (id != other.id) return false + if (!args.contentEquals(other.args)) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + args.contentHashCode() + return result + } + } + + public companion object { + + public operator fun invoke(value: String): Dynamic = Dynamic(value) + public operator fun invoke(id: StringResource, vararg args: Any): Resource = Resource(id, args = args) + } +} diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeClass.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeClass.kt new file mode 100644 index 0000000..ad79a6b --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeClass.kt @@ -0,0 +1,297 @@ +package pro.respawn.kmmutils.compose.windowsize + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize + +/** + * Calculates the window's [WindowSizeClass]. + * + * A new [WindowSizeClass] will be returned whenever a change causes the width or + * height of the window to cross a breakpoint, such as when the device is rotated or the window + * is resized. + */ +@Composable +@ReadOnlyComposable +public fun calculateWindowSizeClass(): WindowSizeClass = WindowSizeClass.calculateFromSize( + size = windowSizePx.toSize(), + density = LocalDensity.current +) + +/** + * Window size classes are a set of opinionated viewport breakpoints to design, develop, and test + * responsive application layouts against. + * For more details check Support different screen sizes documentation. + * + * WindowSizeClass contains a [WindowWidthSizeClass] and [WindowHeightSizeClass], representing the + * window size classes for this window's width and height respectively. + * + * See [calculateWindowSizeClass] to calculate the WindowSizeClass. + * + * @property widthSizeClass width-based window size class ([WindowWidthSizeClass]) + * @property heightSizeClass height-based window size class ([WindowHeightSizeClass]) + */ +@Immutable +public class WindowSizeClass private constructor( + public val widthSizeClass: WindowWidthSizeClass, + public val heightSizeClass: WindowHeightSizeClass, +) { + + public companion object { + + internal fun calculateFromSize(size: DpSize): WindowSizeClass { + val windowWidthSizeClass = WindowWidthSizeClass.fromWidth(size.width) + val windowHeightSizeClass = WindowHeightSizeClass.fromHeight(size.height) + return WindowSizeClass(windowWidthSizeClass, windowHeightSizeClass) + } + + /** + * Calculates the best matched [WindowSizeClass] for a given [size] and [Density] according + * to the provided [supportedWidthSizeClasses] and [supportedHeightSizeClasses]. + * + * @param size of the window + * @param density of the window + * @param supportedWidthSizeClasses the set of width size classes that are supported + * @param supportedHeightSizeClasses the set of height size classes that are supported + * @return [WindowSizeClass] corresponding to the given width and height + */ + public fun calculateFromSize( + size: Size, + density: Density, + supportedWidthSizeClasses: Set = + WindowWidthSizeClass.DefaultSizeClasses, + supportedHeightSizeClasses: Set = + WindowHeightSizeClass.DefaultSizeClasses, + ): WindowSizeClass { + val windowWidthSizeClass = + WindowWidthSizeClass.fromWidth(size.width, density, supportedWidthSizeClasses) + val windowHeightSizeClass = + WindowHeightSizeClass.fromHeight(size.height, density, supportedHeightSizeClasses) + return WindowSizeClass(windowWidthSizeClass, windowHeightSizeClass) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as WindowSizeClass + + if (widthSizeClass != other.widthSizeClass) return false + if (heightSizeClass != other.heightSizeClass) return false + + return true + } + + override fun hashCode(): Int { + var result = widthSizeClass.hashCode() + result = 31 * result + heightSizeClass.hashCode() + return result + } + + override fun toString(): String = "WindowSizeClass($widthSizeClass, $heightSizeClass)" +} + +/** + * Width-based window size class. + * + * A window size class represents a breakpoint that can be used to build responsive layouts. Each + * window size class breakpoint represents a majority case for typical device scenarios so your + * layouts will work well on most devices and configurations. + * + * For more details see Window size classes documentation. + */ +@Immutable +@kotlin.jvm.JvmInline +public value class WindowWidthSizeClass private constructor(private val value: Int) : + Comparable { + + override operator fun compareTo(other: WindowWidthSizeClass): Int = + breakpoint().compareTo(other.breakpoint()) + + override fun toString(): String = "WindowWidthSizeClass." + when (this) { + Compact -> "Compact" + Medium -> "Medium" + Expanded -> "Expanded" + else -> "" + } + + public companion object { + + /** Represents the majority of phones in portrait. */ + public val Compact: WindowWidthSizeClass = WindowWidthSizeClass(0) + + /** + * Represents the majority of tablets in portrait and large unfolded inner displays in + * portrait. + */ + public val Medium: WindowWidthSizeClass = WindowWidthSizeClass(1) + + /** + * Represents the majority of tablets in landscape and large unfolded inner displays in + * landscape. + */ + public val Expanded: WindowWidthSizeClass = WindowWidthSizeClass(2) + + /** + * The default set of size classes that includes [Compact], [Medium], and [Expanded] size + * classes. Should never expand to ensure behavioral consistency. + */ + public val DefaultSizeClasses: Set = setOf(Compact, Medium, Expanded) + + /** + * The standard set of size classes. It's supposed to include all size classes and will be + * expanded whenever a new size class is defined. By default + * [WindowSizeClass.calculateFromSize] will only return size classes in [DefaultSizeClasses] + * in order to avoid behavioral changes when new size classes are added. You can opt in to + * support all available size classes by doing: + * ``` + * WindowSizeClass.calculateFromSize( + * size = size, + * density = density, + * supportedWidthSizeClasses = WindowWidthSizeClass.StandardSizeClasses, + * supportedHeightSizeClasses = WindowHeightSizeClass.StandardSizeClasses + * ) + * ``` + */ + public val StandardSizeClasses: Set get() = DefaultSizeClasses + + private fun WindowWidthSizeClass.breakpoint(): Dp = when { + this == Expanded -> 840.dp + this == Medium -> 600.dp + else -> 0.dp + } + + /** Calculates the [WindowWidthSizeClass] for a given [width] */ + internal fun fromWidth(width: Dp): WindowWidthSizeClass = fromWidth( + with(defaultDensity) { width.toPx() }, + defaultDensity, + DefaultSizeClasses, + ) + + /** + * Calculates the best matched [WindowWidthSizeClass] for a given [width] in Pixels and + * a given [Density] from [supportedSizeClasses]. + */ + internal fun fromWidth( + width: Float, + density: Density, + supportedSizeClasses: Set, + ): WindowWidthSizeClass { + require(width >= 0) { "Width must not be negative" } + require(supportedSizeClasses.isNotEmpty()) { "Must support at least one size class" } + val sortedSizeClasses = supportedSizeClasses.sortedDescending() + // Find the largest supported size class that matches the width + sortedSizeClasses.forEach { + if (width >= with(density) { it.breakpoint().toPx() }) { + return it + } + } + // If none of the size classes matches, return the smallest one. + return sortedSizeClasses.last() + } + } +} + +/** + * Height-based window size class. + * + * A window size class represents a breakpoint that can be used to build responsive layouts. Each + * window size class breakpoint represents a majority case for typical device scenarios so your + * layouts will work well on most devices and configurations. + * + * For more details see Window size classes documentation. + */ +@Immutable +@kotlin.jvm.JvmInline +public value class WindowHeightSizeClass private constructor(private val value: Int) : + Comparable { + + override operator fun compareTo(other: WindowHeightSizeClass): Int = breakpoint().compareTo(other.breakpoint()) + + override fun toString(): String = "WindowHeightSizeClass." + when (this) { + Compact -> "Compact" + Medium -> "Medium" + Expanded -> "Expanded" + else -> "" + } + + public companion object { + + /** Represents the majority of phones in landscape */ + public val Compact: WindowHeightSizeClass = WindowHeightSizeClass(0) + + /** Represents the majority of tablets in landscape and majority of phones in portrait */ + public val Medium: WindowHeightSizeClass = WindowHeightSizeClass(1) + + /** Represents the majority of tablets in portrait */ + public val Expanded: WindowHeightSizeClass = WindowHeightSizeClass(2) + + /** + * The default set of size classes that includes [Compact], [Medium], and [Expanded] size + * classes. Should never expand to ensure behavioral consistency. + */ + public val DefaultSizeClasses: Set = setOf(Compact, Medium, Expanded) + + /** + * The standard set of size classes. It's supposed to include all size classes and will be + * expanded whenever a new size class is defined. By default + * [WindowSizeClass.calculateFromSize] will only return size classes in [DefaultSizeClasses] + * in order to avoid behavioral changes when new size classes are added. You can opt in to + * support all available size classes by doing: + * ``` + * WindowSizeClass.calculateFromSize( + * size = size, + * density = density, + * supportedWidthSizeClasses = WindowWidthSizeClass.StandardSizeClasses, + * supportedHeightSizeClasses = WindowHeightSizeClass.StandardSizeClasses + * ) + * ``` + */ + public val StandardSizeClasses: Set get() = DefaultSizeClasses + + private fun WindowHeightSizeClass.breakpoint(): Dp = when { + this == Expanded -> 900.dp + this == Medium -> 480.dp + else -> 0.dp + } + + /** Calculates the [WindowHeightSizeClass] for a given [height] */ + internal fun fromHeight(height: Dp) = fromHeight( + with(defaultDensity) { height.toPx() }, + defaultDensity, + DefaultSizeClasses, + ) + + /** + * Calculates the best matched [WindowHeightSizeClass] for a given [height] in Pixels and + * a given [Density] from [supportedSizeClasses]. + */ + internal fun fromHeight( + height: Float, + density: Density, + supportedSizeClasses: Set, + ): WindowHeightSizeClass { + require(height >= 0) { "Width must not be negative" } + require(supportedSizeClasses.isNotEmpty()) { "Must support at least one size class" } + val sortedSizeClasses = supportedSizeClasses.sortedDescending() + // Find the largest supported size class that matches the width + sortedSizeClasses.forEach { + if (height >= with(density) { it.breakpoint().toPx() }) { + return it + } + } + // If none of the size classes matches, return the smallest one. + return sortedSizeClasses.last() + } + } +} + +private val defaultDensity = Density(1F, 1F) diff --git a/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.kt b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.kt new file mode 100644 index 0000000..56d561d --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.kt @@ -0,0 +1,59 @@ +package pro.respawn.kmmutils.compose.windowsize + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toSize + +/** + * Whether the window width is long (longer than most phones **in portrait**). + * + * This is not a real device size (although it matches closely on mobile platforms sometimes), but an app window size, + * which can be different when resizing. + * + * @see WindowWidthSizeClass + */ +public inline val isWideScreen: Boolean + @Composable get() = calculateWindowSizeClass().widthSizeClass > WindowWidthSizeClass.Compact + +/** + * Whether the window height is long (longer than most phones **in portrait**). + * + * This is not a real device size (although it matches closely on mobile platforms sometimes), but an app window size + * which can be different when resizing. + * + * @see WindowWidthSizeClass + */ +public inline val isLongScreen: Boolean + @Composable get() = calculateWindowSizeClass().heightSizeClass > WindowHeightSizeClass.Compact + +/** + * Get the window size of the current window in pixels. + * This matches closely width screen size on mobile devices most of the time, but windows can be resized by the user. + */ +public expect val windowSizePx: IntSize @Composable @ReadOnlyComposable get + +/** + * Get the window size of the current window in dp. + * This matches closely width screen size on mobile devices most of the time, but windows can be resized by the user. + */ +public val windowSize: DpSize + @Composable @ReadOnlyComposable get() = with(LocalDensity.current) { + windowSizePx.toSize() + .toDpSize() + } + +/** + * Get the window width of the current window. + * This matches closely width screen size on mobile devices most of the time, but windows can be resized by the user. + */ +public val windowWidth: Dp @Composable get() = with(LocalDensity.current) { windowSizePx.width.toDp() } + +/** + * Get the window height of the current window. + * This matches closely width screen size on mobile devices most of the time, but windows can be resized by the user. + */ +public val windowHeight: Dp @Composable get() = with(LocalDensity.current) { windowSizePx.height.toDp() } diff --git a/compose/src/iosMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.ios.kt b/compose/src/iosMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.ios.kt new file mode 100644 index 0000000..5d894af --- /dev/null +++ b/compose/src/iosMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.ios.kt @@ -0,0 +1,19 @@ +package pro.respawn.kmmutils.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import platform.UIKit.UIApplication + +/** + * Keeps the device screen on while the current composable is in the composition. + * + * Currently supported only on iOS and Android. + * No-op on other platforms + */ +@Composable +public actual fun KeepScreenOn(enabled: Boolean): Unit = DisposableEffect(enabled) { + if (!enabled) return@DisposableEffect onDispose { } + val previous = UIApplication.sharedApplication.isIdleTimerDisabled() + UIApplication.sharedApplication.idleTimerDisabled = true + onDispose { UIApplication.sharedApplication.idleTimerDisabled = previous } +} diff --git a/compose/src/jvmMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.jvm.kt b/compose/src/jvmMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.jvm.kt new file mode 100644 index 0000000..d2678f6 --- /dev/null +++ b/compose/src/jvmMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.jvm.kt @@ -0,0 +1,13 @@ +package pro.respawn.kmmutils.compose + +import androidx.compose.runtime.Composable + +/** + * Keeps the device screen on while the current composable is in the composition. + * + * Currently supported only on iOS and Android. + * No-op on other platforms + */ +// requires win/macos api usage, so not really possible to implement w/o 1st party support +@Composable +public actual fun KeepScreenOn(enabled: Boolean): Unit = Unit diff --git a/compose/src/jvmMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.jvm.kt b/compose/src/jvmMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.jvm.kt new file mode 100644 index 0000000..52a5005 --- /dev/null +++ b/compose/src/jvmMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.jvm.kt @@ -0,0 +1,16 @@ +package pro.respawn.kmmutils.compose.windowsize + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.IntSize + +/** + * Get the window size of the current window in pixels. + * This matches closely width screen size on mobile devices most of the time, but windows can be resized by the user. + */ +@OptIn(ExperimentalComposeUiApi::class) +public actual val windowSizePx: IntSize + @ReadOnlyComposable + @Composable get() = LocalWindowInfo.current.containerSize diff --git a/compose/src/macosMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.macos.kt b/compose/src/macosMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.macos.kt new file mode 100644 index 0000000..9ac452a --- /dev/null +++ b/compose/src/macosMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.macos.kt @@ -0,0 +1,29 @@ +package pro.respawn.kmmutils.compose + +import androidx.compose.runtime.Composable + +/** + * Keeps the device screen on while the current composable is in the composition. + * + * Currently supported only on iOS and Android. + * No-op on other platforms + */ +@Composable +public actual fun KeepScreenOn(enabled: Boolean) { + // val key = currentCompositeKeyHash + // DisposableEffect(enabled) { + // val ref = CFBridgingRetain(key) + // if (!enabled) return@DisposableEffect onDispose { } + // IOPMAssertionCreate( + // CFBridgingRetain(kIOPMAssertPreventUserIdleDisplaySleep) as CFStringRef, + // kIOPMAssertionLevelOn, + // ref + // ) + // onDispose { + // + // } + // } + + // TODO: + // currently huge pain and really unsafe to call +} diff --git a/compose/src/nativeMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.native.kt b/compose/src/nativeMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.native.kt new file mode 100644 index 0000000..52a5005 --- /dev/null +++ b/compose/src/nativeMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.native.kt @@ -0,0 +1,16 @@ +package pro.respawn.kmmutils.compose.windowsize + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.IntSize + +/** + * Get the window size of the current window in pixels. + * This matches closely width screen size on mobile devices most of the time, but windows can be resized by the user. + */ +@OptIn(ExperimentalComposeUiApi::class) +public actual val windowSizePx: IntSize + @ReadOnlyComposable + @Composable get() = LocalWindowInfo.current.containerSize diff --git a/compose/src/webMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.web.kt b/compose/src/webMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.web.kt new file mode 100644 index 0000000..8c90d5a --- /dev/null +++ b/compose/src/webMain/kotlin/pro/respawn/kmmutils/compose/ScreenModifiers.web.kt @@ -0,0 +1,12 @@ +package pro.respawn.kmmutils.compose + +import androidx.compose.runtime.Composable + +/** + * Keeps the device screen on while the current composable is in the composition. + * + * Currently supported only on iOS and Android. + * No-op on other platforms + */ +@Composable +public actual fun KeepScreenOn(enabled: Boolean): Unit = Unit diff --git a/compose/src/webMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.web.kt b/compose/src/webMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.web.kt new file mode 100644 index 0000000..52a5005 --- /dev/null +++ b/compose/src/webMain/kotlin/pro/respawn/kmmutils/compose/windowsize/WindowSizeExt.web.kt @@ -0,0 +1,16 @@ +package pro.respawn.kmmutils.compose.windowsize + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.IntSize + +/** + * Get the window size of the current window in pixels. + * This matches closely width screen size on mobile devices most of the time, but windows can be resized by the user. + */ +@OptIn(ExperimentalComposeUiApi::class) +public actual val windowSizePx: IntSize + @ReadOnlyComposable + @Composable get() = LocalWindowInfo.current.containerSize diff --git a/detekt.yml b/detekt.yml index 3d33b36..e8d20da 100644 --- a/detekt.yml +++ b/detekt.yml @@ -68,17 +68,17 @@ comments: endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' UndocumentedPublicClass: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**', '**/sample/**','**/app/**' ] searchInNestedClass: true searchInInnerClass: true searchInInnerObject: true searchInInnerInterface: true UndocumentedPublicFunction: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**', '**/sample/**', '**/app/**' ] UndocumentedPublicProperty: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**', '**/sample/**', '**/app/**' ] complexity: active: true @@ -141,19 +141,19 @@ complexity: active: false StringLiteralDuplication: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] threshold: 5 ignoreAnnotation: true excludeStringsWithLessThan5Characters: true ignoreStringsRegex: '$^' TooManyFunctions: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] - thresholdInFiles: 22 - thresholdInClasses: 22 - thresholdInInterfaces: 22 - thresholdInObjects: 22 - thresholdInEnums: 22 + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**', '**/sample/**','**/app/**' ] + thresholdInFiles: 50 + thresholdInClasses: 11 + thresholdInInterfaces: 11 + thresholdInObjects: 11 + thresholdInEnums: 11 ignoreDeprecated: true ignorePrivate: true ignoreOverridden: true @@ -221,7 +221,7 @@ exceptions: - 'finalize' InstanceOfCheckForException: active: false - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] NotImplementedDeclaration: active: true ObjectExtendsThrowable: @@ -247,7 +247,7 @@ exceptions: active: true ThrowingExceptionsWithoutMessageOrCause: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] exceptions: - 'ArrayIndexOutOfBoundsException' - 'Error' @@ -261,7 +261,7 @@ exceptions: active: true TooGenericExceptionCaught: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] exceptionNames: - 'ArrayIndexOutOfBoundsException' - 'Error' @@ -421,46 +421,46 @@ naming: active: true ClassNaming: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] classPattern: '[A-Z][a-zA-Z0-9]*' ConstructorParameterNaming: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] parameterPattern: '[a-z][A-Za-z0-9]*' privateParameterPattern: '[a-z][A-Za-z0-9]*' excludeClassPattern: '$^' EnumNaming: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' ForbiddenClassName: active: false - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] forbiddenName: [ ] FunctionMaxLength: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] maximumFunctionNameLength: 40 FunctionMinLength: active: false - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] minimumFunctionNameLength: 3 FunctionNaming: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] functionPattern: '([a-zA-Z][a-zA-Z0-9]*)|(`.*`)' excludeClassPattern: '$^' ignoreAnnotated: - 'Composable' FunctionParameterNaming: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] parameterPattern: '[a-z][A-Za-z0-9]*' excludeClassPattern: '$^' InvalidPackageDeclaration: active: true excludes: [ '**/*.kts' ] - rootPackage: 'com.nek12.respawn' + rootPackage: 'pro.respawn.kmmutils' MatchingDeclarationName: active: false mustBeFirst: true @@ -471,34 +471,34 @@ naming: active: true NonBooleanPropertyPrefixedWithIs: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] ObjectPropertyNaming: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] constantPattern: '[A-Za-z][_A-Za-z0-9]*' propertyPattern: '[A-Za-z][_A-Za-z0-9]*' privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' PackageNaming: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] packagePattern: '[a-z_]+(\.[a-z][A-Za-z0-9_]*)*' TopLevelPropertyNaming: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] constantPattern: '[A-Z][_A-Za-z0-9]*' propertyPattern: '[A-Za-z][_A-Za-z0-9]*' privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' VariableMaxLength: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] maximumVariableNameLength: 64 VariableMinLength: active: false - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] minimumVariableNameLength: 1 VariableNaming: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] variablePattern: '[a-z][A-Za-z0-9]*' privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' excludeClassPattern: '$^' @@ -513,10 +513,10 @@ performance: active: true ForEachOnRange: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] SpreadOperator: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] UnnecessaryTemporaryInstantiation: active: true @@ -573,7 +573,7 @@ potential-bugs: active: true LateinitUsage: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] ignoreAnnotated: [ ] ignoreOnClassesPattern: '' MapGetWithNotNullAssertionOperator: @@ -688,7 +688,7 @@ style: maxJumpCount: 1 MagicNumber: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] ignoreAnnotated: - 'androidx.compose.ui.tooling.preview.Preview' ignoreNumbers: @@ -797,7 +797,8 @@ style: UnusedPrivateMember: active: true allowedNames: '(_|ignored|expected|serialVersionUID)' - ignoreAnnotated: [ 'androidx.compose.ui.tooling.preview.Preview' ] + excludes: [ '**/*test*/**', '**/*.kts' ] + ignoreAnnotated: [ 'androidx.compose.ui.tooling.preview.Preview', 'androidx.compose.desktop.ui.tooling.preview.Preview' ] UseArrayLiteralsInAnnotations: active: true UseCheckNotNull: @@ -830,7 +831,7 @@ style: active: true WildcardImport: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**.kts', '**/kotestTest/**' ] excludeImports: - 'java.util.*' - 'kotlinx.android.synthetic.*' diff --git a/gradle.properties b/gradle.properties index cd3e798..ed26c10 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,6 @@ -org.gradle.jvmargs=-Xms3g -Xmx6g -XX:+UseParallelGC -XX:+UseStringDeduplication -Dkotlin.daemon.jvm.options=-Xmx2g -Dfile.encoding=UTF-8 +# suppress inspection "UnusedProperty" for whole file +org.gradle.jvmargs=-Xmx6g -Xms1g -XX:+UseParallelGC -XX:+UseStringDeduplication -Dfile.encoding=UTF-8 +kotlin.daemon.jvmargs=-Xmx6g -Xms1g -XX:+UseParallelGC -XX:+UseStringDeduplication -XX:MaxMetaspaceSize=2g android.useAndroidX=true kotlin.code.style=official org.gradle.caching=true @@ -7,16 +9,22 @@ android.enableR8.fullMode=true org.gradle.configureondemand=true android.enableJetifier=false kotlin.incremental.usePreciseJavaTracking=true +org.gradle.configuration-cache.problems=warn android.nonTransitiveRClass=true android.experimental.enableSourceSetPathsMap=true android.experimental.cacheCompileLibResources=true kotlin.mpp.enableCInteropCommonization=true kotlin.mpp.stability.nowarn=true +kotlin.mpp.androidGradlePluginCompatibility.nowarn=true org.gradle.unsafe.configuration-cache=true kotlin.mpp.androidSourceSetLayoutVersion=2 android.disableResourceValidation=true +org.gradle.daemon=true android.nonFinalResIds=true -kotlin.mpp.androidGradlePluginCompatibility.nowarn=true kotlin.native.ignoreIncorrectDependencies=true kotlinx.atomicfu.enableJvmIrTransformation=true +android.lint.useK2Uast=true nl.littlerobots.vcu.resolver=true +org.jetbrains.compose.experimental.macos.enabled=true +org.jetbrains.compose.experimental.jscanvas.enabled=true +org.jetbrains.compose.experimental.wasm.enabled=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index be7add9..ae8aca7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,24 +1,30 @@ [versions] composeDetektPlugin = "1.3.0" -coroutines = "1.8.1-Beta" -datetime = "0.6.0-RC.2" -dependencyAnalysisPlugin = "1.31.0" +coroutines = "1.8.1" +datetime = "0.6.0" +dependencyAnalysisPlugin = "1.32.0" detekt = "1.23.6" detektFormattingPlugin = "1.23.6" dokka = "1.9.20" -gradleAndroid = "8.4.0-rc02" -gradleDoctorPlugin = "0.9.2" +gradleAndroid = "8.6.0-alpha03" +gradleDoctorPlugin = "0.10.0" junit = "4.13.2" -kotest = "5.8.1" -kotest-plugin = "5.8.1" +kotest = "5.9.0" # @pin -kotlin = "1.9.23" -kotlinx-atomicfu = "0.23.2" +kotlin = "2.0.0" +kotlinx-atomicfu = "0.24.0" turbine = "1.1.0" versionCatalogUpdatePlugin = "0.8.4" +compose = "1.6.10" +lifecycle = "2.8.0" +androidx-core = "1.13.1" +androidx-activity = "1.9.0" [libraries] android-gradle = { module = "com.android.tools.build:gradle", version.ref = "gradleAndroid" } +androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } +androidx-activity = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } detekt-compose = { module = "ru.kode:detekt-rules-compose", version.ref = "composeDetektPlugin" } detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detektFormattingPlugin" } detekt-gradle = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } @@ -37,6 +43,9 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } +lifecycle-runtime = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime", version.ref = "lifecycle" } +lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose-android", version.ref = "lifecycle" } [bundles] unittest = [ @@ -48,10 +57,15 @@ unittest = [ ] [plugins] +androidLibrary = { id = "com.android.library", version.ref = "gradleAndroid" } atomicfu = "kotlinx-atomicfu:0.23.2" dependencyAnalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysisPlugin" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } gradleDoctor = { id = "com.osacky.doctor", version.ref = "gradleDoctorPlugin" } -kotest = { id = "io.kotest.multiplatform", version.ref = "kotest-plugin" } +kotest = { id = "io.kotest.multiplatform", version.ref = "kotest" } version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "versionCatalogUpdatePlugin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +compose = { id = "org.jetbrains.compose", version.ref = "compose" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index d64cd49..e644113 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradlew.bat b/gradlew.bat index 93e3f59..25da30d 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -43,11 +43,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -57,11 +57,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail diff --git a/settings.gradle.kts b/settings.gradle.kts index 82b8358..5967789 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,13 +12,10 @@ pluginManagement { // TODO: https://github.com/Kotlin/kotlinx-atomicfu/issues/56 resolutionStrategy { eachPlugin { - val module = when (requested.id.id) { + when (requested.id.id) { "kotlinx-atomicfu" -> "org.jetbrains.kotlinx:atomicfu-gradle-plugin:${requested.version}" else -> null - } - if (module != null) { - useModule(module) - } + }?.let(::useModule) } } } @@ -33,73 +30,19 @@ buildscript { dependencyResolutionManagement { // kmm plugin adds "ivy" repo as part of the apply block - repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) + repositoriesMode.set(RepositoriesMode.PREFER_PROJECT) repositories { google() - ivyNative() - node() mavenCentral() } } -fun RepositoryHandler.node() { - exclusiveContent { - forRepository { - ivy("https://nodejs.org/dist/") { - name = "Node Distributions at $url" - patternLayout { artifact("v[revision]/[artifact](-v[revision]-[classifier]).[ext]") } - metadataSources { artifact() } - content { includeModule("org.nodejs", "node") } - } - } - filter { includeGroup("org.nodejs") } - } - - exclusiveContent { - forRepository { - ivy("https://github.com/yarnpkg/yarn/releases/download") { - name = "Yarn Distributions at $url" - patternLayout { artifact("v[revision]/[artifact](-v[revision]).[ext]") } - metadataSources { artifact() } - content { includeModule("com.yarnpkg", "yarn") } - } - } - filter { includeGroup("com.yarnpkg") } - } -} - -fun RepositoryHandler.ivyNative() { - ivy { url = uri("https://download.jetbrains.com") } - - exclusiveContent { - forRepository { - this@ivyNative.ivy("https://download.jetbrains.com/kotlin/native/builds") { - name = "Kotlin Native" - patternLayout { - listOf( - "macos-x86_64", - "macos-aarch64", - "osx-x86_64", - "osx-aarch64", - "linux-x86_64", - "windows-x86_64", - ).forEach { os -> - listOf("dev", "releases").forEach { stage -> - artifact("$stage/[revision]/$os/[artifact]-[revision].[ext]") - } - } - } - metadataSources { artifact() } - } - } - filter { includeModuleByRegex(".*", ".*kotlin-native-prebuilt.*") } - } -} - rootProject.name = "kmmutils" include(":common") include(":datetime") include(":coroutines") include(":inputforms") +include(":compose") +include(":system") diff --git a/stability_definitions.txt b/stability_definitions.txt new file mode 100644 index 0000000..e69de29 diff --git a/system/build.gradle.kts b/system/build.gradle.kts new file mode 100644 index 0000000..d5ddf92 --- /dev/null +++ b/system/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + kotlin("multiplatform") + id("com.android.library") + id("maven-publish") + signing +} + +kotlin { + configureMultiplatform( + this, + android = true, + jvm = false, + linux = false, + js = false, + tvOs = false, + iOs = false, + macOs = false, + watchOs = false, + windows = false, + wasmWasi = false, + wasmJs = false + ) + + sourceSets.androidMain.dependencies { + api(libs.androidx.core) + api(projects.common) + api(libs.androidx.activity) + } +} + +android { + namespace = "${Config.namespace}.system" + configureAndroidLibrary(this) +} + +publishMultiplatform() diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/AndroidExt.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/AndroidExt.kt new file mode 100644 index 0000000..b751015 --- /dev/null +++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/AndroidExt.kt @@ -0,0 +1,129 @@ +@file:Suppress("unused") + +package pro.respawn.kmmutils.system.android + +import android.app.ActivityOptions +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Build.VERSION_CODES +import android.text.format.DateFormat +import android.view.autofill.AutofillManager +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.core.app.TaskStackBuilder +import androidx.core.content.getSystemService +import androidx.core.net.toUri + +/** + * Get the current system [AutofillManager] if present. + */ +public val Context.autofillManager: AutofillManager? + get() = withApiLevel(VERSION_CODES.O, below = { null }) { getSystemService() } + +/** + * Restart the current activity in a new task. + */ +public fun Context.restartActivity() { + // Obtain the startup Intent of the application with the package name of the application + val intent: Intent? = packageManager.getLaunchIntentForPackage(packageName)?.apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + startActivity(intent) +} + +/** + * Returns whether the system is set to 24-hour time format right now. + */ +public val Context.isSystem24Hour: Boolean get() = DateFormat.is24HourFormat(this) + +/** + * Use this [Uri] as a deeplink intent, i.e. open this uri in a new task. + */ +public fun Uri.asDeeplinkIntent( + requestCode: Int, + context: Context +): PendingIntent = TaskStackBuilder.create(context).run { + addNextIntentWithParentStack(intent().allowBackgroundActivityStart()) + getPendingIntent( + requestCode, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + )!! +} + +/** + * Get the uri of this app's package. + */ +public val Context.packageUri: Uri get() = "package:$packageName".toUri() + +/** + * Execute [since] if the current sdk version is at least [versionCode]. Version code comes from [Build.VERSION_CODES]. + */ +@ChecksSdkIntAtLeast(parameter = 0, lambda = 1) +public inline fun withApiLevel(versionCode: Int, since: () -> Unit) { + if (Build.VERSION.SDK_INT >= versionCode) since() +} + +/** + * Execute [since] if the current sdk version is at least [versionCode], otherwise execute [below] + * + * Version code comes from [Build.VERSION_CODES]. + */ +@ChecksSdkIntAtLeast(parameter = 0, lambda = 2) +public inline fun withApiLevel(versionCode: Int, below: () -> R, since: () -> R): R = + if (Build.VERSION.SDK_INT >= versionCode) since() else below() + +/** + * Execute [ifGranted] if the [permission] has been granted, otherwise execute [ifDenied]. + * + * This function does not request the permission. + * + * [permission] parameter is defined in [android.Manifest.permission] + */ +public inline fun Context.withPermission( + permission: String, + ifDenied: Context.(String) -> T, + ifGranted: Context.(String) -> T +): T = if (checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED) + ifGranted(permission) else ifDenied(permission) + +/** + * Execute [ifGranted] if the [permission] has been granted, otherwise do nothing. + * + * This function does not request the permission. + * + * [permission] parameter is defined in [android.Manifest.permission] + */ +public inline fun Context.withPermission( + permission: String, + ifGranted: Context.(String) -> Unit, +) { + if (checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED) ifGranted(permission) +} + +/** + * Create a view intent from this uri. + */ +public fun Uri.intent(): Intent = Intent(Intent.ACTION_VIEW, this) + +/** + * Allows background activity start for this intent starting with SDK 34 (Upside down cake). + * On previous versions, does nothing. + */ +public fun Intent.allowBackgroundActivityStart(): Intent = apply { + withApiLevel(VERSION_CODES.UPSIDE_DOWN_CAKE) { + ActivityOptions + .makeBasic() + .setPendingIntentCreatorBackgroundActivityStartMode(ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED) + .toBundle() + .let(::putExtras) + } +} + +/** + * Get an uri to a google play store page of this app. + */ +public fun Context.getGooglePlayUri(): Uri = + "https://play.google.com/store/apps/details?id=${applicationInfo.packageName}".toUri() diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/ArgsExt.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/ArgsExt.kt new file mode 100644 index 0000000..769ab83 --- /dev/null +++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/ArgsExt.kt @@ -0,0 +1,86 @@ +package pro.respawn.kmmutils.system.android + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import java.io.Serializable +import kotlin.reflect.typeOf + +/** + * Creates an intent to launch a given class. The type must be of an Android system component, such as an Activity. + */ +public inline fun intentFor(context: Context): Intent = Intent(context, T::class.java) + +/** + * Obtain a serializable object of type [T] from this [Bundle] + * @see requireSerializable + */ +@Suppress("DEPRECATION") +public inline fun Bundle.serializable( + key: String +): T? = withApiLevel( + Build.VERSION_CODES.TIRAMISU, + below = { getSerializable(key) as? T? }, +) { getSerializable(key, T::class.java) } + +/** + * Obtain a serializable object of type [T] from this [Intent]'s extras + * @see requireSerializable + */ +public inline fun Intent.serializable(key: String): T? = extras?.serializable(key) + +/** + * Obtain a serializable object of type [T] from this [Bundle] + * @see serializable + */ +public inline fun Bundle.requireSerializable( + key: String +): T = requireNotNull(serializable(key)) { "Bundle contained no Serializable of type ${typeOf()}" } + +/** + * Obtain a serializable object of type [T] from this [Intent]'s extras + * @see serializable + */ +public inline fun Intent.requireSerializable( + key: String +): T = requireNotNull(serializable(key)) { "Bundle contained no Serializable of type ${typeOf()}" } + +/** + * Obtain a parcelable object of type [T] from this [Bundle]. + * + * @see requireParcelable + */ +@Suppress("DEPRECATION") +public inline fun Bundle.parcelable( + key: String +): T? = withApiLevel( + Build.VERSION_CODES.TIRAMISU, + below = { getParcelable(key) as? T? } +) { getParcelable(key, T::class.java) } + +/** + * Obtain a parcelable object of type [T] from this [Intent]'s extras + * + * @see requireParcelable + */ +public inline fun Intent.parcelable(key: String): T? = extras?.parcelable(key) + +/** + * Obtain a parcelable object of type [T] from this [Bundle]. + * + * @see parcelable + */ +public inline fun Bundle.requireParcelable(key: String): T = requireNotNull(parcelable(key)) { + "Bundle contained no Parcelable of type ${typeOf()}" +} + +/** + * Obtain a parcelable object of type [T] from this [Intent]'s extras. + * + * @see parcelable + */ +public inline fun Intent.requireParcelable(key: String): T = requireNotNull(parcelable(key)) { + "Bundle contained no Parcelable of type ${typeOf()}" +} diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/CompatExt.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/CompatExt.kt new file mode 100644 index 0000000..d8c5e55 --- /dev/null +++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/CompatExt.kt @@ -0,0 +1,115 @@ +package pro.respawn.kmmutils.system.android + +import android.Manifest +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.os.Build +import android.os.VibrationAttributes +import android.os.VibrationEffect +import android.os.Vibrator +import androidx.annotation.RequiresPermission + +/** + * Vibrates using this [Vibrator] for [lengthMs] milliseconds. + * + * * Requires a [Manifest.permission.VIBRATE] to function. + * * Works on versions of android down to 21. + * + * @param usage required which is defined in [VibrationAttributes]. + * @param amplitude is defined in [VibrationEffect]. When null, [VibrationEffect.DEFAULT_AMPLITUDE] will be used. + */ +@Suppress("DEPRECATION") +@RequiresPermission(Manifest.permission.VIBRATE) +public fun Vibrator.vibrateCompat( + lengthMs: Long, + usage: Int, + amplitude: Int? = null, +): Unit = when (Build.VERSION.SDK_INT) { + Build.VERSION_CODES.TIRAMISU -> vibrate( + VibrationEffect.createOneShot(lengthMs, amplitude ?: VibrationEffect.DEFAULT_AMPLITUDE), + VibrationAttributes.createForUsage(usage) + ) + Build.VERSION_CODES.Q -> vibrate(VibrationEffect.createOneShot(lengthMs, VibrationEffect.DEFAULT_AMPLITUDE)) + else -> vibrate(lengthMs) +} + +/** + * Vibrates using this [Vibrator] using provided [waveform]. + * + * In effect, the timings array represents the number of milliseconds before turning the vibrator on, + * followed by the number of milliseconds to keep the vibrator on, then the number of milliseconds turned off, + * and so on. Consequently, the first timing value will often be 0, so that the effect will start vibrating immediately. + * + * * Requires a [Manifest.permission.VIBRATE] to function. + * * Works on versions of android down to 21. + * + * @param usage required which is defined in [VibrationAttributes]. + * @param repeat the index into the [waveform], at which to start indefinite repetitions of the vibration, + * where -1 means no repeat. If you pass anything but -1 there, you **must** cancel the vibration manually by calling + * [Vibrator.cancel] + */ +@Suppress("DEPRECATION") +@RequiresPermission(Manifest.permission.VIBRATE) +public fun Vibrator.vibrateCompat( + waveform: LongArray, + usage: Int, + repeat: Int = -1, +): Unit = when (Build.VERSION.SDK_INT) { + Build.VERSION_CODES.TIRAMISU -> vibrate( + VibrationEffect.createWaveform(waveform, repeat), + VibrationAttributes.createForUsage(usage) + ) + Build.VERSION_CODES.Q -> vibrate(VibrationEffect.createWaveform(waveform, repeat)) + else -> vibrate(waveform, repeat) +} + +/** + * + * This function will query the [ApplicationInfo] of the [packageName] provided. + * + * Will work only if [Manifest.permission.QUERY_ALL_PACKAGES] permission is present + * + * @param flags are [PackageManager.ApplicationInfoFlags] and are listed as members of the [PackageManager] class. + */ +@RequiresPermission(Manifest.permission.QUERY_ALL_PACKAGES) +public fun PackageManager.getAppInfoCompat(packageName: String, flags: Int): ApplicationInfo = withApiLevel( + versionCode = Build.VERSION_CODES.TIRAMISU, + below = { getApplicationInfo(packageName, flags) }, + since = { getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(flags.toLong())) } +) + +/** + * This function will query the resolvable (exported) activities of the target [intent] using this [PackageManager]. + * This is useful when you want to start another app. + * + * Will work only if [Manifest.permission.QUERY_ALL_PACKAGES] permission is present. + * + * @param flags are [PackageManager.ResolveInfoFlags] and are listed as members of the [PackageManager] class. + * @return the [List] of [ResolveInfo]s for launchable activities. + */ +@RequiresPermission(Manifest.permission.QUERY_ALL_PACKAGES) +public fun PackageManager.queryIntentActivitiesCompat( + intent: Intent, + flags: Int = 0, +): List = withApiLevel( + versionCode = Build.VERSION_CODES.TIRAMISU, + below = { queryIntentActivities(intent, flags) }, + since = { queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(flags.toLong())) }, +) + +/** + * Will get a full list of userspace apps installed on the device + some of the system apps that can be resolved. + * + * @param flags are [PackageManager.PackageInfoFlags] listed in the [PackageManager] class. + */ +@RequiresPermission(Manifest.permission.QUERY_ALL_PACKAGES) +public fun PackageManager.getInstalledPackagesCompat( + flags: Int = 0, +): List = withApiLevel( + versionCode = Build.VERSION_CODES.TIRAMISU, + below = { getInstalledPackages(flags) }, + since = { getInstalledPackages(PackageManager.PackageInfoFlags.of(flags.toLong())) } +) diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/CoroutineReceiver.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/CoroutineReceiver.kt new file mode 100644 index 0000000..41bb3fa --- /dev/null +++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/CoroutineReceiver.kt @@ -0,0 +1,55 @@ +package pro.respawn.kmmutils.system.android + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration.Companion.seconds + +/** + * A [BroadcastReceiver] that calls [goAsync] and launches a coroutine to execute long-running tasks. + * The [receive] method is still executed on main thread, move to another thread as needed. + * + * According to Android limitations, you still have about **10 seconds** to finish the execution before + * the system forcibly kills the receiver. For longer tasks, use WorkManager or a Service + */ +public abstract class CoroutineReceiver : BroadcastReceiver() { + + protected val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + override fun onReceive(context: Context, intent: Intent?) { + if (intent == null) return + + val result = goAsync() + scope.launch { + try { + withTimeout(MaxDuration) { receive(context, intent) } + } catch (e: TimeoutCancellationException) { + throw IllegalStateException(Message, e) + } finally { + result.finish() + } + } + } + + /** + * Still executed on main thread. + * Maximum duration for execution is about 10 seconds, after which the system will kill the broadcast receiver. + */ + protected abstract suspend fun receive(context: Context, intent: Intent) + + private companion object { + + private val MaxDuration = 10.seconds + private val Message = """ + CoroutineReceiver has been suspended for more than ${MaxDuration.inWholeSeconds} + You cannot execute CoroutineReceiver for a long time because the system will kill it. + If you want to execute long-running tasks, launch a worker from your receiver. + """.trimIndent() + } +} diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/NetworkExt.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/NetworkExt.kt new file mode 100644 index 0000000..d174c91 --- /dev/null +++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/NetworkExt.kt @@ -0,0 +1,102 @@ +@file:Suppress("unused") + +package pro.respawn.kmmutils.system.android + +import android.net.Uri +import androidx.core.net.MailTo + +/** + * If this uri is an http uri, will convert it to an https uri, otherwise do nothing + */ +public val Uri.asHttps: Uri get() = if (scheme == "http") buildUpon().scheme("https").build() else this + +/** + * Whether this uri is an http uri + */ +public val Uri.isHttp: Boolean get() = scheme?.startsWith("http", true) == true + +/** + * Get the [LinkType] of the current URI + */ +public val Uri.linkType: LinkType + get() = when (this.scheme) { + null -> LinkType.Unknown + "http", "https" -> LinkType.Web + "mailto" -> LinkType.Mail + "tel" -> LinkType.Tel + "blob" -> LinkType.Blob + "content" -> LinkType.ContentProvider + "dns" -> LinkType.Dns + "drm" -> LinkType.Drm + "fax" -> LinkType.Fax + "geo" -> LinkType.Geo + "magnet" -> LinkType.Magnet + "maps" -> LinkType.Map + "market" -> LinkType.GooglePlay + "messgage" -> LinkType.AppleMail + "mms", "sms" -> LinkType.TextMessage + "query" -> LinkType.FilesystemQuery + "resource" -> LinkType.Resource + "skype", "callto" -> LinkType.Skype + "ssh" -> LinkType.Ssh + "webcal" -> LinkType.Calendar + "file" -> LinkType.File + else -> LinkType.Other + } + +/** + * Type of the [Uri]'s scheme + */ +public enum class LinkType { + Web, + Mail, + Tel, + Other, + Unknown, + Blob, + ContentProvider, + Dns, + Drm, + File, + Calendar, + Ssh, + Skype, + Resource, + FilesystemQuery, + TextMessage, + AppleMail, + GooglePlay, + Map, + Magnet, + Geo, + Fax +} + +/** + * An object representing an email about to be senta with [sendEmail]. + * + * @param recipients the list of recipient emails, or null to leave blank + * @param subject an optional subject of the email + * @param body the body of the email + */ +public data class Email( + val recipients: List? = null, + val subject: String? = null, + val body: String? = null, +) { + + public companion object { + + /** + * Create an [Email] from the [Uri] + */ + public operator fun invoke(uri: Uri): Email { + val mail = MailTo.parse(uri) + return Email( + recipients = mail.to?.split(", "), + subject = mail.subject, + body = mail.body + ) + } + } +} diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/PickRingtoneContract.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/PickRingtoneContract.kt new file mode 100644 index 0000000..6a01f94 --- /dev/null +++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/PickRingtoneContract.kt @@ -0,0 +1,69 @@ +package pro.respawn.kmmutils.system.android + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.media.RingtoneManager +import android.net.Uri +import android.os.Build +import androidx.activity.result.contract.ActivityResultContract + +/** + * Contract for picking ringtones. + * Returns one of: + * * An uri of the picked ringtone, + * * a Uri that equals System#DEFAULT_RINGTONE_URI, System#DEFAULT_NOTIFICATION_URI, or System#DEFAULT_ALARM_ALERT_URI + * if the default was chosen, + * * null if the "Silent" item was picked or if there was an error. + */ +public class PickRingtoneContract : ActivityResultContract() { + + override fun createIntent(context: Context, input: RingtoneOptions): Intent = + Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply { + putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, input.showDefault) + putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, input.allowSilent) + putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, input.ringtoneType) + input.title?.let { putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, it) } + input.defaultUri?.let { putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, it) } + input.existingUri?.let { putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, it) } + } + + override fun getSynchronousResult(context: Context, input: RingtoneOptions): SynchronousResult? = null + + @Suppress("DEPRECATION") + override fun parseResult(resultCode: Int, intent: Intent?): Uri? = + intent?.takeIf { resultCode == Activity.RESULT_OK }?.run { + data ?: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java) + } else { + intent.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI) + } + } + + /** + * Ringtone options + * @param ringtoneType one of TYPE_RINGTONE, TYPE_NOTIFICATION, TYPE_ALARM, or TYPE_ALL (bitwise or'd together) + * @param title The title of the ringtone picker. Default value is suitable for most cases + * @param defaultUri The uri to the sound to play when the user previews "default" sound. + * This can be one of System#DEFAULT_RINGTONE_URI, + * System#DEFAULT_NOTIFICATION_URI, or System#DEFAULT_ALARM_ALERT_URI + * to have the "Default" point to the current sound for the given default sound type. + * If you are showing a ringtone picker for some other type of sound, you are free to provide any Uri here. + * @param existingUri Given to the ringtone picker as a Uri. The Uri of the current ringtone, + * which will be used to show a checkmark next to the item for this Uri. + * If showing an item for "Default" (@see EXTRA_RINGTONE_SHOW_DEFAULT), + * this can also be one of System#DEFAULT_RINGTONE_URI, + * System#DEFAULT_NOTIFICATION_URI, or System#DEFAULT_ALARM_ALERT_URI to have the "Default" item checked. + * @param allowSilent Whether to show the "Silent" item in the list. + * @param showDefault Whether to show the "Default" item in the list. + * @see RingtoneManager + */ + public data class RingtoneOptions( + val ringtoneType: Int, + val showDefault: Boolean = true, + val allowSilent: Boolean = false, + val title: String? = null, + val defaultUri: Uri? = null, + val existingUri: Uri? = null, + ) +} diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/ResourcesExt.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/ResourcesExt.kt new file mode 100644 index 0000000..4914d6a --- /dev/null +++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/ResourcesExt.kt @@ -0,0 +1,82 @@ +@file:Suppress("unused") + +package pro.respawn.kmmutils.system.android + +import android.content.ContentResolver +import android.content.Context +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.net.Uri +import androidx.core.content.ContextCompat +import androidx.core.os.ConfigurationCompat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.Locale + +/** + * Get dimension dp value from your xml. + * When you use [Resources.getDimension] you get the amount of pixels for that dimen. + * This function returns a proper dp value just like what you wrote in your dimen.xml + */ +public fun Resources.getDimenInDP(id: Int): Int = (getDimension(id) / displayMetrics.density).toInt() + +/** + * Rescales the bitmap + * @param maxSize The maximum size of the longest side of the image (can be either height or width) in pixels + * @return scaled bitmap + */ +public suspend fun Bitmap.scale(maxSize: Int): Bitmap = withContext(Dispatchers.Default) { + val ratio = width.toFloat() / height.toFloat() + var newWidth = maxSize + var newHeight = maxSize + if (ratio > 1) { + newHeight = (maxSize / ratio).toInt() + } else { + newWidth = (maxSize * ratio).toInt() + } + Bitmap.createScaledBitmap(this@scale, newWidth, newHeight, true) +} + +/** + * Uses the value of this int as a **resource id** to parse an [android.graphics.Color] object + */ +public fun Int.asColor(context: Context): Int = ContextCompat.getColor(context, this) + +/** + * Uses this int as a **resource id** to get a drawable + */ +public fun Int.asDrawable(context: Context): Drawable? = ContextCompat.getDrawable(context, this) + +/** + * Returns the currently used locale on the device. + * Returns the first locale the user has listed in the system settings. + */ +public val Resources.currentLocale: Locale + get() = ConfigurationCompat.getLocales(configuration).get(0)!! + +/** + * Returns an uri to the given [resourceId]. + * **This uri is NOT safe to store outside of app lifecycle!** + * + * The uri may change on app update or relaunch. + */ +public fun Context.getResourceUri(resourceId: Int): Uri = Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .authority(resources.getResourcePackageName(resourceId)) + .appendPath(resources.getResourceTypeName(resourceId)) + .appendPath(resources.getResourceEntryName(resourceId)) + .build() + +/** + * Returns an URI to [this] raw resource id. + * + * **This uri is NOT safe to store outside of app lifecycle!** + * + * The uri may change on app update or relaunch. + */ +public fun Int.raw(context: Context): Uri = Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .authority(context.applicationContext.packageName) + .appendPath(toString()) + .build() diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/Spans.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/Spans.kt new file mode 100644 index 0000000..72d23ef --- /dev/null +++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/Spans.kt @@ -0,0 +1,124 @@ +package pro.respawn.kmmutils.system.android + +import android.graphics.Typeface +import android.text.Spannable +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.style.BackgroundColorSpan +import android.text.style.ClickableSpan +import android.text.style.ForegroundColorSpan +import android.text.style.RelativeSizeSpan +import android.text.style.StrikethroughSpan +import android.text.style.StyleSpan +import android.text.style.SubscriptSpan +import android.text.style.SuperscriptSpan +import android.text.style.UnderlineSpan +import android.view.View +import androidx.annotation.ColorInt + +/** + * Create a span with a [clickablePart] of the text, and invokes the [onClickListener] on click. + */ +public fun SpannableString.withClickableSpan( + clickablePart: String, + onClickListener: () -> Unit +): SpannableString { + val clickableSpan = object : ClickableSpan() { + override fun onClick(widget: View) = onClickListener.invoke() + } + val clickablePartStart = indexOf(clickablePart) + setSpan( + clickableSpan, + clickablePartStart, + clickablePartStart + clickablePart.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + return this +} + +/** + * [span] is a ..Span object like a [ForegroundColorSpan] or a [SuperscriptSpan] + * Spans this whole string + */ +public fun CharSequence.span(span: Any): SpannableString = SpannableString(this).setSpan(span) + +/** + * Create a [SpannableStringBuilder] from this + */ +public fun CharSequence.buildSpan(): SpannableStringBuilder = SpannableStringBuilder(this) + +/** + * Create a spannable string + */ +public val CharSequence.spannable: SpannableString get() = SpannableString(this) + +/** + * [span] is a ..Span object like a [ForegroundColorSpan] or a [SuperscriptSpan] + * Spans this whole string + */ +public fun SpannableString.setSpan(span: Any?): SpannableString = apply { + setSpan(span, 0, length, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE) +} + +/** + * Changes the color of the text to [color]. + * + * @returns a [SpannableString] that can be applied to XML views + */ +public fun CharSequence.foregroundColor(@ColorInt color: Int): SpannableString = span(ForegroundColorSpan(color)) + +/** + * Changes the background color of the text to [color]. + * + * @returns a [SpannableString] that can be applied to XML views + */ +public fun CharSequence.backgroundColor(@ColorInt color: Int): SpannableString = span(BackgroundColorSpan(color)) + +/** + * Sets the size of this string to relative size [size], which is a multiplier fraction larger than 0 + * + * @returns a [SpannableString] that can be applied to XML views + */ +public fun CharSequence.relativeSize(size: Float): SpannableString = span(RelativeSizeSpan(size)) + +/** + * Sets this string to be drawn as a superscript (smaller font aligned to the top of the text, such as numeric power) + * + * @returns a [SpannableString] that can be applied to XML views + */ +public fun CharSequence.superscript(): SpannableString = span(SuperscriptSpan()) + +/** + * Sets this string to be drawn as a superscript (smaller font aligned to the bottom of the text) + * + * @returns a [SpannableString] that can be applied to XML views + */ +public fun CharSequence.subscript(): SpannableString = span(SubscriptSpan()) + +/** + * Strikes this string through + * + * @returns a [SpannableString] that can be applied to XML views + */ +public fun CharSequence.strike(): SpannableString = span(StrikethroughSpan()) + +/** + * Applies bold style to this string + * + * @returns a [SpannableString] that can be applied to XML views + */ +public fun CharSequence.bold(): SpannableString = span(StyleSpan(Typeface.BOLD)) + +/** + * Applies italic style to this string + * + * @returns a [SpannableString] that can be applied to XML views + */ +public fun CharSequence.italic(): SpannableString = span(StyleSpan(Typeface.ITALIC)) + +/** + * Adds an underline to this string + * + * @returns a [SpannableString] that can be applied to XML views + */ +public fun CharSequence.underline(): SpannableString = span(UnderlineSpan()) diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/StringExt.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/StringExt.kt new file mode 100644 index 0000000..1c56b23 --- /dev/null +++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/StringExt.kt @@ -0,0 +1,51 @@ +@file:Suppress("unused") + +package pro.respawn.kmmutils.system.android + +import android.graphics.Color +import android.text.Spanned +import androidx.core.text.HtmlCompat +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.util.Locale + +/** + * If this is a valid hex color string representation, returns its R, G and B components + * @throws IllegalArgumentException if the color string is invalid + * + */ +public fun String.hexToRGB(): Triple { + var name = this + if (!name.startsWith("#")) { + name = "#$this" + } + val color = Color.parseColor(name) + val red = Color.red(color) + val green = Color.green(color) + val blue = Color.blue(color) + return Triple(red, green, blue) +} + +/** + * If this is a color int, turns it into a hex string. + */ +public fun Int.colorToHexString(): String = String.format(Locale.ROOT, "#%06X", -0x1 and this).replace("#FF", "#") + +/** + * Create an android [Spanned] from this string by parsing it as an html text + */ +public val String.asHTML: Spanned + get() = HtmlCompat.fromHtml(this, 0) + +/** + * @param algorithm โ€“ the name of the algorithm requested. + * See the [MessageDigest] for information about standard algorithm names and supported API levels + */ +public fun ByteArray.hash(algorithm: String): ByteArray? = try { + MessageDigest.getInstance(algorithm).run { + update(this@hash) + digest() + } +} catch (expected: NoSuchAlgorithmException) { + null +} diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/SystemLaunchers.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/SystemLaunchers.kt new file mode 100644 index 0000000..1ec78c1 --- /dev/null +++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/SystemLaunchers.kt @@ -0,0 +1,216 @@ +package pro.respawn.kmmutils.system.android + +import android.Manifest +import android.annotation.SuppressLint +import android.app.DownloadManager +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build.VERSION_CODES +import android.os.Environment +import android.provider.Settings +import android.webkit.CookieManager +import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.RequiresApi +import androidx.core.app.ActivityOptionsCompat +import androidx.core.content.ContextCompat + +/** + * Start activity safely and handle errors where an activity is not found. + * + * Prefer to **always** use this method instead of the default one, as any activity you may want to launch + * **may not be found** and the original function will **throw**. + * + * Even system activities like file pickers, galleries and settings may be missing. + * Even your app's activities may be unavailable. + */ +public inline fun Context.startActivityCatching(intent: Intent, onNotFound: (ActivityNotFoundException) -> Unit) { + try { + startActivity(intent) + } catch (expected: ActivityNotFoundException) { + onNotFound(expected) + } +} + +/** + * Start activity safely and handle errors where an activity is not found. + * + * Prefer to **always** use this method instead of the default one, as any activity you may want to launch + * **may not be found** and the original function will **throw**. + * + * Even system activities like file pickers, galleries and settings may be missing. + * Even your app's activities may be unavailable. + */ +public inline fun ActivityResultLauncher.launchCatching( + input: I, + options: ActivityOptionsCompat? = null, + onNotFound: (ActivityNotFoundException) -> Unit, +) { + try { + launch(input, options) + } catch (e: ActivityNotFoundException) { + onNotFound(e) + } +} + +/** + * @param numberUri uri of the form tel:+1234567890, containing countryCode + */ +public inline fun Context.dialNumber(numberUri: Uri, onNotFound: (e: ActivityNotFoundException) -> Unit) { + val intent = Intent(Intent.ACTION_DIAL, numberUri) + startActivityCatching(intent, onNotFound) +} + +/** + * Saves file using [DownloadManager] to users /sdcard/[directory] + * + * @param notificationMode an int flag specified in [DownloadManager.Request] outlining the notification visibility. + * If enabled, the download manager posts notifications about downloads through the system NotificationManager. + * By default, a notification is shown only when the download is in progress. + * It can take the following values: VISIBILITY_HIDDEN, VISIBILITY_VISIBLE, VISIBILITY_VISIBLE_NOTIFY_COMPLETED. + * If set to `VISIBILITY_HIDDEN`, this requires the permission [Manifest.permission.DOWNLOAD_WITHOUT_NOTIFICATION] + * + * @param directory - one of the [Environment] `DIRECTORY_*` constants. Not all directories are available without a + * permission to write to external storage. Available dirs are: + * [Environment.DIRECTORY_DOWNLOADS], [Environment.DIRECTORY_PICTURES], [Environment.DIRECTORY_MOVIES], among others + * + * @param onFailure is called if there was an exception. Possible exceptions are: + * - ActivityNotFoundException + * - SecurityException - when permission to write to storage was not granted + * - IllegalStateException - when provided parameters are invalid (i.e. download directory can't be created) + * */ +public inline fun Context.downloadFile( + url: Uri, + fileName: String, + userAgent: String? = null, + title: String = fileName, + description: String? = null, + mimeType: String? = null, + notificationMode: Int = DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED, + directory: String = Environment.DIRECTORY_DOWNLOADS, + onFailure: (e: Exception) -> Unit, +) { + val request = DownloadManager.Request(url).apply { + val cookies = CookieManager.getInstance().getCookie(url.toString()) + addRequestHeader("cookie", cookies) + addRequestHeader("User-Agent", userAgent) + if (description != null) setDescription(description) + setTitle(title) + setMimeType(mimeType) + setNotificationVisibility(notificationMode) + setDestinationInExternalPublicDir(directory, fileName) + } + try { + ContextCompat.getSystemService(this, DownloadManager::class.java) + ?.enqueue(request) + ?: throw ActivityNotFoundException("DownloadManager not found") + } catch (expected: Exception) { + onFailure(expected) + return + } +} + +/** + * Open the system browser for the specified [url]. + * + * **[onAppNotFound] **must** be handled as some phones do not have browsers installed. Display an error message with a + * prompt to install a browser in this case. + */ +public inline fun Context.openBrowser(url: Uri, onAppNotFound: (e: ActivityNotFoundException) -> Unit) { + val intent = Intent(Intent.ACTION_VIEW, url).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + } + startActivityCatching(intent, onAppNotFound) +} + +/** + * Open the system share sheet for sharing the specified [text]. + * + * **[onAppNotFound] **must** be handled as some phones do not have any app to share with. + * Display an error message with a prompt to install an app in this case. + */ +public inline fun Context.shareAsText(text: String, onAppNotFound: (e: ActivityNotFoundException) -> Unit) { + val intent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_TEXT, text) + type = "text/plain" + } + val shareIntent = Intent.createChooser(intent, null) + startActivityCatching(shareIntent, onAppNotFound) +} + +/** + * Open an email app to send the specified [mail]. + * + * **[onAppNotFound] **must** be handled as some phones do not have any email apps. + * Display an error message with a prompt to install an app in this case. + */ +public inline fun Context.sendEmail(mail: Email, onAppNotFound: (e: ActivityNotFoundException) -> Unit) { + // Use SENDTO to avoid showing pickers and letting non-email apps interfere + val sendIntent: Intent = Intent(Intent.ACTION_SENDTO).apply { + data = Uri.parse("mailto:") + // EXTRA_EMAIL should be array + putExtra(Intent.EXTRA_EMAIL, mail.recipients?.toTypedArray()) + putExtra(Intent.EXTRA_SUBJECT, mail.subject) + putExtra(Intent.EXTRA_TEXT, mail.body) + } + startActivityCatching(sendIntent, onAppNotFound) +} + +/** + * Open system notification settings for this app. + */ +@RequiresApi(VERSION_CODES.O) +public fun Context.openNotificationSettings(onError: (ActivityNotFoundException) -> Unit): Unit = startActivityCatching( + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + }, + onNotFound = onError, +) + +/** + * Open system notification settings for the current app. + */ +public fun Context.openAppDetails(onError: (Exception) -> Unit): Unit = startActivityCatching( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, packageUri), + onNotFound = onError, +) + +/** + * Open system overlay (SYSTEM_ALERT_WINDOW) settings. + * + * Due to platform limitations, this will display a simple list of all device apps and the user must scroll and find + * your app there. Instruct the user as appropriate. + */ +public inline fun Context.openSystemOverlaysSettings(onError: (Exception) -> Unit) { + startActivityCatching( + Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, packageUri), + onNotFound = onError, + ) +} + +/** + * Display a prompt to ignore Doze mode optimizations and unrestrict [AlarmManager] APIs for this app. + */ +@SuppressLint("BatteryLife") +public inline fun Context.requestIgnoreBatteryOptimization(onError: (Exception) -> Unit): Unit = startActivityCatching( + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, packageUri), + onNotFound = onError, +) + +/** + * Request a permission too schedule exact alarms from the user. + * This will bring the user to a page that prompts them to flip a switch for your app. + */ +@RequiresApi(VERSION_CODES.S) +public inline fun Context.requestExactAlarmPermission( + onError: (ActivityNotFoundException) -> Unit +): Unit = startActivityCatching(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM), onError) + +/** + * Open the app's play store page. + * + * **[onNotFound] must be handled because some phones do not have neither play store nor a browser app installed** + */ +public inline fun Context.openAppPlayStorePage(onNotFound: (e: Exception) -> Unit): Unit = + openBrowser(getGooglePlayUri(), onNotFound) diff --git a/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/WebClient.kt b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/WebClient.kt new file mode 100644 index 0000000..32ec58f --- /dev/null +++ b/system/src/androidMain/kotlin/pro/respawn/kmmutils/system/android/WebClient.kt @@ -0,0 +1,231 @@ +@file:Suppress("unused") + +package pro.respawn.kmmutils.system.android + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.net.Uri +import android.os.Bundle +import android.webkit.CookieManager +import android.webkit.DownloadListener +import android.webkit.URLUtil +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebStorage +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.core.net.toUri + +/** + * A listener for events that happen in [WebClient] + */ +public interface WebClientListener { + + /** + * Is triggered when whole page could not be loaded (not triggered for css, js, ads & other errors) + */ + public fun onError(url: Uri?) + + /** + * Triggered **once** when the page has finished loading + */ + public fun onSuccess(url: Uri?) + + /** + * Triggered when you call [WebClient.load] + */ + public fun onStartedLoading(url: Uri?) + + /** + * Triggered in the following situations: + * 1. When attempting to load any url that is not in [WebClient.allowedHosts] + * 2. When opening a link that is not browser-openable (i.e. mailto:) + * 3. When attempting to open an unknown link type + */ + public fun onForeignUrlEncountered(url: Uri, linkType: LinkType) + + /** + * Triggered when opening a link pointing to a file, i.e. https://.pdf etc. + */ + public fun onRequestedFileDownload(url: Uri, fileName: String, mimetype: String?) +} + +/** + * A web client that solves many problems of [WebViewClient] and provides a nice API to use. + * **This client automatically sets JS as enabled**. You can override this behavior by calling + * [WebSettings.setJavaScriptEnabled]. Only domains in [allowedHosts] will be opened in the webview, others will + * trigger [WebClientListener.onForeignUrlEncountered]. Don't forget to [attach] the client in onViewCreated and + * [detach] it in onDestroyView. + */ +@SuppressLint("SetJavaScriptEnabled") +public open class WebClient( + private val allowedHosts: List, +) : WebViewClient(), DownloadListener { + + private var webView: WebView? = null + private var listener: WebClientListener? = null + + /** + * Current webview URL, or null if nothing is loaded + */ + public val url: String? get() = webView?.url + + /** + * Whether the web history is not empty and the user can go back + */ + public open val canGoBack: Boolean + get() = webView?.canGoBack() ?: false + + /** + * Refresh current page. + */ + public open fun reload() { + webView?.reload() + } + + /** + * Load an [uri] + */ + public open fun load(uri: Uri) { + webView?.loadUrl(uri.toString()) + } + + /** + * Call this in [androidx.appcompat.app.AppCompatActivity.onSaveInstanceState] state or + * [androidx.fragment.app.Fragment.onViewStateRestored], **if you do not save state, webView will not save it for + * you!** + */ + public open fun restoreState(inState: Bundle) { + webView?.restoreState(inState) + } + + /** + * Call this in [androidx.fragment.app.Fragment.onSaveInstanceState] to **save webview state**. If you do not + * call this, **webview won't save it for you!** + */ + public open fun saveState(outState: Bundle) { + webView?.saveState(outState) + // TODO: Restore and save client-specific fields + } + + /** + * Call this in [androidx.fragment.app.Fragment.onViewCreated] + */ + public open fun attach( + webView: WebView, + listener: WebClientListener? = null, + userAgent: String? = null, + javaScriptEnabled: Boolean = true, + ): WebClient { + this.webView = webView.apply { + settings.apply { + setJavaScriptEnabled(javaScriptEnabled) + loadsImagesAutomatically = true + useWideViewPort = true + userAgent?.let { userAgentString = it } + loadWithOverviewMode = true + javaScriptCanOpenWindowsAutomatically = false + setDownloadListener(this@WebClient) + } + webChromeClient = WebChromeClient() + webViewClient = this@WebClient + } + this.listener = listener + return this + } + + /** + * Call this in [androidx.fragment.app.Fragment.onDestroyView]. If you do not call this, you will get crashes in + * runtime and a memory leak! + */ + public open fun detach() { + webView = null + listener = null + } + + /** + * Go back through the browser history if possible + */ + public open fun goBack() { + if (canGoBack) webView?.goBack() + } + + /** + * Clear the navigation history + */ + public open fun clearHistory() { + webView?.clearHistory() + } + + /** + * Clear all data associated with web views on device. + * Clears cookies and localstorage as well if [forAllWebViews] is true + */ + public open fun clearAllData(forAllWebViews: Boolean = false) { + webView?.clearHistory() + webView?.clearCache(true) + webView?.clearFormData() + if (forAllWebViews) { + WebStorage.getInstance().deleteAllData() + CookieManager.getInstance().removeAllCookies(null) + CookieManager.getInstance().flush() + } + } + + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest): Boolean { + val uri = request.url ?: return false + return when { + uri.host in allowedHosts && uri.linkType == LinkType.Web -> false + else -> { + listener?.onForeignUrlEncountered(uri, uri.linkType) + true + } + } + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + // workaround bug when onPageFinished is triggered 3 times, last one is for 100% + if (view?.progress == 100) { + listener?.onSuccess(url?.toUri()) + } + } + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url?.toUri()?.asHttps.toString(), favicon) + listener?.onStartedLoading(url?.toUri()) + } + + // OnReceivedError indicates no connection + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError? + ) { + super.onReceivedError(view, request, error) + // only handle the main frame because we get a bunch of errors from images, ads etc. + if (request?.isForMainFrame == true) { + if (request.url.scheme == "http") { + // Try to redirect to https links if policy does not allow http. + load(request.url.asHttps) + } else { + listener?.onError(request.url) + } + } + } + + // when we encounter a link to a file, just open it in the browser, let the system handle it + override fun onDownloadStart( + url: String?, + userAgent: String?, + contentDisposition: String?, + mimetype: String?, + contentLength: Long + ) { + val fileName: String? = URLUtil.guessFileName(url, contentDisposition, mimetype) + if (url?.toUri() != null && fileName != null) { + listener?.onRequestedFileDownload(url.toUri(), fileName, mimetype) + } + } +}