PositionIndicator.kt

/*
 * Copyright 2021 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
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.wear.compose.material

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.AnimationVector
import androidx.compose.animation.core.AnimationVector2D
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.TwoWayConverter
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.lerp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import kotlin.math.PI
import kotlin.math.asin
import kotlin.math.max
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

/**
 * Enum used by adapters to specify if the Position Indicator needs to be shown, hidden,
 * or hidden after a small delay.
 */
@Suppress("INLINE_CLASS_DEPRECATED")
public inline class PositionIndicatorVisibility internal constructor(internal val value: Int) {
    companion object {
        /**
         * Show the Position Indicator.
         */
        val Show = PositionIndicatorVisibility(1)

        /**
         * Hide the Position Indicator.
         */
        val Hide = PositionIndicatorVisibility(2)

        /**
         * Hide the Position Indicator after a short delay.
         */
        val AutoHide = PositionIndicatorVisibility(3)
    }
}

/**
 * An object representing the relative position of a scrollbar or rolling side button or rotating
 * bezel position. This interface is implemented by classes that adapt other state information such
 * as [ScalingLazyListState] or [ScrollState] of scrollable containers or to represent the
 * position of say a volume control that can be 'ticked' using a rolling side button or rotating
 * bezel.
 *
 * Implementing classes provide [positionFraction] to determine where in the range [0..1] that the
 * indicator should be displayed and [sizeFraction] to determine the size of the indicator in the
 * range [0..1]. E.g. If a [ScalingLazyListState] had 50 items and the last 5 were visible it
 * would have a position of 1.0f to show that the scroll is positioned at the end of the list and a
 * size of 5 / 50 = 0.1f to indicate that 10% of the visible items are currently visible.
 */
@Stable
interface PositionIndicatorState {
    /**
     * Position of the indicator in the range [0f,1f]. 0f means it is at the top|start, 1f means
     * it is positioned at the bottom|end.
     */
//    @FloatRange(
//        fromInclusive = true, from = 0.0, toInclusive = true, to = 1.0
//    )
    val positionFraction: Float

    /**
     * Size of the indicator in the range [0f,1f]. 1f means it takes the whole space.
     *
     * @param scrollableContainerSizePx the height or width of the container
     * in pixels depending on orientation of the indicator, (height for vertical, width for
     * horizontal)
     */
//    @FloatRange(
//        fromInclusive = true, from = 0.0, toInclusive = true, to = 1.0
//    )
    fun sizeFraction(scrollableContainerSizePx: Float): Float

    /**
     * Should we show the Position Indicator
     *
     * @param scrollableContainerSizePx the height or width of the container
     * in pixels depending on orientation of the indicator, (height for vertical, width for
     * horizontal)
     */
    fun visibility(scrollableContainerSizePx: Float): PositionIndicatorVisibility
}

/**
 * Creates an [PositionIndicator] based on the values in a [ScrollState] object.
 * e.g. a [Column] implementing [Modifier.verticalScroll] provides a [ScrollState].
 *
 * For more information, see the
 * [Scroll indicators](https://developer.android.com/training/wearables/components/scroll)
 * guide.
 *
 * @param scrollState The scrollState to use as the basis for the PositionIndicatorState.
 * @param modifier The modifier to be applied to the component
 * @param reverseDirection Reverses direction of PositionIndicator if true
 */
@Composable
public fun PositionIndicator(
    scrollState: ScrollState,
    modifier: Modifier = Modifier,
    reverseDirection: Boolean = false
) = PositionIndicator(
    ScrollStateAdapter(scrollState),
    indicatorHeight = 50.dp,
    indicatorWidth = 4.dp,
    paddingHorizontal = 5.dp,
    modifier = modifier,
    reverseDirection = reverseDirection
)

/**
 * Creates an [PositionIndicator] based on the values in a [ScalingLazyListState] object that
 * a [ScalingLazyColumn] uses.
 *
 * For more information, see the
 * [Scroll indicators](https://developer.android.com/training/wearables/components/scroll)
 * guide.
 *
 * @param scalingLazyListState the [ScalingLazyListState] to use as the basis for the
 * PositionIndicatorState.
 * @param modifier The modifier to be applied to the component
 * @param reverseDirection Reverses direction of PositionIndicator if true
 */
