diff --git a/jindong-compose/api/jindong-compose.api b/jindong-compose/api/jindong-compose.api index 59eba6c..3df79c9 100644 --- a/jindong-compose/api/jindong-compose.api +++ b/jindong-compose/api/jindong-compose.api @@ -1,3 +1,31 @@ +public final class io/github/compose/jindong/HapticCapabilities { + public static final field $stable I + public fun (ZZLio/github/compose/jindong/HapticPlatform;)V + public final fun component1 ()Z + public final fun component2 ()Z + public final fun component3 ()Lio/github/compose/jindong/HapticPlatform; + public final fun copy (ZZLio/github/compose/jindong/HapticPlatform;)Lio/github/compose/jindong/HapticCapabilities; + public static synthetic fun copy$default (Lio/github/compose/jindong/HapticCapabilities;ZZLio/github/compose/jindong/HapticPlatform;ILjava/lang/Object;)Lio/github/compose/jindong/HapticCapabilities; + public fun equals (Ljava/lang/Object;)Z + public final fun getHasAmplitudeControl ()Z + public final fun getPlatform ()Lio/github/compose/jindong/HapticPlatform; + public fun hashCode ()I + public final fun isSupported ()Z + public fun toString ()Ljava/lang/String; +} + +public final class io/github/compose/jindong/HapticCapabilitiesKt { + public static final fun rememberHapticCapabilities (Landroidx/compose/runtime/Composer;I)Lio/github/compose/jindong/HapticCapabilities; +} + +public final class io/github/compose/jindong/HapticPlatform : java/lang/Enum { + public static final field Android Lio/github/compose/jindong/HapticPlatform; + public static final field Ios Lio/github/compose/jindong/HapticPlatform; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lio/github/compose/jindong/HapticPlatform; + public static fun values ()[Lio/github/compose/jindong/HapticPlatform; +} + public final class io/github/compose/jindong/JindongKt { public static final fun Jindong ([Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V } diff --git a/jindong-compose/api/jindong-compose.klib.api b/jindong-compose/api/jindong-compose.klib.api index a4ea0a2..4acb1cf 100644 --- a/jindong-compose/api/jindong-compose.klib.api +++ b/jindong-compose/api/jindong-compose.klib.api @@ -10,8 +10,40 @@ open annotation class io.github.compose.jindong/JindongScopeMarker : kotlin/Anno constructor () // io.github.compose.jindong/JindongScopeMarker.|(){}[0] } +final enum class io.github.compose.jindong/HapticPlatform : kotlin/Enum { // io.github.compose.jindong/HapticPlatform|null[0] + enum entry Android // io.github.compose.jindong/HapticPlatform.Android|null[0] + enum entry Ios // io.github.compose.jindong/HapticPlatform.Ios|null[0] + + final val entries // io.github.compose.jindong/HapticPlatform.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // io.github.compose.jindong/HapticPlatform.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): io.github.compose.jindong/HapticPlatform // io.github.compose.jindong/HapticPlatform.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // io.github.compose.jindong/HapticPlatform.values|values#static(){}[0] +} + abstract interface io.github.compose.jindong/JindongScope // io.github.compose.jindong/JindongScope|null[0] +final class io.github.compose.jindong/HapticCapabilities { // io.github.compose.jindong/HapticCapabilities|null[0] + constructor (kotlin/Boolean, kotlin/Boolean, io.github.compose.jindong/HapticPlatform) // io.github.compose.jindong/HapticCapabilities.|(kotlin.Boolean;kotlin.Boolean;io.github.compose.jindong.HapticPlatform){}[0] + + final val hasAmplitudeControl // io.github.compose.jindong/HapticCapabilities.hasAmplitudeControl|{}hasAmplitudeControl[0] + final fun (): kotlin/Boolean // io.github.compose.jindong/HapticCapabilities.hasAmplitudeControl.|(){}[0] + final val isSupported // io.github.compose.jindong/HapticCapabilities.isSupported|{}isSupported[0] + final fun (): kotlin/Boolean // io.github.compose.jindong/HapticCapabilities.isSupported.|(){}[0] + final val platform // io.github.compose.jindong/HapticCapabilities.platform|{}platform[0] + final fun (): io.github.compose.jindong/HapticPlatform // io.github.compose.jindong/HapticCapabilities.platform.|(){}[0] + + final fun component1(): kotlin/Boolean // io.github.compose.jindong/HapticCapabilities.component1|component1(){}[0] + final fun component2(): kotlin/Boolean // io.github.compose.jindong/HapticCapabilities.component2|component2(){}[0] + final fun component3(): io.github.compose.jindong/HapticPlatform // io.github.compose.jindong/HapticCapabilities.component3|component3(){}[0] + final fun copy(kotlin/Boolean = ..., kotlin/Boolean = ..., io.github.compose.jindong/HapticPlatform = ...): io.github.compose.jindong/HapticCapabilities // io.github.compose.jindong/HapticCapabilities.copy|copy(kotlin.Boolean;kotlin.Boolean;io.github.compose.jindong.HapticPlatform){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // io.github.compose.jindong/HapticCapabilities.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // io.github.compose.jindong/HapticCapabilities.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // io.github.compose.jindong/HapticCapabilities.toString|toString(){}[0] +} + +final val io.github.compose.jindong/io_github_compose_jindong_HapticCapabilities$stableprop // io.github.compose.jindong/io_github_compose_jindong_HapticCapabilities$stableprop|#static{}io_github_compose_jindong_HapticCapabilities$stableprop[0] + final fun (io.github.compose.jindong/JindongScope).io.github.compose.jindong.dsl/Delay(kotlin.time/Duration, androidx.compose.runtime/Composer?, kotlin/Int) // io.github.compose.jindong.dsl/Delay|Delay@io.github.compose.jindong.JindongScope(kotlin.time.Duration;androidx.compose.runtime.Composer?;kotlin.Int){}[0] final fun (io.github.compose.jindong/JindongScope).io.github.compose.jindong.dsl/Haptic(kotlin.time/Duration, io.github.compose.jindong.core.model/HapticIntensity?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // io.github.compose.jindong.dsl/Haptic|Haptic@io.github.compose.jindong.JindongScope(kotlin.time.Duration;io.github.compose.jindong.core.model.HapticIntensity?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] final fun (io.github.compose.jindong/JindongScope).io.github.compose.jindong.dsl/Repeat(kotlin/Int, kotlin/Function3, androidx.compose.runtime/Composer?, kotlin/Int) // io.github.compose.jindong.dsl/Repeat|Repeat@io.github.compose.jindong.JindongScope(kotlin.Int;kotlin.Function3;androidx.compose.runtime.Composer?;kotlin.Int){}[0] @@ -19,3 +51,5 @@ final fun (io.github.compose.jindong/JindongScope).io.github.compose.jindong.dsl final fun (io.github.compose.jindong/JindongScope).io.github.compose.jindong.dsl/Sequence(kotlin/Function3, androidx.compose.runtime/Composer?, kotlin/Int) // io.github.compose.jindong.dsl/Sequence|Sequence@io.github.compose.jindong.JindongScope(kotlin.Function3;androidx.compose.runtime.Composer?;kotlin.Int){}[0] final fun io.github.compose.jindong/Jindong(kotlin/Array..., kotlin/Function3, androidx.compose.runtime/Composer?, kotlin/Int) // io.github.compose.jindong/Jindong|Jindong(kotlin.Array...;kotlin.Function3;androidx.compose.runtime.Composer?;kotlin.Int){}[0] final fun io.github.compose.jindong/JindongProvider(kotlin/Function2, androidx.compose.runtime/Composer?, kotlin/Int) // io.github.compose.jindong/JindongProvider|JindongProvider(kotlin.Function2;androidx.compose.runtime.Composer?;kotlin.Int){}[0] +final fun io.github.compose.jindong/io_github_compose_jindong_HapticCapabilities$stableprop_getter(): kotlin/Int // io.github.compose.jindong/io_github_compose_jindong_HapticCapabilities$stableprop_getter|io_github_compose_jindong_HapticCapabilities$stableprop_getter(){}[0] +final fun io.github.compose.jindong/rememberHapticCapabilities(androidx.compose.runtime/Composer?, kotlin/Int): io.github.compose.jindong/HapticCapabilities // io.github.compose.jindong/rememberHapticCapabilities|rememberHapticCapabilities(androidx.compose.runtime.Composer?;kotlin.Int){}[0] diff --git a/jindong-compose/src/androidMain/kotlin/io/github/compose/jindong/HapticCapabilities.android.kt b/jindong-compose/src/androidMain/kotlin/io/github/compose/jindong/HapticCapabilities.android.kt new file mode 100644 index 0000000..3ed9cd0 --- /dev/null +++ b/jindong-compose/src/androidMain/kotlin/io/github/compose/jindong/HapticCapabilities.android.kt @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong + +internal actual fun hapticPlatform(): HapticPlatform = HapticPlatform.Android diff --git a/jindong-compose/src/commonMain/kotlin/io/github/compose/jindong/HapticCapabilities.kt b/jindong-compose/src/commonMain/kotlin/io/github/compose/jindong/HapticCapabilities.kt new file mode 100644 index 0000000..e41eb44 --- /dev/null +++ b/jindong-compose/src/commonMain/kotlin/io/github/compose/jindong/HapticCapabilities.kt @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import io.github.compose.jindong.executor.LocalHapticExecutor + +/** + * Platform a haptic capability reading was taken on. + */ +enum class HapticPlatform { + Android, + Ios, +} + +/** + * Snapshot of the current device's haptic capabilities, read from the active [HapticExecutor]. + * + * @property isSupported whether haptic feedback is available at all on this device. + * @property hasAmplitudeControl whether the actuator renders intermediate amplitudes. On Android + * this is `false` for ERM motors (every non-zero amplitude rounds up to 100%) and `true` for LRA + * motors. On iOS Core Haptics is always continuous, so this is always `true`; pair it with + * [platform] when the displayed label needs to distinguish "Yes/No" (Android) from + * "Continuous" (iOS). + * @property platform the platform this reading came from. + */ +data class HapticCapabilities( + val isSupported: Boolean, + val hasAmplitudeControl: Boolean, + val platform: HapticPlatform, +) + +/** + * Reads the current device's [HapticCapabilities] from the haptic executor provided by + * [JindongProvider]. Must be called inside a `JindongProvider { }` scope. + * + * Example: + * ``` + * JindongProvider { + * val capabilities = rememberHapticCapabilities() + * // capabilities.isSupported, capabilities.hasAmplitudeControl, capabilities.platform + * } + * ``` + */ +@Composable +fun rememberHapticCapabilities(): HapticCapabilities { + val executor = LocalHapticExecutor.current + val platform = hapticPlatform() + return remember(executor, platform) { + HapticCapabilities( + isSupported = executor.isSupported, + hasAmplitudeControl = executor.hasAmplitudeControl, + platform = platform, + ) + } +} + +/** + * Returns the [HapticPlatform] the app is currently running on. + */ +internal expect fun hapticPlatform(): HapticPlatform diff --git a/jindong-compose/src/commonTest/kotlin/io/github/compose/jindong/HapticCapabilitiesTest.kt b/jindong-compose/src/commonTest/kotlin/io/github/compose/jindong/HapticCapabilitiesTest.kt new file mode 100644 index 0000000..0ede6c8 --- /dev/null +++ b/jindong-compose/src/commonTest/kotlin/io/github/compose/jindong/HapticCapabilitiesTest.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong + +import io.kotest.assertions.assertSoftly +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe + +class HapticCapabilitiesTest : + FunSpec({ + test("HapticCapabilities holds the supplied values") { + val capabilities = HapticCapabilities( + isSupported = true, + hasAmplitudeControl = false, + platform = HapticPlatform.Android, + ) + + assertSoftly(capabilities) { + isSupported shouldBe true + hasAmplitudeControl shouldBe false + platform shouldBe HapticPlatform.Android + } + } + }) diff --git a/jindong-compose/src/iosMain/kotlin/io/github/compose/jindong/HapticCapabilities.ios.kt b/jindong-compose/src/iosMain/kotlin/io/github/compose/jindong/HapticCapabilities.ios.kt new file mode 100644 index 0000000..7a72088 --- /dev/null +++ b/jindong-compose/src/iosMain/kotlin/io/github/compose/jindong/HapticCapabilities.ios.kt @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong + +internal actual fun hapticPlatform(): HapticPlatform = HapticPlatform.Ios diff --git a/jindong-core/api/jindong-core.api b/jindong-core/api/jindong-core.api index 34c51bc..86c4681 100644 --- a/jindong-core/api/jindong-core.api +++ b/jindong-core/api/jindong-core.api @@ -88,10 +88,15 @@ public final class io/github/compose/jindong/core/element/VibrationElement : io/ public abstract interface class io/github/compose/jindong/core/executor/HapticExecutor { public abstract fun execute (Lio/github/compose/jindong/core/model/HapticPattern;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun executeAsync (Lio/github/compose/jindong/core/model/HapticPattern;)Lio/github/compose/jindong/core/executor/HapticHandle; + public fun getHasAmplitudeControl ()Z public abstract fun isSupported ()Z public abstract fun release ()V } +public final class io/github/compose/jindong/core/executor/HapticExecutor$DefaultImpls { + public static fun getHasAmplitudeControl (Lio/github/compose/jindong/core/executor/HapticExecutor;)Z +} + public final class io/github/compose/jindong/core/executor/HapticExecutor_androidKt { public static final fun createHapticExecutor (Ljava/lang/Object;)Lio/github/compose/jindong/core/executor/HapticExecutor; public static synthetic fun createHapticExecutor$default (Ljava/lang/Object;ILjava/lang/Object;)Lio/github/compose/jindong/core/executor/HapticExecutor; diff --git a/jindong-core/api/jindong-core.klib.api b/jindong-core/api/jindong-core.klib.api index 025c345..5cc58cf 100644 --- a/jindong-core/api/jindong-core.klib.api +++ b/jindong-core/api/jindong-core.klib.api @@ -21,6 +21,8 @@ abstract interface io.github.compose.jindong.core.element/HapticElement { // io. abstract interface io.github.compose.jindong.core.executor/HapticExecutor { // io.github.compose.jindong.core.executor/HapticExecutor|null[0] abstract val isSupported // io.github.compose.jindong.core.executor/HapticExecutor.isSupported|{}isSupported[0] abstract fun (): kotlin/Boolean // io.github.compose.jindong.core.executor/HapticExecutor.isSupported.|(){}[0] + open val hasAmplitudeControl // io.github.compose.jindong.core.executor/HapticExecutor.hasAmplitudeControl|{}hasAmplitudeControl[0] + open fun (): kotlin/Boolean // io.github.compose.jindong.core.executor/HapticExecutor.hasAmplitudeControl.|(){}[0] abstract fun executeAsync(io.github.compose.jindong.core.model/HapticPattern): io.github.compose.jindong.core.executor/HapticHandle // io.github.compose.jindong.core.executor/HapticExecutor.executeAsync|executeAsync(io.github.compose.jindong.core.model.HapticPattern){}[0] abstract fun release() // io.github.compose.jindong.core.executor/HapticExecutor.release|release(){}[0] diff --git a/jindong-core/src/androidMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.android.kt b/jindong-core/src/androidMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.android.kt index 940ab7e..61c3124 100644 --- a/jindong-core/src/androidMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.android.kt +++ b/jindong-core/src/androidMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.android.kt @@ -55,6 +55,12 @@ internal class DefaultAndroidHapticExecutor(context: Context) : HapticExecutor { vibrator.hasVibrator() } + // ERM actuators round any non-zero amplitude up to 100%, so per-level intensity is indistinguishable; + // callers read this to warn that LIGHT/MEDIUM/STRONG/HIGH feel identical on such devices. + override val hasAmplitudeControl: Boolean by lazy { + vibrator.hasAmplitudeControl() + } + @RequiresPermission(Manifest.permission.VIBRATE) override suspend fun execute(pattern: HapticPattern) { if (!isSupported || pattern.events.isEmpty()) return diff --git a/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.kt b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.kt index fc739ad..849ec0d 100644 --- a/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.kt +++ b/jindong-core/src/commonMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.kt @@ -34,6 +34,22 @@ interface HapticExecutor { */ val isSupported: Boolean + /** + * Whether the actuator can render intermediate (non-binary) amplitudes. + * + * - **Android**: reflects `Vibrator.hasAmplitudeControl()`. ERM motors round every non-zero + * amplitude up to 100%, so they report `false` and effects like fall ramps are lost; LRA + * motors report `true`. + * - **iOS**: Core Haptics renders continuous intensity whenever haptics are supported, so this + * tracks [isSupported]. The "discrete vs. continuous" distinction is platform-specific; read + * the platform alongside this flag if the label matters (see the Compose `HapticCapabilities`). + * + * Defaults to `false` so existing third-party [HapticExecutor] implementations remain + * source- and binary-compatible without having to declare it. + */ + val hasAmplitudeControl: Boolean + get() = false + /** * Executes haptic pattern. Cancellable via coroutine cancellation. * When cancelled, the ongoing vibration is stopped immediately. diff --git a/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/HapticExecutorCapabilityTest.kt b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/HapticExecutorCapabilityTest.kt new file mode 100644 index 0000000..180f603 --- /dev/null +++ b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/executor/HapticExecutorCapabilityTest.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.core.executor + +import io.github.compose.jindong.core.fake.FakeHapticExecutor +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe + +class HapticExecutorCapabilityTest : + FunSpec({ + test("hasAmplitudeControl defaults to true") { + FakeHapticExecutor().hasAmplitudeControl shouldBe true + } + + test("hasAmplitudeControl can be overridden via constructor") { + FakeHapticExecutor(hasAmplitudeControl = false).hasAmplitudeControl shouldBe false + } + }) diff --git a/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/fake/FakeHapticExecutor.kt b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/fake/FakeHapticExecutor.kt index 2e99b90..957f3b3 100644 --- a/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/fake/FakeHapticExecutor.kt +++ b/jindong-core/src/commonTest/kotlin/io/github/compose/jindong/core/fake/FakeHapticExecutor.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.delay */ class FakeHapticExecutor( override val isSupported: Boolean = true, + override val hasAmplitudeControl: Boolean = true, ) : HapticExecutor { private val _executedPatterns = mutableListOf() diff --git a/jindong-core/src/iosMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.ios.kt b/jindong-core/src/iosMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.ios.kt index 6a4ea01..30ba7b1 100644 --- a/jindong-core/src/iosMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.ios.kt +++ b/jindong-core/src/iosMain/kotlin/io/github/compose/jindong/core/executor/HapticExecutor.ios.kt @@ -55,6 +55,11 @@ internal class DefaultIosHapticExecutor : HapticExecutor { CHHapticEngine.capabilitiesForHardware().supportsHaptics() } + // Core Haptics renders intensity as a continuous float on supported devices; gate on support so we + // never report amplitude control on a device that has no haptics at all (e.g. the simulator). + override val hasAmplitudeControl: Boolean + get() = isSupported + override suspend fun execute(pattern: HapticPattern) { if (!isSupported || pattern.events.isEmpty()) return diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index b7ae48a..2d887ea 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -58,6 +58,10 @@ kotlin { implementation(compose.ui) implementation(compose.components.resources) } + androidMain.dependencies { + // System back handling on Android (PlatformBackHandler actual). + implementation(libs.androidx.activity.compose) + } } } diff --git a/sample/shared/src/androidMain/kotlin/io/github/compose/jindong/sample/nav/PlatformBackHandler.android.kt b/sample/shared/src/androidMain/kotlin/io/github/compose/jindong/sample/nav/PlatformBackHandler.android.kt new file mode 100644 index 0000000..41b3283 --- /dev/null +++ b/sample/shared/src/androidMain/kotlin/io/github/compose/jindong/sample/nav/PlatformBackHandler.android.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.nav + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable + +@Composable +actual fun PlatformBackHandler( + enabled: Boolean, + onBack: () -> Unit, +) { + BackHandler(enabled = enabled, onBack = onBack) +} diff --git a/sample/shared/src/commonMain/composeResources/files/IBMPlexMono-OFL.txt b/sample/shared/src/commonMain/composeResources/files/IBMPlexMono-OFL.txt new file mode 100644 index 0000000..01497cc --- /dev/null +++ b/sample/shared/src/commonMain/composeResources/files/IBMPlexMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/sample/shared/src/commonMain/composeResources/files/InstrumentSans-OFL.txt b/sample/shared/src/commonMain/composeResources/files/InstrumentSans-OFL.txt new file mode 100644 index 0000000..26bd2f9 --- /dev/null +++ b/sample/shared/src/commonMain/composeResources/files/InstrumentSans-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/sample/shared/src/commonMain/composeResources/font/ibm_plex_mono_medium.ttf b/sample/shared/src/commonMain/composeResources/font/ibm_plex_mono_medium.ttf new file mode 100644 index 0000000..39f178d Binary files /dev/null and b/sample/shared/src/commonMain/composeResources/font/ibm_plex_mono_medium.ttf differ diff --git a/sample/shared/src/commonMain/composeResources/font/ibm_plex_mono_regular.ttf b/sample/shared/src/commonMain/composeResources/font/ibm_plex_mono_regular.ttf new file mode 100644 index 0000000..81ca3dc Binary files /dev/null and b/sample/shared/src/commonMain/composeResources/font/ibm_plex_mono_regular.ttf differ diff --git a/sample/shared/src/commonMain/composeResources/font/ibm_plex_mono_semibold.ttf b/sample/shared/src/commonMain/composeResources/font/ibm_plex_mono_semibold.ttf new file mode 100644 index 0000000..73dd5a4 Binary files /dev/null and b/sample/shared/src/commonMain/composeResources/font/ibm_plex_mono_semibold.ttf differ diff --git a/sample/shared/src/commonMain/composeResources/font/instrument_sans_variable.ttf b/sample/shared/src/commonMain/composeResources/font/instrument_sans_variable.ttf new file mode 100644 index 0000000..3589b81 Binary files /dev/null and b/sample/shared/src/commonMain/composeResources/font/instrument_sans_variable.ttf differ diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/SampleApp.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/SampleApp.kt index eb5f112..ba57f3e 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/SampleApp.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/SampleApp.kt @@ -15,204 +15,17 @@ */ package io.github.compose.jindong.sample -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import io.github.compose.jindong.Jindong +import androidx.compose.runtime.Composable import io.github.compose.jindong.JindongProvider -import io.github.compose.jindong.JindongScope -import io.github.compose.jindong.core.model.HapticIntensity -import io.github.compose.jindong.dsl.Delay -import io.github.compose.jindong.dsl.Haptic -import io.github.compose.jindong.dsl.Repeat -import io.github.compose.jindong.dsl.Sequence -import org.jetbrains.compose.resources.stringResource -import kotlin.time.Duration.Companion.milliseconds +import io.github.compose.jindong.sample.nav.AppRoot +/** + * Sample entry point. [JindongProvider] sits at the root, once, OUTSIDE the theme/nav switch so the + * haptic executor survives navigation; [AppRoot] owns the theme and the single-level nav host. + */ @Composable fun SampleApp() { - MaterialTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - JindongProvider { - HapticPatternListScreen() - } - } - } -} - -@Composable -private fun HapticPatternListScreen() { - val patterns = remember { HapticPatternType.entries } - - Column( - modifier = Modifier.fillMaxSize(), - ) { - Text( - text = stringResource(Res.string.haptic_patterns_title), - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(16.dp), - ) - - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(patterns) { pattern -> - HapticPatternCard(pattern) - } - } - } -} - -@Composable -private fun HapticPatternCard(patternType: HapticPatternType) { - var triggerCount by remember { mutableIntStateOf(0) } - - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = patternType.displayName, - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = patternType.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Button(onClick = { triggerCount++ }) { - Text(stringResource(Res.string.play_button)) - } - } - } - - HapticPatternEffect(patternType, triggerCount) -} - -@Composable -private fun HapticPatternEffect(patternType: HapticPatternType, trigger: Int) { - if (trigger == 0) return - - val pattern: @Composable JindongScope.() -> Unit = when (patternType) { - HapticPatternType.SINGLE_TAP -> { - { Haptic(50.milliseconds) } - } - - HapticPatternType.DOUBLE_TAP -> { - { - Haptic(30.milliseconds) - Delay(50.milliseconds) - Haptic(30.milliseconds) - } - } - - HapticPatternType.TRIPLE_TAP -> { - { - Repeat(3) { - Sequence { - Haptic(25.milliseconds) - Delay(40.milliseconds) - } - } - } - } - - HapticPatternType.LIGHT_FEEDBACK -> { - { Haptic(40.milliseconds, HapticIntensity.LIGHT) } - } - - HapticPatternType.STRONG_FEEDBACK -> { - { Haptic(80.milliseconds, HapticIntensity.STRONG) } - } - - HapticPatternType.HEARTBEAT -> { - { - Repeat(2) { - Sequence { - Haptic(60.milliseconds, HapticIntensity.STRONG) - Delay(80.milliseconds) - Haptic(40.milliseconds, HapticIntensity.MEDIUM) - Delay(400.milliseconds) - } - } - } - } - - HapticPatternType.NOTIFICATION -> { - { - Haptic(50.milliseconds, HapticIntensity.MEDIUM) - Delay(100.milliseconds) - Haptic(100.milliseconds, HapticIntensity.STRONG) - } - } - - HapticPatternType.SUCCESS -> { - { - Haptic(30.milliseconds, HapticIntensity.LIGHT) - Delay(50.milliseconds) - Haptic(60.milliseconds, HapticIntensity.MEDIUM) - Delay(50.milliseconds) - Haptic(100.milliseconds, HapticIntensity.STRONG) - } - } - - HapticPatternType.ERROR -> { - { - Repeat(3) { - Sequence { - Haptic(100.milliseconds, HapticIntensity.HIGH) - Delay(100.milliseconds) - } - } - } - } - - HapticPatternType.RAMP_UP -> { - { - Haptic(50.milliseconds, HapticIntensity.LIGHT) - Delay(30.milliseconds) - Haptic(50.milliseconds, HapticIntensity.MEDIUM) - Delay(30.milliseconds) - Haptic(50.milliseconds, HapticIntensity.STRONG) - Delay(30.milliseconds) - Haptic(50.milliseconds, HapticIntensity.HIGH) - } - } + JindongProvider { + AppRoot() } - - Jindong(trigger, content = pattern) -} - -enum class HapticPatternType( - val displayName: String, - val description: String, -) { - SINGLE_TAP("Single Tap", "Simple single vibration"), - DOUBLE_TAP("Double Tap", "Two quick taps"), - TRIPLE_TAP("Triple Tap", "Three quick taps"), - LIGHT_FEEDBACK("Light Feedback", "Gentle vibration"), - STRONG_FEEDBACK("Strong Feedback", "Strong vibration"), - HEARTBEAT("Heartbeat", "Heart beating pattern"), - NOTIFICATION("Notification", "Alert notification pattern"), - SUCCESS("Success", "Success confirmation pattern"), - ERROR("Error", "Error alert pattern"), - RAMP_UP("Ramp Up", "Gradually increasing intensity"), } diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/CapabilityBadge.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/CapabilityBadge.kt new file mode 100644 index 0000000..947ed00 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/CapabilityBadge.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import io.github.compose.jindong.sample.theme.Dimens +import io.github.compose.jindong.sample.theme.JindongTheme + +/** + * Single capability row: [key] on the left (sans, text2), a status [dotColor] dot and [value] + * (mono, text) on the right. + */ +@Composable +fun CapabilityBadge( + key: String, + value: String, + dotColor: Color, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = key, + style = JindongTheme.typography.rowTitle.copy(fontWeight = FontWeight.Normal), + color = JindongTheme.colors.text2, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(Dimens.rowGapSmall), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.size(Dimens.dotSize).clip(CircleShape).background(dotColor), + ) + Text( + text = value, + style = JindongTheme.typography.monoValue, + color = JindongTheme.colors.text, + ) + } + } +} diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/Chip.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/Chip.kt new file mode 100644 index 0000000..bb31e50 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/Chip.kt @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.github.compose.jindong.sample.theme.Dimens +import io.github.compose.jindong.sample.theme.JindongTheme + +/** + * Selectable preset chip. Active = accent border/text over accentBg; inactive = border2 border over + * transparent with text2. Optional [subValue] renders below the label in dim mono. Wrapped to a + * >= 48dp hit area. + */ +@Composable +fun PresetChip( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + subValue: String? = null, +) { + val colors = JindongTheme.colors + val borderColor = if (selected) colors.accent else colors.border2 + val labelColor = if (selected) colors.accent else colors.text2 + val bgColor = if (selected) colors.accentBg else Color.Transparent + + // Click + 48dp min-touch live on the same (outer) node so the whole hit area is tappable; the + // inner content keeps the compact chip visuals (8/11 padding). + Box( + modifier = + modifier + .defaultMinSize(minHeight = Dimens.minTouch) + .clip(RoundedCornerShape(Dimens.radiusChip)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = + Modifier + .clip(RoundedCornerShape(Dimens.radiusChip)) + .background(bgColor) + .border(Dimens.stroke, borderColor, RoundedCornerShape(Dimens.radiusChip)) + .padding(horizontal = 11.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = label, style = JindongTheme.typography.chipLabel, color = labelColor) + if (subValue != null) { + Text( + text = subValue, + style = JindongTheme.typography.monoAxis.copy(fontSize = 9.sp), + color = labelColor.copy(alpha = 0.65f), + ) + } + } + } +} + +/** + * Selectable node chip for sequence/rhythm editing. Adds a 7dp marker: a filled accent circle for a + * tap node ([isTap] true) or a square border2 marker for a pause. Active state mirrors [PresetChip]. + */ +@Composable +fun NodeChip( + label: String, + msText: String, + isTap: Boolean, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val colors = JindongTheme.colors + val borderColor = if (selected) colors.accent else colors.border2 + val contentColor = if (selected) colors.accent else colors.text2 + val bgColor = if (selected) colors.accentBg else Color.Transparent + val markerShape = if (isTap) CircleShape else RoundedCornerShape(1.dp) + val markerColor = if (isTap) colors.accent else colors.border2 + + Box( + modifier = + modifier + .defaultMinSize(minHeight = Dimens.minTouch) + .clip(RoundedCornerShape(Dimens.radiusChip)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Row( + modifier = + Modifier + .clip(RoundedCornerShape(Dimens.radiusChip)) + .background(bgColor) + .border(Dimens.stroke, borderColor, RoundedCornerShape(Dimens.radiusChip)) + .padding(horizontal = 11.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(Dimens.rowGapSmall), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.size(Dimens.nodeDotSize).clip(markerShape).background(markerColor)) + Text(text = label, style = JindongTheme.typography.chipLabel, color = contentColor) + Text( + text = msText, + style = JindongTheme.typography.monoAxis.copy(fontSize = 9.sp), + color = contentColor.copy(alpha = 0.65f), + ) + } + } +} diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/HapticTimeline.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/HapticTimeline.kt new file mode 100644 index 0000000..893be71 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/HapticTimeline.kt @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.unit.dp +import io.github.compose.jindong.core.model.HapticPattern +import io.github.compose.jindong.core.model.ScheduledHapticEvent +import io.github.compose.jindong.sample.theme.Dimens +import io.github.compose.jindong.sample.theme.JindongTheme + +/** + * One timeline bar in normalized [0,1] coordinates. Anchored to the plot bottom; height grows + * upward. [color] is usually the accent token, or a per-card tone on the preset screen. + */ +@Immutable +data class TimelineBar( + val leftFraction: Float, + val widthFraction: Float, + val heightFraction: Float, + val color: Color, +) + +/** + * Converts a [HapticPattern]'s events into normalized [TimelineBar]s. + * + * - `leftFraction = start / window` + * - `widthFraction = max(0.006, dur / window)` (min 0.6% so an instantaneous tap stays visible) + * - `heightFraction = max(0.06, intensity * 0.98)` (min 6%) + */ +object TimelineMapper { + fun toBars( + pattern: HapticPattern, + windowMs: Long, + color: (ScheduledHapticEvent) -> Color, + ): List { + if (windowMs <= 0L) return emptyList() + val window = windowMs.toFloat() + return pattern.events.map { event -> + TimelineBar( + leftFraction = (event.startTimeMs / window).coerceIn(0f, 1f), + widthFraction = maxOf(0.006f, event.durationMs / window), + heightFraction = maxOf(0.06f, event.intensity.value * 0.98f), + color = color(event), + ) + } + } +} + +/** + * Signature haptic timeline: horizontal = time, vertical = intensity, gap = rest. + * + * The plot is drawn in a single [Canvas]; [bars] and [playheadProgress] are read inside the draw + * lambda, so wiring an animated playhead later only redraws (no recomposition). Screens currently + * render a static timeline ([playheadVisible] = false) and rebuild [bars] on each state change. + * + * @param bars normalized bars to render; this component renders exactly what it is given. + * @param playheadProgress 0..1 horizontal position of the playhead. + * @param playheadVisible gates the playhead alpha (visible only while playing). + */ +@Composable +fun HapticTimeline( + bars: List, + topLeft: String, + topRight: String, + minLabel: String, + maxLabel: String, + playheadProgress: Float, + playheadVisible: Boolean, + modifier: Modifier = Modifier, +) { + val colors = JindongTheme.colors + val axisColor = colors.border2 + val playheadColor = colors.text + + Column( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Dimens.radiusTimeline)) + .background(colors.surface2) + .border(Dimens.stroke, colors.border, RoundedCornerShape(Dimens.radiusTimeline)) + .padding(PaddingValues(start = 16.dp, top = 15.dp, end = 16.dp, bottom = 14.dp)), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = topLeft, style = JindongTheme.typography.monoAxis, color = colors.text3) + Text(text = topRight, style = JindongTheme.typography.monoAxis, color = colors.text3) + } + + Canvas( + modifier = + Modifier + .fillMaxWidth() + .height(Dimens.plotHeight) + .padding(top = 8.dp), + ) { + drawLAxis(axisColor, Dimens.axisStroke.toPx()) + bars.forEach { bar -> drawBar(bar, Dimens.barMinWidth.toPx(), Dimens.barCornerRadius.toPx()) } + if (playheadVisible) { + drawPlayhead( + progress = playheadProgress.coerceIn(0f, 1f), + color = playheadColor, + strokeWidth = Dimens.playheadStroke.toPx(), + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 7.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = minLabel, style = JindongTheme.typography.monoAxis, color = colors.text3) + Text(text = "TIME (ms) →", style = JindongTheme.typography.monoAxis, color = colors.text3) + Text(text = maxLabel, style = JindongTheme.typography.monoAxis, color = colors.text3) + } + } +} + +private fun DrawScope.drawLAxis( + color: Color, + strokeWidth: Float, +) { + // Left + bottom only (L-shaped axis); top/right have no border. + drawLine(color, Offset(0f, 0f), Offset(0f, size.height), strokeWidth) + drawLine(color, Offset(0f, size.height), Offset(size.width, size.height), strokeWidth) +} + +private fun DrawScope.drawBar( + bar: TimelineBar, + minWidthPx: Float, + cornerRadiusPx: Float, +) { + val left = bar.leftFraction * size.width + val width = maxOf(minWidthPx, bar.widthFraction * size.width) + val height = bar.heightFraction * size.height + val top = size.height - height + // Top corners rounded only; anchored to the plot bottom. + val path = + Path().apply { + val r = minOf(cornerRadiusPx, width / 2f, height) + moveTo(left, size.height) + lineTo(left, top + r) + quadraticTo(left, top, left + r, top) + lineTo(left + width - r, top) + quadraticTo(left + width, top, left + width, top + r) + lineTo(left + width, size.height) + close() + } + drawPath(path, bar.color) +} + +private fun DrawScope.drawPlayhead( + progress: Float, + color: Color, + strokeWidth: Float, +) { + val x = progress * size.width + drawLine(color, Offset(x, 0f), Offset(x, size.height), strokeWidth) +} + +/** Default accent tone for callers that do not supply a per-event color lambda. */ +@Composable +fun timelineAccentColor(): Color = JindongTheme.colors.accent diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/JindongIcons.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/JindongIcons.kt new file mode 100644 index 0000000..7fc0057 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/JindongIcons.kt @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.components + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +/** + * Self-defined [ImageVector]s for the harness, replacing emoji/text glyphs that render as inconsistent + * color emoji on iOS. All icons live on a 24x24 viewport; filled icons (Play) tint via the [Icon] + * `tint`, stroked icons (chevrons, close, theme) use a 2px stroke also driven by `tint`. + * + * Built lazily and memoized so the path is allocated once per call site. + */ +object JindongIcons { + + /** Solid right-pointing play triangle. */ + val Play: ImageVector + get() = cache("play") { + filled { + moveTo(8f, 5f) + lineTo(19f, 12f) + lineTo(8f, 19f) + close() + } + } + + /** Crescent moon (light theme indicator: tap to go dark). */ + val Moon: ImageVector + get() = cache("moon") { + filled { + // Crescent = outer circle minus an offset circle, expressed as a single subpath. + moveTo(21f, 14.5f) + arcToRelative(8f, 8f, 0f, true, true, -9.5f, -11f) + arcToRelative(6.2f, 6.2f, 0f, false, false, 9.5f, 11f) + close() + } + } + + /** Sun with rays (dark theme indicator: tap to go light). */ + val Sun: ImageVector + get() = cache("sun") { + stroked { + // Core + moveTo(12f, 8.2f) + arcToRelative(3.8f, 3.8f, 0f, true, true, 0f, 7.6f) + arcToRelative(3.8f, 3.8f, 0f, true, true, 0f, -7.6f) + close() + // Rays + moveTo(12f, 2.5f) + lineTo(12f, 4.5f) + moveTo(12f, 19.5f) + lineTo(12f, 21.5f) + moveTo(2.5f, 12f) + lineTo(4.5f, 12f) + moveTo(19.5f, 12f) + lineTo(21.5f, 12f) + moveTo(5.2f, 5.2f) + lineTo(6.6f, 6.6f) + moveTo(17.4f, 17.4f) + lineTo(18.8f, 18.8f) + moveTo(18.8f, 5.2f) + lineTo(17.4f, 6.6f) + moveTo(6.6f, 17.4f) + lineTo(5.2f, 18.8f) + } + } + + /** Left chevron (back). */ + val ChevronLeft: ImageVector + get() = cache("chevron-left") { + stroked { + moveTo(15f, 5f) + lineTo(8f, 12f) + lineTo(15f, 19f) + } + } + + /** Right chevron (navigation affordance). */ + val ChevronRight: ImageVector + get() = cache("chevron-right") { + stroked { + moveTo(9f, 5f) + lineTo(16f, 12f) + lineTo(9f, 19f) + } + } + + /** Down caret (accordion expand indicator). */ + val CaretDown: ImageVector + get() = cache("caret-down") { + stroked { + moveTo(5f, 9f) + lineTo(12f, 16f) + lineTo(19f, 9f) + } + } + + /** Close / delete (X). */ + val Close: ImageVector + get() = cache("close") { + stroked { + moveTo(6f, 6f) + lineTo(18f, 18f) + moveTo(18f, 6f) + lineTo(6f, 18f) + } + } +} + +private val iconCache = HashMap() + +private fun cache(name: String, build: ImageVector.Builder.() -> Unit): ImageVector = iconCache.getOrPut(name) { + ImageVector.Builder( + name = name, + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply(build).build() +} + +/** Adds a filled path tinted by the [Icon] color (fillAlpha 1). */ +private fun ImageVector.Builder.filled(pathBuilder: androidx.compose.ui.graphics.vector.PathBuilder.() -> Unit) { + path( + fill = SolidColor(Color.Black), + stroke = null, + pathBuilder = pathBuilder, + ) +} + +/** Adds a 2px stroked path tinted by the [Icon] color (no fill). */ +private fun ImageVector.Builder.stroked(pathBuilder: androidx.compose.ui.graphics.vector.PathBuilder.() -> Unit) { + path( + fill = null, + stroke = SolidColor(Color.Black), + strokeLineWidth = 2f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + pathBuilder = pathBuilder, + ) +} diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/JindongScaffold.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/JindongScaffold.kt new file mode 100644 index 0000000..69c717d --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/JindongScaffold.kt @@ -0,0 +1,223 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.github.compose.jindong.sample.nav.Screen +import io.github.compose.jindong.sample.theme.Dimens +import io.github.compose.jindong.sample.theme.JindongTheme + +/** + * Sticky 56dp app bar over a vertically scrollable [content] column. + * + * App bar layout: + * - left: back chevron `‹` (only when [screen] is not the hub) tapping [onBack]. + * - center: [Screen.title] (the Home title doubles as the wordmark). + * - right: a mono counter (`진동` on Home, else `NN / 07`) plus a 30x30 theme toggle (`☾` light / + * `☀` dark) calling [onToggleTheme]. Both controls keep a >= 48dp hit area. + * + * The body uses [Dimens.screenPadH] horizontal padding, [Dimens.bodyTop] top and [Dimens.bodyBottom] + * bottom padding. + */ +@Composable +fun JindongScaffold( + screen: Screen, + isDark: Boolean, + onBack: () -> Unit, + onToggleTheme: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + val colors = JindongTheme.colors + // Horizontal cutout insets (landscape notch / iOS side safe area) apply to the whole frame; the + // status-bar inset is consumed by the app bar and the navigation-bar inset by the scrolling body, + // so each edge is padded exactly once. + Column( + modifier = + modifier + .fillMaxSize() + .background(colors.bg) + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)), + ) { + AppBar( + screen = screen, + isDark = isDark, + onBack = onBack, + onToggleTheme = onToggleTheme, + ) + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)) + .padding( + start = Dimens.screenPadH, + end = Dimens.screenPadH, + top = Dimens.bodyTop, + bottom = Dimens.bodyBottom, + ), + content = content, + ) + } +} + +@Composable +private fun AppBar( + screen: Screen, + isDark: Boolean, + onBack: () -> Unit, + onToggleTheme: () -> Unit, +) { + val colors = JindongTheme.colors + val strokePx = with(LocalDensity.current) { Dimens.stroke.toPx() } + // Background fills behind the status bar; the status-bar inset then pushes the 56dp bar content + // below it, and the bottom border draws at the visible bottom of that content. + Box( + modifier = + Modifier + .fillMaxWidth() + .background(colors.bg) + .windowInsetsPadding(WindowInsets.statusBars.only(WindowInsetsSides.Top)) + .height(Dimens.appBarHeight) + .drawBehind { + drawLine( + color = colors.border, + start = Offset(0f, size.height - strokePx / 2f), + end = Offset(size.width, size.height - strokePx / 2f), + strokeWidth = strokePx, + ) + } + .padding(horizontal = Dimens.screenPadH), + ) { + if (screen.index > 0) { + Box( + modifier = + Modifier + .align(Alignment.CenterStart) + .size(Dimens.minTouch) + .clip(RoundedCornerShape(Dimens.radiusSmall)) + .clickable(onClick = onBack, role = Role.Button) + .semantics { contentDescription = "Back" }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = JindongIcons.ChevronLeft, + contentDescription = null, + tint = colors.text2, + modifier = Modifier.size(22.dp), + ) + } + } + + Text( + text = screen.title, + style = + if (screen.index == 0) { + JindongTheme.typography.wordmark + } else { + JindongTheme.typography.appBarTitle + }, + color = colors.text, + modifier = Modifier.align(Alignment.Center), + ) + + Row( + modifier = Modifier.align(Alignment.CenterEnd), + horizontalArrangement = Arrangement.spacedBy(Dimens.rowGapMedium), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (screen.index == 0) "진동" else "${pad2(screen.index)} / 07", + style = JindongTheme.typography.monoMicro.copy(fontWeight = FontWeight.Normal), + color = colors.text3, + ) + ThemeToggle(isDark = isDark, onToggleTheme = onToggleTheme) + } + } +} + +@Composable +private fun ThemeToggle( + isDark: Boolean, + onToggleTheme: () -> Unit, +) { + val colors = JindongTheme.colors + Box( + modifier = + Modifier + .size(Dimens.minTouch) + .clip(RoundedCornerShape(Dimens.radiusSmall)) + .clickable(onClick = onToggleTheme, role = Role.Button) + .semantics { contentDescription = if (isDark) "Switch to light theme" else "Switch to dark theme" }, + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .size(Dimens.toggleSize) + .clip(RoundedCornerShape(Dimens.radiusIcon)) + .border(Dimens.stroke, colors.border, RoundedCornerShape(Dimens.radiusIcon)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (isDark) JindongIcons.Sun else JindongIcons.Moon, + contentDescription = null, + tint = colors.text2, + modifier = Modifier.size(15.dp), + ) + } + } +} + +/** Zero-pads a non-negative index to two digits without relying on JVM-only String.format. */ +private fun pad2(value: Int): String = if (value < 10) "0$value" else value.toString() diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/LabeledSlider.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/LabeledSlider.kt new file mode 100644 index 0000000..fe3db0d --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/LabeledSlider.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import io.github.compose.jindong.sample.theme.JindongTheme + +/** + * Label row (label left, [valueText] mono right) over a Material3 [Slider] tinted with the accent + * token for the thumb/active track and border2 for the inactive track. + */ +@Composable +fun LabeledSlider( + label: String, + valueText: String, + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + valueRange: ClosedFloatingPointRange = 0f..1f, + steps: Int = 0, +) { + val colors = JindongTheme.colors + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = JindongTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium), + color = colors.text2, + ) + Text( + text = valueText, + style = JindongTheme.typography.monoValue.copy(fontSize = 12.sp), + color = colors.text, + ) + } + Slider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + steps = steps, + colors = + SliderDefaults.colors( + thumbColor = colors.accent, + activeTrackColor = colors.accent, + inactiveTrackColor = colors.border2, + ), + ) + } +} diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/MonoLabel.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/MonoLabel.kt new file mode 100644 index 0000000..a40b904 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/MonoLabel.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.components + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.github.compose.jindong.sample.theme.JindongTheme + +/** + * Uppercase mono section label (e.g. `DEVICE CAPABILITY`, `MODULES`). Renders [text] uppercased in + * the [JindongTypography.sectionLabel] role with [JindongColors.text3]. + */ +@Composable +fun MonoLabel( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text.uppercase(), + style = JindongTheme.typography.sectionLabel, + color = JindongTheme.colors.text3, + modifier = modifier, + ) +} diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/PlayButton.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/PlayButton.kt new file mode 100644 index 0000000..1622f06 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/PlayButton.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import io.github.compose.jindong.sample.theme.Dimens +import io.github.compose.jindong.sample.theme.JindongTheme + +/** + * Full-width primary action button: a [JindongIcons.Play] glyph plus [text] label. Inverted ink/white + * surface ([JindongColors.btnBg] / [JindongColors.btnText]) — not accent — per the handoff. Wrapped + * to a >= 48dp hit area. + */ +@Composable +fun PlayButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: String = "Play", +) { + Box( + modifier = + modifier + .fillMaxWidth() + .defaultMinSize(minHeight = Dimens.minTouch) + .clip(RoundedCornerShape(Dimens.radiusButton)) + .background(JindongTheme.colors.btnBg) + .clickable(onClick = onClick) + .padding(14.dp), + contentAlignment = Alignment.Center, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = JindongIcons.Play, + contentDescription = null, + tint = JindongTheme.colors.btnText, + modifier = Modifier.size(15.dp), + ) + Text( + text = text, + style = JindongTheme.typography.cardTitle, + color = JindongTheme.colors.btnText, + ) + } + } +} diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/ScreenPieces.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/ScreenPieces.kt new file mode 100644 index 0000000..bcda8ea --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/ScreenPieces.kt @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.Dp +import io.github.compose.jindong.sample.theme.Dimens +import io.github.compose.jindong.sample.theme.JindongTheme +import kotlin.math.roundToLong + +/** + * Intro / description paragraph at the top of every module screen. 13sp, [text2], line-height 1.55. + * Pass an [AnnotatedString] when a fragment needs the emphasized [text]+600 treatment (e.g. + * "see vibration", "Width = duration"). + */ +@Composable +fun ScreenDescription( + text: AnnotatedString, + modifier: Modifier = Modifier, +) { + Text( + text = text, + style = JindongTheme.typography.intro, + color = JindongTheme.colors.text2, + modifier = modifier.fillMaxWidth(), + ) +} + +/** + * Plain bordered card container ([surface] over a [border] 1dp outline). [radius] defaults to the + * card radius; preset/large cards pass [Dimens.radiusBigCard]. Content lays out in a [ColumnScope]. + */ +@Composable +fun SectionCard( + modifier: Modifier = Modifier, + radius: Dp = Dimens.radiusCard, + padding: Dp = Dimens.cardPadding, + content: @Composable ColumnScope.() -> Unit, +) { + val colors = JindongTheme.colors + Column( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(radius)) + .background(colors.surface) + .border(Dimens.stroke, colors.border, RoundedCornerShape(radius)) + .padding(padding), + content = content, + ) +} + +/** Vertical spacer of the given [height]; a terse alias used between stacked screen sections. */ +@Composable +fun VGap(height: Dp) { + Spacer(Modifier.height(height)) +} + +/** Formats a Float to a fixed-fraction string without JVM-only String.format (e.g. 0.5 -> "0.50"). */ +fun formatFixed( + value: Float, + decimals: Int, +): String { + if (decimals <= 0) return value.roundToLong().toString() + var scale = 1 + repeat(decimals) { scale *= 10 } + val scaled = (value * scale + if (value >= 0f) 0.5f else -0.5f).toLong() + val whole = scaled / scale + val frac = (if (scaled < 0) -scaled else scaled) % scale + val fracStr = frac.toString().padStart(decimals, '0') + return "$whole.$fracStr" +} diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/SegmentedToggle.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/SegmentedToggle.kt new file mode 100644 index 0000000..246a209 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/components/SegmentedToggle.kt @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.github.compose.jindong.sample.theme.Dimens +import io.github.compose.jindong.sample.theme.JindongTheme + +/** + * N-up segment control. Equal-weight segments separated by a 7dp gap. Active segment uses accent + * border/text over accentBg; inactive uses border2 over transparent with text2. + */ +@Composable +fun SegmentedToggle( + options: List, + selectedIndex: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val colors = JindongTheme.colors + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimens.rowGapSmall), + verticalAlignment = Alignment.CenterVertically, + ) { + options.forEachIndexed { index, option -> + val selected = index == selectedIndex + val borderColor = if (selected) colors.accent else colors.border2 + val contentColor = if (selected) colors.accent else colors.text2 + val bgColor = if (selected) colors.accentBg else Color.Transparent + // 48dp min-touch on the same node as clickable so each segment is independently tappable. + Box( + modifier = + Modifier + .weight(1f) + .defaultMinSize(minHeight = Dimens.minTouch) + .clip(RoundedCornerShape(Dimens.radiusChip)) + .background(bgColor) + .border(Dimens.stroke, borderColor, RoundedCornerShape(Dimens.radiusChip)) + .clickable { onSelect(index) } + .padding(10.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = option, + style = JindongTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), + color = contentColor, + textAlign = TextAlign.Center, + ) + } + } + } +} diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/nav/AppRoot.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/nav/AppRoot.kt new file mode 100644 index 0000000..e579703 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/nav/AppRoot.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.nav + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import io.github.compose.jindong.sample.components.JindongScaffold +import io.github.compose.jindong.sample.screens.HomeScreen +import io.github.compose.jindong.sample.screens.IntensityLabScreen +import io.github.compose.jindong.sample.screens.PresetGalleryScreen +import io.github.compose.jindong.sample.screens.ReactiveScreen +import io.github.compose.jindong.sample.screens.RepeatScreen +import io.github.compose.jindong.sample.screens.RepeatWithIndexScreen +import io.github.compose.jindong.sample.screens.SingleHapticScreen +import io.github.compose.jindong.sample.screens.TimingScreen +import io.github.compose.jindong.sample.theme.JindongTheme + +/** + * Single-level navigation host. Home lists the seven modules; tapping one swaps [current], the app + * bar back chevron returns to Home. The theme follows the system preference unless [darkOverride] is + * set by the app-bar toggle. + * + * Back handling: on a module screen the Android system back gesture/button returns to Home via + * [PlatformBackHandler] (a no-op on iOS, where the app-bar chevron is the affordance). This avoids + * the deprecated `androidx.compose.ui.backhandler.BackHandler`, which on iOS requires a + * navigation-event dispatcher owner that `ComposeUIViewController` does not provide. + */ +@Composable +fun AppRoot() { + var current by rememberSaveable(stateSaver = ScreenSaver) { + mutableStateOf(Screen.Home) + } + var darkOverride by rememberSaveable { mutableStateOf(null) } + val dark = darkOverride ?: isSystemInDarkTheme() + + PlatformBackHandler(enabled = current != Screen.Home) { current = Screen.Home } + + JindongTheme(darkTheme = dark) { + JindongScaffold( + screen = current, + isDark = dark, + onBack = { current = Screen.Home }, + onToggleTheme = { darkOverride = !dark }, + ) { + when (current) { + Screen.Home -> HomeScreen(onModuleClick = { current = it }) + Screen.Single -> SingleHapticScreen() + Screen.Intensity -> IntensityLabScreen() + Screen.Timing -> TimingScreen() + Screen.Repeat -> RepeatScreen() + Screen.RepeatIdx -> RepeatWithIndexScreen() + Screen.Preset -> PresetGalleryScreen() + Screen.Reactive -> ReactiveScreen() + } + } + } +} + +/** Saves a [Screen] by its [Screen.routeId] and restores it by looking up Home + the modules. */ +private val ScreenSaver: Saver = + Saver( + save = { it.routeId }, + restore = { id -> (listOf(Screen.Home) + Screen.modules).firstOrNull { it.routeId == id } ?: Screen.Home }, + ) diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/nav/PlatformBackHandler.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/nav/PlatformBackHandler.kt new file mode 100644 index 0000000..912e869 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/nav/PlatformBackHandler.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.nav + +import androidx.compose.runtime.Composable + +/** + * Intercepts the platform back action while [enabled]. On Android this is the system back + * gesture/button (via `androidx.activity.compose.BackHandler`); on iOS there is no system back, so + * the actual is a no-op and the app-bar chevron remains the affordance. + */ +@Composable +expect fun PlatformBackHandler( + enabled: Boolean, + onBack: () -> Unit, +) diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/nav/Screen.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/nav/Screen.kt new file mode 100644 index 0000000..ffe5430 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/nav/Screen.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.nav + +/** + * Harness destinations. [Home] is the hub (index 0); [modules] lists the seven feature screens that + * Home links to, in display order. [index] doubles as the app-bar counter value ("NN / 07"). + */ +sealed interface Screen { + val routeId: String + val title: String + val index: Int + val subtitle: String + + data object Home : Screen { + override val routeId = "home" + override val title = "Jindong" + override val index = 0 + override val subtitle = "Device capability + module hub" + } + + data object Single : Screen { + override val routeId = "single" + override val title = "Single Haptic" + override val index = 1 + override val subtitle = "Duration × intensity, isolated" + } + + data object Intensity : Screen { + override val routeId = "intensity" + override val title = "Intensity Lab" + override val index = 2 + override val subtitle = "Compare 5 strength levels" + } + + data object Timing : Screen { + override val routeId = "timing" + override val title = "Timing" + override val index = 3 + override val subtitle = "Delay & sequence rhythm" + } + + data object Repeat : Screen { + override val routeId = "repeat" + override val title = "Repeat" + override val index = 4 + override val subtitle = "Static N-times repeat" + } + + data object RepeatIdx : Screen { + override val routeId = "repeat-index" + override val title = "Repeat w/ Index" + override val index = 5 + override val subtitle = "Per-cycle intensity ramp" + } + + data object Preset : Screen { + override val routeId = "preset" + override val title = "Preset Gallery" + override val index = 6 + override val subtitle = "10 real-world patterns" + } + + data object Reactive : Screen { + override val routeId = "reactive" + override val title = "Reactive" + override val index = 7 + override val subtitle = "State-driven · no buttons ★" + } + + companion object { + val modules: List = + listOf(Single, Intensity, Timing, Repeat, RepeatIdx, Preset, Reactive) + } +} diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/HarnessLogic.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/HarnessLogic.kt new file mode 100644 index 0000000..9c7361c --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/HarnessLogic.kt @@ -0,0 +1,174 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.screens + +import androidx.compose.ui.graphics.Color +import io.github.compose.jindong.core.model.HapticIntensity +import io.github.compose.jindong.core.model.HapticPattern +import io.github.compose.jindong.core.model.ScheduledHapticEvent +import kotlin.math.ceil + +// Pure, side-effect-free logic shared across the harness screens. Kept out of the composables so the +// timeline math (which must always reflect live control values) is trivially correct and verifiable. + +/** Intensity-chip identifiers for the Single Haptic screen, in display order. */ +internal val intensityChips: List = listOf("LIGHT", "MEDIUM", "STRONG", "HIGH", "CUSTOM") + +/** The four amplitude presets shown on the Intensity Lab, ascending. */ +internal val ascendingPresets: List = + listOf(HapticIntensity.LIGHT, HapticIntensity.MEDIUM, HapticIntensity.STRONG, HapticIntensity.HIGH) + +/** Maps a preset chip label to its fixed intensity value (CUSTOM resolves at the call site). */ +internal fun presetIntensity(chip: String): HapticIntensity = when (chip) { + "LIGHT" -> HapticIntensity.LIGHT + "MEDIUM" -> HapticIntensity.MEDIUM + "STRONG" -> HapticIntensity.STRONG + "HIGH" -> HapticIntensity.HIGH + else -> HapticIntensity.MEDIUM +} + +/** Ramp direction for Repeat-with-Index. */ +internal enum class RampDirection { In, Out, None } + +/** + * Per-cycle intensity ramp for iteration [i] of [count] passes (handoff 05). + * + * `f = (count <= 1) ? 0 : i / (count - 1)` + * - [RampDirection.In]: `0.2 + 0.8f` (weak to strong) + * - [RampDirection.Out]: `1.0 - 0.8f` (strong to weak) + * - [RampDirection.None]: flat `0.7` + */ +internal fun ramp( + i: Int, + count: Int, + dir: RampDirection, +): Float { + val f = if (count <= 1) 0f else i.toFloat() / (count - 1) + return when (dir) { + RampDirection.In -> 0.2f + 0.8f * f + RampDirection.Out -> 1.0f - 0.8f * f + RampDirection.None -> 0.7f + } +} + +/** + * Timeline window for the Timing screen (handoff 03): `max(600, ceil(total / 200) * 200)` where + * [totalMs] is the sum of all node durations, floored at 200. + */ +internal fun timingWindow(totalMs: Int): Long { + val total = maxOf(200, totalMs) + val rounded = ceil(total / 200.0).toLong() * 200L + return maxOf(600L, rounded) +} + +/** A node in the Timing screen rhythm builder: either a tap of [ms] or a pause of [ms] (handoff 03). */ +internal enum class NodeType { Tap, Pause } + +internal data class Node( + val type: NodeType, + val ms: Int, +) + +/** A single literal event tuple `(start, dur, intensity)` for a preset (handoff 06). */ +internal data class PresetEvent( + val start: Long, + val dur: Long, + val intensity: Float, +) + +/** A preset gallery entry: name, description, tone color, total span and its literal event list. */ +internal data class Preset( + val name: String, + val desc: String, + val tone: Long, + val span: Long, + val events: List, +) + +private fun ev( + start: Long, + dur: Long, + intensity: Float, +) = PresetEvent(start, dur, intensity) + +// Tone colors copied verbatim from handoff 06. Neutral entries resolve to text3 at the call site. +private const val TONE_NEUTRAL = 0L // sentinel: render with text3 +private const val TONE_GREEN = 0xFF22C55E +private const val TONE_BLUE = 0xFF2F5FE0 +private const val TONE_RED = 0xFFEF4444 +private const val TONE_ORANGE = 0xFFF59E0B + +/** The ten real-world presets (handoff 06), event tuples and tone hex copied verbatim. */ +internal val presets: List = + listOf( + Preset("Single Tap", "50ms single", TONE_NEUTRAL, 200, listOf(ev(0, 50, 0.6f))), + Preset("Double Tap", "tap-pause-tap", TONE_NEUTRAL, 300, listOf(ev(0, 40, 0.6f), ev(130, 40, 0.6f))), + Preset( + "Triple Tap", + "(tap+pause) × 3", + TONE_NEUTRAL, + 420, + listOf(ev(0, 40, 0.6f), ev(130, 40, 0.6f), ev(260, 40, 0.6f)), + ), + Preset("Light Feedback", "40ms LIGHT", TONE_GREEN, 160, listOf(ev(0, 40, 0.25f))), + Preset("Strong Feedback", "80ms STRONG", TONE_BLUE, 200, listOf(ev(0, 80, 0.75f))), + Preset( + "Heartbeat", + "strong-soft × 2, long rest", + TONE_RED, + 900, + listOf(ev(0, 60, 0.9f), ev(110, 50, 0.4f), ev(480, 60, 0.9f), ev(590, 50, 0.4f)), + ), + Preset("Notification", "soft → strong", TONE_ORANGE, 340, listOf(ev(0, 50, 0.4f), ev(160, 60, 0.8f))), + Preset( + "Success", + "LIGHT→MEDIUM→STRONG", + TONE_GREEN, + 460, + listOf(ev(0, 50, 0.3f), ev(150, 50, 0.55f), ev(300, 60, 0.8f)), + ), + Preset( + "Error", + "HIGH × 3, urgent", + TONE_RED, + 360, + listOf(ev(0, 60, 1.0f), ev(120, 60, 1.0f), ev(240, 60, 1.0f)), + ), + Preset( + "Ramp Up", + "LIGHT→MEDIUM→STRONG→HIGH", + TONE_BLUE, + 560, + listOf(ev(0, 50, 0.25f), ev(140, 50, 0.5f), ev(280, 50, 0.75f), ev(420, 60, 1.0f)), + ), + ) + +/** Resolves a preset tone constant to a Color, or null for the neutral (text3) sentinel. */ +internal fun Preset.toneColor(): Color? = if (tone == TONE_NEUTRAL) null else Color(tone) + +/** Timeline window for a preset card (handoff 06): `max(span * 1.1, 200)`. */ +internal fun presetWindow(span: Long): Long = maxOf((span * 1.1).toLong(), 200L) + +/** Builds a [HapticPattern] from a preset's literal event tuples. */ +internal fun Preset.toPattern(): HapticPattern = HapticPattern( + events.map { event -> + ScheduledHapticEvent( + startTimeMs = event.start, + durationMs = event.dur, + intensity = HapticIntensity.Custom(event.intensity), + ) + }, +) diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/HomeScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/HomeScreen.kt new file mode 100644 index 0000000..893abba --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/HomeScreen.kt @@ -0,0 +1,223 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import io.github.compose.jindong.HapticPlatform +import io.github.compose.jindong.rememberHapticCapabilities +import io.github.compose.jindong.sample.components.CapabilityBadge +import io.github.compose.jindong.sample.components.JindongIcons +import io.github.compose.jindong.sample.components.MonoLabel +import io.github.compose.jindong.sample.components.ScreenDescription +import io.github.compose.jindong.sample.components.SectionCard +import io.github.compose.jindong.sample.components.VGap +import io.github.compose.jindong.sample.nav.Screen +import io.github.compose.jindong.sample.theme.Dimens +import io.github.compose.jindong.sample.theme.JindongTheme + +/** + * Home / Device Capability hub (handoff 00). Reads live [rememberHapticCapabilities] into three + * badges, then lists the seven feature modules. When the device lacks amplitude control on Android, + * modules 02 and 05 carry a warn caption because their intensity steps will feel identical. + */ +@Composable +fun HomeScreen( + onModuleClick: (Screen) -> Unit, + modifier: Modifier = Modifier, +) { + val colors = JindongTheme.colors + val caps = rememberHapticCapabilities() + val isAndroid = caps.platform == HapticPlatform.Android + val amplitudeFlat = isAndroid && !caps.hasAmplitudeControl + + Column(modifier = modifier.fillMaxWidth()) { + ScreenDescription( + buildAnnotatedString { + append("A verification harness for the Jindong haptic library. Each module isolates one ") + append("variable so you can ") + withStyle(SpanStyle(color = colors.text, fontWeight = FontWeight.SemiBold)) { + append("see vibration") + } + append(" on the timeline.") + }, + ) + + VGap(18.dp) + + SectionCard(radius = Dimens.radiusBigCard, padding = 0.dp) { + MonoLabel( + text = "DEVICE CAPABILITY", + modifier = Modifier.padding(start = 15.dp, top = 12.dp, end = 15.dp, bottom = 9.dp), + ) + CapabilityRow( + key = "Haptics supported", + value = if (caps.isSupported) "Yes" else "No", + dotColor = if (caps.isSupported) colors.ok else colors.warn, + ) + CapabilityRow( + key = "Amplitude control", + value = + when { + caps.platform == HapticPlatform.Ios -> "Continuous" + caps.hasAmplitudeControl -> "Yes" + else -> "No" + }, + dotColor = + if (caps.platform == HapticPlatform.Ios || caps.hasAmplitudeControl) { + colors.ok + } else { + colors.warn + }, + ) + CapabilityRow( + key = "Platform", + value = if (isAndroid) "Android" else "iOS", + dotColor = colors.text3, + ) + } + + VGap(20.dp) + + MonoLabel(text = "MODULES", modifier = Modifier.padding(bottom = 4.dp)) + + Screen.modules.forEach { module -> + val warnCaption = + if (amplitudeFlat && (module == Screen.Intensity || module == Screen.RepeatIdx)) { + "On this device, intensity levels feel identical" + } else { + null + } + ModuleRow( + screen = module, + warnCaption = warnCaption, + onClick = { onModuleClick(module) }, + ) + } + } +} + +/** Single capability badge row with a top [divider] hairline and 11/15 padding. */ +@Composable +private fun CapabilityRow( + key: String, + value: String, + dotColor: androidx.compose.ui.graphics.Color, +) { + val colors = JindongTheme.colors + val strokePx = with(LocalDensity.current) { Dimens.stroke.toPx() } + Box( + modifier = + Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = colors.divider, + start = Offset(0f, strokePx / 2f), + end = Offset(size.width, strokePx / 2f), + strokeWidth = strokePx, + ) + } + .padding(horizontal = 15.dp, vertical = 11.dp), + ) { + CapabilityBadge(key = key, value = value, dotColor = dotColor) + } +} + +/** Tappable module row: mono number, title + subtitle stack, trailing chevron, divider underline. */ +@Composable +private fun ModuleRow( + screen: Screen, + warnCaption: String?, + onClick: () -> Unit, +) { + val colors = JindongTheme.colors + val strokePx = with(LocalDensity.current) { Dimens.stroke.toPx() } + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Dimens.radiusSmall)) + .clickable(onClick = onClick) + .drawBehind { + drawLine( + color = colors.divider, + start = Offset(0f, size.height - strokePx / 2f), + end = Offset(size.width, size.height - strokePx / 2f), + strokeWidth = strokePx, + ) + } + .padding(vertical = 14.dp, horizontal = 4.dp), + horizontalArrangement = Arrangement.spacedBy(Dimens.rowGapLarge), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = pad2(screen.index), + style = JindongTheme.typography.chipLabel.copy(fontWeight = FontWeight.Normal), + color = colors.text3, + modifier = Modifier.widthIn(min = 18.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text(text = screen.title, style = JindongTheme.typography.cardTitle, color = colors.text) + Text( + text = screen.subtitle, + style = JindongTheme.typography.bodySmall, + color = colors.text3, + modifier = Modifier.padding(top = 1.dp), + ) + if (warnCaption != null) { + Text( + text = warnCaption, + style = JindongTheme.typography.chipLabel.copy(fontWeight = FontWeight.Normal), + color = colors.warn, + modifier = Modifier.padding(top = 3.dp), + ) + } + } + Icon( + imageVector = JindongIcons.ChevronRight, + contentDescription = null, + tint = colors.border2, + modifier = Modifier.size(16.dp), + ) + } +} + +private fun pad2(value: Int): String = if (value < 10) "0$value" else value.toString() diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/IntensityLabScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/IntensityLabScreen.kt new file mode 100644 index 0000000..19fd5f2 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/IntensityLabScreen.kt @@ -0,0 +1,302 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import io.github.compose.jindong.Jindong +import io.github.compose.jindong.core.dsl.buildHapticPattern +import io.github.compose.jindong.core.model.HapticIntensity +import io.github.compose.jindong.core.ms +import io.github.compose.jindong.dsl.Delay +import io.github.compose.jindong.dsl.Haptic +import io.github.compose.jindong.dsl.Sequence +import io.github.compose.jindong.sample.components.HapticTimeline +import io.github.compose.jindong.sample.components.JindongIcons +import io.github.compose.jindong.sample.components.PlayButton +import io.github.compose.jindong.sample.components.ScreenDescription +import io.github.compose.jindong.sample.components.TimelineMapper +import io.github.compose.jindong.sample.components.VGap +import io.github.compose.jindong.sample.components.formatFixed +import io.github.compose.jindong.sample.components.timelineAccentColor +import io.github.compose.jindong.sample.theme.Dimens +import io.github.compose.jindong.sample.theme.JindongTheme + +/** + * Intensity Lab (handoff 02): compare LIGHT/MEDIUM/STRONG/HIGH at a fixed 100ms, plus a custom + * fader. The timeline draws an ascending staircase; "Play all" fires the four presets in sequence. + */ +@Composable +fun IntensityLabScreen(modifier: Modifier = Modifier) { + val colors = JindongTheme.colors + var ilCustom by remember { mutableFloatStateOf(0.6f) } + // One trigger per actionable button so each tap re-fires its own pattern key. + var playAll by remember { mutableIntStateOf(0) } + var rowTrigger by remember { mutableIntStateOf(0) } + var rowIntensity by remember { mutableStateOf(HapticIntensity.LIGHT) } + var customTrigger by remember { mutableIntStateOf(0) } + + val window = 900L + // Ascending staircase: each preset is 100ms with an 80ms gap, so events land at 20/200/380/560ms. + val staircase = + buildHapticPattern { + ascendingPresets.forEachIndexed { i, intensity -> + if (i == 0) delay(20.ms) else delay(80.ms) + haptic(100.ms, intensity) + } + } + val accent = timelineAccentColor() + val bars = TimelineMapper.toBars(staircase, window) { accent } + + Column(modifier = modifier.fillMaxWidth()) { + ScreenDescription( + buildAnnotatedString { + append("Compare strength levels side by side. ") + withStyle(SpanStyle(color = colors.text, fontWeight = FontWeight.SemiBold)) { + append("Duration fixed at 100 ms") + } + append(" — only height changes.") + }, + ) + + VGap(14.dp) + + HapticTimeline( + bars = bars, + topLeft = "INTENSITY ▲", + topRight = "0.25 → 1.00", + minLabel = "0", + maxLabel = "900", + playheadProgress = 0f, + playheadVisible = false, + ) + + VGap(16.dp) + + PlayButton(onClick = { playAll++ }, text = "Play all (ascending)") + + VGap(18.dp) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ascendingPresets.forEach { intensity -> + IntensityRow( + intensity = intensity, + onPlay = { + rowIntensity = intensity + rowTrigger++ + }, + ) + } + } + + VGap(14.dp) + + CustomCard( + value = ilCustom, + onValueChange = { ilCustom = it }, + onPlay = { customTrigger++ }, + ) + } + + // VIBRATION PATH: key only on the explicit play triggers so moving a control never auto-fires + // (Reactive is the auto-firing screen). Each block reads live state when its trigger bumps. + Jindong(playAll) { + Sequence { + // Match the rendered staircase exactly: 100ms pulses with a 20ms lead-in then 80ms gaps. + ascendingPresets.forEachIndexed { i, intensity -> + if (i == 0) Delay(20.ms) else Delay(80.ms) + Haptic(100.ms, intensity) + } + } + } + Jindong(rowTrigger) { + Haptic(100.ms, rowIntensity) + } + Jindong(customTrigger) { + Haptic(100.ms, HapticIntensity.Custom(ilCustom)) + } +} + +/** One intensity row: mini bar preview, label + "100 ms · v", trailing play affordance. */ +@Composable +private fun IntensityRow( + intensity: HapticIntensity, + onPlay: () -> Unit, +) { + val colors = JindongTheme.colors + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Dimens.radiusCard)) + .border(Dimens.stroke, colors.border, RoundedCornerShape(Dimens.radiusCard)) + .padding(horizontal = 13.dp, vertical = 11.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MiniBar(intensity.value, accent = colors.accent) + Column(modifier = Modifier.weight(1f)) { + Text( + text = intensityLabel(intensity), + style = JindongTheme.typography.rowTitle, + color = colors.text, + ) + Text( + text = "100 ms · ${formatFixed(intensity.value, 2)}", + style = JindongTheme.typography.monoMicro, + color = colors.text3, + ) + } + PlayChip(onClick = onPlay) + } +} + +/** 40x34 mini bar area with a width-14 accent bar whose height is `intensity * 30`dp. */ +@Composable +private fun MiniBar( + intensity: Float, + accent: Color, +) { + Box( + modifier = Modifier.width(40.dp).height(34.dp), + contentAlignment = Alignment.BottomStart, + ) { + Box( + modifier = + Modifier + .padding(start = 13.dp) + .width(14.dp) + .height((intensity * 30f).dp) + .clip(RoundedCornerShape(topStart = 2.dp, topEnd = 2.dp)) + .background(accent), + ) + } +} + +/** Custom intensity card: label row + value, accent slider, trailing play. */ +@Composable +private fun CustomCard( + value: Float, + onValueChange: (Float) -> Unit, + onPlay: () -> Unit, +) { + val colors = JindongTheme.colors + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Dimens.radiusCard)) + .border(Dimens.stroke, colors.border, RoundedCornerShape(Dimens.radiusCard)) + .padding(13.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 9.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = "Custom", style = JindongTheme.typography.rowTitle, color = colors.text) + Text( + text = formatFixed(value, 2), + style = JindongTheme.typography.monoValue.copy(fontSize = androidx.compose.ui.unit.TextUnit.Unspecified), + color = colors.text, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Slider( + value = value, + onValueChange = onValueChange, + valueRange = 0f..1f, + steps = 99, + colors = + SliderDefaults.colors( + thumbColor = colors.accent, + activeTrackColor = colors.accent, + inactiveTrackColor = colors.border2, + ), + modifier = Modifier.weight(1f), + ) + PlayChip(onClick = onPlay) + } + } +} + +/** Small bordered play affordance used on the rows and the custom card (>= 48dp hit area). */ +@Composable +private fun PlayChip(onClick: () -> Unit) { + val colors = JindongTheme.colors + // min-touch + clickable on the same node as the bordered pill so the full hit area is tappable. + Box( + modifier = + Modifier + .defaultMinSize(minWidth = Dimens.minTouch, minHeight = Dimens.minTouch) + .clip(RoundedCornerShape(Dimens.radiusSmall)) + .border(Dimens.stroke, colors.border2, RoundedCornerShape(Dimens.radiusSmall)) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 7.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = JindongIcons.Play, + contentDescription = "Play", + tint = colors.text2, + modifier = Modifier.size(13.dp), + ) + } +} + +private fun intensityLabel(intensity: HapticIntensity): String = when (intensity) { + HapticIntensity.LIGHT -> "LIGHT" + HapticIntensity.MEDIUM -> "MEDIUM" + HapticIntensity.STRONG -> "STRONG" + HapticIntensity.HIGH -> "HIGH" + else -> "CUSTOM" +} diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/PresetGalleryScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/PresetGalleryScreen.kt new file mode 100644 index 0000000..11c0faa --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/PresetGalleryScreen.kt @@ -0,0 +1,213 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.screens + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.github.compose.jindong.Jindong +import io.github.compose.jindong.core.model.HapticIntensity +import io.github.compose.jindong.core.ms +import io.github.compose.jindong.dsl.Delay +import io.github.compose.jindong.dsl.Haptic +import io.github.compose.jindong.dsl.Sequence +import io.github.compose.jindong.sample.components.HapticTimeline +import io.github.compose.jindong.sample.components.JindongIcons +import io.github.compose.jindong.sample.components.ScreenDescription +import io.github.compose.jindong.sample.components.TimelineMapper +import io.github.compose.jindong.sample.components.VGap +import io.github.compose.jindong.sample.theme.Dimens +import io.github.compose.jindong.sample.theme.JindongTheme + +/** + * Preset Gallery (handoff 06): ten real-world patterns built from the primitives, each an accordion + * card with a tone-colored timeline. One card open at a time (Heartbeat initially). + */ +@Composable +fun PresetGalleryScreen(modifier: Modifier = Modifier) { + val colors = JindongTheme.colors + var open by remember { mutableStateOf("Heartbeat") } + var playName by remember { mutableStateOf("") } + var playTrigger by remember { mutableIntStateOf(0) } + + Column(modifier = modifier.fillMaxWidth()) { + ScreenDescription( + buildAnnotatedString { + append("Ten real-world patterns built from the primitives above. Tap a card to expand its timeline.") + }, + ) + + VGap(16.dp) + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + presets.forEach { preset -> + PresetCard( + preset = preset, + expanded = open == preset.name, + onToggle = { open = if (open == preset.name) "" else preset.name }, + onPlay = { + open = preset.name + playName = preset.name + playTrigger++ + }, + ) + } + } + } + + // VIBRATION PATH (stale pre-#84; see SingleHapticScreen for the rationale). Each preset's literal + // events are replayed serially via the Compose DSL; the timeline cards above are always live. + val current = remember(playName) { presets.firstOrNull { it.name == playName } } + if (current != null) { + Jindong(playTrigger, playName) { + Sequence { + var cursor = 0L + current.events.forEach { event -> + val gap = event.start - cursor + if (gap > 0) Delay(gap.ms) + Haptic(event.dur.ms, HapticIntensity.Custom(event.intensity)) + cursor = event.start + event.dur + } + } + } + } +} + +/** One accordion preset card: tone dot, name/desc, play affordance, caret, and an expanded timeline. */ +@Composable +private fun PresetCard( + preset: Preset, + expanded: Boolean, + onToggle: () -> Unit, + onPlay: () -> Unit, +) { + val colors = JindongTheme.colors + val tone = preset.toneColor() ?: colors.text3 + val caretRotation by animateFloatAsState(if (expanded) 180f else 0f) + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Dimens.radiusBigCard)) + .border(Dimens.stroke, colors.border, RoundedCornerShape(Dimens.radiusBigCard)), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(Dimens.cardPadding), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.size(9.dp).clip(CircleShape).background(tone)) + Column(modifier = Modifier.weight(1f)) { + Text(text = preset.name, style = JindongTheme.typography.cardTitle, color = colors.text) + Text( + text = preset.desc, + style = JindongTheme.typography.bodySmall, + color = colors.text3, + modifier = Modifier.padding(top = 1.dp), + ) + } + PresetPlayChip(onClick = onPlay) + Icon( + imageVector = JindongIcons.CaretDown, + contentDescription = if (expanded) "Collapse" else "Expand", + tint = colors.text3, + modifier = Modifier.size(16.dp).rotate(caretRotation), + ) + } + + if (expanded) { + Box(modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 14.dp)) { + PresetTimeline(preset = preset, tone = tone) + } + } + } +} + +/** Tone-colored timeline for the expanded preset, window = max(span * 1.1, 200). */ +@Composable +private fun PresetTimeline( + preset: Preset, + tone: Color, +) { + val window = presetWindow(preset.span) + val pattern = remember(preset.name) { preset.toPattern() } + val bars = TimelineMapper.toBars(pattern, window) { tone } + HapticTimeline( + bars = bars, + topLeft = "INTENSITY ▲", + topRight = "${preset.span} ms", + minLabel = "0", + maxLabel = window.toString(), + playheadProgress = 0f, + playheadVisible = false, + ) +} + +/** Small bordered play affordance on each preset header (>= 48dp hit area). */ +@Composable +private fun PresetPlayChip(onClick: () -> Unit) { + val colors = JindongTheme.colors + // min-touch + clickable on the same node as the bordered pill so the full hit area is tappable. + Box( + modifier = + Modifier + .defaultMinSize(minWidth = Dimens.minTouch, minHeight = Dimens.minTouch) + .clip(RoundedCornerShape(Dimens.radiusSmall)) + .border(Dimens.stroke, colors.border2, RoundedCornerShape(Dimens.radiusSmall)) + .clickable(onClick = onClick) + .padding(horizontal = 13.dp, vertical = 7.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = JindongIcons.Play, + contentDescription = "Play", + tint = colors.text2, + modifier = Modifier.size(13.dp), + ) + } +} diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/ReactiveScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/ReactiveScreen.kt new file mode 100644 index 0000000..a9521ea --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/ReactiveScreen.kt @@ -0,0 +1,400 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.screens + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import io.github.compose.jindong.Jindong +import io.github.compose.jindong.core.model.HapticIntensity +import io.github.compose.jindong.core.model.HapticPattern +import io.github.compose.jindong.core.model.ScheduledHapticEvent +import io.github.compose.jindong.core.ms +import io.github.compose.jindong.dsl.Delay +import io.github.compose.jindong.dsl.Haptic +import io.github.compose.jindong.dsl.Repeat +import io.github.compose.jindong.sample.components.HapticTimeline +import io.github.compose.jindong.sample.components.LabeledSlider +import io.github.compose.jindong.sample.components.ScreenDescription +import io.github.compose.jindong.sample.components.TimelineMapper +import io.github.compose.jindong.sample.components.VGap +import io.github.compose.jindong.sample.components.formatFixed +import io.github.compose.jindong.sample.components.timelineAccentColor +import io.github.compose.jindong.sample.theme.Dimens +import io.github.compose.jindong.sample.theme.JindongTheme +import kotlin.math.roundToLong + +/** + * Reactive / State-driven (handoff 07): the one differentiator — moving a control re-fires the + * pattern with no Play button, and the pattern itself changes with state. This screen doubles as a + * regression oracle: each card's Expected/Actual proves whether the live path tracks the controls. + * + * NOTE: pre-#84 the VIBRATION (the actual buzz from `Jindong`) is the bug surface — it replays the + * first-compiled pattern. The TIMELINE here stays live (it never touches the buggy remember), so the + * Expected/Actual comparison still reads true; once #84 lands the buzz matches with zero changes. + */ +@Composable +fun ReactiveScreen(modifier: Modifier = Modifier) { + val colors = JindongTheme.colors + + Column(modifier = modifier.fillMaxWidth()) { + ScreenDescription( + buildAnnotatedString { + append("No play buttons here. Move a control and the pattern ") + withStyleSemiBold(colors.text) { append("re-fires by itself") } + append(" — that's Jindong reacting to state.") + }, + ) + + VGap(8.dp) + + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 18.dp), + horizontalArrangement = Arrangement.spacedBy(Dimens.rowGapSmall), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.size(6.dp).clip(CircleShape).background(colors.ok)) + Text( + text = "REGRESSION ORACLE · timeline must update on every change", + style = JindongTheme.typography.monoMicro, + color = colors.text3, + ) + } + + ThresholdCard() + CountCard() + MultiInputCard() + } +} + +/** Card A — Threshold branch. value >= 3 → strong pulse (0.9) else soft (0.3). */ +@Composable +private fun ThresholdCard() { + var rxThresh by remember { mutableIntStateOf(2) } + val strong = rxThresh >= 3 + val intensity = if (strong) 0.9f else 0.3f + val accent = timelineAccentColor() + val pattern = remember(intensity) { singleBar(start = 20L, dur = 120L, intensity = intensity) } + val bars = TimelineMapper.toBars(pattern, 600L) { accent } + // EXPECTED comes from the control logic; ACTUAL is read back from the rendered bar, so the two + // diverge if singleBar / TimelineMapper ever stops tracking state (a real regression oracle). + val expected = if (strong) "tall bar (0.90)" else "short bar (0.30)" + val actualHeight = bars.firstOrNull()?.let { barIntensity(it) } ?: 0f + val actual = (if (actualHeight >= 0.5f) "tall bar (" else "short bar (") + formatFixed(actualHeight, 2) + ")" + + CaseCard( + title = "Threshold branch", + tag = "multi-key", + rule = "value ≥ 3 → strong pulse, else soft. Crossing the line re-fires instantly.", + flashKey = rxThresh, + bars = bars, + topRight = if (strong) "STRONG" else "soft", + maxLabel = "600", + expected = expected, + actual = actual, + controls = { + LabeledSlider( + label = "Value", + valueText = rxThresh.toString(), + value = rxThresh.toFloat(), + onValueChange = { rxThresh = it.toInt() }, + valueRange = 0f..5f, + steps = 4, + ) + }, + ) + + // VIBRATION PATH (stale pre-#84). The card's timeline is live; the buzz self-heals once #84 lands. + Jindong(rxThresh) { + Haptic(120.ms, HapticIntensity.Custom(intensity)) + } +} + +/** Card B — Count reaction. count bars follow the slider. */ +@Composable +private fun CountCard() { + var rxCount by remember { mutableIntStateOf(3) } + val accent = timelineAccentColor() + val window = 700L // (60 + 80) * 5 + val pattern = remember(rxCount) { reactiveCountPattern(rxCount) } + val bars = TimelineMapper.toBars(pattern, window) { accent } + // ACTUAL counts the rendered bars, not the slider value, so a mapping regression would show up. + val expected = "$rxCount bars" + val actual = "${bars.size} bars" + + CaseCard( + title = "Count reaction", + tag = "multi-key", + rule = "Repeat count follows the slider. Bar count must match live.", + flashKey = rxCount, + bars = bars, + topRight = "× $rxCount", + maxLabel = window.toString(), + expected = expected, + actual = actual, + controls = { + LabeledSlider( + label = "Repeat count", + valueText = rxCount.toString(), + value = rxCount.toFloat(), + onValueChange = { rxCount = it.toInt() }, + valueRange = 1f..5f, + steps = 3, + ) + }, + ) + + // VIBRATION PATH (stale pre-#84). The card's timeline is live; the buzz self-heals once #84 lands. + Jindong(rxCount) { + Repeat(rxCount) { + Haptic(60.ms, HapticIntensity.Custom(0.7f)) + Delay(80.ms) + } + } +} + +/** Card C — Multiple inputs. duration + intensity feed one pattern that re-fires on any change. */ +@Composable +private fun MultiInputCard() { + var rxDur by remember { mutableIntStateOf(160) } + var rxInt by remember { mutableFloatStateOf(0.6f) } + val accent = timelineAccentColor() + val window = 600L + val pattern = remember(rxDur, rxInt) { singleBar(start = 20L, dur = rxDur.toLong(), intensity = rxInt) } + val bars = TimelineMapper.toBars(pattern, window) { accent } + // ACTUAL is reconstructed from the rendered bar fractions (width*window, height/0.98), so it only + // matches EXPECTED while the bar genuinely tracks both faders. + val expected = "width ${rxDur}ms, height ${formatFixed(rxInt, 1)}" + val actual = + bars.firstOrNull()?.let { bar -> + "width ${(bar.widthFraction * window).roundToLong()}ms, height ${formatFixed(barIntensity(bar), 1)}" + } ?: "no bar" + + CaseCard( + title = "Multiple inputs", + tag = "2 keys", + rule = "Change either fader — both feed one pattern that re-fires on any change.", + flashKey = rxDur * 1000 + (rxInt * 100).toInt(), + bars = bars, + topRight = "${rxDur}ms · ${formatFixed(rxInt, 1)}", + maxLabel = window.toString(), + expected = expected, + actual = actual, + controls = { + LabeledSlider( + label = "Duration", + valueText = "$rxDur ms", + value = rxDur.toFloat(), + onValueChange = { rxDur = it.toInt() }, + valueRange = 20f..400f, + steps = 37, + ) + VGap(14.dp) + LabeledSlider( + label = "Intensity", + valueText = formatFixed(rxInt, 2), + value = rxInt, + onValueChange = { rxInt = it }, + valueRange = 0f..1f, + steps = 19, + ) + }, + ) + + // VIBRATION PATH (stale pre-#84). The card's timeline is live; the buzz self-heals once #84 lands. + Jindong(rxDur, rxInt) { + Haptic(rxDur.ms, HapticIntensity.Custom(rxInt)) + } +} + +/** Shared case-card chrome: flash bar, header, rule, live timeline, controls, Expected/Actual. */ +@Composable +private fun CaseCard( + title: String, + tag: String, + rule: String, + flashKey: Any, + bars: List, + topRight: String, + maxLabel: String, + expected: String, + actual: String, + controls: @Composable () -> Unit, +) { + val colors = JindongTheme.colors + // Flash bar: alpha 1 -> 0 over 0.4s whenever the card's state changes (handoff 07). + val flash = remember { Animatable(0f) } + LaunchedEffect(flashKey) { + flash.snapTo(1f) + flash.animateTo(0f, animationSpec = tween(durationMillis = 400)) + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 14.dp) + .clip(RoundedCornerShape(Dimens.radiusBigCard)) + .border(Dimens.stroke, colors.border, RoundedCornerShape(Dimens.radiusBigCard)), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(2.dp) + .alpha(flash.value) + .background(colors.accent), + ) + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = title, style = JindongTheme.typography.cardTitle, color = colors.text) + Text(text = tag, style = JindongTheme.typography.monoMicro, color = colors.text3) + } + Text( + text = rule, + style = JindongTheme.typography.bodySmall, + color = colors.text3, + modifier = Modifier.padding(top = 4.dp, bottom = 14.dp), + ) + + HapticTimeline( + bars = bars, + topLeft = "INTENSITY ▲", + topRight = topRight, + minLabel = "0", + maxLabel = maxLabel, + playheadProgress = 0f, + playheadVisible = false, + ) + + Column(modifier = Modifier.padding(top = 14.dp)) { controls() } + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + ExpectedActualBox(label = "EXPECTED", value = expected, accentBox = false, modifier = Modifier.weight(1f)) + ExpectedActualBox(label = "ACTUAL", value = actual, accentBox = true, modifier = Modifier.weight(1f)) + } + } + } +} + +/** Expected/Actual box: surface2 + border for expected, accentBg + accent for actual. */ +@Composable +private fun ExpectedActualBox( + label: String, + value: String, + accentBox: Boolean, + modifier: Modifier = Modifier, +) { + val colors = JindongTheme.colors + Column( + modifier = + modifier + .clip(RoundedCornerShape(Dimens.radiusChip)) + .background(if (accentBox) colors.accentBg else colors.surface2) + .border( + Dimens.stroke, + if (accentBox) colors.accent else colors.border, + RoundedCornerShape(Dimens.radiusChip), + ) + .padding(horizontal = 11.dp, vertical = 9.dp), + ) { + Text( + text = label, + style = JindongTheme.typography.monoAxis.copy(letterSpacing = androidx.compose.ui.unit.TextUnit.Unspecified), + color = if (accentBox) colors.accent else colors.text3, + ) + Text( + text = value, + style = JindongTheme.typography.bodySmall.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold), + color = colors.text, + modifier = Modifier.padding(top = 2.dp), + ) + } +} + +private inline fun androidx.compose.ui.text.AnnotatedString.Builder.withStyleSemiBold( + color: androidx.compose.ui.graphics.Color, + block: androidx.compose.ui.text.AnnotatedString.Builder.() -> Unit, +) { + withStyle( + androidx.compose.ui.text.SpanStyle( + color = color, + fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, + ), + ) { block() } +} + +/** + * Reconstructs an event intensity from a rendered [TimelineBar]. Inverse of `TimelineMapper`'s + * `heightFraction = max(0.06, intensity * 0.98)`; used by the Reactive cards so ACTUAL is read from + * the rendered bar rather than re-derived from the same state as EXPECTED. + */ +internal fun barIntensity(bar: io.github.compose.jindong.sample.components.TimelineBar): Float = (bar.heightFraction / 0.98f).coerceIn(0f, 1f) + +/** A one-bar pattern at [start]/[dur]/[intensity] (Cards A and C). */ +internal fun singleBar( + start: Long, + dur: Long, + intensity: Float, +): HapticPattern = HapticPattern( + listOf(ScheduledHapticEvent(startTimeMs = start, durationMs = dur, intensity = HapticIntensity.Custom(intensity))), +) + +/** Card B timeline pattern: count bars, dur 60, gap 80, intensity 0.7. */ +internal fun reactiveCountPattern(count: Int): HapticPattern { + val events = + (0 until count).map { i -> + ScheduledHapticEvent( + startTimeMs = (i * 140L), + durationMs = 60L, + intensity = HapticIntensity.Custom(0.7f), + ) + } + return HapticPattern(events) +} diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/RepeatScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/RepeatScreen.kt new file mode 100644 index 0000000..9bf163d --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/RepeatScreen.kt @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import io.github.compose.jindong.Jindong +import io.github.compose.jindong.core.model.HapticIntensity +import io.github.compose.jindong.core.model.HapticPattern +import io.github.compose.jindong.core.model.ScheduledHapticEvent +import io.github.compose.jindong.core.ms +import io.github.compose.jindong.dsl.Delay +import io.github.compose.jindong.dsl.Haptic +import io.github.compose.jindong.dsl.Repeat +import io.github.compose.jindong.sample.components.HapticTimeline +import io.github.compose.jindong.sample.components.LabeledSlider +import io.github.compose.jindong.sample.components.MonoLabel +import io.github.compose.jindong.sample.components.PlayButton +import io.github.compose.jindong.sample.components.ScreenDescription +import io.github.compose.jindong.sample.components.TimelineMapper +import io.github.compose.jindong.sample.components.VGap +import io.github.compose.jindong.sample.components.timelineAccentColor +import io.github.compose.jindong.sample.theme.Dimens +import io.github.compose.jindong.sample.theme.JindongTheme + +private const val UNIT_INTENSITY = 0.7f + +/** + * Repeat (handoff 04): repeat one unit pattern N times (static). The timeline window is fixed at + * 1500ms (max count 10), so bar count maps 1:1 to the slider value. + */ +@Composable +fun RepeatScreen(modifier: Modifier = Modifier) { + val colors = JindongTheme.colors + var rpCount by remember { mutableIntStateOf(4) } + var playTrigger by remember { mutableIntStateOf(0) } + + val window = 1500L + val accent = timelineAccentColor() + + // TIMELINE PATH (always live): emit count bars at explicit start times (i*150+10). + val pattern = remember(rpCount) { repeatPattern(rpCount) } + val bars = TimelineMapper.toBars(pattern, window) { accent } + + androidx.compose.foundation.layout.Column(modifier = modifier.fillMaxWidth()) { + ScreenDescription( + buildAnnotatedString { + append("Repeat one unit pattern ") + withStyle(SpanStyle(color = colors.text, fontWeight = FontWeight.SemiBold)) { append("N times") } + append(". Bar count on the timeline equals the count exactly.") + }, + ) + + VGap(14.dp) + + HapticTimeline( + bars = bars, + topLeft = "INTENSITY ▲", + topRight = "× $rpCount", + minLabel = "0", + maxLabel = window.toString(), + playheadProgress = 0f, + playheadVisible = false, + ) + + VGap(16.dp) + + UnitPreviewCard() + + VGap(18.dp) + + LabeledSlider( + label = "Count", + valueText = "× $rpCount", + value = rpCount.toFloat(), + onValueChange = { rpCount = it.toInt() }, + valueRange = 1f..10f, + steps = 8, + ) + + VGap(24.dp) + + PlayButton(onClick = { playTrigger++ }) + } + + // VIBRATION PATH (stale pre-#84; see SingleHapticScreen for the rationale). The timeline above is + // always live; this executor call self-heals once #84 lands. + Jindong(playTrigger, rpCount) { + Repeat(rpCount) { + Haptic(60.ms, HapticIntensity.Custom(UNIT_INTENSITY)) + Delay(90.ms) + } + } +} + +/** "UNIT" preview: a tap bar, a gap, and a small rest marker — the repeated atom. */ +@Composable +private fun UnitPreviewCard() { + val colors = JindongTheme.colors + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Dimens.radiusCard)) + .border(Dimens.stroke, colors.border, RoundedCornerShape(Dimens.radiusCard)) + .padding(horizontal = 14.dp, vertical = 13.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MonoLabel(text = "UNIT") + Row( + modifier = Modifier.height(24.dp), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Box( + modifier = + Modifier + .width(11.dp) + .height(17.dp) + .clip(RoundedCornerShape(topStart = 2.dp, topEnd = 2.dp)) + .background(colors.accent), + ) + Spacer(Modifier.width(3.dp)) + Box(modifier = Modifier.width(6.dp).height(2.dp).background(colors.border2)) + } + Text(text = "tap + pause", style = JindongTheme.typography.bodySmall, color = colors.text3) + } +} + +/** + * Builds the Repeat timeline pattern: count bars, dur 60, intensity 0.7, start i*150. Start times + * mirror the replayed `Repeat { Haptic(60); Delay(90) }` (first pulse at 0ms) so the preview matches. + */ +internal fun repeatPattern(count: Int): HapticPattern { + val events = + (0 until count).map { i -> + ScheduledHapticEvent( + startTimeMs = i * 150L, + durationMs = 60L, + intensity = HapticIntensity.Custom(UNIT_INTENSITY), + ) + } + return HapticPattern(events) +} diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/RepeatWithIndexScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/RepeatWithIndexScreen.kt new file mode 100644 index 0000000..3c0ba33 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/RepeatWithIndexScreen.kt @@ -0,0 +1,229 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.github.compose.jindong.Jindong +import io.github.compose.jindong.core.model.HapticIntensity +import io.github.compose.jindong.core.model.HapticPattern +import io.github.compose.jindong.core.model.ScheduledHapticEvent +import io.github.compose.jindong.core.ms +import io.github.compose.jindong.dsl.Delay +import io.github.compose.jindong.dsl.Haptic +import io.github.compose.jindong.dsl.RepeatWithIndex +import io.github.compose.jindong.sample.components.HapticTimeline +import io.github.compose.jindong.sample.components.LabeledSlider +import io.github.compose.jindong.sample.components.MonoLabel +import io.github.compose.jindong.sample.components.PlayButton +import io.github.compose.jindong.sample.components.ScreenDescription +import io.github.compose.jindong.sample.components.SegmentedToggle +import io.github.compose.jindong.sample.components.TimelineMapper +import io.github.compose.jindong.sample.components.VGap +import io.github.compose.jindong.sample.components.formatFixed +import io.github.compose.jindong.sample.components.timelineAccentColor +import io.github.compose.jindong.sample.theme.Dimens +import io.github.compose.jindong.sample.theme.JindongTheme + +/** + * Repeat With Index (handoff 05): repeat with a per-cycle ramp so each pass gets a different + * intensity. The intended (outline) preview heights must match the compiled (filled) timeline bars. + * Window is fixed at 1120ms. + */ +@Composable +fun RepeatWithIndexScreen(modifier: Modifier = Modifier) { + val colors = JindongTheme.colors + var riCount by remember { mutableIntStateOf(5) } + var riDir by remember { mutableStateOf(RampDirection.Out) } + var playTrigger by remember { mutableIntStateOf(0) } + + val window = 1120L + val accent = timelineAccentColor() + + // TIMELINE PATH (always live): emit count bars at i*140+10 with the ramp intensity per pass. + val pattern = remember(riCount, riDir) { repeatIndexPattern(riCount, riDir) } + val bars = TimelineMapper.toBars(pattern, window) { accent } + val intensities = (0 until riCount).map { ramp(it, riCount, riDir) } + + Column(modifier = modifier.fillMaxWidth()) { + ScreenDescription( + buildAnnotatedString { + append("Repeat with a ") + withStyle(SpanStyle(color = colors.text, fontWeight = FontWeight.SemiBold)) { append("per-cycle ramp") } + append(" — intensity changes each pass. Preview heights should match the compiled timeline.") + }, + ) + + VGap(14.dp) + + HapticTimeline( + bars = bars, + topLeft = "COMPILED ▲", + topRight = "× $riCount", + minLabel = "0", + maxLabel = window.toString(), + playheadProgress = 0f, + playheadVisible = false, + ) + + VGap(16.dp) + + IntendedPreviewCard(intensities = intensities) + + VGap(18.dp) + + Text( + text = "Ramp direction", + style = JindongTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium), + color = colors.text2, + modifier = Modifier.padding(bottom = 10.dp), + ) + SegmentedToggle( + options = listOf("Fade in", "Fade out", "None"), + selectedIndex = + when (riDir) { + RampDirection.In -> 0 + RampDirection.Out -> 1 + RampDirection.None -> 2 + }, + onSelect = { + riDir = + when (it) { + 0 -> RampDirection.In + 1 -> RampDirection.Out + else -> RampDirection.None + } + }, + ) + + VGap(18.dp) + + LabeledSlider( + label = "Count", + valueText = "× $riCount", + value = riCount.toFloat(), + onValueChange = { riCount = it.toInt() }, + valueRange = 2f..8f, + steps = 5, + ) + + VGap(24.dp) + + PlayButton(onClick = { playTrigger++ }) + } + + // VIBRATION PATH (stale pre-#84; see SingleHapticScreen for the rationale). The timeline above is + // always live; this executor call self-heals once #84 lands. + Jindong(playTrigger, riCount, riDir) { + RepeatWithIndex(riCount) { i -> + Haptic(70.ms, HapticIntensity.Custom(ramp(i, riCount, riDir))) + Delay(70.ms) + } + } +} + +/** Outline ("intended") per-cycle intensity bars: accentBg fill, accent 1dp border, value caption. */ +@Composable +private fun IntendedPreviewCard(intensities: List) { + val colors = JindongTheme.colors + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Dimens.radiusCard)) + .border(Dimens.stroke, colors.border, RoundedCornerShape(Dimens.radiusCard)) + .padding(horizontal = 14.dp, vertical = 13.dp), + ) { + MonoLabel(text = "INTENDED PER-CYCLE INTENSITY", modifier = Modifier.padding(bottom = 10.dp)) + Row( + modifier = Modifier.fillMaxWidth().height(40.dp), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + intensities.forEach { intensity -> + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Bottom, + ) { + Box( + modifier = + Modifier + .widthIn(max = 18.dp) + .fillMaxWidth() + .height((intensity * 36f).dp) + .clip(RoundedCornerShape(topStart = 2.dp, topEnd = 2.dp)) + .background(colors.accentBg) + .border(Dimens.stroke, colors.accent, RoundedCornerShape(topStart = 2.dp, topEnd = 2.dp)), + ) + Text( + text = formatFixed(intensity, 1), + style = JindongTheme.typography.monoAxis.copy(fontSize = 8.sp, letterSpacing = androidx.compose.ui.unit.TextUnit.Unspecified), + color = colors.text3, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + } + } +} + +/** + * Builds the RepeatWithIndex timeline pattern: count bars, dur 70, start i*140, ramp intensity. + * Start times mirror the replayed `RepeatWithIndex { Haptic(70); Delay(70) }` (first pulse at 0ms) + * so the compiled preview lines up with what the screen actually plays. + */ +internal fun repeatIndexPattern( + count: Int, + dir: RampDirection, +): HapticPattern { + val events = + (0 until count).map { i -> + ScheduledHapticEvent( + startTimeMs = i * 140L, + durationMs = 70L, + intensity = HapticIntensity.Custom(ramp(i, count, dir)), + ) + } + return HapticPattern(events) +} diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/SingleHapticScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/SingleHapticScreen.kt new file mode 100644 index 0000000..0658e6f --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/SingleHapticScreen.kt @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.github.compose.jindong.Jindong +import io.github.compose.jindong.core.dsl.buildHapticPattern +import io.github.compose.jindong.core.model.HapticIntensity +import io.github.compose.jindong.core.ms +import io.github.compose.jindong.dsl.Haptic +import io.github.compose.jindong.sample.components.HapticTimeline +import io.github.compose.jindong.sample.components.LabeledSlider +import io.github.compose.jindong.sample.components.PlayButton +import io.github.compose.jindong.sample.components.PresetChip +import io.github.compose.jindong.sample.components.ScreenDescription +import io.github.compose.jindong.sample.components.TimelineMapper +import io.github.compose.jindong.sample.components.VGap +import io.github.compose.jindong.sample.components.formatFixed +import io.github.compose.jindong.sample.components.timelineAccentColor +import io.github.compose.jindong.sample.theme.JindongTheme + +/** + * Single Haptic (handoff 01): isolate one `Haptic(duration, intensity)` and watch the bar's width + * track duration and its height track intensity. Window is fixed at 600ms; the single bar starts at + * 60ms. + */ +@Composable +fun SingleHapticScreen(modifier: Modifier = Modifier) { + val colors = JindongTheme.colors + var sgDur by remember { mutableIntStateOf(120) } + var sgInt by remember { mutableFloatStateOf(0.5f) } + var sgChip by remember { mutableStateOf("MEDIUM") } + var playTrigger by remember { mutableIntStateOf(0) } + + val custom = sgChip == "CUSTOM" + val resolvedIntensity = if (custom) HapticIntensity.Custom(sgInt) else presetIntensity(sgChip) + + // TIMELINE PATH (always live): recompute the pattern from scratch every recomposition via the + // non-Compose buildHapticPattern, so the bar always reflects the current slider/chip values. + val window = 600L + val pattern = + buildHapticPattern { + delay(60.ms) + haptic(sgDur.ms, resolvedIntensity) + } + val accent = timelineAccentColor() + val bars = TimelineMapper.toBars(pattern, window) { accent } + + Column(modifier = modifier.fillMaxWidth()) { + ScreenDescription( + buildAnnotatedString { + append("Change one variable, replay, watch the bar scale. ") + withStyle(SpanStyle(color = colors.text, fontWeight = FontWeight.SemiBold)) { + append("Width = duration") + } + append(", ") + withStyle(SpanStyle(color = colors.text, fontWeight = FontWeight.SemiBold)) { + append("height = intensity") + } + append(".") + }, + ) + + VGap(16.dp) + + HapticTimeline( + bars = bars, + topLeft = "INTENSITY ▲", + topRight = formatFixed(resolvedIntensity.value, 2), + minLabel = "0", + maxLabel = "600", + playheadProgress = 0f, + playheadVisible = false, + ) + + VGap(18.dp) + + LabeledSlider( + label = "Duration", + valueText = "$sgDur ms", + value = sgDur.toFloat(), + onValueChange = { sgDur = it.toInt() }, + valueRange = 10f..500f, + steps = (500 - 10) / 5 - 1, + ) + + VGap(18.dp) + + Text( + text = "Intensity", + style = JindongTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium), + color = colors.text2, + modifier = Modifier.padding(bottom = 11.dp), + ) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + intensityChips.forEach { chip -> + PresetChip( + label = chip, + selected = sgChip == chip, + onClick = { sgChip = chip }, + subValue = chipSubValue(chip), + ) + } + } + + if (custom) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Slider( + value = sgInt, + onValueChange = { sgInt = it }, + valueRange = 0f..1f, + steps = 99, + colors = + SliderDefaults.colors( + thumbColor = colors.accent, + activeTrackColor = colors.accent, + inactiveTrackColor = colors.border2, + ), + modifier = Modifier.weight(1f), + ) + Text( + text = formatFixed(sgInt, 2), + style = JindongTheme.typography.monoValue.copy(fontSize = 13.sp), + color = colors.text, + textAlign = TextAlign.End, + modifier = Modifier.widthIn(min = 38.dp), + ) + } + } + + VGap(26.dp) + + PlayButton(onClick = { playTrigger++ }) + } + + // VIBRATION PATH: key only on playTrigger so this stays an explicit-play screen (Reactive is the + // auto-firing one). The block reads live sgDur/resolvedIntensity, so Play always uses the current + // values. The timeline above is driven separately from state and stays live regardless of #84. + Jindong(playTrigger) { + Haptic(sgDur.ms, resolvedIntensity) + } +} + +/** Mono sub-value rendered under each intensity chip (handoff 01). */ +private fun chipSubValue(chip: String): String = when (chip) { + "LIGHT" -> "0.25" + "MEDIUM" -> "0.50" + "STRONG" -> "0.75" + "HIGH" -> "1.00" + else -> "custom" +} diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/TimingScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/TimingScreen.kt new file mode 100644 index 0000000..d76ceb1 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/screens/TimingScreen.kt @@ -0,0 +1,301 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import io.github.compose.jindong.Jindong +import io.github.compose.jindong.core.model.HapticIntensity +import io.github.compose.jindong.core.model.HapticPattern +import io.github.compose.jindong.core.model.ScheduledHapticEvent +import io.github.compose.jindong.core.ms +import io.github.compose.jindong.dsl.Delay +import io.github.compose.jindong.dsl.Haptic +import io.github.compose.jindong.dsl.Sequence +import io.github.compose.jindong.sample.components.HapticTimeline +import io.github.compose.jindong.sample.components.JindongIcons +import io.github.compose.jindong.sample.components.PlayButton +import io.github.compose.jindong.sample.components.ScreenDescription +import io.github.compose.jindong.sample.components.TimelineMapper +import io.github.compose.jindong.sample.components.VGap +import io.github.compose.jindong.sample.components.timelineAccentColor +import io.github.compose.jindong.sample.theme.Dimens +import io.github.compose.jindong.sample.theme.JindongTheme + +private const val TAP_INTENSITY = 0.7f + +/** + * Timing (handoff 03): stack tap and pause nodes into a rhythm. The timeline draws a bar per tap node + * (intensity 0.7); pause nodes advance the cursor only, so widening a pause widens the visible gap. + */ +@Composable +fun TimingScreen(modifier: Modifier = Modifier) { + val colors = JindongTheme.colors + val nodes = remember { + mutableStateListOf(Node(NodeType.Tap, 30), Node(NodeType.Pause, 120), Node(NodeType.Tap, 30)) + } + var selected by remember { mutableIntStateOf(0) } + var playTrigger by remember { mutableIntStateOf(0) } + + val total = nodes.sumOf { it.ms } + val window = timingWindow(total) + val accent = timelineAccentColor() + + // TIMELINE PATH (always live): walk the node list with an explicit cursor and emit one event per + // tap. Building events directly keeps absolute start times deterministic as nodes/ms change. + val pattern = remember(nodes.toList()) { timingPattern(nodes.toList()) } + val bars = TimelineMapper.toBars(pattern, window) { accent } + + Column(modifier = modifier.fillMaxWidth()) { + ScreenDescription( + buildAnnotatedString { + append("Stack ") + withStyle(SpanStyle(color = colors.text, fontWeight = FontWeight.SemiBold)) { append("taps") } + append(" and ") + withStyle(SpanStyle(color = colors.text, fontWeight = FontWeight.SemiBold)) { append("pauses") } + append(" into a rhythm. Widen a pause → the gap on the timeline grows.") + }, + ) + + VGap(14.dp) + + HapticTimeline( + bars = bars, + topLeft = "INTENSITY ▲", + topRight = "$total ms", + minLabel = "0", + maxLabel = window.toString(), + playheadProgress = 0f, + playheadVisible = false, + ) + + VGap(16.dp) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimens.rowGapSmall), + verticalArrangement = Arrangement.spacedBy(Dimens.rowGapSmall), + ) { + nodes.forEachIndexed { index, node -> + NodeBuilderChip( + node = node, + selected = index == selected, + onClick = { selected = index }, + ) + } + AddNodeButton(label = "+ Tap") { + nodes.add(Node(NodeType.Tap, 30)) + selected = nodes.lastIndex + } + AddNodeButton(label = "+ Pause") { + nodes.add(Node(NodeType.Pause, 100)) + selected = nodes.lastIndex + } + } + + if (selected in nodes.indices) { + VGap(16.dp) + NodeEditorCard( + node = nodes[selected], + canDelete = nodes.size > 1, + onMsChange = { nodes[selected] = nodes[selected].copy(ms = it) }, + onDelete = { + nodes.removeAt(selected) + selected = (selected - 1).coerceIn(0, nodes.lastIndex) + }, + ) + } + + VGap(18.dp) + + PlayButton(onClick = { playTrigger++ }, text = "Play sequence") + } + + // VIBRATION PATH (stale pre-#84; see SingleHapticScreen for the rationale). The timeline above is + // always live; this executor call self-heals once #84 lands. + Jindong(playTrigger, nodes.toList()) { + Sequence { + nodes.toList().forEach { node -> + when (node.type) { + NodeType.Tap -> Haptic(node.ms.ms, HapticIntensity.Custom(TAP_INTENSITY)) + NodeType.Pause -> Delay(node.ms.ms) + } + } + } + } +} + +/** A tap or pause node chip; taps render a round accent marker, pauses a small square. */ +@Composable +private fun NodeBuilderChip( + node: Node, + selected: Boolean, + onClick: () -> Unit, +) { + io.github.compose.jindong.sample.components.NodeChip( + label = if (node.type == NodeType.Tap) "Tap" else "Pause", + msText = "${node.ms}ms", + isTap = node.type == NodeType.Tap, + selected = selected, + onClick = onClick, + ) +} + +/** Dashed-style add button for appending a tap or pause node. */ +@Composable +private fun AddNodeButton( + label: String, + onClick: () -> Unit, +) { + val colors = JindongTheme.colors + // min-touch + clickable on the same node as the bordered pill so the full hit area is tappable. + Box( + modifier = + Modifier + .defaultMinSize(minHeight = Dimens.minTouch) + .clip(RoundedCornerShape(Dimens.radiusChip)) + .border(Dimens.stroke, colors.border2, RoundedCornerShape(Dimens.radiusChip)) + .clickable(onClick = onClick) + .padding(horizontal = 11.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = JindongTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), + color = colors.text2, + ) + } +} + +/** Editor for the selected node: label, value, delete affordance and a step-10 slider. */ +@Composable +private fun NodeEditorCard( + node: Node, + canDelete: Boolean, + onMsChange: (Int) -> Unit, + onDelete: () -> Unit, +) { + val colors = JindongTheme.colors + val isTap = node.type == NodeType.Tap + val range = if (isTap) 10f..120f else 20f..600f + val steps = ((range.endInclusive - range.start) / 10f).toInt() - 1 + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Dimens.radiusCard)) + .border(Dimens.stroke, colors.border, RoundedCornerShape(Dimens.radiusCard)) + .padding(Dimens.cardPadding), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (isTap) "Tap duration" else "Pause length", + style = JindongTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), + color = colors.text, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(Dimens.rowGapMedium), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${node.ms} ms", + style = JindongTheme.typography.monoValue.copy(fontSize = androidx.compose.ui.unit.TextUnit.Unspecified), + color = colors.text, + ) + if (canDelete) { + Box( + modifier = Modifier.defaultMinSize(minWidth = Dimens.minTouch, minHeight = Dimens.minTouch).clickable(onClick = onDelete), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = JindongIcons.Close, + contentDescription = "Delete node", + tint = colors.text3, + modifier = Modifier.size(14.dp), + ) + } + } + } + } + androidx.compose.material3.Slider( + value = node.ms.toFloat(), + onValueChange = { onMsChange(it.toInt()) }, + valueRange = range, + steps = steps, + colors = + androidx.compose.material3.SliderDefaults.colors( + thumbColor = colors.accent, + activeTrackColor = colors.accent, + inactiveTrackColor = colors.border2, + ), + ) + } +} + +/** Builds the pattern for the Timing timeline: one event per tap, pauses advance the cursor. */ +internal fun timingPattern(nodes: List): HapticPattern { + var cursor = 0L + val events = mutableListOf() + nodes.forEach { node -> + when (node.type) { + NodeType.Tap -> { + events.add( + ScheduledHapticEvent( + startTimeMs = cursor, + durationMs = node.ms.toLong(), + intensity = HapticIntensity.Custom(TAP_INTENSITY), + ), + ) + cursor += node.ms + } + + NodeType.Pause -> cursor += node.ms + } + } + return HapticPattern(events) +} diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/theme/Dimens.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/theme/Dimens.kt new file mode 100644 index 0000000..737fd34 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/theme/Dimens.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.theme + +import androidx.compose.ui.unit.dp + +/** + * Spacing, radius and stroke tokens for the harness. Prototype px map to dp 1:1; touch-target and + * accessibility minimums override the visual sizes where the handoff calls for it. + */ +object Dimens { + // Layout + val screenPadH = 16.dp + val bodyTop = 18.dp + val bodyBottom = 48.dp + val appBarHeight = 56.dp + val plotHeight = 120.dp + + // Stroke / touch + val stroke = 1.dp + val minTouch = 48.dp + + // Radii + val radiusSmall = 8.dp + val radiusIcon = 8.dp + val radiusChip = 9.dp + val radiusCard = 11.dp + val radiusButton = 11.dp + val radiusBigCard = 13.dp + val radiusTimeline = 12.dp + + // Padding / gaps + val cardPadding = 14.dp + val rowGapSmall = 7.dp + val rowGapMedium = 11.dp + val rowGapLarge = 14.dp + + // Component metrics + val toggleSize = 30.dp + val dotSize = 6.dp + val nodeDotSize = 7.dp + val barCornerRadius = 3.dp + val barMinWidth = 3.dp + val axisStroke = 1.dp + val playheadStroke = 1.5.dp +} diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/theme/JindongColors.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/theme/JindongColors.kt new file mode 100644 index 0000000..9845db9 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/theme/JindongColors.kt @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +/** + * Semantic color tokens for the Jindong sample harness. Token names mirror the design handoff CSS + * variables 1:1 so the prototype and the Compose port stay in sync. + * + * [accent] is reserved for data visualization (timeline bars) and active states; the primary button + * uses the inverted [btnBg] / [btnText] pair, not [accent]. + */ +@Immutable +data class JindongColors( + val bg: Color, + val surface: Color, + val surface2: Color, + val border: Color, + val border2: Color, + val divider: Color, + val text: Color, + val text2: Color, + val text3: Color, + val accent: Color, + val accentBg: Color, + val ok: Color, + val warn: Color, + val btnBg: Color, + val btnText: Color, +) + +fun lightColors(): JindongColors = JindongColors( + bg = Color(0xFFFFFFFF), + surface = Color(0xFFFFFFFF), + surface2 = Color(0xFFFAFAFA), + border = Color(0xFFECECEC), + border2 = Color(0xFFE4E4E7), + divider = Color(0xFFF4F4F5), + text = Color(0xFF18181B), + text2 = Color(0xFF52525B), + text3 = Color(0xFFA1A1AA), + accent = Color(0xFF2F5FE0), + // rgba(47,95,224,0.07) baked: 0.07*255 ~= 18 = 0x12 + accentBg = Color(0x122F5FE0), + ok = Color(0xFF16A34A), + warn = Color(0xFFD97706), + btnBg = Color(0xFF18181B), + btnText = Color(0xFFFFFFFF), +) + +fun darkColors(): JindongColors = JindongColors( + bg = Color(0xFF101012), + surface = Color(0xFF161618), + surface2 = Color(0xFF16161A), + border = Color(0xFF232327), + border2 = Color(0xFF2E2E32), + divider = Color(0xFF1C1C1F), + text = Color(0xFFF4F4F5), + text2 = Color(0xFFA1A1AA), + text3 = Color(0xFF71717A), + accent = Color(0xFF6B93FF), + // rgba(107,147,255,0.14) baked: 0.14*255 ~= 36 = 0x24 + accentBg = Color(0x246B93FF), + ok = Color(0xFF34D399), + warn = Color(0xFFFBBF24), + btnBg = Color(0xFFF4F4F5), + btnText = Color(0xFF101012), +) diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/theme/JindongTheme.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/theme/JindongTheme.kt new file mode 100644 index 0000000..c23baf4 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/theme/JindongTheme.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.theme + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier + +private val LocalJindongColors = + staticCompositionLocalOf { + error("JindongColors not provided. Wrap content in JindongTheme { }.") + } + +private val LocalJindongTypography = + staticCompositionLocalOf { + error("JindongTypography not provided. Wrap content in JindongTheme { }.") + } + +/** + * Root theme for the Jindong sample harness. Provides [JindongColors] and [JindongTypography] and + * paints [JindongColors.bg] as the screen background. + * + * The [darkTheme] flag is driven by the caller (AppRoot) so the manual toggle can override the + * system preference per the handoff. + */ +@Composable +fun JindongTheme( + darkTheme: Boolean, + content: @Composable () -> Unit, +) { + val colors = if (darkTheme) darkColors() else lightColors() + val typography = jindongTypography(sans = instrumentSansFamily(), mono = ibmPlexMonoFamily()) + CompositionLocalProvider( + LocalJindongColors provides colors, + LocalJindongTypography provides typography, + ) { + Box(modifier = Modifier.fillMaxSize().background(colors.bg)) { + content() + } + } +} + +object JindongTheme { + val colors: JindongColors + @Composable + @ReadOnlyComposable + get() = LocalJindongColors.current + + val typography: JindongTypography + @Composable + @ReadOnlyComposable + get() = LocalJindongTypography.current +} diff --git a/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/theme/JindongTypography.kt b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/theme/JindongTypography.kt new file mode 100644 index 0000000..80659c7 --- /dev/null +++ b/sample/shared/src/commonMain/kotlin/io/github/compose/jindong/sample/theme/JindongTypography.kt @@ -0,0 +1,175 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontVariation +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import io.github.compose.jindong.sample.Res +import io.github.compose.jindong.sample.ibm_plex_mono_medium +import io.github.compose.jindong.sample.ibm_plex_mono_regular +import io.github.compose.jindong.sample.ibm_plex_mono_semibold +import io.github.compose.jindong.sample.instrument_sans_variable +import org.jetbrains.compose.resources.Font + +/** + * Role-based text styles for the harness. Numbers and uppercase section labels that the user must be + * able to verify use the [JindongTypography.monoValue]/etc. mono roles; everything else uses sans. + * + * Build with [jindongTypography] passing the families from [instrumentSansFamily] / + * [ibmPlexMonoFamily]. + */ +@Immutable +data class JindongTypography( + val intro: TextStyle, + val cardTitle: TextStyle, + val sectionLabel: TextStyle, + val monoValue: TextStyle, + val monoMicro: TextStyle, + val monoAxis: TextStyle, + val appBarTitle: TextStyle, + val wordmark: TextStyle, + val chipLabel: TextStyle, + val bodySmall: TextStyle, + val rowTitle: TextStyle, +) + +/** + * Instrument Sans is a single variable font (wght 400-700). Each weight is a [Font] entry over the + * same [Res.font.instrument_sans_variable] resource, selecting the axis via + * [FontVariation.weight]. This family is the single swap point if a weight ever fails to resolve on + * a target. + */ +@Composable +fun instrumentSansFamily(): FontFamily = FontFamily( + Font( + Res.font.instrument_sans_variable, + weight = FontWeight.Normal, + style = FontStyle.Normal, + variationSettings = FontVariation.Settings(FontVariation.weight(400)), + ), + Font( + Res.font.instrument_sans_variable, + weight = FontWeight.Medium, + style = FontStyle.Normal, + variationSettings = FontVariation.Settings(FontVariation.weight(500)), + ), + Font( + Res.font.instrument_sans_variable, + weight = FontWeight.SemiBold, + style = FontStyle.Normal, + variationSettings = FontVariation.Settings(FontVariation.weight(600)), + ), + Font( + Res.font.instrument_sans_variable, + weight = FontWeight.Bold, + style = FontStyle.Normal, + variationSettings = FontVariation.Settings(FontVariation.weight(700)), + ), +) + +/** + * IBM Plex Mono ships as three static files mapped to W400 / W500 / W600. + */ +@Composable +fun ibmPlexMonoFamily(): FontFamily = FontFamily( + Font(Res.font.ibm_plex_mono_regular, weight = FontWeight.Normal), + Font(Res.font.ibm_plex_mono_medium, weight = FontWeight.Medium), + Font(Res.font.ibm_plex_mono_semibold, weight = FontWeight.SemiBold), +) + +@Composable +fun jindongTypography( + sans: FontFamily, + mono: FontFamily, +): JindongTypography = JindongTypography( + intro = + TextStyle( + fontFamily = sans, + fontWeight = FontWeight.Normal, + fontSize = 13.sp, + lineHeight = (13 * 1.55).sp, + ), + cardTitle = + TextStyle( + fontFamily = sans, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + ), + sectionLabel = + TextStyle( + fontFamily = mono, + fontWeight = FontWeight.Medium, + fontSize = 9.sp, + letterSpacing = 0.16.em, + ), + monoValue = + TextStyle( + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + ), + monoMicro = + TextStyle( + fontFamily = mono, + fontWeight = FontWeight.Normal, + fontSize = 10.sp, + ), + monoAxis = + TextStyle( + fontFamily = mono, + fontWeight = FontWeight.Normal, + fontSize = 9.sp, + letterSpacing = 0.12.em, + ), + appBarTitle = + TextStyle( + fontFamily = sans, + fontWeight = FontWeight.SemiBold, + fontSize = 15.sp, + letterSpacing = (-0.01).em, + ), + wordmark = + TextStyle( + fontFamily = sans, + fontWeight = FontWeight.SemiBold, + fontSize = 19.sp, + ), + chipLabel = + TextStyle( + fontFamily = sans, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + ), + bodySmall = + TextStyle( + fontFamily = sans, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + ), + rowTitle = + TextStyle( + fontFamily = sans, + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + ), +) diff --git a/sample/shared/src/iosMain/kotlin/io/github/compose/jindong/sample/nav/PlatformBackHandler.ios.kt b/sample/shared/src/iosMain/kotlin/io/github/compose/jindong/sample/nav/PlatformBackHandler.ios.kt new file mode 100644 index 0000000..9977ec7 --- /dev/null +++ b/sample/shared/src/iosMain/kotlin/io/github/compose/jindong/sample/nav/PlatformBackHandler.ios.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2026 compose-jindong + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.compose.jindong.sample.nav + +import androidx.compose.runtime.Composable + +// iOS has no system back gesture in this single-view host; the app-bar chevron is the back affordance. +@Composable +actual fun PlatformBackHandler( + enabled: Boolean, + onBack: () -> Unit, +) = Unit