Add Horologist module and initial classes that are going to be needed for wear project.

Bug: 299958033
Test: N/A - project has automated tests in github

Change-Id: I6b7cea8041caa32109daeaa46ccc358452967d4a
diff --git a/packages/CredentialManager/horologist/Android.bp b/packages/CredentialManager/horologist/Android.bp
new file mode 100644
index 0000000..bb324bb
--- /dev/null
+++ b/packages/CredentialManager/horologist/Android.bp
@@ -0,0 +1,27 @@
+package {
+    // See: http://go/android-license-faq
+    // A large-scale-change added 'default_applicable_licenses' to import
+    // all of the 'license_kinds' from "frameworks_base_license"
+    // to get the below license kinds:
+    //   SPDX-license-identifier-Apache-2.0
+    default_applicable_licenses: ["frameworks_base_license"],
+}
+
+// TODO: ag/24733147 - Remove this project once it is imported.
+android_library {
+    name: "Horologist",
+    manifest: "AndroidManifest.xml",
+    srcs: ["src/**/*.kt"],
+    static_libs: [
+        "androidx.compose.foundation_foundation",
+        "androidx.compose.runtime_runtime",
+        "androidx.compose.ui_ui",
+        "androidx.navigation_navigation-compose",
+        "androidx.lifecycle_lifecycle-extensions",
+        "androidx.lifecycle_lifecycle-runtime-ktx",
+        "androidx.lifecycle_lifecycle-viewmodel-compose",
+        "androidx.wear.compose_compose-foundation",
+        "androidx.wear.compose_compose-material",
+        "androidx.wear.compose_compose-navigation",
+    ],
+}
diff --git a/packages/CredentialManager/horologist/AndroidManifest.xml b/packages/CredentialManager/horologist/AndroidManifest.xml
new file mode 100644
index 0000000..e386ce2
--- /dev/null
+++ b/packages/CredentialManager/horologist/AndroidManifest.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/*
+ * Copyright (c) 2023 Google Inc.
+ *
+ * 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.
+ */
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.google.android.horologist">
+
+    <uses-feature android:name="android.hardware.type.watch" />
+
+</manifest>
diff --git a/packages/CredentialManager/horologist/src/com/google/android/horologist/annotations/ExperimentalHorologistApi.kt b/packages/CredentialManager/horologist/src/com/google/android/horologist/annotations/ExperimentalHorologistApi.kt
new file mode 100644
index 0000000..ae77605
--- /dev/null
+++ b/packages/CredentialManager/horologist/src/com/google/android/horologist/annotations/ExperimentalHorologistApi.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.horologist.annotations
+
+@RequiresOptIn(
+        message = "Horologist API is experimental. The API may be changed in the future.",
+)
+@Retention(AnnotationRetention.BINARY)
+public annotation class ExperimentalHorologistApi
diff --git a/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/layout/ScalingLazyColumnDefaults.kt b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/layout/ScalingLazyColumnDefaults.kt
new file mode 100644
index 0000000..c88bbd8
--- /dev/null
+++ b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/layout/ScalingLazyColumnDefaults.kt
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:Suppress("ObjectLiteralToLambda")
+
+package com.google.android.horologist.compose.layout
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.foundation.lazy.AutoCenteringParams
+import androidx.wear.compose.foundation.lazy.ScalingLazyListAnchorType
+import com.google.android.horologist.annotations.ExperimentalHorologistApi
+import com.google.android.horologist.compose.layout.ScalingLazyColumnState.RotaryMode
+
+/**
+ * Default layouts for ScalingLazyColumnState, based on UX guidance.
+ */
+public object ScalingLazyColumnDefaults {
+    /**
+     * Layout the first item, directly under the time text.
+     * This is positioned from the top of the screen instead of the
+     * center.
+     */
+    @ExperimentalHorologistApi
+    public fun belowTimeText(
+            rotaryMode: RotaryMode = RotaryMode.Scroll,
+            firstItemIsFullWidth: Boolean = false,
+            verticalArrangement: Arrangement.Vertical =
+                    Arrangement.spacedBy(
+                            space = 4.dp,
+                            alignment = Alignment.Top,
+                    ),
+            horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
+            contentPadding: PaddingValues = PaddingValues(horizontal = 10.dp),
+            topPaddingDp: Dp = 32.dp + (if (firstItemIsFullWidth) 20.dp else 0.dp),
+    ): ScalingLazyColumnState.Factory {
+        return object : ScalingLazyColumnState.Factory {
+            @Composable
+            override fun create(): ScalingLazyColumnState {
+                val density = LocalDensity.current
+                val configuration = LocalConfiguration.current
+
+                return remember {
+                    val screenHeightPx =
+                            with(density) { configuration.screenHeightDp.dp.roundToPx() }
+                    val topPaddingPx = with(density) { topPaddingDp.roundToPx() }
+                    val topScreenOffsetPx = screenHeightPx / 2 - topPaddingPx
+
+                    ScalingLazyColumnState(
+                            initialScrollPosition = ScalingLazyColumnState.ScrollPosition(
+                                    index = 0,
+                                    offsetPx = topScreenOffsetPx,
+                            ),
+                            anchorType = ScalingLazyListAnchorType.ItemStart,
+                            rotaryMode = rotaryMode,
+                            verticalArrangement = verticalArrangement,
+                            horizontalAlignment = horizontalAlignment,
+                            contentPadding = contentPadding,
+                    )
+                }
+            }
+        }
+    }
+
+    /**
+     * Layout the item [initialCenterIndex] at [initialCenterOffset] from the
+     * center of the screen.
+     */
+    @ExperimentalHorologistApi
+    public fun scalingLazyColumnDefaults(
+            rotaryMode: RotaryMode = RotaryMode.Scroll,
+            initialCenterIndex: Int = 1,
+            initialCenterOffset: Int = 0,
+            verticalArrangement: Arrangement.Vertical =
+                    Arrangement.spacedBy(
+                            space = 4.dp,
+                            alignment = Alignment.Top,
+                    ),
+            horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
+            contentPadding: PaddingValues = PaddingValues(horizontal = 10.dp),
+            autoCentering: AutoCenteringParams? = AutoCenteringParams(
+                    initialCenterIndex,
+                    initialCenterOffset,
+            ),
+            anchorType: ScalingLazyListAnchorType = ScalingLazyListAnchorType.ItemCenter,
+            hapticsEnabled: Boolean = true,
+            reverseLayout: Boolean = false,
+    ): ScalingLazyColumnState.Factory {
+        return object : ScalingLazyColumnState.Factory {
+            @Composable
+            override fun create(): ScalingLazyColumnState {
+                return remember {
+                    ScalingLazyColumnState(
+                            initialScrollPosition = ScalingLazyColumnState.ScrollPosition(
+                                    index = initialCenterIndex,
+                                    offsetPx = initialCenterOffset,
+                            ),
+                            rotaryMode = rotaryMode,
+                            verticalArrangement = verticalArrangement,
+                            horizontalAlignment = horizontalAlignment,
+                            contentPadding = contentPadding,
+                            autoCentering = autoCentering,
+                            anchorType = anchorType,
+                            hapticsEnabled = hapticsEnabled,
+                            reverseLayout = reverseLayout,
+                    )
+                }
+            }
+        }
+    }
+}
diff --git a/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/layout/ScalingLazyColumnState.kt b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/layout/ScalingLazyColumnState.kt
new file mode 100644
index 0000000..3a12b9f
--- /dev/null
+++ b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/layout/ScalingLazyColumnState.kt
@@ -0,0 +1,168 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:Suppress("ObjectLiteralToLambda")
+@file:OptIn(ExperimentalHorologistApi::class, ExperimentalWearFoundationApi::class)
+
+package com.google.android.horologist.compose.layout
+
+import androidx.compose.foundation.gestures.FlingBehavior
+import androidx.compose.foundation.gestures.ScrollableDefaults
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
+import androidx.wear.compose.foundation.lazy.AutoCenteringParams
+import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
+import androidx.wear.compose.foundation.lazy.ScalingLazyListAnchorType
+import androidx.wear.compose.foundation.lazy.ScalingLazyListScope
+import androidx.wear.compose.foundation.lazy.ScalingLazyListState
+import androidx.wear.compose.foundation.lazy.ScalingParams
+import androidx.wear.compose.foundation.rememberActiveFocusRequester
+import com.google.android.horologist.annotations.ExperimentalHorologistApi
+import com.google.android.horologist.compose.layout.ScalingLazyColumnState.RotaryMode
+import com.google.android.horologist.compose.rotaryinput.rememberDisabledHaptic
+import com.google.android.horologist.compose.rotaryinput.rememberRotaryHapticHandler
+import com.google.android.horologist.compose.rotaryinput.rotaryWithScroll
+import com.google.android.horologist.compose.rotaryinput.rotaryWithSnap
+import com.google.android.horologist.compose.rotaryinput.toRotaryScrollAdapter
+import androidx.wear.compose.foundation.lazy.ScalingLazyColumnDefaults as WearScalingLazyColumnDefaults
+
+/**
+ * A Config and State object wrapping up all configuration for a [ScalingLazyColumn].
+ * This allows defaults such as [ScalingLazyColumnDefaults.belowTimeText].
+ */
+@ExperimentalHorologistApi
+public class ScalingLazyColumnState(
+        public val initialScrollPosition: ScrollPosition = ScrollPosition(1, 0),
+        public val autoCentering: AutoCenteringParams? = AutoCenteringParams(
+                initialScrollPosition.index,
+                initialScrollPosition.offsetPx,
+        ),
+        public val anchorType: ScalingLazyListAnchorType = ScalingLazyListAnchorType.ItemCenter,
+        public val contentPadding: PaddingValues = PaddingValues(horizontal = 10.dp),
+        public val rotaryMode: RotaryMode = RotaryMode.Scroll,
+        public val reverseLayout: Boolean = false,
+        public val verticalArrangement: Arrangement.Vertical =
+                Arrangement.spacedBy(
+                        space = 4.dp,
+                        alignment = if (!reverseLayout) Alignment.Top else Alignment.Bottom,
+                ),
+        public val horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
+        public val flingBehavior: FlingBehavior? = null,
+        public val userScrollEnabled: Boolean = true,
+        public val scalingParams: ScalingParams = WearScalingLazyColumnDefaults.scalingParams(),
+        public val hapticsEnabled: Boolean = true,
+) {
+    private var _state: ScalingLazyListState? = null
+    public var state: ScalingLazyListState
+        get() {
+            if (_state == null) {
+                _state = ScalingLazyListState(
+                        initialScrollPosition.index,
+                        initialScrollPosition.offsetPx,
+                )
+            }
+            return _state!!
+        }
+        set(value) {
+            _state = value
+        }
+
+    public sealed interface RotaryMode {
+        public object Snap : RotaryMode
+        public object Scroll : RotaryMode
+
+        @Deprecated(
+                "Use RotaryMode.Scroll instead",
+                replaceWith = ReplaceWith("RotaryMode.Scroll"),
+        )
+        public object Fling : RotaryMode
+    }
+
+    public data class ScrollPosition(
+            val index: Int,
+            val offsetPx: Int,
+    )
+
+    public fun interface Factory {
+        @Composable
+        public fun create(): ScalingLazyColumnState
+    }
+}
+
+@Composable
+public fun rememberColumnState(
+        factory: ScalingLazyColumnState.Factory = ScalingLazyColumnDefaults.belowTimeText(),
+): ScalingLazyColumnState {
+    val columnState = factory.create()
+
+    columnState.state = rememberSaveable(saver = ScalingLazyListState.Saver) {
+        columnState.state
+    }
+
+    return columnState
+}
+
+@ExperimentalHorologistApi
+@Composable
+public fun ScalingLazyColumn(
+        columnState: ScalingLazyColumnState,
+        modifier: Modifier = Modifier,
+        content: ScalingLazyListScope.() -> Unit,
+) {
+    val focusRequester = rememberActiveFocusRequester()
+
+    val rotaryHaptics = if (columnState.hapticsEnabled) {
+        rememberRotaryHapticHandler(columnState.state)
+    } else {
+        rememberDisabledHaptic()
+    }
+    val modifierWithRotary = when (columnState.rotaryMode) {
+        RotaryMode.Snap -> modifier.rotaryWithSnap(
+                focusRequester = focusRequester,
+                rotaryScrollAdapter = columnState.state.toRotaryScrollAdapter(),
+                reverseDirection = columnState.reverseLayout,
+                rotaryHaptics = rotaryHaptics,
+        )
+
+        else -> modifier.rotaryWithScroll(
+                focusRequester = focusRequester,
+                scrollableState = columnState.state,
+                reverseDirection = columnState.reverseLayout,
+                rotaryHaptics = rotaryHaptics,
+        )
+    }
+
+    ScalingLazyColumn(
+            modifier = modifierWithRotary,
+            state = columnState.state,
+            contentPadding = columnState.contentPadding,
+            reverseLayout = columnState.reverseLayout,
+            verticalArrangement = columnState.verticalArrangement,
+            horizontalAlignment = columnState.horizontalAlignment,
+            flingBehavior = columnState.flingBehavior ?: ScrollableDefaults.flingBehavior(),
+            userScrollEnabled = columnState.userScrollEnabled,
+            scalingParams = columnState.scalingParams,
+            anchorType = columnState.anchorType,
+            autoCentering = columnState.autoCentering,
+            content = content,
+    )
+}
diff --git a/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/layout/ScrollAway.kt b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/layout/ScrollAway.kt
new file mode 100644
index 0000000..623ae1a
--- /dev/null
+++ b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/layout/ScrollAway.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.horologist.compose.layout
+
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.gestures.ScrollableState
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.runtime.State
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.platform.LocalDensity
+import androidx.wear.compose.foundation.lazy.ScalingLazyListState
+import androidx.wear.compose.material.scrollAway
+import com.google.android.horologist.compose.navscaffold.ScalingLazyColumnScrollableState
+
+internal fun Modifier.scrollAway(
+        scrollState: State<ScrollableState?>,
+): Modifier = composed {
+    when (val state = scrollState.value) {
+        is ScalingLazyColumnScrollableState -> {
+            val offsetDp = with(LocalDensity.current) {
+                state.initialOffsetPx.toDp()
+            }
+            this.scrollAway(state.scalingLazyListState, state.initialIndex, offsetDp)
+        }
+        is ScalingLazyListState -> this.scrollAway(state)
+        is LazyListState -> this.scrollAway(state)
+        is ScrollState -> this.scrollAway(state)
+        // Disabled
+        null -> this.hidden()
+        // Enabled but no scroll state
+        else -> this
+    }
+}
+
+internal fun Modifier.hidden(): Modifier = layout { _, _ -> layout(0, 0) {} }
diff --git a/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/navscaffold/NavScaffoldViewModel.kt b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/navscaffold/NavScaffoldViewModel.kt
new file mode 100644
index 0000000..14c0ba1
--- /dev/null
+++ b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/navscaffold/NavScaffoldViewModel.kt
@@ -0,0 +1,297 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(ExperimentalHorologistApi::class, SavedStateHandleSaveableApi::class)
+
+package com.google.android.horologist.compose.navscaffold
+
+import android.os.Bundle
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.gestures.ScrollableState
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
+import androidx.lifecycle.viewmodel.compose.saveable
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavHostController
+import androidx.wear.compose.foundation.lazy.ScalingLazyListState
+import androidx.wear.compose.material.PositionIndicator
+import androidx.wear.compose.material.Scaffold
+import androidx.wear.compose.material.TimeText
+import androidx.wear.compose.material.Vignette
+import androidx.wear.compose.material.VignettePosition
+import com.google.android.horologist.annotations.ExperimentalHorologistApi
+import com.google.android.horologist.compose.layout.ScalingLazyColumnState
+import com.google.android.horologist.compose.navscaffold.NavScaffoldViewModel.PositionIndicatorMode
+import com.google.android.horologist.compose.navscaffold.NavScaffoldViewModel.TimeTextMode
+import com.google.android.horologist.compose.navscaffold.NavScaffoldViewModel.TimeTextMode.ScrollAway
+import com.google.android.horologist.compose.navscaffold.NavScaffoldViewModel.VignetteMode.Off
+import com.google.android.horologist.compose.navscaffold.NavScaffoldViewModel.VignetteMode.On
+import com.google.android.horologist.compose.navscaffold.NavScaffoldViewModel.VignetteMode.WhenScrollable
+
+/**
+ * A ViewModel that backs the WearNavScaffold to allow each composable to interact and effect
+ * the [Scaffold] positionIndicator, vignette and timeText.
+ *
+ * A ViewModel is used to allow the same current instance to be shared between the WearNavScaffold
+ * and the composable screen via [NavHostController.currentBackStackEntry].
+ */
+public open class NavScaffoldViewModel(
+        private val savedStateHandle: SavedStateHandle,
+) : ViewModel() {
+    internal var initialIndex: Int? = null
+    internal var initialOffsetPx: Int? = null
+    internal var scrollType by mutableStateOf<ScrollType?>(null)
+
+    private lateinit var _scrollableState: ScrollableState
+
+    /**
+     * Returns the scrollable state for this composable or null if the scaffold should
+     * not consider this element to be scrollable.
+     */
+    public val scrollableState: ScrollableState?
+        get() = if (scrollType == null || scrollType == ScrollType.None) {
+            null
+        } else {
+            _scrollableState
+        }
+
+    /**
+     * The configuration of [Vignette], [WhenScrollable], [Off], [On] and if so whether top and
+     * bottom. Defaults to on for scrollable screens.
+     */
+    public var vignettePosition: VignetteMode by mutableStateOf(WhenScrollable)
+
+    /**
+     * The configuration of [TimeText], defaults to [TimeTextMode.ScrollAway] which will move the
+     * time text above the screen to avoid overlapping with the content moving up.
+     */
+    public var timeTextMode: TimeTextMode by mutableStateOf(ScrollAway)
+
+    /**
+     * The configuration of [PositionIndicator].  The default is to show a scroll bar while the
+     * scroll is in progress.
+     */
+    public var positionIndicatorMode: PositionIndicatorMode
+            by mutableStateOf(PositionIndicatorMode.On)
+
+    internal fun initializeScrollState(scrollStateBuilder: () -> ScrollState): ScrollState {
+        check(scrollType == null || scrollType == ScrollType.ScrollState)
+
+        if (scrollType == null) {
+            scrollType = ScrollType.ScrollState
+
+            _scrollableState = savedStateHandle.saveable(
+                    key = "navScaffold.ScrollState",
+                    saver = ScrollState.Saver,
+            ) {
+                scrollStateBuilder()
+            }
+        }
+
+        return _scrollableState as ScrollState
+    }
+
+    internal fun initializeScalingLazyListState(
+            scrollableStateBuilder: () -> ScalingLazyListState,
+    ): ScalingLazyListState {
+        check(scrollType == null || scrollType == ScrollType.ScalingLazyColumn)
+
+        if (scrollType == null) {
+            scrollType = ScrollType.ScalingLazyColumn
+
+            _scrollableState = savedStateHandle.saveable(
+                    key = "navScaffold.ScalingLazyListState",
+                    saver = ScalingLazyListState.Saver,
+            ) {
+                scrollableStateBuilder().also {
+                    initialIndex = it.centerItemIndex
+                    initialOffsetPx = it.centerItemScrollOffset
+                }
+            }
+        }
+
+        return _scrollableState as ScalingLazyListState
+    }
+
+    internal fun initializeScalingLazyListState(
+            columnState: ScalingLazyColumnState,
+    ) {
+        check(scrollType == null || scrollType == ScrollType.ScalingLazyColumn)
+
+        if (scrollType == null) {
+            scrollType = ScrollType.ScalingLazyColumn
+
+            initialIndex = columnState.initialScrollPosition.index
+            initialOffsetPx = columnState.initialScrollPosition.offsetPx
+
+            _scrollableState = savedStateHandle.saveable(
+                    key = "navScaffold.ScalingLazyListState",
+                    saver = ScalingLazyListState.Saver,
+            ) {
+                columnState.state
+            }
+        }
+
+        columnState.state = _scrollableState as ScalingLazyListState
+    }
+
+    internal fun initializeLazyList(
+            scrollableStateBuilder: () -> LazyListState,
+    ): LazyListState {
+        check(scrollType == null || scrollType == ScrollType.LazyList)
+
+        if (scrollType == null) {
+            scrollType = ScrollType.LazyList
+
+            _scrollableState = savedStateHandle.saveable(
+                    key = "navScaffold.LazyListState",
+                    saver = LazyListState.Saver,
+            ) {
+                scrollableStateBuilder()
+            }
+        }
+
+        return _scrollableState as LazyListState
+    }
+
+    internal enum class ScrollType {
+        None, ScalingLazyColumn, ScrollState, LazyList
+    }
+
+    /**
+     * The configuration of [TimeText], defaults to [ScrollAway] which will move the time text above the
+     * screen to avoid overlapping with the content moving up.
+     */
+    public enum class TimeTextMode {
+        On, Off, ScrollAway
+    }
+
+    /**
+     * The configuration of [PositionIndicator].  The default is to show a scroll bar while the
+     * scroll is in progress.
+     */
+    public enum class PositionIndicatorMode {
+        On, Off
+    }
+
+    /**
+     * The configuration of [Vignette], [WhenScrollable], [Off], [On] and if so whether top and
+     * bottom. Defaults to on for scrollable screens.
+     */
+    public sealed interface VignetteMode {
+        public object WhenScrollable : VignetteMode
+        public object Off : VignetteMode
+        public data class On(val position: VignettePosition) : VignetteMode
+    }
+
+    internal fun timeTextScrollableState(): ScrollableState? {
+        return when (timeTextMode) {
+            ScrollAway -> {
+                when (this.scrollType) {
+                    ScrollType.ScrollState -> {
+                        this.scrollableState as ScrollState
+                    }
+
+                    ScrollType.ScalingLazyColumn -> {
+                        val scalingLazyListState =
+                                this.scrollableState as ScalingLazyListState
+
+                        ScalingLazyColumnScrollableState(scalingLazyListState, initialIndex
+                                ?: 1, initialOffsetPx ?: 0)
+                    }
+
+                    ScrollType.LazyList -> {
+                        this.scrollableState as LazyListState
+                    }
+
+                    else -> {
+                        ScrollState(0)
+                    }
+                }
+            }
+
+            TimeTextMode.On -> {
+                ScrollState(0)
+            }
+
+            else -> {
+                null
+            }
+        }
+    }
+}
+
+internal class ScalingLazyColumnScrollableState(
+        val scalingLazyListState: ScalingLazyListState,
+        val initialIndex: Int,
+        val initialOffsetPx: Int,
+) : ScrollableState by scalingLazyListState
+
+/**
+ * The context items provided to a navigation composable.
+ *
+ * The [viewModel] can be used to customise the scaffold behaviour.
+ */
+public data class ScaffoldContext<T : ScrollableState>(
+        val backStackEntry: NavBackStackEntry,
+        val scrollableState: T,
+        val viewModel: NavScaffoldViewModel,
+) {
+    var timeTextMode: TimeTextMode by viewModel::timeTextMode
+
+    var positionIndicatorMode: PositionIndicatorMode by viewModel::positionIndicatorMode
+
+    val arguments: Bundle?
+        get() = backStackEntry.arguments
+}
+
+public data class NonScrollableScaffoldContext(
+        val backStackEntry: NavBackStackEntry,
+        val viewModel: NavScaffoldViewModel,
+) {
+    var timeTextMode: TimeTextMode by viewModel::timeTextMode
+
+    var positionIndicatorMode: PositionIndicatorMode by viewModel::positionIndicatorMode
+
+    val arguments: Bundle?
+        get() = backStackEntry.arguments
+}
+
+/**
+ * The context items provided to a navigation composable.
+ *
+ * The [viewModel] can be used to customise the scaffold behaviour.
+ */
+public data class ScrollableScaffoldContext(
+        val backStackEntry: NavBackStackEntry,
+        val columnState: ScalingLazyColumnState,
+        val viewModel: NavScaffoldViewModel,
+) {
+    val scrollableState: ScalingLazyListState
+        get() = columnState.state
+
+    var timeTextMode: TimeTextMode by viewModel::timeTextMode
+
+    var positionIndicatorMode: PositionIndicatorMode by viewModel::positionIndicatorMode
+
+    val arguments: Bundle?
+        get() = backStackEntry.arguments
+}
diff --git a/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/navscaffold/WearNavScaffold.kt b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/navscaffold/WearNavScaffold.kt
new file mode 100644
index 0000000..315d822
--- /dev/null
+++ b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/navscaffold/WearNavScaffold.kt
@@ -0,0 +1,321 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(ExperimentalWearFoundationApi::class)
+
+package com.google.android.horologist.compose.navscaffold
+
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.gestures.ScrollableState
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.State
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NamedNavArgument
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavDeepLink
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavHostController
+import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
+import androidx.wear.compose.foundation.HierarchicalFocusCoordinator
+import androidx.wear.compose.foundation.lazy.ScalingLazyListState
+import androidx.wear.compose.material.PositionIndicator
+import androidx.wear.compose.material.Scaffold
+import androidx.wear.compose.material.TimeText
+import androidx.wear.compose.material.Vignette
+import androidx.wear.compose.navigation.SwipeDismissableNavHost
+import androidx.wear.compose.navigation.SwipeDismissableNavHostState
+import androidx.wear.compose.navigation.composable
+import androidx.wear.compose.navigation.currentBackStackEntryAsState
+import androidx.wear.compose.navigation.rememberSwipeDismissableNavHostState
+import com.google.android.horologist.annotations.ExperimentalHorologistApi
+import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults
+import com.google.android.horologist.compose.layout.ScalingLazyColumnState
+import com.google.android.horologist.compose.layout.scrollAway
+
+/**
+ * A Navigation and Scroll aware [Scaffold].
+ *
+ * In addition to [NavGraphBuilder.scrollable], 3 additional extensions are supported
+ * [scalingLazyColumnComposable], [scrollStateComposable] and
+ * [lazyListComposable].
+ *
+ * These should be used to build the [ScrollableState] or [FocusRequester] as well as
+ * configure the behaviour of [TimeText], [PositionIndicator] or [Vignette].
+ */
+@Composable
+public fun WearNavScaffold(
+        startDestination: String,
+        navController: NavHostController,
+        modifier: Modifier = Modifier,
+        snackbar: @Composable () -> Unit = {},
+        timeText: @Composable (Modifier) -> Unit = {
+            TimeText(
+                    modifier = it,
+            )
+        },
+        state: SwipeDismissableNavHostState = rememberSwipeDismissableNavHostState(),
+        builder: NavGraphBuilder.() -> Unit,
+) {
+    val currentBackStackEntry: NavBackStackEntry? by navController.currentBackStackEntryAsState()
+
+    val viewModel: NavScaffoldViewModel? = currentBackStackEntry?.let {
+        viewModel(viewModelStoreOwner = it)
+    }
+
+    val scrollState: State<ScrollableState?> = remember(viewModel) {
+        derivedStateOf {
+            viewModel?.timeTextScrollableState()
+        }
+    }
+
+    Scaffold(
+            modifier = modifier.fillMaxSize(),
+            timeText = {
+                timeText(Modifier.scrollAway(scrollState))
+            },
+            positionIndicator = {
+                key(currentBackStackEntry?.destination?.route) {
+                    val mode = viewModel?.positionIndicatorMode
+
+                    if (mode == NavScaffoldViewModel.PositionIndicatorMode.On) {
+                        NavPositionIndicator(viewModel)
+                    }
+                }
+            },
+            vignette = {
+                key(currentBackStackEntry?.destination?.route) {
+                    val vignettePosition = viewModel?.vignettePosition
+                    if (vignettePosition is NavScaffoldViewModel.VignetteMode.On) {
+                        Vignette(vignettePosition = vignettePosition.position)
+                    }
+                }
+            },
+    ) {
+        Box {
+            SwipeDismissableNavHost(
+                    navController = navController,
+                    startDestination = startDestination,
+                    state = state,
+            ) {
+                builder()
+            }
+
+            snackbar()
+        }
+    }
+}
+
+@Composable
+private fun NavPositionIndicator(viewModel: NavScaffoldViewModel) {
+    when (viewModel.scrollType) {
+        NavScaffoldViewModel.ScrollType.ScrollState ->
+            PositionIndicator(
+                    scrollState = viewModel.scrollableState as ScrollState,
+            )
+
+        NavScaffoldViewModel.ScrollType.ScalingLazyColumn -> {
+            PositionIndicator(
+                    scalingLazyListState = viewModel.scrollableState as ScalingLazyListState,
+            )
+        }
+
+        NavScaffoldViewModel.ScrollType.LazyList ->
+            PositionIndicator(
+                    lazyListState = viewModel.scrollableState as LazyListState,
+            )
+
+        else -> {}
+    }
+}
+
+/**
+ * Add a screen to the navigation graph featuring a ScalingLazyColumn.
+ *
+ * The scalingLazyListState must be taken from the [ScaffoldContext].
+ */
+@Deprecated(
+        "Use listComposable",
+)
+public fun NavGraphBuilder.scalingLazyColumnComposable(
+        route: String,
+        arguments: List<NamedNavArgument> = emptyList(),
+        deepLinks: List<NavDeepLink> = emptyList(),
+        scrollStateBuilder: () -> ScalingLazyListState,
+        content: @Composable (ScaffoldContext<ScalingLazyListState>) -> Unit,
+) {
+    composable(route, arguments, deepLinks) {
+        FocusedDestination {
+            val viewModel: NavScaffoldViewModel = viewModel(it)
+
+            val scrollState = viewModel.initializeScalingLazyListState(scrollStateBuilder)
+
+            content(ScaffoldContext(it, scrollState, viewModel))
+        }
+    }
+}
+
+/**
+ * Add a screen to the navigation graph featuring a ScalingLazyColumn.
+ *
+ * The [ScalingLazyColumnState] must be taken from the [ScrollableScaffoldContext].
+ */
+@ExperimentalHorologistApi
+public fun NavGraphBuilder.scrollable(
+        route: String,
+        arguments: List<NamedNavArgument> = emptyList(),
+        deepLinks: List<NavDeepLink> = emptyList(),
+        columnStateFactory: ScalingLazyColumnState.Factory =
+                ScalingLazyColumnDefaults.belowTimeText(),
+        content: @Composable (ScrollableScaffoldContext) -> Unit,
+) {
+    this@scrollable.composable(route, arguments, deepLinks) {
+        FocusedDestination {
+            val columnState = columnStateFactory.create()
+
+            val viewModel: NavScaffoldViewModel = viewModel(it)
+
+            viewModel.initializeScalingLazyListState(columnState)
+
+            content(ScrollableScaffoldContext(it, columnState, viewModel))
+        }
+    }
+}
+
+/**
+ * Add a screen to the navigation graph featuring a Scrollable item.
+ *
+ * The scrollState must be taken from the [ScaffoldContext].
+ */
+public fun NavGraphBuilder.scrollStateComposable(
+        route: String,
+        arguments: List<NamedNavArgument> = emptyList(),
+        deepLinks: List<NavDeepLink> = emptyList(),
+        scrollStateBuilder: () -> ScrollState = { ScrollState(0) },
+        content: @Composable (ScaffoldContext<ScrollState>) -> Unit,
+) {
+    composable(route, arguments, deepLinks) {
+        FocusedDestination {
+            val viewModel: NavScaffoldViewModel = viewModel(it)
+
+            val scrollState = viewModel.initializeScrollState(scrollStateBuilder)
+
+            content(ScaffoldContext(it, scrollState, viewModel))
+        }
+    }
+}
+
+/**
+ * Add a screen to the navigation graph featuring a Lazy list such as LazyColumn.
+ *
+ * The scrollState must be taken from the [ScaffoldContext].
+ */
+public fun NavGraphBuilder.lazyListComposable(
+        route: String,
+        arguments: List<NamedNavArgument> = emptyList(),
+        deepLinks: List<NavDeepLink> = emptyList(),
+        lazyListStateBuilder: () -> LazyListState = { LazyListState() },
+        content: @Composable (ScaffoldContext<LazyListState>) -> Unit,
+) {
+    composable(route, arguments, deepLinks) {
+        FocusedDestination {
+            val viewModel: NavScaffoldViewModel = viewModel(it)
+
+            val scrollState = viewModel.initializeLazyList(lazyListStateBuilder)
+
+            content(ScaffoldContext(it, scrollState, viewModel))
+        }
+    }
+}
+
+/**
+ * Add non scrolling screen to the navigation graph. The [NavBackStackEntry] and
+ * [NavScaffoldViewModel] are passed into the [content] block so that
+ * the Scaffold may be customised, such as disabling TimeText.
+ */
+@Deprecated(
+        "Use composable",
+        ReplaceWith("composable(route, arguments, deepLinks, lazyListStateBuilder, content)"),
+)
+public fun NavGraphBuilder.wearNavComposable(
+        route: String,
+        arguments: List<NamedNavArgument> = emptyList(),
+        deepLinks: List<NavDeepLink> = emptyList(),
+        content: @Composable (NavBackStackEntry, NavScaffoldViewModel) -> Unit,
+) {
+    composable(route, arguments, deepLinks) {
+        FocusedDestination {
+            val viewModel: NavScaffoldViewModel = viewModel()
+
+            content(it, viewModel)
+        }
+    }
+}
+
+/**
+ * Add non scrolling screen to the navigation graph. The [NavBackStackEntry] and
+ * [NavScaffoldViewModel] are passed into the [content] block so that
+ * the Scaffold may be customised, such as disabling TimeText.
+ */
+@ExperimentalHorologistApi
+public fun NavGraphBuilder.composable(
+        route: String,
+        arguments: List<NamedNavArgument> = emptyList(),
+        deepLinks: List<NavDeepLink> = emptyList(),
+        content: @Composable (NonScrollableScaffoldContext) -> Unit,
+) {
+    this@composable.composable(route, arguments, deepLinks) {
+        FocusedDestination {
+            val viewModel: NavScaffoldViewModel = viewModel()
+
+            content(NonScrollableScaffoldContext(it, viewModel))
+        }
+    }
+}
+
+@Composable
+internal fun FocusedDestination(content: @Composable () -> Unit) {
+    val lifecycle = LocalLifecycleOwner.current.lifecycle
+    val focused =
+            remember { mutableStateOf(lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) }
+
+    DisposableEffect(lifecycle) {
+        val listener = LifecycleEventObserver { _, _ ->
+            focused.value = lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
+        }
+        lifecycle.addObserver(listener)
+        onDispose {
+            lifecycle.removeObserver(listener)
+        }
+    }
+
+    HierarchicalFocusCoordinator(requiresFocus = { focused.value }) {
+        content()
+    }
+}
diff --git a/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/rotaryinput/Haptics.kt b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/rotaryinput/Haptics.kt
new file mode 100644
index 0000000..c4af4a6
--- /dev/null
+++ b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/rotaryinput/Haptics.kt
@@ -0,0 +1,380 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(ExperimentalHorologistApi::class)
+
+package com.google.android.horologist.compose.rotaryinput
+
+import android.os.Build
+import android.view.HapticFeedbackConstants
+import android.view.View
+import androidx.compose.foundation.gestures.ScrollableState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalView
+import com.google.android.horologist.annotations.ExperimentalHorologistApi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.conflate
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.withContext
+import kotlin.math.abs
+
+private const val DEBUG = false
+
+/**
+ * Debug logging that can be enabled.
+ */
+private inline fun debugLog(generateMsg: () -> String) {
+    if (DEBUG) {
+        println("RotaryHaptics: ${generateMsg()}")
+    }
+}
+
+/**
+ * Throttling events within specified timeframe. Only first and last events will be received.
+ * For a flow emitting elements 1 to 30, with a 100ms delay between them:
+ * ```
+ * val flow = flow {
+ *     for (i in 1..30) {
+ *         delay(100)
+ *         emit(i)
+ *     }
+ * }
+ * ```
+ * With timeframe=1000 only those integers will be received: 1, 10, 20, 30 .
+ */
+internal fun <T> Flow<T>.throttleLatest(timeframe: Long): Flow<T> =
+        flow {
+            conflate().collect {
+                emit(it)
+                delay(timeframe)
+            }
+        }
+
+/**
+ * Handles haptics for rotary usage
+ */
+@ExperimentalHorologistApi
+public interface RotaryHapticHandler {
+
+    /**
+     * Handles haptics when scroll is used
+     */
+    @ExperimentalHorologistApi
+    public fun handleScrollHaptic(scrollDelta: Float)
+
+    /**
+     * Handles haptics when scroll with snap is used
+     */
+    @ExperimentalHorologistApi
+    public fun handleSnapHaptic(scrollDelta: Float)
+}
+
+/**
+ * Default implementation of [RotaryHapticHandler]. It handles haptic feedback based
+ * on the [scrollableState], scrolled pixels and [hapticsThresholdPx].
+ * Haptic is not fired in this class, instead it's sent to [hapticsChannel]
+ * where it'll performed later.
+ *
+ * @param scrollableState Haptic performed based on this state
+ * @param hapticsChannel Channel to which haptic events will be sent
+ * @param hapticsThresholdPx A scroll threshold after which haptic is produced.
+ */
+public class DefaultRotaryHapticHandler(
+        private val scrollableState: ScrollableState,
+        private val hapticsChannel: Channel<RotaryHapticsType>,
+        private val hapticsThresholdPx: Long = 50,
+) : RotaryHapticHandler {
+
+    private var overscrollHapticTriggered = false
+    private var currScrollPosition = 0f
+    private var prevHapticsPosition = 0f
+
+    override fun handleScrollHaptic(scrollDelta: Float) {
+        if ((scrollDelta > 0 && !scrollableState.canScrollForward) ||
+                (scrollDelta < 0 && !scrollableState.canScrollBackward)
+        ) {
+            if (!overscrollHapticTriggered) {
+                trySendHaptic(RotaryHapticsType.ScrollLimit)
+                overscrollHapticTriggered = true
+            }
+        } else {
+            overscrollHapticTriggered = false
+            currScrollPosition += scrollDelta
+            val diff = abs(currScrollPosition - prevHapticsPosition)
+
+            if (diff >= hapticsThresholdPx) {
+                trySendHaptic(RotaryHapticsType.ScrollTick)
+                prevHapticsPosition = currScrollPosition
+            }
+        }
+    }
+
+    override fun handleSnapHaptic(scrollDelta: Float) {
+        if ((scrollDelta > 0 && !scrollableState.canScrollForward) ||
+                (scrollDelta < 0 && !scrollableState.canScrollBackward)
+        ) {
+            if (!overscrollHapticTriggered) {
+                trySendHaptic(RotaryHapticsType.ScrollLimit)
+                overscrollHapticTriggered = true
+            }
+        } else {
+            overscrollHapticTriggered = false
+            trySendHaptic(RotaryHapticsType.ScrollItemFocus)
+        }
+    }
+
+    private fun trySendHaptic(rotaryHapticsType: RotaryHapticsType) {
+        // Ok to ignore the ChannelResult because we default to capacity = 2 and DROP_OLDEST
+        @Suppress("UNUSED_VARIABLE")
+        val unused = hapticsChannel.trySend(rotaryHapticsType)
+    }
+}
+
+/**
+ * Interface for Rotary haptic feedback
+ */
+@ExperimentalHorologistApi
+public interface RotaryHapticFeedback {
+    @ExperimentalHorologistApi
+    public fun performHapticFeedback(type: RotaryHapticsType)
+}
+
+/**
+ * Rotary haptic types
+ */
+@ExperimentalHorologistApi
+@JvmInline
+public value class RotaryHapticsType(private val type: Int) {
+    public companion object {
+        /**
+         * A scroll ticking haptic. Similar to texture haptic - performed each time when
+         * a scrollable content is scrolled by a certain distance
+         */
+        @ExperimentalHorologistApi
+        public val ScrollTick: RotaryHapticsType = RotaryHapticsType(1)
+
+        /**
+         * An item focus (snap) haptic. Performed when a scrollable content is snapped
+         * to a specific item.
+         */
+        @ExperimentalHorologistApi
+        public val ScrollItemFocus: RotaryHapticsType = RotaryHapticsType(2)
+
+        /**
+         * A limit(overscroll) haptic. Performed when a list reaches the limit
+         * (start or end) and can't scroll further
+         */
+        @ExperimentalHorologistApi
+        public val ScrollLimit: RotaryHapticsType = RotaryHapticsType(3)
+    }
+}
+
+/**
+ * Remember disabled haptics handler
+ */
+@ExperimentalHorologistApi
+@Composable
+public fun rememberDisabledHaptic(): RotaryHapticHandler = remember {
+    object : RotaryHapticHandler {
+
+        override fun handleScrollHaptic(scrollDelta: Float) {
+            // Do nothing
+        }
+
+        override fun handleSnapHaptic(scrollDelta: Float) {
+            // Do nothing
+        }
+    }
+}
+
+/**
+ * Remember rotary haptic handler.
+ * @param scrollableState A scrollableState, used to determine whether the end of the scrollable
+ * was reached or not.
+ * @param throttleThresholdMs Throttling events within specified timeframe.
+ * Only first and last events will be received. Check [throttleLatest] for more info.
+ * @param hapticsThresholdPx A scroll threshold after which haptic is produced.
+ * @param hapticsChannel Channel to which haptic events will be sent
+ * @param rotaryHaptics Interface for Rotary haptic feedback which performs haptics
+ */
+@ExperimentalHorologistApi
+@Composable
+public fun rememberRotaryHapticHandler(
+        scrollableState: ScrollableState,
+        throttleThresholdMs: Long = 30,
+        hapticsThresholdPx: Long = 50,
+        hapticsChannel: Channel<RotaryHapticsType> = rememberHapticChannel(),
+        rotaryHaptics: RotaryHapticFeedback = rememberDefaultRotaryHapticFeedback(),
+): RotaryHapticHandler {
+    return remember(scrollableState, hapticsChannel, rotaryHaptics) {
+        DefaultRotaryHapticHandler(scrollableState, hapticsChannel, hapticsThresholdPx)
+    }.apply {
+        LaunchedEffect(hapticsChannel) {
+            hapticsChannel.receiveAsFlow()
+                    .throttleLatest(throttleThresholdMs)
+                    .collect { hapticType ->
+                        // 'withContext' launches performHapticFeedback in a separate thread,
+                        // as otherwise it produces a visible lag (b/219776664)
+                        val currentTime = System.currentTimeMillis()
+                        debugLog { "Haptics started" }
+                        withContext(Dispatchers.Default) {
+                            debugLog {
+                                "Performing haptics, delay: " +
+                                        "${System.currentTimeMillis() - currentTime}"
+                            }
+                            rotaryHaptics.performHapticFeedback(hapticType)
+                        }
+                    }
+        }
+    }
+}
+
+@Composable
+private fun rememberHapticChannel() =
+        remember {
+            Channel<RotaryHapticsType>(
+                    capacity = 2,
+                    onBufferOverflow = BufferOverflow.DROP_OLDEST,
+            )
+        }
+
+@ExperimentalHorologistApi
+@Composable
+public fun rememberDefaultRotaryHapticFeedback(): RotaryHapticFeedback =
+        LocalView.current.let { view -> remember { findDeviceSpecificHapticFeedback(view) } }
+
+internal fun findDeviceSpecificHapticFeedback(view: View): RotaryHapticFeedback =
+        if (isGooglePixelWatch()) {
+            PixelWatchRotaryHapticFeedback(view)
+        } else if (isGalaxyWatchClassic()) {
+            GalaxyWatchClassicHapticFeedback(view)
+        } else {
+            DefaultRotaryHapticFeedback(view)
+        }
+
+/**
+ * Default Rotary implementation for [RotaryHapticFeedback]
+ */
+@ExperimentalHorologistApi
+public class DefaultRotaryHapticFeedback(private val view: View) : RotaryHapticFeedback {
+
+    @ExperimentalHorologistApi
+    override fun performHapticFeedback(
+            type: RotaryHapticsType,
+    ) {
+        when (type) {
+            RotaryHapticsType.ScrollItemFocus -> {
+                view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
+            }
+
+            RotaryHapticsType.ScrollTick -> {
+                view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
+            }
+
+            RotaryHapticsType.ScrollLimit -> {
+                view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
+            }
+        }
+    }
+}
+
+/**
+ * Implementation of [RotaryHapticFeedback] for Pixel Watch
+ */
+@ExperimentalHorologistApi
+private class PixelWatchRotaryHapticFeedback(private val view: View) : RotaryHapticFeedback {
+
+    @ExperimentalHorologistApi
+    override fun performHapticFeedback(
+            type: RotaryHapticsType,
+    ) {
+        when (type) {
+            RotaryHapticsType.ScrollItemFocus -> {
+                view.performHapticFeedback(
+                        if (Build.VERSION.SDK_INT >= 33) {
+                            ROTARY_SCROLL_ITEM_FOCUS
+                        } else {
+                            WEAR_SCROLL_ITEM_FOCUS
+                        },
+                )
+            }
+
+            RotaryHapticsType.ScrollTick -> {
+                view.performHapticFeedback(
+                        if (Build.VERSION.SDK_INT >= 33) ROTARY_SCROLL_TICK else WEAR_SCROLL_TICK,
+                )
+            }
+
+            RotaryHapticsType.ScrollLimit -> {
+                view.performHapticFeedback(
+                        if (Build.VERSION.SDK_INT >= 33) ROTARY_SCROLL_LIMIT else WEAR_SCROLL_LIMIT,
+                )
+            }
+        }
+    }
+
+    private companion object {
+        // Hidden constants from HapticFeedbackConstants.java specific for Pixel Watch
+        // API 33
+        public const val ROTARY_SCROLL_TICK: Int = 18
+        public const val ROTARY_SCROLL_ITEM_FOCUS: Int = 19
+        public const val ROTARY_SCROLL_LIMIT: Int = 20
+
+        // API 30
+        public const val WEAR_SCROLL_TICK: Int = 10002
+        public const val WEAR_SCROLL_ITEM_FOCUS: Int = 10003
+        public const val WEAR_SCROLL_LIMIT: Int = 10003
+    }
+}
+
+/**
+ * Implementation of [RotaryHapticFeedback] for Galaxy Watch 4 Classic
+ */
+@ExperimentalHorologistApi
+private class GalaxyWatchClassicHapticFeedback(private val view: View) : RotaryHapticFeedback {
+
+    @ExperimentalHorologistApi
+    override fun performHapticFeedback(
+            type: RotaryHapticsType,
+    ) {
+        when (type) {
+            RotaryHapticsType.ScrollItemFocus -> {
+                // No haptic for scroll snap ( we have physical bezel)
+            }
+
+            RotaryHapticsType.ScrollTick -> {
+                // No haptic for scroll tick ( we have physical bezel)
+            }
+
+            RotaryHapticsType.ScrollLimit -> {
+                view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
+            }
+        }
+    }
+}
+
+private fun isGalaxyWatchClassic(): Boolean =
+        Build.MODEL.matches("SM-R8[89]5.".toRegex())
+
+private fun isGooglePixelWatch(): Boolean =
+        Build.MODEL.startsWith("Google Pixel Watch")
diff --git a/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/rotaryinput/Rotary.kt b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/rotaryinput/Rotary.kt
new file mode 100644
index 0000000..3ca16c1
--- /dev/null
+++ b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/rotaryinput/Rotary.kt
@@ -0,0 +1,1301 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(ExperimentalHorologistApi::class)
+
+package com.google.android.horologist.compose.rotaryinput
+
+import android.view.ViewConfiguration
+import androidx.compose.animation.core.AnimationState
+import androidx.compose.animation.core.CubicBezierEasing
+import androidx.compose.animation.core.Easing
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.SpringSpec
+import androidx.compose.animation.core.animateTo
+import androidx.compose.animation.core.copy
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.MutatePriority
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.gestures.FlingBehavior
+import androidx.compose.foundation.gestures.ScrollableDefaults
+import androidx.compose.foundation.gestures.ScrollableState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.input.rotary.onRotaryScrollEvent
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.util.fastSumBy
+import androidx.compose.ui.util.lerp
+import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
+import androidx.wear.compose.foundation.lazy.ScalingLazyListState
+import androidx.wear.compose.foundation.rememberActiveFocusRequester
+import com.google.android.horologist.annotations.ExperimentalHorologistApi
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.transformLatest
+import kotlin.math.abs
+import kotlin.math.absoluteValue
+import kotlin.math.sign
+
+private const val DEBUG = false
+
+/**
+ * Debug logging that can be enabled.
+ */
+private inline fun debugLog(generateMsg: () -> String) {
+    if (DEBUG) {
+        println("RotaryScroll: ${generateMsg()}")
+    }
+}
+
+/**
+ * A modifier which connects rotary events with scrollable.
+ * This modifier supports fling.
+ *
+ *  Fling algorithm:
+ * - A scroll with RSB/ Bezel happens.
+ * - If this is a first rotary event after the threshold ( by default 200ms), a new scroll
+ * session starts by resetting all necessary parameters
+ * - A delta value is added into VelocityTracker and a new speed is calculated.
+ * - If the current speed is bigger than the previous one,  this value is remembered as
+ * a latest fling speed with a timestamp
+ * - After each scroll event a fling countdown starts ( by default 70ms) which
+ * resets if new scroll event is received
+ * - If fling countdown is finished - it means that the finger was probably raised from RSB, there will be no other events and probably
+ * this is the last event during this session. After it a fling is triggered.
+ * - Fling is stopped when a new scroll event happens
+ *
+ * The screen containing the scrollable item should request the focus
+ * by calling [requestFocus] method
+ *
+ * ```
+ * LaunchedEffect(Unit) {
+ *   focusRequester.requestFocus()
+ * }
+ * ```
+ * @param focusRequester Requests the focus for rotary input
+ * @param scrollableState Scrollable state which will be scrolled while receiving rotary events
+ * @param flingBehavior Logic describing fling behavior.
+ * @param rotaryHaptics Class which will handle haptic feedback
+ * @param reverseDirection Reverse the direction of scrolling. Should be aligned with
+ * Scrollable `reverseDirection` parameter
+ */
+@ExperimentalHorologistApi
+@Suppress("ComposableModifierFactory")
+@Deprecated(
+        "Use rotaryWithScroll instead",
+        ReplaceWith(
+                "this.rotaryWithScroll(scrollableState, focusRequester, " +
+                        "flingBehavior, rotaryHaptics, reverseDirection)",
+        ),
+)
+@Composable
+public fun Modifier.rotaryWithFling(
+        focusRequester: FocusRequester,
+        scrollableState: ScrollableState,
+        flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
+        rotaryHaptics: RotaryHapticHandler = rememberRotaryHapticHandler(scrollableState),
+        reverseDirection: Boolean = false,
+): Modifier = rotaryHandler(
+        rotaryScrollHandler = RotaryDefaults.rememberFlingHandler(scrollableState, flingBehavior),
+        reverseDirection = reverseDirection,
+        rotaryHaptics = rotaryHaptics,
+)
+        .focusRequester(focusRequester)
+        .focusable()
+
+/**
+ * A modifier which connects rotary events with scrollable.
+ * This modifier supports scroll with fling.
+ *
+ * @param scrollableState Scrollable state which will be scrolled while receiving rotary events
+ * @param focusRequester Requests the focus for rotary input.
+ * By default comes from [rememberActiveFocusRequester],
+ * which is used with [HierarchicalFocusCoordinator]
+ * @param flingBehavior Logic describing fling behavior. If null fling will not happen.
+ * @param rotaryHaptics Class which will handle haptic feedback
+ * @param reverseDirection Reverse the direction of scrolling. Should be aligned with
+ * Scrollable `reverseDirection` parameter
+ */
+@OptIn(ExperimentalWearFoundationApi::class)
+@ExperimentalHorologistApi
+@Suppress("ComposableModifierFactory")
+@Composable
+public fun Modifier.rotaryWithScroll(
+        scrollableState: ScrollableState,
+        focusRequester: FocusRequester = rememberActiveFocusRequester(),
+        flingBehavior: FlingBehavior? = ScrollableDefaults.flingBehavior(),
+        rotaryHaptics: RotaryHapticHandler = rememberRotaryHapticHandler(scrollableState),
+        reverseDirection: Boolean = false,
+): Modifier = rotaryHandler(
+        rotaryScrollHandler = RotaryDefaults.rememberFlingHandler(scrollableState, flingBehavior),
+        reverseDirection = reverseDirection,
+        rotaryHaptics = rotaryHaptics,
+)
+        .focusRequester(focusRequester)
+        .focusable()
+
+/**
+ * A modifier which connects rotary events with scrollable.
+ * This modifier supports snap.
+ *
+ * @param focusRequester Requests the focus for rotary input.
+ * By default comes from [rememberActiveFocusRequester],
+ * which is used with [HierarchicalFocusCoordinator]
+ * @param rotaryScrollAdapter A connection between scrollable objects and rotary events
+ * @param rotaryHaptics Class which will handle haptic feedback
+ * @param reverseDirection Reverse the direction of scrolling. Should be aligned with
+ * Scrollable `reverseDirection` parameter
+ */
+@OptIn(ExperimentalWearFoundationApi::class)
+@ExperimentalHorologistApi
+@Suppress("ComposableModifierFactory")
+@Composable
+public fun Modifier.rotaryWithSnap(
+        rotaryScrollAdapter: RotaryScrollAdapter,
+        focusRequester: FocusRequester = rememberActiveFocusRequester(),
+        snapParameters: SnapParameters = RotaryDefaults.snapParametersDefault(),
+        rotaryHaptics: RotaryHapticHandler =
+                rememberRotaryHapticHandler(rotaryScrollAdapter.scrollableState),
+        reverseDirection: Boolean = false,
+): Modifier = rotaryHandler(
+        rotaryScrollHandler =
+            RotaryDefaults.rememberSnapHandler(rotaryScrollAdapter, snapParameters),
+        reverseDirection = reverseDirection,
+        rotaryHaptics = rotaryHaptics,
+)
+        .focusRequester(focusRequester)
+        .focusable()
+
+/**
+ * An extension function for creating [RotaryScrollAdapter] from [ScalingLazyListState]
+ */
+@ExperimentalHorologistApi
+public fun ScalingLazyListState.toRotaryScrollAdapter(): RotaryScrollAdapter =
+        ScalingLazyColumnRotaryScrollAdapter(this)
+
+/**
+ * An implementation of rotary scroll adapter for [ScalingLazyColumn]
+ */
+@ExperimentalHorologistApi
+public class ScalingLazyColumnRotaryScrollAdapter(
+        override val scrollableState: ScalingLazyListState,
+) : RotaryScrollAdapter {
+
+    /**
+     * Calculates an average height of an item by taking an average from visible items height.
+     */
+    override fun averageItemSize(): Float {
+        val visibleItems = scrollableState.layoutInfo.visibleItemsInfo
+        return (visibleItems.fastSumBy { it.unadjustedSize } / visibleItems.size).toFloat()
+    }
+
+    /**
+     * Current (centred) item index
+     */
+    override fun currentItemIndex(): Int = scrollableState.centerItemIndex
+
+    /**
+     * An offset from the item centre
+     */
+    override fun currentItemOffset(): Float = scrollableState.centerItemScrollOffset.toFloat()
+
+    /**
+     * The total count of items in ScalingLazyColumn
+     */
+    override fun totalItemsCount(): Int = scrollableState.layoutInfo.totalItemsCount
+}
+
+/**
+ * An adapter which connects scrollableState to Rotary
+ */
+@ExperimentalHorologistApi
+public interface RotaryScrollAdapter {
+
+    /**
+     * A scrollable state. Used for performing scroll when Rotary events received
+     */
+    @ExperimentalHorologistApi
+    public val scrollableState: ScrollableState
+
+    /**
+     * Average size of an item. Used for estimating the scrollable distance
+     */
+    @ExperimentalHorologistApi
+    public fun averageItemSize(): Float
+
+    /**
+     * A current item index. Used for scrolling
+     */
+    @ExperimentalHorologistApi
+    public fun currentItemIndex(): Int
+
+    /**
+     * An offset from the centre or the border of the current item.
+     */
+    @ExperimentalHorologistApi
+    public fun currentItemOffset(): Float
+
+    /**
+     * The total count of items in [scrollableState]
+     */
+    @ExperimentalHorologistApi
+    public fun totalItemsCount(): Int
+}
+
+/**
+ * Defaults for rotary modifiers
+ */
+@ExperimentalHorologistApi
+public object RotaryDefaults {
+
+    /**
+     * Handles scroll with fling.
+     * @param scrollableState Scrollable state which will be scrolled while receiving rotary events
+     * @param flingBehavior Logic describing Fling behavior. If null - fling will not happen
+     * @param isLowRes Whether the input is Low-res (a bezel) or high-res(a crown/rsb)
+     */
+    @ExperimentalHorologistApi
+    @Composable
+    public fun rememberFlingHandler(
+            scrollableState: ScrollableState,
+            flingBehavior: FlingBehavior? = null,
+            isLowRes: Boolean = isLowResInput(),
+    ): RotaryScrollHandler {
+        val viewConfiguration = ViewConfiguration.get(LocalContext.current)
+
+        return remember(scrollableState, flingBehavior, isLowRes) {
+            debugLog { "isLowRes : $isLowRes" }
+            fun rotaryFlingBehavior() = flingBehavior?.run {
+                DefaultRotaryFlingBehavior(
+                        scrollableState,
+                        flingBehavior,
+                        viewConfiguration,
+                        flingTimeframe =
+                            if (isLowRes) lowResFlingTimeframe else highResFlingTimeframe,
+                )
+            }
+
+            fun scrollBehavior() = AnimationScrollBehavior(scrollableState)
+
+            if (isLowRes) {
+                LowResRotaryScrollHandler(
+                        rotaryFlingBehaviorFactory = { rotaryFlingBehavior() },
+                        scrollBehaviorFactory = { scrollBehavior() },
+                )
+            } else {
+                HighResRotaryScrollHandler(
+                        rotaryFlingBehaviorFactory = { rotaryFlingBehavior() },
+                        scrollBehaviorFactory = { scrollBehavior() },
+                )
+            }
+        }
+    }
+
+    /**
+     * Handles scroll with snap
+     * @param rotaryScrollAdapter A connection between scrollable objects and rotary events
+     * @param snapParameters Snap parameters
+     */
+    @ExperimentalHorologistApi
+    @Composable
+    public fun rememberSnapHandler(
+            rotaryScrollAdapter: RotaryScrollAdapter,
+            snapParameters: SnapParameters = snapParametersDefault(),
+            isLowRes: Boolean = isLowResInput(),
+    ): RotaryScrollHandler {
+        return remember(rotaryScrollAdapter, snapParameters) {
+            if (isLowRes) {
+                LowResSnapHandler(
+                        snapBehaviourFactory = {
+                            DefaultSnapBehavior(rotaryScrollAdapter, snapParameters)
+                        },
+                )
+            } else {
+                HighResSnapHandler(
+                        resistanceFactor = snapParameters.resistanceFactor,
+                        thresholdBehaviorFactory = {
+                            ThresholdBehavior(
+                                    rotaryScrollAdapter,
+                                    snapParameters.thresholdDivider,
+                            )
+                        },
+                        snapBehaviourFactory = {
+                            DefaultSnapBehavior(rotaryScrollAdapter, snapParameters)
+                        },
+                        scrollBehaviourFactory = {
+                            AnimationScrollBehavior(rotaryScrollAdapter.scrollableState)
+                        },
+                )
+            }
+        }
+    }
+
+    /**
+     * Returns default [SnapParameters]
+     */
+    @ExperimentalHorologistApi
+    public fun snapParametersDefault(): SnapParameters =
+            SnapParameters(
+                    snapOffset = 0,
+                    thresholdDivider = 1.5f,
+                    resistanceFactor = 3f,
+            )
+
+    /**
+     * Returns whether the input is Low-res (a bezel) or high-res(a crown/rsb).
+     */
+    @ExperimentalHorologistApi
+    @Composable
+    public fun isLowResInput(): Boolean = LocalContext.current.packageManager
+            .hasSystemFeature("android.hardware.rotaryencoder.lowres")
+
+    private val lowResFlingTimeframe: Long = 100L
+    private val highResFlingTimeframe: Long = 30L
+}
+
+/**
+ * Parameters used for snapping
+ *
+ * @param snapOffset an optional offset to be applied when snapping the item. After the snap the
+ * snapped items offset will be [snapOffset].
+ */
+public class SnapParameters(
+        public val snapOffset: Int,
+        public val thresholdDivider: Float,
+        public val resistanceFactor: Float,
+) {
+    /**
+     * Returns a snapping offset in [Dp]
+     */
+    @Composable
+    public fun snapOffsetDp(): Dp {
+        return with(LocalDensity.current) {
+            snapOffset.toDp()
+        }
+    }
+}
+
+/**
+ * An interface for handling scroll events
+ */
+@ExperimentalHorologistApi
+public interface RotaryScrollHandler {
+    /**
+     * Handles scrolling events
+     * @param coroutineScope A scope for performing async actions
+     * @param event A scrollable event from rotary input, containing scrollable delta and timestamp
+     * @param rotaryHaptics
+     */
+    @ExperimentalHorologistApi
+    public suspend fun handleScrollEvent(
+            coroutineScope: CoroutineScope,
+            event: TimestampedDelta,
+            rotaryHaptics: RotaryHapticHandler,
+    )
+}
+
+/**
+ * An interface for scrolling behavior
+ */
+@ExperimentalHorologistApi
+public interface RotaryScrollBehavior {
+    /**
+     * Handles scroll event to [targetValue]
+     */
+    @ExperimentalHorologistApi
+    public suspend fun handleEvent(targetValue: Float)
+}
+
+/**
+ * Default implementation of [RotaryFlingBehavior]
+ */
+@ExperimentalHorologistApi
+public class DefaultRotaryFlingBehavior(
+        private val scrollableState: ScrollableState,
+        private val flingBehavior: FlingBehavior,
+        viewConfiguration: ViewConfiguration,
+        private val flingTimeframe: Long,
+) : RotaryFlingBehavior {
+
+    // A time range during which the fling is valid.
+    // For simplicity it's twice as long as [flingTimeframe]
+    private val timeRangeToFling = flingTimeframe * 2
+
+    //  A default fling factor for making fling slower
+    private val flingScaleFactor = 0.7f
+
+    private var previousVelocity = 0f
+
+    private val rotaryVelocityTracker = RotaryVelocityTracker()
+
+    private val minFlingSpeed = viewConfiguration.scaledMinimumFlingVelocity.toFloat()
+    private val maxFlingSpeed = viewConfiguration.scaledMaximumFlingVelocity.toFloat()
+    private var latestEventTimestamp: Long = 0
+
+    private var flingVelocity: Float = 0f
+    private var flingTimestamp: Long = 0
+
+    @ExperimentalHorologistApi
+    override fun startFlingTracking(timestamp: Long) {
+        rotaryVelocityTracker.start(timestamp)
+        latestEventTimestamp = timestamp
+        previousVelocity = 0f
+    }
+
+    @ExperimentalHorologistApi
+    override fun observeEvent(timestamp: Long, delta: Float) {
+        rotaryVelocityTracker.move(timestamp, delta)
+        latestEventTimestamp = timestamp
+    }
+
+    @ExperimentalHorologistApi
+    override suspend fun trackFling(beforeFling: () -> Unit) {
+        val currentVelocity = rotaryVelocityTracker.velocity
+        debugLog { "currentVelocity: $currentVelocity" }
+
+        if (abs(currentVelocity) >= abs(previousVelocity)) {
+            flingTimestamp = latestEventTimestamp
+            flingVelocity = currentVelocity * flingScaleFactor
+        }
+        previousVelocity = currentVelocity
+
+        // Waiting for a fixed amount of time before checking the fling
+        delay(flingTimeframe)
+
+        // For making a fling 2 criteria should be met:
+        // 1) no more than
+        // `rangeToFling` ms should pass between last fling detection
+        // and the time of last motion event
+        // 2) flingVelocity should exceed the minFlingSpeed
+        debugLog {
+            "Check fling:  flingVelocity: $flingVelocity " +
+                    "minFlingSpeed: $minFlingSpeed, maxFlingSpeed: $maxFlingSpeed"
+        }
+        if (latestEventTimestamp - flingTimestamp < timeRangeToFling &&
+                abs(flingVelocity) > minFlingSpeed
+        ) {
+            // Stops scrollAnimationCoroutine because a fling will be performed
+            beforeFling()
+            val velocity = flingVelocity.coerceIn(-maxFlingSpeed, maxFlingSpeed)
+            scrollableState.scroll(MutatePriority.UserInput) {
+                with(flingBehavior) {
+                    debugLog { "Flinging with velocity $velocity" }
+                    performFling(velocity)
+                }
+            }
+        }
+    }
+}
+
+/**
+ * An interface for flinging with rotary
+ */
+@ExperimentalHorologistApi
+public interface RotaryFlingBehavior {
+
+    /**
+     * Observing new event within a fling tracking session with new timestamp and delta
+     */
+    @ExperimentalHorologistApi
+    public fun observeEvent(timestamp: Long, delta: Float)
+
+    /**
+     * Performing fling if necessary and calling [beforeFling] lambda before it is triggered
+     */
+    @ExperimentalHorologistApi
+    public suspend fun trackFling(beforeFling: () -> Unit)
+
+    /**
+     * Starts a new fling tracking session
+     * with specified timestamp
+     */
+    @ExperimentalHorologistApi
+    public fun startFlingTracking(timestamp: Long)
+}
+
+/**
+ * An interface for snapping with rotary
+ */
+@ExperimentalHorologistApi
+public interface RotarySnapBehavior {
+
+    /**
+     * Preparing snapping. This method should be called before [snapToTargetItem] is called.
+     *
+     * Snapping is done for current + [moveForElements] items.
+     *
+     * If [sequentialSnap] is true, items are summed up together.
+     * For example, if [prepareSnapForItems] is called with
+     * [moveForElements] = 2, 3, 5 -> then the snapping will happen to current + 10 items
+     *
+     * If [sequentialSnap] is false, then [moveForElements] are not summed up together.
+     */
+    public fun prepareSnapForItems(moveForElements: Int, sequentialSnap: Boolean)
+
+    /**
+     * Performs snapping to the closest item.
+     */
+    public suspend fun snapToClosestItem()
+
+    /**
+     * Returns true if top edge was reached
+     */
+    public fun topEdgeReached(): Boolean
+
+    /**
+     * Returns true if bottom edge was reached
+     */
+    public fun bottomEdgeReached(): Boolean
+
+    /**
+     * Performs snapping to the specified in [prepareSnapForItems] element
+     */
+    public suspend fun snapToTargetItem()
+}
+
+/**
+ * A rotary event object which contains a [timestamp] of the rotary event and a scrolled [delta].
+ */
+@ExperimentalHorologistApi
+public data class TimestampedDelta(val timestamp: Long, val delta: Float)
+
+/** Animation implementation of [RotaryScrollBehavior].
+ * This class does a smooth animation when the scroll by N pixels is done.
+ * This animation works well on Rsb(high-res) and Bezel(low-res) devices.
+ */
+@ExperimentalHorologistApi
+public class AnimationScrollBehavior(
+        private val scrollableState: ScrollableState,
+) : RotaryScrollBehavior {
+    private var sequentialAnimation = false
+    private var scrollAnimation = AnimationState(0f)
+    private var prevPosition = 0f
+
+    @ExperimentalHorologistApi
+    override suspend fun handleEvent(targetValue: Float) {
+        scrollableState.scroll(MutatePriority.UserInput) {
+            debugLog { "ScrollAnimation value before start: ${scrollAnimation.value}" }
+
+            scrollAnimation.animateTo(
+                    targetValue,
+                    animationSpec = spring(),
+                    sequentialAnimation = sequentialAnimation,
+            ) {
+                val delta = value - prevPosition
+                debugLog { "Animated by $delta, value: $value" }
+                scrollBy(delta)
+                prevPosition = value
+                sequentialAnimation = value != this.targetValue
+            }
+        }
+    }
+}
+
+/**
+ * An animated implementation of [RotarySnapBehavior]. Uses animateScrollToItem
+ * method for snapping to the Nth item
+ */
+@ExperimentalHorologistApi
+public class DefaultSnapBehavior(
+        private val rotaryScrollAdapter: RotaryScrollAdapter,
+        private val snapParameters: SnapParameters,
+) : RotarySnapBehavior {
+    private var snapTarget: Int = rotaryScrollAdapter.currentItemIndex()
+    private var sequentialSnap: Boolean = false
+
+    private var anim = AnimationState(0f)
+    private var expectedDistance = 0f
+
+    private val defaultStiffness = 200f
+    private var snapTargetUpdated = true
+
+    @ExperimentalHorologistApi
+    override fun prepareSnapForItems(moveForElements: Int, sequentialSnap: Boolean) {
+        this.sequentialSnap = sequentialSnap
+        if (sequentialSnap) {
+            snapTarget += moveForElements
+        } else {
+            snapTarget = rotaryScrollAdapter.currentItemIndex() + moveForElements
+        }
+        snapTargetUpdated = true
+        snapTarget = snapTarget.coerceIn(0 until rotaryScrollAdapter.totalItemsCount())
+    }
+
+    override suspend fun snapToClosestItem() {
+        // Snapping to the closest item by using performFling method with 0 speed
+        rotaryScrollAdapter.scrollableState.scroll(MutatePriority.UserInput) {
+            debugLog { "snap to closest item" }
+            var prevPosition = 0f
+            AnimationState(0f).animateTo(
+                    targetValue = -rotaryScrollAdapter.currentItemOffset(),
+                    animationSpec = tween(durationMillis = 100, easing = FastOutSlowInEasing),
+            ) {
+                val animDelta = value - prevPosition
+                scrollBy(animDelta)
+                prevPosition = value
+            }
+            snapTarget = rotaryScrollAdapter.currentItemIndex()
+        }
+    }
+
+    override fun topEdgeReached(): Boolean = snapTarget <= 0
+
+    override fun bottomEdgeReached(): Boolean =
+            snapTarget >= rotaryScrollAdapter.totalItemsCount() - 1
+
+    override suspend fun snapToTargetItem() {
+        if (sequentialSnap) {
+            anim = anim.copy(0f)
+        } else {
+            anim = AnimationState(0f)
+        }
+        rotaryScrollAdapter.scrollableState.scroll(MutatePriority.UserInput) {
+            // If snapTargetUpdated is true - then the target was updated so we
+            // need to do snap again
+            while (snapTargetUpdated) {
+                snapTargetUpdated = false
+                var latestCenterItem: Int
+                var continueFirstScroll = true
+                debugLog { "snapTarget $snapTarget" }
+                while (continueFirstScroll) {
+                    latestCenterItem = rotaryScrollAdapter.currentItemIndex()
+                    anim = anim.copy(0f)
+                    expectedDistance = expectedDistanceTo(snapTarget, snapParameters.snapOffset)
+                    debugLog {
+                        "expectedDistance = $expectedDistance, " +
+                                "scrollableState.centerItemScrollOffset " +
+                                "${rotaryScrollAdapter.currentItemOffset()}"
+                    }
+                    continueFirstScroll = false
+                    var prevPosition = 0f
+
+                    anim.animateTo(
+                            expectedDistance,
+                            animationSpec = SpringSpec(
+                                    stiffness = defaultStiffness,
+                                    visibilityThreshold = 0.1f,
+                            ),
+                            sequentialAnimation = (anim.velocity != 0f),
+                    ) {
+                        val animDelta = value - prevPosition
+                        debugLog {
+                            "First animation, value:$value, velocity:$velocity, " +
+                                    "animDelta:$animDelta"
+                        }
+
+                        // Exit animation if snap target was updated
+                        if (snapTargetUpdated) cancelAnimation()
+
+                        scrollBy(animDelta)
+                        prevPosition = value
+
+                        if (latestCenterItem != rotaryScrollAdapter.currentItemIndex()) {
+                            continueFirstScroll = true
+                            cancelAnimation()
+                            return@animateTo
+                        }
+
+                        debugLog { "centerItemIndex =  ${rotaryScrollAdapter.currentItemIndex()}" }
+                        if (rotaryScrollAdapter.currentItemIndex() == snapTarget) {
+                            debugLog { "Target is visible. Cancelling first animation" }
+                            debugLog {
+                                "scrollableState.centerItemScrollOffset " +
+                                        "${rotaryScrollAdapter.currentItemOffset()}"
+                            }
+                            expectedDistance = -rotaryScrollAdapter.currentItemOffset()
+                            continueFirstScroll = false
+                            cancelAnimation()
+                            return@animateTo
+                        }
+                    }
+                }
+                // Exit animation if snap target was updated
+                if (snapTargetUpdated) continue
+
+                anim = anim.copy(0f)
+                var prevPosition = 0f
+                anim.animateTo(
+                        expectedDistance,
+                        animationSpec = SpringSpec(
+                                stiffness = defaultStiffness,
+                                visibilityThreshold = 0.1f,
+                        ),
+                        sequentialAnimation = (anim.velocity != 0f),
+                ) {
+                    // Exit animation if snap target was updated
+                    if (snapTargetUpdated) cancelAnimation()
+
+                    val animDelta = value - prevPosition
+                    debugLog { "Final animation. velocity:$velocity, animDelta:$animDelta" }
+                    scrollBy(animDelta)
+                    prevPosition = value
+                }
+            }
+        }
+    }
+
+    private fun expectedDistanceTo(index: Int, targetScrollOffset: Int): Float {
+        val averageSize = rotaryScrollAdapter.averageItemSize()
+        val indexesDiff = index - rotaryScrollAdapter.currentItemIndex()
+        debugLog { "Average size $averageSize" }
+        return (averageSize * indexesDiff) +
+                targetScrollOffset - rotaryScrollAdapter.currentItemOffset()
+    }
+}
+
+/**
+ * A modifier which handles rotary events.
+ * It accepts ScrollHandler as the input - a class where main logic about how
+ * scroll should be handled is lying
+ */
+@ExperimentalHorologistApi
+@OptIn(ExperimentalComposeUiApi::class)
+public fun Modifier.rotaryHandler(
+        rotaryScrollHandler: RotaryScrollHandler,
+        // TODO: batching causes additional delays. Return once it's clear that
+        //  we will use it
+        /* batchTimeframe: Long = 0L,*/
+        reverseDirection: Boolean,
+        rotaryHaptics: RotaryHapticHandler,
+): Modifier = composed {
+    val channel = rememberTimestampChannel()
+    val eventsFlow = remember(channel) { channel.receiveAsFlow() }
+
+    composed {
+        LaunchedEffect(eventsFlow) {
+            eventsFlow
+                    // TODO: batching causes additional delays. Return once it's clear that
+                    //  we will use it
+                    // Do we really need to do this on this level?
+//                .batchRequestsWithinTimeframe(batchTimeframe)
+                    .collectLatest {
+                        debugLog {
+                            "Scroll event received: " +
+                                    "delta:${it.delta}, timestamp:${it.timestamp}"
+                        }
+                        rotaryScrollHandler.handleScrollEvent(this, it, rotaryHaptics)
+                    }
+        }
+        this
+                .onRotaryScrollEvent {
+                    // Okay to ignore the ChannelResult returned from trySend because it is conflated
+                    // (see rememberTimestampChannel()).
+                    @Suppress("UNUSED_VARIABLE")
+                    val unused = channel.trySend(
+                            TimestampedDelta(
+                                    it.uptimeMillis,
+                                    it.verticalScrollPixels * if (reverseDirection) -1f else 1f,
+                            ),
+                    )
+                    true
+                }
+    }
+}
+
+/**
+ * Batching requests for scrolling events. This function combines all events together
+ * (except first) within specified timeframe. Should help with performance on high-res devices.
+ */
+@ExperimentalHorologistApi
+@OptIn(ExperimentalCoroutinesApi::class)
+public fun Flow<TimestampedDelta>.batchRequestsWithinTimeframe(
+        timeframe: Long
+): Flow<TimestampedDelta> {
+    var delta = 0f
+    var lastTimestamp = -timeframe
+    return if (timeframe == 0L) {
+        this
+    } else {
+        this.transformLatest {
+            delta += it.delta
+            debugLog { "Batching requests. delta:$delta" }
+            if (lastTimestamp + timeframe <= it.timestamp) {
+                lastTimestamp = it.timestamp
+                debugLog { "No events before, delta= $delta" }
+                emit(TimestampedDelta(it.timestamp, delta))
+            } else {
+                delay(timeframe)
+                debugLog { "After delay, delta= $delta" }
+                if (delta > 0f) {
+                    emit(TimestampedDelta(it.timestamp, delta))
+                }
+            }
+            delta = 0f
+        }
+    }
+}
+
+/**
+ * A scroll handler for RSB(high-res) without snapping and with or without fling
+ * A list is scrolled by the number of pixels received from the rotary device.
+ *
+ * This class is a little bit different from LowResScrollHandler class - it has a filtering
+ * for events which are coming with wrong sign ( this happens to rsb devices,
+ * especially at the end of the scroll)
+ *
+ * This scroll handler supports fling. It can be set with [RotaryFlingBehavior].
+ */
+internal class HighResRotaryScrollHandler(
+        private val rotaryFlingBehaviorFactory: () -> RotaryFlingBehavior?,
+        private val scrollBehaviorFactory: () -> RotaryScrollBehavior,
+        private val hapticsThreshold: Long = 50,
+) : RotaryScrollHandler {
+
+    // This constant is specific for high-res devices. Because that input values
+    // can sometimes come with different sign, we have to filter them in this threshold
+    private val gestureThresholdTime = 200L
+    private var scrollJob: Job = CompletableDeferred<Unit>()
+    private var flingJob: Job = CompletableDeferred<Unit>()
+
+    private var previousScrollEventTime = 0L
+    private var rotaryScrollDistance = 0f
+
+    private var rotaryFlingBehavior: RotaryFlingBehavior? = rotaryFlingBehaviorFactory()
+    private var scrollBehavior: RotaryScrollBehavior = scrollBehaviorFactory()
+
+    override suspend fun handleScrollEvent(
+            coroutineScope: CoroutineScope,
+            event: TimestampedDelta,
+            rotaryHaptics: RotaryHapticHandler,
+    ) {
+        val time = event.timestamp
+        val isOppositeScrollValue = isOppositeValueAfterScroll(event.delta)
+
+        if (isNewScrollEvent(time)) {
+            debugLog { "New scroll event" }
+            resetTracking(time)
+            rotaryScrollDistance = event.delta
+        } else {
+            // Due to the physics of Rotary side button, some events might come
+            // with an opposite axis value - either at the start or at the end of the motion.
+            // We don't want to use these values for fling calculations.
+            if (!isOppositeScrollValue) {
+                rotaryFlingBehavior?.observeEvent(event.timestamp, event.delta)
+            } else {
+                debugLog { "Opposite value after scroll :${event.delta}" }
+            }
+            rotaryScrollDistance += event.delta
+        }
+
+        scrollJob.cancel()
+
+        rotaryHaptics.handleScrollHaptic(event.delta)
+        debugLog { "Rotary scroll distance: $rotaryScrollDistance" }
+
+        previousScrollEventTime = time
+        scrollJob = coroutineScope.async {
+            scrollBehavior.handleEvent(rotaryScrollDistance)
+        }
+
+        if (rotaryFlingBehavior != null) {
+            flingJob.cancel()
+            flingJob = coroutineScope.async {
+                rotaryFlingBehavior?.trackFling(beforeFling = {
+                    debugLog { "Calling before fling section" }
+                    scrollJob.cancel()
+                    scrollBehavior = scrollBehaviorFactory()
+                })
+            }
+        }
+    }
+
+    private fun isOppositeValueAfterScroll(delta: Float): Boolean =
+            sign(rotaryScrollDistance) * sign(delta) == -1f &&
+                    (abs(delta) < abs(rotaryScrollDistance))
+
+    private fun isNewScrollEvent(timestamp: Long): Boolean {
+        val timeDelta = timestamp - previousScrollEventTime
+        return previousScrollEventTime == 0L || timeDelta > gestureThresholdTime
+    }
+
+    private fun resetTracking(timestamp: Long) {
+        scrollBehavior = scrollBehaviorFactory()
+        rotaryFlingBehavior = rotaryFlingBehaviorFactory()
+        rotaryFlingBehavior?.startFlingTracking(timestamp)
+    }
+}
+
+/**
+ * A scroll handler for Bezel(low-res) without snapping.
+ * This scroll handler supports fling. It can be set with RotaryFlingBehavior.
+ */
+internal class LowResRotaryScrollHandler(
+        private val rotaryFlingBehaviorFactory: () -> RotaryFlingBehavior?,
+        private val scrollBehaviorFactory: () -> RotaryScrollBehavior,
+) : RotaryScrollHandler {
+
+    private val gestureThresholdTime = 200L
+    private var previousScrollEventTime = 0L
+    private var rotaryScrollDistance = 0f
+
+    private var scrollJob: Job = CompletableDeferred<Unit>()
+    private var flingJob: Job = CompletableDeferred<Unit>()
+
+    private var rotaryFlingBehavior: RotaryFlingBehavior? = rotaryFlingBehaviorFactory()
+    private var scrollBehavior: RotaryScrollBehavior = scrollBehaviorFactory()
+
+    override suspend fun handleScrollEvent(
+            coroutineScope: CoroutineScope,
+            event: TimestampedDelta,
+            rotaryHaptics: RotaryHapticHandler,
+    ) {
+        val time = event.timestamp
+
+        if (isNewScrollEvent(time)) {
+            resetTracking(time)
+            rotaryScrollDistance = event.delta
+        } else {
+            rotaryFlingBehavior?.observeEvent(event.timestamp, event.delta)
+            rotaryScrollDistance += event.delta
+        }
+
+        scrollJob.cancel()
+        flingJob.cancel()
+
+        rotaryHaptics.handleScrollHaptic(event.delta)
+        debugLog { "Rotary scroll distance: $rotaryScrollDistance" }
+
+        previousScrollEventTime = time
+        scrollJob = coroutineScope.async {
+            scrollBehavior.handleEvent(rotaryScrollDistance)
+        }
+
+        flingJob = coroutineScope.async {
+            rotaryFlingBehavior?.trackFling(
+                    beforeFling = {
+                        debugLog { "Calling before fling section" }
+                        scrollJob.cancel()
+                        scrollBehavior = scrollBehaviorFactory()
+                    },
+            )
+        }
+    }
+
+    private fun isNewScrollEvent(timestamp: Long): Boolean {
+        val timeDelta = timestamp - previousScrollEventTime
+        return previousScrollEventTime == 0L || timeDelta > gestureThresholdTime
+    }
+
+    private fun resetTracking(timestamp: Long) {
+        scrollBehavior = scrollBehaviorFactory()
+        debugLog { "Velocity tracker reset" }
+        rotaryFlingBehavior = rotaryFlingBehaviorFactory()
+        rotaryFlingBehavior?.startFlingTracking(timestamp)
+    }
+}
+
+/**
+ * A scroll handler for RSB(high-res) with snapping and without fling
+ * Snapping happens after a threshold is reached ( set in [RotarySnapBehavior])
+ *
+ * This scroll handler doesn't support fling.
+ */
+internal class HighResSnapHandler(
+        private val resistanceFactor: Float,
+        private val thresholdBehaviorFactory: () -> ThresholdBehavior,
+        private val snapBehaviourFactory: () -> RotarySnapBehavior,
+        private val scrollBehaviourFactory: () -> RotaryScrollBehavior,
+) : RotaryScrollHandler {
+    private val gestureThresholdTime = 200L
+    private val snapDelay = 100L
+    private val maxSnapsPerEvent = 2
+
+    private var scrollJob: Job = CompletableDeferred<Unit>()
+    private var snapJob: Job = CompletableDeferred<Unit>()
+
+    private var previousScrollEventTime = 0L
+    private var snapAccumulator = 0f
+    private var rotaryScrollDistance = 0f
+    private var scrollInProgress = false
+
+    private var snapBehaviour = snapBehaviourFactory()
+    private var scrollBehaviour = scrollBehaviourFactory()
+    private var thresholdBehavior = thresholdBehaviorFactory()
+
+    private val scrollEasing: Easing = CubicBezierEasing(0.0f, 0.0f, 0.5f, 1.0f)
+
+    override suspend fun handleScrollEvent(
+            coroutineScope: CoroutineScope,
+            event: TimestampedDelta,
+            rotaryHaptics: RotaryHapticHandler,
+    ) {
+        val time = event.timestamp
+
+        if (isNewScrollEvent(time)) {
+            debugLog { "New scroll event" }
+            resetTracking()
+            snapJob.cancel()
+            snapBehaviour = snapBehaviourFactory()
+            scrollBehaviour = scrollBehaviourFactory()
+            thresholdBehavior = thresholdBehaviorFactory()
+            thresholdBehavior.startThresholdTracking(time)
+            snapAccumulator = 0f
+            rotaryScrollDistance = 0f
+        }
+
+        if (!isOppositeValueAfterScroll(event.delta)) {
+            thresholdBehavior.observeEvent(event.timestamp, event.delta)
+        } else {
+            debugLog { "Opposite value after scroll :${event.delta}" }
+        }
+
+        thresholdBehavior.applySmoothing()
+        val snapThreshold = thresholdBehavior.snapThreshold()
+
+        snapAccumulator += event.delta
+        if (!snapJob.isActive) {
+            val resistanceCoeff =
+                    1 - scrollEasing.transform(rotaryScrollDistance.absoluteValue / snapThreshold)
+            rotaryScrollDistance += event.delta * resistanceCoeff
+        }
+
+        debugLog { "Snap accumulator: $snapAccumulator" }
+        debugLog { "Rotary scroll distance: $rotaryScrollDistance" }
+
+        debugLog { "snapThreshold: $snapThreshold" }
+        previousScrollEventTime = time
+
+        if (abs(snapAccumulator) > snapThreshold) {
+            scrollInProgress = false
+            scrollBehaviour = scrollBehaviourFactory()
+            scrollJob.cancel()
+
+            val snapDistance = (snapAccumulator / snapThreshold).toInt()
+                    .coerceIn(-maxSnapsPerEvent..maxSnapsPerEvent)
+            snapAccumulator -= snapThreshold * snapDistance
+            val sequentialSnap = snapJob.isActive
+
+            debugLog {
+                "Snap threshold reached: snapDistance:$snapDistance, " +
+                        "sequentialSnap: $sequentialSnap, " +
+                        "snap accumulator remaining: $snapAccumulator"
+            }
+            if ((!snapBehaviour.topEdgeReached() && snapDistance < 0) ||
+                    (!snapBehaviour.bottomEdgeReached() && snapDistance > 0)
+            ) {
+                rotaryHaptics.handleSnapHaptic(event.delta)
+            }
+
+            snapBehaviour.prepareSnapForItems(snapDistance, sequentialSnap)
+            if (!snapJob.isActive) {
+                snapJob.cancel()
+                snapJob = coroutineScope.async {
+                    debugLog { "Snap started" }
+                    try {
+                        snapBehaviour.snapToTargetItem()
+                    } finally {
+                        debugLog { "Snap called finally" }
+                    }
+                }
+            }
+            rotaryScrollDistance = 0f
+        } else {
+            if (!snapJob.isActive) {
+                scrollJob.cancel()
+                debugLog { "Scrolling for $rotaryScrollDistance/$resistanceFactor px" }
+                scrollJob = coroutineScope.async {
+                    scrollBehaviour.handleEvent(rotaryScrollDistance / resistanceFactor)
+                }
+                delay(snapDelay)
+                scrollInProgress = false
+                scrollBehaviour = scrollBehaviourFactory()
+                rotaryScrollDistance = 0f
+                snapAccumulator = 0f
+                snapBehaviour.prepareSnapForItems(0, false)
+
+                snapJob.cancel()
+                snapJob = coroutineScope.async {
+                    snapBehaviour.snapToClosestItem()
+                }
+            }
+        }
+    }
+
+    private fun isOppositeValueAfterScroll(delta: Float): Boolean =
+            sign(rotaryScrollDistance) * sign(delta) == -1f &&
+                    (abs(delta) < abs(rotaryScrollDistance))
+
+    private fun isNewScrollEvent(timestamp: Long): Boolean {
+        val timeDelta = timestamp - previousScrollEventTime
+        return previousScrollEventTime == 0L || timeDelta > gestureThresholdTime
+    }
+
+    private fun resetTracking() {
+        scrollInProgress = true
+    }
+}
+
+/**
+ * A scroll handler for RSB(high-res) with snapping and without fling
+ * Snapping happens after a threshold is reached ( set in [RotarySnapBehavior])
+ *
+ * This scroll handler doesn't support fling.
+ */
+internal class LowResSnapHandler(
+        private val snapBehaviourFactory: () -> RotarySnapBehavior,
+) : RotaryScrollHandler {
+    private val gestureThresholdTime = 200L
+
+    private var snapJob: Job = CompletableDeferred<Unit>()
+
+    private var previousScrollEventTime = 0L
+    private var snapAccumulator = 0f
+    private var scrollInProgress = false
+
+    private var snapBehaviour = snapBehaviourFactory()
+
+    override suspend fun handleScrollEvent(
+            coroutineScope: CoroutineScope,
+            event: TimestampedDelta,
+            rotaryHaptics: RotaryHapticHandler,
+    ) {
+        val time = event.timestamp
+
+        if (isNewScrollEvent(time)) {
+            debugLog { "New scroll event" }
+            resetTracking()
+            snapJob.cancel()
+            snapBehaviour = snapBehaviourFactory()
+            snapAccumulator = 0f
+        }
+
+        snapAccumulator += event.delta
+
+        debugLog { "Snap accumulator: $snapAccumulator" }
+
+        previousScrollEventTime = time
+
+        if (abs(snapAccumulator) > 1f) {
+            scrollInProgress = false
+
+            val snapDistance = sign(snapAccumulator).toInt()
+            rotaryHaptics.handleSnapHaptic(event.delta)
+            val sequentialSnap = snapJob.isActive
+            debugLog {
+                "Snap threshold reached: snapDistance:$snapDistance, " +
+                        "sequentialSnap: $sequentialSnap, " +
+                        "snap accumulator: $snapAccumulator"
+            }
+
+            snapBehaviour.prepareSnapForItems(snapDistance, sequentialSnap)
+            if (!snapJob.isActive) {
+                snapJob.cancel()
+                snapJob = coroutineScope.async {
+                    debugLog { "Snap started" }
+                    try {
+                        snapBehaviour.snapToTargetItem()
+                    } finally {
+                        debugLog { "Snap called finally" }
+                    }
+                }
+            }
+            snapAccumulator = 0f
+        }
+    }
+
+    private fun isNewScrollEvent(timestamp: Long): Boolean {
+        val timeDelta = timestamp - previousScrollEventTime
+        return previousScrollEventTime == 0L || timeDelta > gestureThresholdTime
+    }
+
+    private fun resetTracking() {
+        scrollInProgress = true
+    }
+}
+
+internal class ThresholdBehavior(
+        private val rotaryScrollAdapter: RotaryScrollAdapter,
+        private val thresholdDivider: Float,
+        private val minVelocity: Float = 300f,
+        private val maxVelocity: Float = 3000f,
+        private val smoothingConstant: Float = 0.4f,
+) {
+    private val thresholdDividerEasing: Easing = CubicBezierEasing(0.5f, 0.0f, 0.5f, 1.0f)
+
+    private val rotaryVelocityTracker = RotaryVelocityTracker()
+
+    private var smoothedVelocity = 0f
+    fun startThresholdTracking(time: Long) {
+        rotaryVelocityTracker.start(time)
+        smoothedVelocity = 0f
+    }
+
+    fun observeEvent(timestamp: Long, delta: Float) {
+        rotaryVelocityTracker.move(timestamp, delta)
+    }
+
+    fun applySmoothing() {
+        if (rotaryVelocityTracker.velocity != 0.0f) {
+            // smooth the velocity
+            smoothedVelocity = exponentialSmoothing(
+                    currentVelocity = rotaryVelocityTracker.velocity.absoluteValue,
+                    prevVelocity = smoothedVelocity,
+                    smoothingConstant = smoothingConstant,
+            )
+        }
+        debugLog { "rotaryVelocityTracker velocity: ${rotaryVelocityTracker.velocity}" }
+        debugLog { "SmoothedVelocity: $smoothedVelocity" }
+    }
+
+    fun snapThreshold(): Float {
+        val thresholdDividerFraction =
+                thresholdDividerEasing.transform(
+                        inverseLerp(
+                                minVelocity,
+                                maxVelocity,
+                                smoothedVelocity,
+                        ),
+                )
+        return rotaryScrollAdapter.averageItemSize() / lerp(
+                1f,
+                thresholdDivider,
+                thresholdDividerFraction,
+        )
+    }
+
+    private fun exponentialSmoothing(
+            currentVelocity: Float,
+            prevVelocity: Float,
+            smoothingConstant: Float,
+    ): Float =
+            smoothingConstant * currentVelocity + (1 - smoothingConstant) * prevVelocity
+}
+
+@Composable
+private fun rememberTimestampChannel() = remember {
+    Channel<TimestampedDelta>(capacity = Channel.CONFLATED)
+}
+
+private fun inverseLerp(start: Float, stop: Float, value: Float): Float {
+    return ((value - start) / (stop - start)).coerceIn(0f, 1f)
+}
diff --git a/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/rotaryinput/RotaryVelocityTracker.kt b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/rotaryinput/RotaryVelocityTracker.kt
new file mode 100644
index 0000000..6e627c6
--- /dev/null
+++ b/packages/CredentialManager/horologist/src/com/google/android/horologist/compose/rotaryinput/RotaryVelocityTracker.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.horologist.compose.rotaryinput
+
+import androidx.compose.ui.input.pointer.util.VelocityTracker1D
+
+/**
+ * A wrapper around VelocityTracker1D to provide support for rotary input.
+ */
+public class RotaryVelocityTracker {
+    private var velocityTracker: VelocityTracker1D = VelocityTracker1D(true)
+
+    /**
+     * Retrieve the last computed velocity.
+     */
+    public val velocity: Float
+        get() = velocityTracker.calculateVelocity()
+
+    /**
+     * Start tracking motion.
+     */
+    public fun start(currentTime: Long) {
+        velocityTracker.resetTracking()
+        velocityTracker.addDataPoint(currentTime, 0f)
+    }
+
+    /**
+     * Continue tracking motion as the input rotates.
+     */
+    public fun move(currentTime: Long, delta: Float) {
+        velocityTracker.addDataPoint(currentTime, delta)
+    }
+
+    /**
+     * Stop tracking motion.
+     */
+    public fun end() {
+        velocityTracker.resetTracking()
+    }
+}