@Composable
public fun PositionIndicator(
    scalingLazyListState: ScalingLazyListState,
    modifier: Modifier = Modifier,
    reverseDirection: Boolean = false
) = PositionIndicator(
    state = ScalingLazyColumnStateAdapter(
        state = scalingLazyListState
    ),
    indicatorHeight = 50.dp,
    indicatorWidth = 4.dp,
    paddingHorizontal = 5.dp,
    modifier = modifier,
    reverseDirection = reverseDirection
)

/**
 * Creates an [PositionIndicator] based on the values in a [LazyListState] object that
 * a [LazyColumn] uses.
 *
 * For more information, see the
 * [Scroll indicators](https://developer.android.com/training/wearables/components/scroll)
 * guide.
 *
 * @param lazyListState the [LazyListState] to use as the basis for the
 * PositionIndicatorState.
 * @param modifier The modifier to be applied to the component
 * @param reverseDirection Reverses direction of PositionIndicator if true
 */
@Composable
public fun PositionIndicator(
    lazyListState: LazyListState,
    modifier: Modifier = Modifier,
    reverseDirection: Boolean = false
) = PositionIndicator(
    state = LazyColumnStateAdapter(
        state = lazyListState
    ),
    indicatorHeight = 50.dp,
    indicatorWidth = 4.dp,
    paddingHorizontal = 5.dp,
    modifier = modifier,
    reverseDirection = reverseDirection
)

/**
 * Specifies where in the screen the Position indicator will be.
 */
@Suppress("INLINE_CLASS_DEPRECATED")
inline class PositionIndicatorAlignment internal constructor(internal val pos: Int) {
    companion object {
        /**
         * Position the indicator at the end of the layout (at the right for LTR and left for RTL)
         * This is the norm for scroll indicators.
         */
        val End = PositionIndicatorAlignment(0)

        // TODO(b/224770222): Add tests.
        /**
         * Position the indicator opposite to the physical rsb. (at the left for default mode and
         * at the right if the device is rotated 180 degrees)
         * This is the default for RSB indicators as we want to avoid it being obscured when the
         * user is interacting with the RSB.
         */
        val OppositeRsb = PositionIndicatorAlignment(1)

        /**
         * Position the indicator at the left of the screen.
         * This is useful to implement custom positioning, but usually
         * [PositionIndicatorAlignment#End] or [PositionIndicatorAlignment#OppositeRsb] should be
         * used.
         */
        val Left = PositionIndicatorAlignment(2)

        /**
         * Position the indicator at the right of the screen
         * This is useful to implement custom positioning, but usually
         * [PositionIndicatorAlignment#End] or [PositionIndicatorAlignment#OppositeRsb] should be
         * used.
         */
        val Right = PositionIndicatorAlignment(3)
    }

    override fun toString(): String {
        return when (this) {
            End -> "PositionIndicatorAlignment.End"
            OppositeRsb -> "PositionIndicatorAlignment.OppositeRsb"
            Left -> "PositionIndicatorAlignment.Left"
            Right -> "PositionIndicatorAlignment.Right"
            else -> "PositionIndicatorAlignment.unknown"
        }
    }
}

/**
 * Creates a [PositionIndicator] for controls like rotating side button, rotating bezel or slider.
 *
 * For more information, see the
 * [Scroll indicators](https://developer.android.com/training/wearables/components/scroll)
 * guide.
 *
 * @param value Value of the indicator in the [range] where 1 represents the
 * maximum value. E.g. If displaying a volume value from 0..11 then the [value] will be
 * volume/11.
 * @param range range of values that [value] can take
 * @param modifier Modifier to be applied to the component
 * @param color Color to draw the indicator on.
 * @param reverseDirection Reverses direction of PositionIndicator if true
 * @param position indicates where to put the PositionIndicator in the screen, default is
 * [PositionIndicatorPosition#OppositeRsb]
 */
@Composable
public fun PositionIndicator(
    value: () -> Float,
    modifier: Modifier = Modifier,
    range: ClosedFloatingPointRange<Float> = 0f..1f,
    color: Color = MaterialTheme.colors.onBackground,
    reverseDirection: Boolean = false,
    position: PositionIndicatorAlignment = PositionIndicatorAlignment.OppositeRsb
) = PositionIndicator(
    state = FractionPositionIndicatorState {
        (value() - range.start) / (range.endInclusive - range.start)
    },
    indicatorHeight = 76.dp,
    indicatorWidth = 6.dp,
    paddingHorizontal = 5.dp,
    color = color,
    modifier = modifier,
    reverseDirection = reverseDirection,
    position = position
)

/**
 * An indicator on one side of the screen to show the current [PositionIndicatorState].
 *
 * Typically used with the [Scaffold] but can be used to decorate any full screen situation.
 *
 * This composable should only be used to fill the whole screen as Wear Material Design language
 * requires the placement of the position indicator to be right center of the screen as the
 * indicator is curved on circular devices.
 *
 * It detects if the screen is round or square and draws itself as a curve or line.
 *
 * Note that the composable will take the whole screen, but it will be drawn with the given
 * dimensions [indicatorHeight] and [indicatorWidth], and position with respect to the edge of the
 * screen according to [paddingHorizontal]
 *
 * For more information, see the
 * [Scroll indicators](https://developer.android.com/training/wearables/components/scroll)
 * guide.
 *
 * @param state the [PositionIndicatorState] of the state we are displaying.
 * @param indicatorHeight the height of the position indicator in Dp.
 * @param indicatorWidth the width of the position indicator in Dp.
 * @param paddingHorizontal the padding to apply between the indicator and the border of the screen.
 * @param modifier The modifier to be applied to the component.
 * @param color the color to draw the active part of the indicator in.
 * @param background the color to draw the non-active part of the position indicator.
 * @param reverseDirection Reverses direction of PositionIndicator if true.
 * @param position indicates where to put the PositionIndicator on the screen, default is
 * [PositionIndicatorPosition#End]
 */
@Composable
public fun PositionIndicator(
    state: PositionIndicatorState,
    indicatorHeight: Dp,
    indicatorWidth: Dp,
    paddingHorizontal: Dp,
    modifier: Modifier = Modifier,
    color: Color = MaterialTheme.colors.onBackground,
    background: Color = MaterialTheme.colors.onBackground.copy(alpha = 0.3f),
    reverseDirection: Boolean = false,
    position: PositionIndicatorAlignment = PositionIndicatorAlignment.End
) {
    val isScreenRound = isRoundDevice()
    val layoutDirection = LocalLayoutDirection.current
    val leftyMode = isLeftyModeEnabled()
    val actuallyVisible = remember { mutableStateOf(false) }
    var containerHeight by remember { mutableStateOf(1f) }

    val displayState by remember(state) {
        derivedStateOf {
            DisplayState(
                state.positionFraction,
                state.sizeFraction(containerHeight)
            )
        }
    }

    val highlightAlpha = remember { Animatable(0f) }
    val animatedDisplayState = customAnimateValueAsState(
        displayState,
        DisplayStateTwoWayConverter,
        animationSpec = tween(
            durationMillis = 500,
            easing = CubicBezierEasing(0f, 0f, 0f, 1f)
        )
    ) { _, _ ->
        launch {
            highlightAlpha.animateTo(
                0.33f,
                animationSpec = tween(
                    durationMillis = 150,
                    easing = CubicBezierEasing(0f, 0f, 0.2f, 1f) // Standard In
                )
            )
            highlightAlpha.animateTo(
                0f,
                animationSpec = tween(
                    durationMillis = 500,
                    easing = CubicBezierEasing(0.25f, 0f, 0.75f, 1f)
                )
            )
        }
    }

    val visibility by remember(state) { derivedStateOf { state.visibility(containerHeight) } }
    when (visibility) {
        PositionIndicatorVisibility.Show -> actuallyVisible.value = true
        PositionIndicatorVisibility.Hide -> actuallyVisible.value = false
        PositionIndicatorVisibility.AutoHide -> if (actuallyVisible.value) {
            LaunchedEffect(true) {
                delay(2000)
                actuallyVisible.value = false
            }
        }
    }

    AnimatedVisibility(
        visible = actuallyVisible.value,
        enter = fadeIn(),
        exit = fadeOut()
    ) {
        Box(
            modifier = modifier
                .fillMaxSize()
                .onGloballyPositioned {
                    containerHeight = it.size.height.toFloat()
                }
                .drawWithContent {
                    val indicatorOnTheRight = when (position) {
                        PositionIndicatorAlignment.Left -> false
                        PositionIndicatorAlignment.Right -> true
                        PositionIndicatorAlignment.OppositeRsb -> leftyMode
                        PositionIndicatorAlignment.End -> layoutDirection == LayoutDirection.Ltr
                        else -> true
                    }

                    // We need to invert reverseDirection when the screen is round and we are on
                    // the left.
                    val actualReverseDirection = if (isScreenRound && !indicatorOnTheRight) {
                        !reverseDirection
                    } else {
                        reverseDirection
                    }

                    val indicatorPosition = if (actualReverseDirection) {
                        1 - animatedDisplayState.value.position
                    } else {
                        animatedDisplayState.value.position
                    }

                    val indicatorWidthPx = indicatorWidth.toPx()

                    // We want position = 0 be the indicator aligned at the top of its area and
                    // position = 1 be aligned at the bottom of the area.
                    val indicatorStart = indicatorPosition * (1 - animatedDisplayState.value.size)

                    val diameter = max(size.width, size.height)

                    // Note that indicators are on right or left, centered vertically.
                    val paddingHorizontalPx = paddingHorizontal.toPx()
                    if (isScreenRound) {
                        val usableHalf = diameter / 2f - paddingHorizontalPx
                        val sweepDegrees =
                            (2 * asin((indicatorHeight.toPx() / 2) / usableHalf)).toDegrees()

                        drawCurvedIndicator(
                            color,
                            background,
                            paddingHorizontalPx,
                            indicatorOnTheRight,
                            sweepDegrees,
                            indicatorWidthPx,
                            indicatorStart,
                            animatedDisplayState.value.size,
                            highlightAlpha.value
                        )
                    } else {
                        drawStraightIndicator(
                            color,
                            background,
                            paddingHorizontalPx,
                            indicatorOnTheRight,
                            indicatorWidthPx,
                            indicatorHeightPx = indicatorHeight.toPx(),
                            indicatorStart,
                            animatedDisplayState.value.size,
                            highlightAlpha.value
                        )
                    }
                }
        )
    }
}

// Copy of animateValueAsState, changing the listener to be notified before the animation starts,
// so we can link this animation with another one.
@Composable
internal fun <T, V : AnimationVector> customAnimateValueAsState(
    targetValue: T,
    typeConverter: TwoWayConverter<T, V>,
    animationSpec: AnimationSpec<T>,
    changeListener: (CoroutineScope.(T, T) -> Unit)? = null
): State<T> {
    val animatable = remember { Animatable(targetValue, typeConverter) }
    val listener by rememberUpdatedState(changeListener)
    val animSpec by rememberUpdatedState(animationSpec)
    val channel = remember { Channel<T>(Channel.CONFLATED) }
    SideEffect {
        channel.trySend(targetValue)
    }
    LaunchedEffect(channel) {
        for (target in channel) {
            // This additional poll is needed because when the channel suspends on receive and
            // two values are produced before consumers' dispatcher resumes, only the first value
            // will be received.
            // It may not be an issue elsewhere, but in animation we want to avoid being one
            // frame late.
            val newTarget = channel.tryReceive().getOrNull() ?: target
            launch {
                if (newTarget != animatable.targetValue) {
                    listener?.invoke(this, animatable.value, newTarget)
                    animatable.animateTo(newTarget, animSpec)
                }
            }
        }
    }
    return animatable.asState()
}

internal class DisplayState(
    val position: Float,
    val size: Float
)

internal val DisplayStateTwoWayConverter: TwoWayConverter<DisplayState, AnimationVector2D> =
    TwoWayConverter(
        convertToVector = { ds ->
            AnimationVector2D(ds.position, ds.size)
        },
        convertFromVector = { av ->
            DisplayState(av.v1, av.v2)
        }
    )

/**
 * An implementation of [PositionIndicatorState] to display a value that is being incremented or
 * decremented with a rolling side button, rotating bezel or a slider e.g. a volume control.
 *
 * @param fraction Value of the indicator in the range 0..1 where 1 represents the
 * maximum value. E.g. If displaying a volume value from 0..11 then the [fraction] will be
 * volume/11.
 *
 * @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
 */
internal class FractionPositionIndicatorState(
    private val fraction: () -> Float
) : PositionIndicatorState {
    override val positionFraction = 1f // Position indicator always starts at the bottom|end

    override fun sizeFraction(scrollableContainerSizePx: Float) = fraction()

    override fun equals(other: Any?) =
        (other as? FractionPositionIndicatorState)?.fraction?.invoke() == fraction()

    override fun hashCode(): Int = fraction().hashCode()

    override fun visibility(scrollableContainerSizePx: Float) = PositionIndicatorVisibility.Show
}

/**
 * An implementation of [PositionIndicatorState] to display the amount and position of a component
 * implementing the [ScrollState] class such as a [Column] implementing [Modifier.verticalScroll].
 *
 * @param scrollState the [ScrollState] to adapt
 *
 * @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
 */
internal class ScrollStateAdapter(private val scrollState: ScrollState) : PositionIndicatorState {
    override val positionFraction: Float
        get() {
            return if (scrollState.maxValue == 0) {
                0f
            } else {
                scrollState.value.toFloat() / scrollState.maxValue
            }
        }

    override fun sizeFraction(scrollableContainerSizePx: Float) =
        if (scrollableContainerSizePx + scrollState.maxValue == 0.0f) {
            1.0f
        } else {
            scrollableContainerSizePx / (scrollableContainerSizePx + scrollState.maxValue)
        }

    override fun visibility(scrollableContainerSizePx: Float) = if (scrollState.maxValue == 0) {
        PositionIndicatorVisibility.Hide
    } else if (scrollState.isScrollInProgress) {
        PositionIndicatorVisibility.Show
    } else {
        PositionIndicatorVisibility.AutoHide
    }

    override fun equals(other: Any?): Boolean {
        return (other as? ScrollStateAdapter)?.scrollState == scrollState
    }

    override fun hashCode(): Int {
        return scrollState.hashCode()
    }
}

/**
 * An implementation of [PositionIndicatorState] to display the amount and position of a
 * [ScalingLazyColumn] component via its [ScalingLazyListState].
 *
 * Note that size and position calculations ignore spacing between list items both for determining
 * the number and the number of visible items.

 * @param state the [ScalingLazyListState] to adapt.
 *
 * @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
 */
internal class ScalingLazyColumnStateAdapter(
    private val state: ScalingLazyListState
) : PositionIndicatorState {
    override val positionFraction: Float
        get() {
            return if (state.layoutInfo.visibleItemsInfo.isEmpty()) {
                0.0f
            } else {
                val decimalFirstItemIndex = decimalFirstItemIndex()
                val decimalLastItemIndex = decimalLastItemIndex()
                val decimalLastItemIndexDistanceFromEnd = state.layoutInfo.totalItemsCount -
                    decimalLastItemIndex

                if (decimalFirstItemIndex + decimalLastItemIndexDistanceFromEnd == 0.0f) {
                    0.0f
                } else {
                    decimalFirstItemIndex /
                        (decimalFirstItemIndex + decimalLastItemIndexDistanceFromEnd)
                }
            }
        }

    override fun sizeFraction(scrollableContainerSizePx: Float) =
        if (state.layoutInfo.totalItemsCount == 0) {
            1.0f
        } else {
            val decimalFirstItemIndex = decimalFirstItemIndex()
            val decimalLastItemIndex = decimalLastItemIndex()

            (decimalLastItemIndex - decimalFirstItemIndex) /
                state.layoutInfo.totalItemsCount.toFloat()
        }

    override fun visibility(scrollableContainerSizePx: Float): PositionIndicatorVisibility {
        val canScroll = state.layoutInfo.visibleItemsInfo.isNotEmpty() &&
            (decimalFirstItemIndex() > 0 ||
                decimalLastItemIndex() < state.layoutInfo.totalItemsCount)
        return if (canScroll) {
            if (state.isScrollInProgress) {
                PositionIndicatorVisibility.Show
            } else {
                PositionIndicatorVisibility.AutoHide
            }
        } else {
            PositionIndicatorVisibility.Hide
        }
    }

    override fun hashCode(): Int {
        return state.hashCode()
    }

    override fun equals(other: Any?): Boolean {
        return (other as? ScalingLazyColumnStateAdapter)?.state == state
    }

    /**
     * Provide a float value that represents the index of the last visible list item in a scaling
     * lazy column. The value should be in the range from [n,n+1] for a given index n, where n is
     * the index of the last visible item and a value of n represents that only the very start|top
     * of the item is visible, and n+1 means that whole of the item is visible in the viewport.
     *
     * Note that decimal index calculations ignore spacing between list items both for determining
     * the number and the number of visible items.
     */
    private fun decimalLastItemIndex(): Float {
        if (state.layoutInfo.visibleItemsInfo.isEmpty()) return 0f
        val lastItem = state.layoutInfo.visibleItemsInfo.last()
        // This is the offset of the last item w.r.t. the ScalingLazyColumn coordinate system where
        // 0 in the center of the visible viewport and +/-(state.viewportHeightPx / 2f) are the
        // start and end of the viewport.
        //
        // Note that [ScalingLazyListAnchorType] determines how the list items are anchored to the
        // center of the viewport, it does not change viewport coordinates. As a result this
        // calculation needs to take the anchorType into account to calculate the correct end
        // of list item offset.
        val lastItemEndOffset = lastItem.startOffset(state.anchorType.value!!) + lastItem.size
        val viewportEndOffset = state.viewportHeightPx.value!! / 2f
        val lastItemVisibleFraction =
            (1f - ((lastItemEndOffset - viewportEndOffset) / lastItem.size)).coerceAtMost(1f)

        return lastItem.index.toFloat() + lastItemVisibleFraction
    }

    /**
     * Provide a float value that represents the index of first visible list item in a scaling lazy
     * column. The value should be in the range from [n,n+1] for a given index n, where n is the
     * index of the first visible item and a value of n represents that all of the item is visible
     * in the viewport and a value of n+1 means that only the very end|bottom of the list item is
     * visible at the start|top of the viewport.
     *
     * Note that decimal index calculations ignore spacing between list items both for determining
     * the number and the number of visible items.
     */
    private fun decimalFirstItemIndex(): Float {
        if (state.layoutInfo.visibleItemsInfo.isEmpty()) return 0f
        val firstItem = state.layoutInfo.visibleItemsInfo.first()
        val firstItemStartOffset = firstItem.startOffset(state.anchorType.value!!)
        val viewportStartOffset = - (state.viewportHeightPx.value!! / 2f)
        val firstItemInvisibleFraction =
            ((viewportStartOffset - firstItemStartOffset) / firstItem.size).coerceAtLeast(0f)

        return firstItem.index.toFloat() + firstItemInvisibleFraction
    }
}

/**
 * An implementation of [PositionIndicatorState] to display the amount and position of a
 * [LazyColumn] component via its [LazyListState].
 *
 * @param state the [LazyListState] to adapt.
 *
 * @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
 */
internal class LazyColumnStateAdapter(
    private val state: LazyListState
) : PositionIndicatorState {
    override val positionFraction: Float
        get() {
            return if (state.layoutInfo.visibleItemsInfo.isEmpty()) {
                0.0f
            } else {
                val decimalFirstItemIndex = decimalFirstItemIndex()
                val decimalLastItemIndex = decimalLastItemIndex()

                val decimalLastItemIndexDistanceFromEnd = state.layoutInfo.totalItemsCount -
                    decimalLastItemIndex

                if (decimalFirstItemIndex + decimalLastItemIndexDistanceFromEnd == 0.0f) {
                    0.0f
                } else {
                    decimalFirstItemIndex /
                        (decimalFirstItemIndex + decimalLastItemIndexDistanceFromEnd)
                }
            }
        }

    override fun sizeFraction(scrollableContainerSizePx: Float) =
        if (state.layoutInfo.totalItemsCount == 0) {
            1.0f
        } else {
            val decimalFirstItemIndex = decimalFirstItemIndex()
            val decimalLastItemIndex = decimalLastItemIndex()

            (decimalLastItemIndex - decimalFirstItemIndex) /
                state.layoutInfo.totalItemsCount.toFloat()
        }

    override fun visibility(scrollableContainerSizePx: Float): PositionIndicatorVisibility {
        return if (sizeFraction(scrollableContainerSizePx) < 0.999f) {
            if (state.isScrollInProgress) {
                PositionIndicatorVisibility.Show
            } else {
                PositionIndicatorVisibility.AutoHide
            }
        } else {
            PositionIndicatorVisibility.Hide
        }
    }

    override fun hashCode(): Int {
        return state.hashCode()
    }

    override fun equals(other: Any?): Boolean {
        return (other as? LazyColumnStateAdapter)?.state == state
    }

    private fun decimalLastItemIndex(): Float {
        if (state.layoutInfo.visibleItemsInfo.isEmpty()) return 0f
        val lastItem = state.layoutInfo.visibleItemsInfo.last()
        val lastItemVisibleSize = state.layoutInfo.viewportEndOffset - lastItem.offset
        return lastItem.index.toFloat() +
            lastItemVisibleSize.toFloat() / lastItem.size.toFloat()
    }

    private fun decimalFirstItemIndex(): Float {
        if (state.layoutInfo.visibleItemsInfo.isEmpty()) return 0f
        val firstItem = state.layoutInfo.visibleItemsInfo.first()
        val firstItemOffset = firstItem.offset - state.layoutInfo.viewportStartOffset
        return firstItem.index.toFloat() -
            firstItemOffset.coerceAtMost(0).toFloat() / firstItem.size.toFloat()
    }
}

// TODO(ssancho): implement min/max thumb size (1/10 & 9/10)
private fun ContentDrawScope.drawCurvedIndicator(
    color: Color,
    background: Color,
    paddingHorizontalPx: Float,
    indicatorOnTheRight: Boolean,
    sweepDegrees: Float,
    indicatorWidthPx: Float,
    indicatorStart: Float,
    indicatorSize: Float,
    highlightAlpha: Float
) {
    val diameter = max(size.width, size.height)
    val arcSize = Size(
        diameter - 2 * paddingHorizontalPx - indicatorWidthPx,
        diameter - 2 * paddingHorizontalPx - indicatorWidthPx
    )
    val arcTopLeft = Offset(
        size.width - diameter + paddingHorizontalPx + indicatorWidthPx / 2f,
        (size.height - diameter) / 2f + paddingHorizontalPx + indicatorWidthPx / 2f,
    )
    val startAngleOffset = if (indicatorOnTheRight) 0f else 180f
    drawArc(
        background,
        startAngle = startAngleOffset - sweepDegrees / 2,
        sweepDegrees,
        useCenter = false,
        topLeft = arcTopLeft,
        size = arcSize,
        style = Stroke(width = indicatorWidthPx, cap = StrokeCap.Round)
    )
    drawArc(
        lerp(color, Color.White, highlightAlpha),
        startAngle = startAngleOffset + sweepDegrees * (-0.5f + indicatorStart),
        sweepAngle = sweepDegrees * indicatorSize,
        useCenter = false,
        topLeft = arcTopLeft,
        size = arcSize,
        style = Stroke(width = indicatorWidthPx, cap = StrokeCap.Round)
    )
}

private fun ContentDrawScope.drawStraightIndicator(
    color: Color,
    background: Color,
    paddingHorizontalPx: Float,
    indicatorOnTheRight: Boolean,
    indicatorWidthPx: Float,
    indicatorHeightPx: Float,
    indicatorStart: Float,
    indicatorSize: Float,
    highlightAlpha: Float
) {
    val x = if (indicatorOnTheRight) {
        size.width - paddingHorizontalPx - indicatorWidthPx / 2
    } else {
        paddingHorizontalPx + indicatorWidthPx / 2
    }
    val lineTop = Offset(x, (size.height - indicatorHeightPx) / 2f)
    val lineBottom = lineTop + Offset(0f, indicatorHeightPx)
    drawLine(
        color = background,
        lineTop,
        lineBottom,
        strokeWidth = indicatorWidthPx,
        cap = StrokeCap.Round
    )
    drawLine(
        lerp(color, Color.White, highlightAlpha),
        lerp(lineTop, lineBottom, indicatorStart),
        lerp(lineTop, lineBottom, indicatorStart + indicatorSize),
        strokeWidth = indicatorWidthPx,
        cap = StrokeCap.Round
    )
}

internal fun Float.toDegrees() = this * 180f / PI.toFloat()