From 2cbe956cfa1860722be9b82ecc138881abc2bac9 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Tue, 7 May 2024 13:46:53 +0530 Subject: [PATCH 1/2] Update CHANGELOG --- README.md | 6 ++++++ readme_images/bugsnag.png | Bin 0 -> 30171 bytes 2 files changed, 6 insertions(+) create mode 100644 readme_images/bugsnag.png diff --git a/README.md b/README.md index 5140a4ea1..4b1137fd4 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,12 @@ This project uses ktfmt, provided via the spotless gradle plugin, and the bundle - [Sasikanth Miriyampalli](https://www.sasikanth.dev) / Development - [Eduardo Pratti](https://twitter.com/edpratti) / Design +## Error Reporting by + + + bugsnag logo + + ## License ``` diff --git a/readme_images/bugsnag.png b/readme_images/bugsnag.png new file mode 100644 index 0000000000000000000000000000000000000000..fb8d2ac0f8abeb7a77618f65df28b399a8be2285 GIT binary patch literal 30171 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 Date: Fri, 26 Jul 2024 13:33:57 +0530 Subject: [PATCH 2/2] [1.200.*] Pre-release merge (#661) * Move featured section background inside `HorizontalPager` Improves performance, since we can lazily load the feature content and use pager state to animate content backgrounds to fade in and out smoothly. * Extract post read/unread alpha variables to `Constants` * Add content description for buttons in `ReaderScreen` * Use M3 BottomSheetScaffold in `HomeScreen` * Remove custom `BottomSheetScaffold` * Fix gradient overlay moving in `FeatureSection` when swiping between pages * Fix featured item layout height jumping when scrolling * Fix app crashing when changing posts filter type --------- Co-authored-by: Sasikanth Miriyampalli --- .../resources/strings/DeTwineStrings.kt | 1 + .../resources/strings/EnTwineStrings.kt | 1 + .../resources/strings/TrTwineStrings.kt | 1 + .../reader/resources/strings/TwineStrings.kt | 1 + .../resources/strings/ZhTwineStrings.kt | 1 + .../components/bottomsheet/ActualAndroid.kt | 20 - .../rss/reader/about/ui/AboutScreen.kt | 2 +- .../bottomsheet/AnchoredDraggable.kt | 649 -------------- .../bottomsheet/BottomSheetScaffold.kt | 807 ------------------ .../bottomsheet/InternalMutatorMutex.kt | 168 ---- .../rss/reader/feeds/ui/FeedsBottomSheet.kt | 8 +- .../sasikanth/rss/reader/home/HomeEvent.kt | 4 +- .../rss/reader/home/HomePresenter.kt | 10 +- .../sasikanth/rss/reader/home/HomeState.kt | 7 +- .../rss/reader/home/ui/FeaturedPostItem.kt | 9 +- .../rss/reader/home/ui/FeaturedSection.kt | 126 ++- .../rss/reader/home/ui/HomeScreen.kt | 304 ++++--- .../sasikanth/rss/reader/home/ui/PostList.kt | 6 +- .../rss/reader/reader/ui/ReaderScreen.kt | 15 +- .../sasikanth/rss/reader/utils/Constants.kt | 3 + .../components/bottomsheet/ActualIOS.kt | 35 - 21 files changed, 261 insertions(+), 1917 deletions(-) delete mode 100644 shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/components/bottomsheet/ActualAndroid.kt delete mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/bottomsheet/AnchoredDraggable.kt delete mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/bottomsheet/BottomSheetScaffold.kt delete mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/bottomsheet/InternalMutatorMutex.kt delete mode 100644 shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/components/bottomsheet/ActualIOS.kt diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/DeTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/DeTwineStrings.kt index d615e3c2d..a77fb3e5e 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/DeTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/DeTwineStrings.kt @@ -164,4 +164,5 @@ val DeTwineStrings = noPinnedSources = "No pinned feeds/groups", databaseMaintainenceTitle = "Please wait...", databaseMaintainenceSubtitle = "Performing database maintainence, don't close the app", + cdLoadFullArticle = "Load full article", ) diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt index 3c943d7f6..e4ba6b336 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt @@ -171,4 +171,5 @@ val EnTwineStrings = noPinnedSources = "No pinned feeds/groups", databaseMaintainenceTitle = "Please wait...", databaseMaintainenceSubtitle = "Performing database maintainence, don't close the app", + cdLoadFullArticle = "Load full article", ) diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TrTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TrTwineStrings.kt index 0102a419f..6fd107ab6 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TrTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TrTwineStrings.kt @@ -160,4 +160,5 @@ val TrTwineStrings = noPinnedSources = "Sabitlenmiş yayın/grup yok", databaseMaintainenceTitle = "Lütfen bekleyin...", databaseMaintainenceSubtitle = "Veritabanı bakımı gerçekleştiriliyor, uygulamayı kapatmayın", + cdLoadFullArticle = "Load full article", ) diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt index 5affd741d..30922c6ae 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt @@ -151,6 +151,7 @@ data class TwineStrings( val noPinnedSources: String, val databaseMaintainenceTitle: String, val databaseMaintainenceSubtitle: String, + val cdLoadFullArticle: String, ) object Locales { diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/ZhTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/ZhTwineStrings.kt index 80f26cab5..ea25bd3ad 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/ZhTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/ZhTwineStrings.kt @@ -151,4 +151,5 @@ val ZhTwineStrings = noPinnedSources = "没有置顶的订阅/分组", databaseMaintainenceTitle = "请稍候...", databaseMaintainenceSubtitle = "正在进行数据库维护,请勿关闭应用", + cdLoadFullArticle = "Load full article", ) diff --git a/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/components/bottomsheet/ActualAndroid.kt b/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/components/bottomsheet/ActualAndroid.kt deleted file mode 100644 index 87eaf7ccf..000000000 --- a/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/components/bottomsheet/ActualAndroid.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2020 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 dev.sasikanth.rss.reader.components.bottomsheet - -import java.util.concurrent.atomic.AtomicReference - -internal actual typealias InternalAtomicReference = AtomicReference diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/about/ui/AboutScreen.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/about/ui/AboutScreen.kt index dc129719b..421048fbe 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/about/ui/AboutScreen.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/about/ui/AboutScreen.kt @@ -53,11 +53,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach import dev.sasikanth.rss.reader.about.AboutEvent import dev.sasikanth.rss.reader.about.AboutPresenter import dev.sasikanth.rss.reader.about.Person import dev.sasikanth.rss.reader.about.Social -import dev.sasikanth.rss.reader.components.bottomsheet.fastForEach import dev.sasikanth.rss.reader.components.image.AsyncImage import dev.sasikanth.rss.reader.platform.LocalLinkHandler import dev.sasikanth.rss.reader.resources.icons.ArrowBack diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/bottomsheet/AnchoredDraggable.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/bottomsheet/AnchoredDraggable.kt deleted file mode 100644 index 63747a245..000000000 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/bottomsheet/AnchoredDraggable.kt +++ /dev/null @@ -1,649 +0,0 @@ -/* - * Copyright 2020 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 dev.sasikanth.rss.reader.components.bottomsheet - -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.SpringSpec -import androidx.compose.animation.core.animate -import androidx.compose.foundation.MutatePriority -import androidx.compose.foundation.gestures.DragScope -import androidx.compose.foundation.gestures.DraggableState -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.offset -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp -import dev.sasikanth.rss.reader.components.bottomsheet.AnchoredDraggableState.AnchorChangedCallback -import kotlin.math.abs -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch - -/** - * Enable drag gestures between a set of predefined values. - * - * When a drag is detected, the offset of the [AnchoredDraggableState] will be updated with the drag - * delta. You should use this offset to move your content accordingly (see [Modifier.offset]). When - * the drag ends, the offset will be animated to one of the anchors and when that anchor is reached, - * the value of the [AnchoredDraggableState] will also be updated to the value corresponding to the - * new anchor. - * - * Dragging is constrained between the minimum and maximum anchors. - * - * @param state The associated [AnchoredDraggableState]. - * @param orientation The orientation in which the [anchoredDraggable] can be dragged. - * @param enabled Whether this [anchoredDraggable] is enabled and should react to the user's input. - * @param reverseDirection Whether to reverse the direction of the drag, so a top to bottom drag - * will behave like bottom to top, and a left to right drag will behave like right to left. - * @param interactionSource Optional [MutableInteractionSource] that will passed on to the internal - * [Modifier.draggable]. - */ -@ExperimentalMaterialApi -internal fun Modifier.anchoredDraggable( - state: AnchoredDraggableState, - orientation: Orientation, - enabled: Boolean = true, - reverseDirection: Boolean = false, - interactionSource: MutableInteractionSource? = null -) = - draggable( - state = state.draggableState, - orientation = orientation, - enabled = enabled, - interactionSource = interactionSource, - reverseDirection = reverseDirection, - startDragImmediately = state.isAnimationRunning, - onDragStopped = { velocity -> launch { state.settle(velocity) } } - ) - -/** - * Scope used for suspending anchored drag blocks. Allows to set [AnchoredDraggableState.offset] to - * a new value. - * - * @see [AnchoredDraggableState.anchoredDrag] to learn how to start the anchored drag and get the - * access to this scope. - */ -internal interface AnchoredDragScope { - /** - * Assign a new value for an offset value for [AnchoredDraggableState]. - * - * @param newOffset new value for [AnchoredDraggableState.offset]. - * @param lastKnownVelocity last known velocity (if known) - */ - fun dragTo(newOffset: Float, lastKnownVelocity: Float = 0f) -} - -/** - * State of the [anchoredDraggable] modifier. - * - * This contains necessary information about any ongoing drag or animation and provides methods to - * change the state either immediately or by starting an animation. To create and remember a - * [AnchoredDraggableState] use [rememberAnchoredDraggableState]. - * - * @param initialValue The initial value of the state. - * @param animationSpec The default animation that will be used to animate to a new state. - * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change. - * @param positionalThreshold The positional threshold, in px, to be used when calculating the - * target state while a drag is in progress and when settling after the drag ends. This is the - * distance from the start of a transition. It will be, depending on the direction of the - * interaction, added or subtracted from/to the origin offset. It should always be a positive - * value. - * @param velocityThreshold The velocity threshold (in px per second) that the end velocity has to - * exceed in order to animate to the next state, even if the [positionalThreshold] has not been - * reached. - */ -@Stable -@ExperimentalMaterialApi -internal class AnchoredDraggableState( - initialValue: T, - internal val positionalThreshold: (totalDistance: Float) -> Float, - internal val velocityThreshold: () -> Float, - val animationSpec: AnimationSpec = AnchoredDraggableDefaults.AnimationSpec, - internal val confirmValueChange: (newValue: T) -> Boolean = { true } -) { - - private val dragMutex = InternalMutatorMutex() - - internal val draggableState = - object : DraggableState { - - private val dragScope = - object : DragScope { - override fun dragBy(pixels: Float) { - with(anchoredDragScope) { dragTo(newOffsetForDelta(pixels)) } - } - } - - override suspend fun drag(dragPriority: MutatePriority, block: suspend DragScope.() -> Unit) { - this@AnchoredDraggableState.anchoredDrag(dragPriority) { with(dragScope) { block() } } - } - - override fun dispatchRawDelta(delta: Float) { - this@AnchoredDraggableState.dispatchRawDelta(delta) - } - } - - /** The current value of the [AnchoredDraggableState]. */ - var currentValue: T by mutableStateOf(initialValue) - private set - - /** - * The target value. This is the closest value to the current offset (taking into account - * positional thresholds). If no interactions like animations or drags are in progress, this will - * be the current value. - */ - val targetValue: T by derivedStateOf { - animationTarget - ?: run { - val currentOffset = offset - if (!currentOffset.isNaN()) { - computeTarget(currentOffset, currentValue, velocity = 0f) - } else currentValue - } - } - - /** - * The current offset, or [Float.NaN] if it has not been initialized yet. - * - * The offset will be initialized when the anchors are first set through [updateAnchors]. - * - * Strongly consider using [requireOffset] which will throw if the offset is read before it is - * initialized. This helps catch issues early in your workflow. - */ - var offset: Float by mutableStateOf(Float.NaN) - private set - - /** - * Require the current offset. - * - * @throws IllegalStateException If the offset has not been initialized yet - * @see offset - */ - fun requireOffset(): Float { - check(!offset.isNaN()) { - "The offset was read before being initialized. Did you access the offset in a phase " + - "before layout, like effects or composition?" - } - return offset - } - - /** Whether an animation is currently in progress. */ - val isAnimationRunning: Boolean - get() = animationTarget != null - - /** - * The fraction of the progress going from [currentValue] to [targetValue], within [0f..1f] - * bounds. - */ - /*@FloatRange(from = 0f, to = 1f)*/ - val progress: Float by derivedStateOf { - val a = anchors[currentValue] ?: 0f - val b = anchors[targetValue] ?: 0f - val distance = abs(b - a) - if (distance > 1e-6f) { - val progress = (this.requireOffset() - a) / (b - a) - // If we are very close to 0f or 1f, we round to the closest - if (progress < 1e-6f) 0f else if (progress > 1 - 1e-6f) 1f else progress - } else 1f - } - - /** - * The velocity of the last known animation. Gets reset to 0f when an animation completes - * successfully, but does not get reset when an animation gets interrupted. You can use this value - * to provide smooth reconciliation behavior when re-targeting an animation. - */ - var lastVelocity: Float by mutableStateOf(0f) - private set - - /** - * The minimum offset this state can reach. This will be the smallest anchor, or - * [Float.NEGATIVE_INFINITY] if the anchors are not initialized yet. - */ - val minOffset by derivedStateOf { anchors.minOrNull() ?: Float.NEGATIVE_INFINITY } - - /** - * The maximum offset this state can reach. This will be the biggest anchor, or - * [Float.POSITIVE_INFINITY] if the anchors are not initialized yet. - */ - val maxOffset by derivedStateOf { anchors.maxOrNull() ?: Float.POSITIVE_INFINITY } - - private var animationTarget: T? by mutableStateOf(null) - - internal var anchors by mutableStateOf(emptyMap()) - - /** - * Update the anchors. If the previous set of anchors was empty, attempt to update the offset to - * match the initial value's anchor. If the [newAnchors] are different to the existing anchors, or - * there is no anchor for the [currentValue], the [onAnchorsChanged] callback will be invoked. - * - * If your anchors depend on the size of the layout, updateAnchors should be called in the - * layout (placement) phase, e.g. through Modifier.onSizeChanged. This ensures that the state - * is set up within the same frame. For static anchors, or anchors with different data - * dependencies, updateAnchors is safe to be called any time, for example from a side effect. - * - * @param newAnchors The new anchors - * @param onAnchorsChanged Optional callback to be invoked if the state needs to be updated after - * updating the anchors, for example if the anchor for the [currentValue] has been removed - */ - internal fun updateAnchors( - newAnchors: Map, - onAnchorsChanged: AnchorChangedCallback? = null - ) { - if (anchors != newAnchors) { - val previousAnchors = anchors - val previousTarget = targetValue - val previousAnchorsEmpty = anchors.isEmpty() - anchors = newAnchors - - val currentValueHasAnchor = anchors[currentValue] != null - if (previousAnchorsEmpty && currentValueHasAnchor) { - trySnapTo(currentValue) - } else { - onAnchorsChanged?.onAnchorsChanged( - previousTargetValue = previousTarget, - previousAnchors = previousAnchors, - newAnchors = newAnchors - ) - } - } - } - - /** Whether the [value] has an anchor associated with it. */ - fun hasAnchorForValue(value: T): Boolean = anchors.containsKey(value) - - /** - * Find the closest anchor taking into account the velocity and settle at it with an animation. - */ - suspend fun settle(velocity: Float) { - val previousValue = this.currentValue - val targetValue = - computeTarget(offset = requireOffset(), currentValue = previousValue, velocity = velocity) - if (confirmValueChange(targetValue)) { - animateTo(targetValue, velocity) - } else { - // If the user vetoed the state change, rollback to the previous state. - animateTo(previousValue, velocity) - } - } - - private fun computeTarget(offset: Float, currentValue: T, velocity: Float): T { - val currentAnchors = anchors - val currentAnchor = currentAnchors[currentValue] - val velocityThresholdPx = velocityThreshold() - return if (currentAnchor == offset || currentAnchor == null) { - currentValue - } else if (currentAnchor < offset) { - // Swiping from lower to upper (positive). - if (velocity >= velocityThresholdPx) { - currentAnchors.closestAnchor(offset, true) - } else { - val upper = currentAnchors.closestAnchor(offset, true) - val distance = abs(currentAnchors.getValue(upper) - currentAnchor) - val relativeThreshold = abs(positionalThreshold(distance)) - val absoluteThreshold = abs(currentAnchor + relativeThreshold) - if (offset < absoluteThreshold) currentValue else upper - } - } else { - // Swiping from upper to lower (negative). - if (velocity <= -velocityThresholdPx) { - currentAnchors.closestAnchor(offset, false) - } else { - val lower = currentAnchors.closestAnchor(offset, false) - val distance = abs(currentAnchor - currentAnchors.getValue(lower)) - val relativeThreshold = abs(positionalThreshold(distance)) - val absoluteThreshold = abs(currentAnchor - relativeThreshold) - if (offset < 0) { - // For negative offsets, larger absolute thresholds are closer to lower anchors - // than smaller ones. - if (abs(offset) < absoluteThreshold) currentValue else lower - } else { - if (offset > absoluteThreshold) currentValue else lower - } - } - } - } - - private val anchoredDragScope: AnchoredDragScope = - object : AnchoredDragScope { - override fun dragTo(newOffset: Float, lastKnownVelocity: Float) { - offset = newOffset - lastVelocity = lastKnownVelocity - } - } - - /** - * Call this function to take control of drag logic and perform anchored drag. - * - * All actions that change the [offset] of this [AnchoredDraggableState] must be performed within - * an [anchoredDrag] block (even if they don't call any other methods on this object) in order to - * guarantee that mutual exclusion is enforced. - * - * If [anchoredDrag] is called from elsewhere with the [dragPriority] higher or equal to ongoing - * drag, ongoing drag will be canceled. - * - * @param dragPriority of the drag operation - * @param block perform anchored drag given the current anchor provided - */ - suspend fun anchoredDrag( - dragPriority: MutatePriority = MutatePriority.Default, - block: suspend AnchoredDragScope.(anchors: Map) -> Unit - ): Unit = doAnchoredDrag(null, dragPriority, block) - - /** - * Call this function to take control of drag logic and perform anchored drag. - * - * All actions that change the [offset] of this [AnchoredDraggableState] must be performed within - * an [anchoredDrag] block (even if they don't call any other methods on this object) in order to - * guarantee that mutual exclusion is enforced. - * - * This overload allows the caller to hint the target value that this [anchoredDrag] is intended - * to arrive to. This will set [AnchoredDraggableState.targetValue] to provided value so consumers - * can reflect it in their UIs. - * - * If [anchoredDrag] is called from elsewhere with the [dragPriority] higher or equal to ongoing - * drag, ongoing drag will be canceled. - * - * @param targetValue hint the target value that this [anchoredDrag] is intended to arrive to - * @param dragPriority of the drag operation - * @param block perform anchored drag given the current anchor provided - */ - suspend fun anchoredDrag( - targetValue: T, - dragPriority: MutatePriority = MutatePriority.Default, - block: suspend AnchoredDragScope.(anchors: Map) -> Unit - ): Unit = doAnchoredDrag(targetValue, dragPriority, block) - - private suspend fun doAnchoredDrag( - targetValue: T?, - dragPriority: MutatePriority, - block: suspend AnchoredDragScope.(anchors: Map) -> Unit - ) = coroutineScope { - if (targetValue == null || anchors.containsKey(targetValue)) { - try { - dragMutex.mutate(dragPriority) { - if (targetValue != null) animationTarget = targetValue - anchoredDragScope.block(anchors) - } - } finally { - if (targetValue != null) animationTarget = null - val endState = - anchors.entries - .firstOrNull { (_, anchorOffset) -> abs(anchorOffset - offset) < 0.5f } - ?.key - - if (endState != null && confirmValueChange.invoke(endState)) { - currentValue = endState - } - } - } else if (confirmValueChange(targetValue)) { - currentValue = targetValue - } - } - - internal fun newOffsetForDelta(delta: Float) = - ((if (offset.isNaN()) 0f else offset) + delta).coerceIn(minOffset, maxOffset) - - /** - * Drag by the [delta], coerce it in the bounds and dispatch it to the [AnchoredDraggableState]. - * - * @return The delta the consumed by the [AnchoredDraggableState] - */ - fun dispatchRawDelta(delta: Float): Float { - val newOffset = newOffsetForDelta(delta) - val oldOffset = if (offset.isNaN()) 0f else offset - offset = newOffset - return newOffset - oldOffset - } - - /** - * Attempt to snap synchronously. Snapping can happen synchronously when there is no other drag - * transaction like a drag or an animation is progress. If there is another interaction in - * progress, the suspending [snapTo] overload needs to be used. - * - * @return true if the synchronous snap was successful, or false if we couldn't snap synchronous - */ - internal fun trySnapTo(targetValue: T): Boolean = - dragMutex.tryMutate { - with(anchoredDragScope) { - val targetOffset = anchors[targetValue] - if (targetOffset != null) { - dragTo(targetOffset) - animationTarget = null - } - currentValue = targetValue - } - } - - companion object { - /** The default [Saver] implementation for [AnchoredDraggableState]. */ - @ExperimentalMaterialApi - fun Saver( - animationSpec: AnimationSpec, - confirmValueChange: (T) -> Boolean, - positionalThreshold: (distance: Float) -> Float, - velocityThreshold: () -> Float - ) = - Saver, T>( - save = { it.currentValue }, - restore = { - AnchoredDraggableState( - initialValue = it, - animationSpec = animationSpec, - confirmValueChange = confirmValueChange, - positionalThreshold = positionalThreshold, - velocityThreshold = velocityThreshold - ) - } - ) - } - - /** - * Defines a callback that is invoked when the anchors have changed. - * - * Components with custom reconciliation logic should implement this callback, for example to - * re-target an in-progress animation when the anchors change. - * - * @see AnchoredDraggableDefaults.ReconcileAnimationOnAnchorChangedCallback for a default - * implementation - */ - @ExperimentalMaterialApi - fun interface AnchorChangedCallback { - - /** - * Callback that is invoked when the anchors have changed, after the [AnchoredDraggableState] - * has been updated with them. Use this hook to re-launch animations or interrupt them if - * needed. - * - * @param previousTargetValue The target value before the anchors were updated - * @param previousAnchors The previously set anchors - * @param newAnchors The newly set anchors - */ - fun onAnchorsChanged( - previousTargetValue: T, - previousAnchors: Map, - newAnchors: Map, - ) - } -} - -/** - * Snap to a [targetValue] without any animation. If the [targetValue] is not in the set of anchors, - * the [AnchoredDraggableState.currentValue] will be updated to the [targetValue] without updating - * the offset. - * - * @param targetValue The target value of the animation - * @throws CancellationException if the interaction interrupted by another interaction like a - * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call. - */ -@ExperimentalMaterialApi -internal suspend fun AnchoredDraggableState.snapTo(targetValue: T) { - anchoredDrag(targetValue = targetValue) { anchors -> - val targetOffset = anchors[targetValue] - if (targetOffset != null) dragTo(targetOffset) - } -} - -/** - * Animate to a [targetValue]. If the [targetValue] is not in the set of anchors, the - * [AnchoredDraggableState.currentValue] will be updated to the [targetValue] without updating the - * offset. - * - * @param targetValue The target value of the animation - * @param velocity The velocity the animation should start with - * @throws CancellationException if the interaction interrupted by another interaction like a - * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call. - */ -@ExperimentalMaterialApi -internal suspend fun AnchoredDraggableState.animateTo( - targetValue: T, - velocity: Float = this.lastVelocity, -) { - anchoredDrag(targetValue = targetValue) { anchors -> - val targetOffset = anchors[targetValue] - if (targetOffset != null) { - var prev = if (offset.isNaN()) 0f else offset - animate(prev, targetOffset, velocity, animationSpec) { value, velocity -> - // Our onDrag coerces the value within the bounds, but an animation may - // overshoot, for example a spring animation or an overshooting interpolator - // We respect the user's intention and allow the overshoot, but still use - // DraggableState's drag for its mutex. - dragTo(value, velocity) - prev = value - } - } - } -} - -/** - * Create and remember a [AnchoredDraggableState]. - * - * @param initialValue The initial value. - * @param animationSpec The default animation that will be used to animate to a new value. - * @param confirmValueChange Optional callback invoked to confirm or veto a pending value change. - */ -@Composable -@ExperimentalMaterialApi -internal fun rememberAnchoredDraggableState( - initialValue: T, - animationSpec: AnimationSpec = AnchoredDraggableDefaults.AnimationSpec, - confirmValueChange: (newValue: T) -> Boolean = { true } -): AnchoredDraggableState { - val positionalThreshold = AnchoredDraggableDefaults.positionalThreshold - val velocityThreshold = AnchoredDraggableDefaults.velocityThreshold - return rememberSaveable( - initialValue, - animationSpec, - confirmValueChange, - positionalThreshold, - velocityThreshold, - saver = - AnchoredDraggableState.Saver( - animationSpec = animationSpec, - confirmValueChange = confirmValueChange, - positionalThreshold = positionalThreshold, - velocityThreshold = velocityThreshold - ), - ) { - AnchoredDraggableState( - initialValue = initialValue, - animationSpec = animationSpec, - confirmValueChange = confirmValueChange, - positionalThreshold = positionalThreshold, - velocityThreshold = velocityThreshold - ) - } -} - -/** Contains useful defaults for [anchoredDraggable] and [AnchoredDraggableState]. */ -@Stable -@ExperimentalMaterialApi -internal object AnchoredDraggableDefaults { - /** The default animation used by [AnchoredDraggableState]. */ - @get:ExperimentalMaterialApi - @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") - @ExperimentalMaterialApi - val AnimationSpec = SpringSpec() - - /** - * The default velocity threshold (1.8 dp per millisecond) used by - * [rememberAnchoredDraggableState]. - */ - @get:ExperimentalMaterialApi - @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") - @ExperimentalMaterialApi - val velocityThreshold: () -> Float - @Composable get() = with(LocalDensity.current) { { 125.dp.toPx() } } - - /** The default positional threshold (56 dp) used by [rememberAnchoredDraggableState] */ - @get:ExperimentalMaterialApi - @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") - @ExperimentalMaterialApi - val positionalThreshold: (totalDistance: Float) -> Float - @Composable get() = with(LocalDensity.current) { { 56.dp.toPx() } } - - /** - * A [AnchorChangedCallback] implementation that attempts to reconcile an in-progress animation by - * re-targeting it if necessary or finding the closest new anchor. If the previous anchor is not - * in the new set of anchors, this implementation will snap to the closest anchor. - * - * Consider implementing a custom handler for more complex components like sheets. - */ - @ExperimentalMaterialApi - internal fun ReconcileAnimationOnAnchorChangedCallback( - state: AnchoredDraggableState, - scope: CoroutineScope - ) = - AnchorChangedCallback { previousTarget, previousAnchors, newAnchors -> - val previousTargetOffset = previousAnchors[previousTarget] - val newTargetOffset = newAnchors[previousTarget] - if (previousTargetOffset != newTargetOffset) { - if (newTargetOffset != null) { - scope.launch { state.animateTo(previousTarget, state.lastVelocity) } - } else { - scope.launch { - state.snapTo( - newAnchors.closestAnchor(offset = state.requireOffset(), searchUpwards = false) - ) - } - } - } - } -} - -private fun Map.closestAnchor(offset: Float, searchUpwards: Boolean): T { - require(isNotEmpty()) { "The anchors were empty when trying to find the closest anchor" } - return minBy { (_, anchor) -> - val delta = if (searchUpwards) anchor - offset else offset - anchor - if (delta < 0) Float.POSITIVE_INFINITY else delta - } - .key -} - -private fun Map.minOrNull() = minOfOrNull { (_, offset) -> offset } - -private fun Map.maxOrNull() = maxOfOrNull { (_, offset) -> offset } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/bottomsheet/BottomSheetScaffold.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/bottomsheet/BottomSheetScaffold.kt deleted file mode 100644 index d005658fe..000000000 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/bottomsheet/BottomSheetScaffold.kt +++ /dev/null @@ -1,807 +0,0 @@ -/* - * Copyright 2020 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 dev.sasikanth.rss.reader.components.bottomsheet - -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.requiredHeightIn -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material.DrawerDefaults -import androidx.compose.material.DrawerState -import androidx.compose.material.DrawerValue -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.FabPosition -import androidx.compose.material.MaterialTheme -import androidx.compose.material.ModalDrawer -import androidx.compose.material.SnackbarHost -import androidx.compose.material.SnackbarHostState -import androidx.compose.material.Surface -import androidx.compose.material.SwipeableDefaults -import androidx.compose.material.contentColorFor -import androidx.compose.material.rememberDrawerState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.SubcomposeLayout -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.semantics.collapse -import androidx.compose.ui.semantics.expand -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.Velocity -import androidx.compose.ui.unit.dp -import dev.sasikanth.rss.reader.components.bottomsheet.AnchoredDraggableState.AnchorChangedCallback -import dev.sasikanth.rss.reader.components.bottomsheet.BottomSheetValue.Collapsed -import dev.sasikanth.rss.reader.components.bottomsheet.BottomSheetValue.Expanded -import kotlin.contracts.ExperimentalContracts -import kotlin.contracts.contract -import kotlin.jvm.JvmName -import kotlin.math.roundToInt -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -/** - * Iterates through a [List] using the index and calls [action] for each item. This does not - * allocate an iterator like [Iterable.forEach]. - * - * **Do not use for collections that come from public APIs**, since they may not support random - * access in an efficient way, and this method may actually be a lot slower. Only use for - * collections that are created by code we control and are known to support random access. - */ -@Suppress("BanInlineOptIn") -@OptIn(ExperimentalContracts::class) -inline fun List.fastForEach(action: (T) -> Unit) { - contract { callsInPlace(action) } - for (index in indices) { - val item = get(index) - action(item) - } -} - -// TODO: should be fastMaxByOrNull to match stdlib -/** - * Returns the first element yielding the largest value of the given function or `null` if there are - * no elements. - * - * **Do not use for collections that come from public APIs**, since they may not support random - * access in an efficient way, and this method may actually be a lot slower. Only use for - * collections that are created by code we control and are known to support random access. - */ -@Suppress("BanInlineOptIn") -@OptIn(ExperimentalContracts::class) -inline fun > List.fastMaxBy(selector: (T) -> R): T? { - contract { callsInPlace(selector) } - if (isEmpty()) return null - var maxElem = get(0) - var maxValue = selector(maxElem) - for (i in 1..lastIndex) { - val e = get(i) - val v = selector(e) - if (maxValue < v) { - maxElem = e - maxValue = v - } - } - return maxElem -} - -/** Possible values of [BottomSheetState]. */ -@ExperimentalMaterialApi -enum class BottomSheetValue { - /** The bottom sheet is visible, but only showing its peek height. */ - Collapsed, - - /** The bottom sheet is visible at its maximum height. */ - Expanded -} - -@Deprecated( - message = - "This constructor is deprecated. confirmStateChange has been renamed to " + - "confirmValueChange.", - replaceWith = - ReplaceWith("BottomSheetScaffoldState(initialValue, animationSpec, " + "confirmStateChange)") -) -@Suppress("Deprecation") -@ExperimentalMaterialApi -fun BottomSheetScaffoldState( - initialValue: BottomSheetValue, - animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, - confirmStateChange: (BottomSheetValue) -> Boolean -) = - BottomSheetState( - initialValue = initialValue, - animationSpec = animationSpec, - confirmValueChange = confirmStateChange - ) - -/** - * State of the persistent bottom sheet in [BottomSheetScaffold]. - * - * @param initialValue The initial value of the state. - * @param density The density that this state can use to convert values to and from dp. - * @param animationSpec The default animation that will be used to animate to a new state. - * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change. - */ -@Suppress("Deprecation") -@ExperimentalMaterialApi -@Stable -fun BottomSheetState( - initialValue: BottomSheetValue, - density: Density, - animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, - confirmValueChange: (BottomSheetValue) -> Boolean = { true } -) = BottomSheetState(initialValue, animationSpec, confirmValueChange).also { it.density = density } - -/** - * State of the persistent bottom sheet in [BottomSheetScaffold]. - * - * @param initialValue The initial value of the state. - * @param animationSpec The default animation that will be used to animate to a new state. - * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change. - */ -@ExperimentalMaterialApi -@Stable -class BottomSheetState -@Deprecated( - "This constructor is deprecated. Density must be provided by the component. " + - "Please use the constructor that provides a [Density].", - ReplaceWith( - """ - BottomSheetState( - initialValue = initialValue, - density = LocalDensity.current, - animationSpec = animationSpec, - confirmValueChange = confirmValueChange - ) - """ - ) -) -constructor( - initialValue: BottomSheetValue, - animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, - confirmValueChange: (BottomSheetValue) -> Boolean = { true } -) { - - internal val anchoredDraggableState = - AnchoredDraggableState( - initialValue = initialValue, - animationSpec = animationSpec, - confirmValueChange = confirmValueChange, - positionalThreshold = { - with(requireDensity()) { BottomSheetScaffoldPositionalThreshold.toPx() } - }, - velocityThreshold = { with(requireDensity()) { BottomSheetScaffoldVelocityThreshold.toPx() } } - ) - - val currentValue: BottomSheetValue - get() = anchoredDraggableState.currentValue - - val targetValue: BottomSheetValue - get() = anchoredDraggableState.targetValue - - /** Whether the bottom sheet is expanded. */ - val isExpanded: Boolean - get() = anchoredDraggableState.currentValue == Expanded - - /** Whether the bottom sheet is collapsed. */ - val isCollapsed: Boolean - get() = anchoredDraggableState.currentValue == Collapsed - - /** - * The fraction of the progress going from [currentValue] to the targetValue, within [0f..1f] - * bounds, or 1f if the sheet is in a settled state. - */ - /*@FloatRange(from = 0f, to = 1f)*/ - val progress: Float - get() = anchoredDraggableState.progress - - val offsetProgress: Float by derivedStateOf { - val maxOffset = anchoredDraggableState.maxOffset - val minOffset = anchoredDraggableState.minOffset - val currentOffset = anchoredDraggableState.offset - - val delta = maxOffset - minOffset - if (!currentOffset.isNaN()) { - ((maxOffset - anchoredDraggableState.requireOffset()) / delta).coerceIn( - minimumValue = 0f, - maximumValue = 1f - ) - } else { - 0f - } - } - - /** - * Expand the bottom sheet with an animation and suspend until the animation finishes or is - * cancelled. Note: If the peek height is equal to the sheet height, this method will animate to - * the [Collapsed] state. - * - * This method will throw [CancellationException] if the animation is interrupted. - */ - suspend fun expand() { - val target = if (anchoredDraggableState.hasAnchorForValue(Expanded)) Expanded else Collapsed - anchoredDraggableState.animateTo(target) - } - - /** - * Collapse the bottom sheet with animation and suspend until it if fully collapsed or animation - * has been cancelled. This method will throw [CancellationException] if the animation is - * interrupted. - */ - suspend fun collapse() = anchoredDraggableState.animateTo(Collapsed) - - @Deprecated( - message = "Use requireOffset() to access the offset.", - replaceWith = ReplaceWith("requireOffset()") - ) - val offset: Float - get() = error("Use requireOffset() to access the offset.") - - /** - * Require the current offset. - * - * @throws IllegalStateException If the offset has not been initialized yet - */ - fun requireOffset() = anchoredDraggableState.requireOffset() - - internal suspend fun animateTo( - target: BottomSheetValue, - velocity: Float = anchoredDraggableState.lastVelocity - ) = anchoredDraggableState.animateTo(target, velocity) - - internal suspend fun snapTo(target: BottomSheetValue) = anchoredDraggableState.snapTo(target) - - internal fun trySnapTo(target: BottomSheetValue) = anchoredDraggableState.trySnapTo(target) - - internal val isAnimationRunning: Boolean - get() = anchoredDraggableState.isAnimationRunning - - internal var density: Density? = null - - private fun requireDensity() = - requireNotNull(density) { - "The density on BottomSheetState ($this) was not set. Did you use BottomSheetState with " + - "the BottomSheetScaffold composable?" - } - - internal val lastVelocity: Float - get() = anchoredDraggableState.lastVelocity - - companion object { - - /** The default [Saver] implementation for [BottomSheetState]. */ - fun Saver( - animationSpec: AnimationSpec, - confirmStateChange: (BottomSheetValue) -> Boolean, - density: Density - ): Saver = - Saver( - save = { it.anchoredDraggableState.currentValue }, - restore = { - BottomSheetState( - initialValue = it, - density = density, - animationSpec = animationSpec, - confirmValueChange = confirmStateChange - ) - } - ) - - /** The default [Saver] implementation for [BottomSheetState]. */ - @Deprecated( - message = - "This function is deprecated. Please use the overload where Density is" + " provided.", - replaceWith = ReplaceWith("Saver(animationSpec, confirmStateChange, density)") - ) - @Suppress("Deprecation") - fun Saver( - animationSpec: AnimationSpec, - confirmStateChange: (BottomSheetValue) -> Boolean - ): Saver = - Saver( - save = { it.anchoredDraggableState.currentValue }, - restore = { - BottomSheetState( - initialValue = it, - animationSpec = animationSpec, - confirmValueChange = confirmStateChange - ) - } - ) - } -} - -/** - * Create a [BottomSheetState] and [remember] it. - * - * @param initialValue The initial value of the state. - * @param animationSpec The default animation that will be used to animate to a new state. - * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. - */ -@Composable -@ExperimentalMaterialApi -fun rememberBottomSheetState( - initialValue: BottomSheetValue, - animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, - confirmStateChange: (BottomSheetValue) -> Boolean = { true } -): BottomSheetState { - val density = LocalDensity.current - return rememberSaveable( - animationSpec, - saver = - BottomSheetState.Saver( - animationSpec = animationSpec, - confirmStateChange = confirmStateChange, - density = density - ) - ) { - BottomSheetState( - initialValue = initialValue, - animationSpec = animationSpec, - confirmValueChange = confirmStateChange, - density = density - ) - } -} - -/** - * State of the [BottomSheetScaffold] composable. - * - * @param drawerState The state of the navigation drawer. - * @param bottomSheetState The state of the persistent bottom sheet. - * @param snackbarHostState The [SnackbarHostState] used to show snackbars inside the scaffold. - */ -@ExperimentalMaterialApi -@Stable -class BottomSheetScaffoldState( - val drawerState: DrawerState, - val bottomSheetState: BottomSheetState, - val snackbarHostState: SnackbarHostState -) - -/** - * Create and [remember] a [BottomSheetScaffoldState]. - * - * @param drawerState The state of the navigation drawer. - * @param bottomSheetState The state of the persistent bottom sheet. - * @param snackbarHostState The [SnackbarHostState] used to show snackbars inside the scaffold. - */ -@Composable -@ExperimentalMaterialApi -fun rememberBottomSheetScaffoldState( - drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed), - bottomSheetState: BottomSheetState = rememberBottomSheetState(Collapsed), - snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } -): BottomSheetScaffoldState { - return remember(drawerState, bottomSheetState, snackbarHostState) { - BottomSheetScaffoldState( - drawerState = drawerState, - bottomSheetState = bottomSheetState, - snackbarHostState = snackbarHostState - ) - } -} - -/** - * Material Design standard bottom sheet. - * - * Standard bottom sheets co-exist with the screen’s main UI region and allow for simultaneously - * viewing and interacting with both regions. They are commonly used to keep a feature or secondary - * content visible on screen when content in main UI region is frequently scrolled or panned. - * - * ![Standard bottom sheet - * image](https://developer.android.com/images/reference/androidx/compose/material/standard-bottom-sheet.png) - * - * This component provides an API to put together several material components to construct your - * screen. For a similar component which implements the basic material design layout strategy with - * app bars, floating action buttons and navigation drawers, use the standard [Scaffold]. For - * similar component that uses a backdrop as the centerpiece of the screen, use [BackdropScaffold]. - * - * A simple example of a bottom sheet scaffold looks like this: - * - * @param sheetContent The content of the bottom sheet. - * @param modifier An optional [Modifier] for the root of the scaffold. - * @param scaffoldState The state of the scaffold. - * @param topBar An optional top app bar. - * @param snackbarHost The composable hosting the snackbars shown inside the scaffold. - * @param floatingActionButton An optional floating action button. - * @param floatingActionButtonPosition The position of the floating action button. - * @param sheetGesturesEnabled Whether the bottom sheet can be interacted with by gestures. - * @param sheetShape The shape of the bottom sheet. - * @param sheetElevation The elevation of the bottom sheet. - * @param sheetBackgroundColor The background color of the bottom sheet. - * @param sheetContentColor The preferred content color provided by the bottom sheet to its - * children. Defaults to the matching content color for [sheetBackgroundColor], or if that is not - * a color from the theme, this will keep the same content color set above the bottom sheet. - * @param sheetPeekHeight The height of the bottom sheet when it is collapsed. If the peek height - * equals the sheet's full height, the sheet will only have a collapsed state. - * @param drawerContent The content of the drawer sheet. - * @param drawerGesturesEnabled Whether the drawer sheet can be interacted with by gestures. - * @param drawerShape The shape of the drawer sheet. - * @param drawerElevation The elevation of the drawer sheet. - * @param drawerBackgroundColor The background color of the drawer sheet. - * @param drawerContentColor The preferred content color provided by the drawer sheet to its - * children. Defaults to the matching content color for [drawerBackgroundColor], or if that is not - * a color from the theme, this will keep the same content color set above the drawer sheet. - * @param drawerScrimColor The color of the scrim that is applied when the drawer is open. - * @param content The main content of the screen. You should use the provided [PaddingValues] to - * properly offset the content, so that it is not obstructed by the bottom sheet when collapsed. - * @sample androidx.compose.material.samples.BottomSheetScaffoldSample - */ -@Composable -@ExperimentalMaterialApi -fun BottomSheetScaffold( - sheetContent: @Composable ColumnScope.() -> Unit, - modifier: Modifier = Modifier, - scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(), - topBar: (@Composable () -> Unit)? = null, - snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, - floatingActionButton: (@Composable () -> Unit)? = null, - floatingActionButtonPosition: FabPosition = FabPosition.End, - sheetGesturesEnabled: Boolean = true, - sheetShape: Shape = MaterialTheme.shapes.large, - sheetElevation: Dp = BottomSheetScaffoldDefaults.SheetElevation, - sheetBackgroundColor: Color = MaterialTheme.colors.surface, - sheetContentColor: Color = contentColorFor(sheetBackgroundColor), - sheetPeekHeight: Dp = BottomSheetScaffoldDefaults.SheetPeekHeight, - drawerContent: @Composable (ColumnScope.() -> Unit)? = null, - drawerGesturesEnabled: Boolean = true, - drawerShape: Shape = MaterialTheme.shapes.large, - drawerElevation: Dp = DrawerDefaults.Elevation, - drawerBackgroundColor: Color = MaterialTheme.colors.surface, - drawerContentColor: Color = contentColorFor(drawerBackgroundColor), - drawerScrimColor: Color = DrawerDefaults.scrimColor, - backgroundColor: Color = MaterialTheme.colors.background, - contentColor: Color = contentColorFor(backgroundColor), - content: @Composable (PaddingValues) -> Unit -) { - // b/278692145 Remove this once deprecated methods without density are removed - if (scaffoldState.bottomSheetState.density == null) { - val density = LocalDensity.current - SideEffect { scaffoldState.bottomSheetState.density = density } - } - - val peekHeightPx = - with(LocalDensity.current) { sheetPeekHeight.toPx() + WindowInsets.systemBars.getBottom(this) } - val child = - @Composable { - BottomSheetScaffoldLayout( - topBar = topBar, - body = content, - bottomSheet = { layoutHeight -> - val nestedScroll = - if (sheetGesturesEnabled) { - Modifier.nestedScroll( - remember(scaffoldState.bottomSheetState.anchoredDraggableState) { - ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( - state = scaffoldState.bottomSheetState.anchoredDraggableState, - orientation = Orientation.Vertical - ) - } - ) - } else Modifier - BottomSheet( - state = scaffoldState.bottomSheetState, - modifier = - nestedScroll - .fillMaxWidth() - .requiredHeightIn(min = sheetPeekHeight) - .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)), - calculateAnchors = { sheetSize -> - val sheetHeight = sheetSize.height.toFloat() - val collapsedHeight = layoutHeight - peekHeightPx - if (sheetHeight == 0f || sheetHeight == peekHeightPx) { - mapOf(Collapsed to collapsedHeight) - } else { - mapOf(Collapsed to collapsedHeight, Expanded to layoutHeight - sheetHeight) - } - }, - sheetBackgroundColor = sheetBackgroundColor, - sheetContentColor = sheetContentColor, - sheetElevation = sheetElevation, - sheetGesturesEnabled = sheetGesturesEnabled, - sheetShape = sheetShape, - content = sheetContent - ) - }, - floatingActionButton = floatingActionButton, - snackbarHost = { snackbarHost(scaffoldState.snackbarHostState) }, - sheetOffset = { scaffoldState.bottomSheetState.requireOffset() }, - sheetPeekHeight = sheetPeekHeight, - sheetState = scaffoldState.bottomSheetState, - floatingActionButtonPosition = floatingActionButtonPosition - ) - } - Surface(modifier.fillMaxSize(), color = backgroundColor, contentColor = contentColor) { - if (drawerContent == null) { - child() - } else { - ModalDrawer( - drawerContent = drawerContent, - drawerState = scaffoldState.drawerState, - gesturesEnabled = drawerGesturesEnabled, - drawerShape = drawerShape, - drawerElevation = drawerElevation, - drawerBackgroundColor = drawerBackgroundColor, - drawerContentColor = drawerContentColor, - scrimColor = drawerScrimColor, - content = child - ) - } - } -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -private fun BottomSheet( - state: BottomSheetState, - sheetGesturesEnabled: Boolean, - calculateAnchors: (sheetSize: IntSize) -> Map, - sheetShape: Shape, - sheetElevation: Dp, - sheetBackgroundColor: Color, - sheetContentColor: Color, - modifier: Modifier = Modifier, - content: @Composable ColumnScope.() -> Unit -) { - val scope = rememberCoroutineScope() - val anchorChangeCallback = - remember(state, scope) { BottomSheetScaffoldAnchorChangeCallback(state, scope) } - Surface( - modifier - .anchoredDraggable( - state = state.anchoredDraggableState, - orientation = Orientation.Vertical, - enabled = sheetGesturesEnabled, - ) - .onSizeChanged { layoutSize -> - state.anchoredDraggableState.updateAnchors( - newAnchors = calculateAnchors(layoutSize), - onAnchorsChanged = anchorChangeCallback - ) - } - .semantics { - // If we don't have anchors yet, or have only one anchor we don't want any - // accessibility actions - if (state.anchoredDraggableState.anchors.size > 1) { - if (state.isCollapsed) { - expand { - if (state.anchoredDraggableState.confirmValueChange(Expanded)) { - scope.launch { state.expand() } - } - true - } - } else { - collapse { - if (state.anchoredDraggableState.confirmValueChange(Collapsed)) { - scope.launch { state.collapse() } - } - true - } - } - } - }, - shape = sheetShape, - elevation = sheetElevation, - color = sheetBackgroundColor, - contentColor = sheetContentColor, - content = { Column(content = content) } - ) -} - -/** Contains useful defaults for [BottomSheetScaffold]. */ -object BottomSheetScaffoldDefaults { - /** The default elevation used by [BottomSheetScaffold]. */ - val SheetElevation = 8.dp - - /** The default peek height used by [BottomSheetScaffold]. */ - val SheetPeekHeight = 56.dp -} - -private enum class BottomSheetScaffoldLayoutSlot { - TopBar, - Body, - Sheet, - Fab, - Snackbar -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -private fun BottomSheetScaffoldLayout( - topBar: @Composable (() -> Unit)?, - body: @Composable (innerPadding: PaddingValues) -> Unit, - bottomSheet: @Composable (layoutHeight: Int) -> Unit, - floatingActionButton: (@Composable () -> Unit)?, - snackbarHost: @Composable () -> Unit, - sheetPeekHeight: Dp, - floatingActionButtonPosition: FabPosition, - sheetOffset: () -> Float, - sheetState: BottomSheetState, -) { - SubcomposeLayout { constraints -> - val layoutWidth = constraints.maxWidth - val layoutHeight = constraints.maxHeight - val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) - - val sheetPlaceables = - subcompose(BottomSheetScaffoldLayoutSlot.Sheet) { bottomSheet(layoutHeight) } - .map { it.measure(looseConstraints) } - val sheetOffsetY = sheetOffset().roundToInt() - - val topBarPlaceables = - topBar?.let { - subcompose(BottomSheetScaffoldLayoutSlot.TopBar, topBar).map { - it.measure(looseConstraints) - } - } - val topBarHeight = topBarPlaceables?.fastMaxBy { it.height }?.height ?: 0 - - val bodyConstraints = looseConstraints.copy(maxHeight = layoutHeight) - val bodyPlaceables = - subcompose(BottomSheetScaffoldLayoutSlot.Body) { - body(PaddingValues(top = topBarHeight.toDp(), bottom = sheetPeekHeight)) - } - .map { it.measure(bodyConstraints) } - - val fabPlaceable = - floatingActionButton?.let { fab -> - subcompose(BottomSheetScaffoldLayoutSlot.Fab, fab).map { it.measure(looseConstraints) } - } - val fabWidth = fabPlaceable?.fastMaxBy { it.width }?.width ?: 0 - val fabHeight = fabPlaceable?.fastMaxBy { it.height }?.height ?: 0 - val fabOffsetX = - when (floatingActionButtonPosition) { - FabPosition.Center -> (layoutWidth - fabWidth) / 2 - else -> layoutWidth - fabWidth - } - val fabOffsetY = sheetOffsetY - fabHeight - - val snackbarPlaceables = - subcompose(BottomSheetScaffoldLayoutSlot.Snackbar, snackbarHost).map { - it.measure(looseConstraints) - } - val snackbarWidth = snackbarPlaceables.fastMaxBy { it.width }?.width ?: 0 - val snackbarHeight = snackbarPlaceables.fastMaxBy { it.height }?.height ?: 0 - val snackbarOffsetX = (layoutWidth - snackbarWidth) / 2 - val snackbarOffsetY = - when (sheetState.currentValue) { - Collapsed -> fabOffsetY - snackbarHeight - Expanded -> layoutHeight - snackbarHeight - } - layout(layoutWidth, layoutHeight) { - // Placement order is important for elevation - bodyPlaceables.fastForEach { it.placeRelative(0, 0) } - topBarPlaceables?.fastForEach { it.placeRelative(0, 0) } - fabPlaceable?.fastForEach { it.placeRelative(fabOffsetX, fabOffsetY) } - sheetPlaceables.fastForEach { it.placeRelative(0, sheetOffsetY) } - snackbarPlaceables.fastForEach { it.placeRelative(snackbarOffsetX, snackbarOffsetY) } - } - } -} - -@OptIn(ExperimentalMaterialApi::class) -private fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( - state: AnchoredDraggableState<*>, - orientation: Orientation -): NestedScrollConnection = - object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - val delta = available.toFloat() - return if (delta < 0 && source == NestedScrollSource.Drag) { - state.dispatchRawDelta(delta).toOffset() - } else { - Offset.Zero - } - } - - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ): Offset { - return if (source == NestedScrollSource.Drag) { - state.dispatchRawDelta(available.toFloat()).toOffset() - } else { - Offset.Zero - } - } - - override suspend fun onPreFling(available: Velocity): Velocity { - val toFling = available.toFloat() - val currentOffset = state.requireOffset() - return if (toFling < 0 && currentOffset > state.minOffset) { - state.settle(velocity = toFling) - // since we go to the anchor with tween settling, consume all for the best UX - available - } else { - Velocity.Zero - } - } - - override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { - state.settle(velocity = available.toFloat()) - return available - } - - private fun Float.toOffset(): Offset = - Offset( - x = if (orientation == Orientation.Horizontal) this else 0f, - y = if (orientation == Orientation.Vertical) this else 0f - ) - - @JvmName("velocityToFloat") - private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y - - @JvmName("offsetToFloat") - private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y - } - -@OptIn(ExperimentalMaterialApi::class) -private fun BottomSheetScaffoldAnchorChangeCallback( - state: BottomSheetState, - scope: CoroutineScope -) = - AnchorChangedCallback { prevTarget, prevAnchors, newAnchors -> - val previousTargetOffset = prevAnchors[prevTarget] - val newTarget = - when (prevTarget) { - Collapsed -> Collapsed - Expanded -> if (newAnchors.containsKey(Expanded)) Expanded else Collapsed - } - val newTargetOffset = newAnchors.getValue(newTarget) - if (newTargetOffset != previousTargetOffset) { - if (state.isAnimationRunning) { - // Re-target the animation to the new offset if it changed - scope.launch { state.animateTo(newTarget, velocity = state.lastVelocity) } - } else { - // Snap to the new offset value of the target if no animation was running - val didSnapSynchronously = state.trySnapTo(newTarget) - if (!didSnapSynchronously) scope.launch { state.snapTo(newTarget) } - } - } - } - -private val BottomSheetScaffoldPositionalThreshold = 56.dp -private val BottomSheetScaffoldVelocityThreshold = 125.dp diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/bottomsheet/InternalMutatorMutex.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/bottomsheet/InternalMutatorMutex.kt deleted file mode 100644 index 756141b45..000000000 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/bottomsheet/InternalMutatorMutex.kt +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright 2020 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 dev.sasikanth.rss.reader.components.bottomsheet - -import androidx.compose.foundation.MutatePriority -import androidx.compose.runtime.Stable -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Job -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -/** - * This is an internal copy of androidx.compose.foundation.MutatorMutex with an additional tryMutate - * method. Do not modify, except for tryMutate. ** - */ -expect class InternalAtomicReference(value: V) { - fun get(): V - - fun set(value: V) - - fun compareAndSet(expect: V, newValue: V): Boolean -} - -/** - * Mutual exclusion for UI state mutation over time. - * - * [mutate] permits interruptible state mutation over time using a standard [MutatePriority]. A - * [InternalMutatorMutex] enforces that only a single writer can be active at a time for a - * particular state resource. Instead of queueing callers that would acquire the lock like a - * traditional [Mutex], new attempts to [mutate] the guarded state will either cancel the current - * mutator or if the current mutator has a higher priority, the new caller will throw - * [CancellationException]. - * - * [InternalMutatorMutex] should be used for implementing hoisted state objects that many mutators - * may want to manipulate over time such that those mutators can coordinate with one another. The - * [InternalMutatorMutex] instance should be hidden as an implementation detail. For example: - */ -@Stable -internal class InternalMutatorMutex { - private class Mutator(val priority: MutatePriority, val job: Job) { - fun canInterrupt(other: Mutator) = priority >= other.priority - - fun cancel() = job.cancel() - } - - private val currentMutator = InternalAtomicReference(null) - private val mutex = Mutex() - - private fun tryMutateOrCancel(mutator: Mutator) { - while (true) { - val oldMutator = currentMutator.get() - if (oldMutator == null || mutator.canInterrupt(oldMutator)) { - if (currentMutator.compareAndSet(oldMutator, mutator)) { - oldMutator?.cancel() - break - } - } else throw CancellationException("Current mutation had a higher priority") - } - } - - /** - * Enforce that only a single caller may be active at a time. - * - * If [mutate] is called while another call to [mutate] or [mutateWith] is in progress, their - * [priority] values are compared. If the new caller has a [priority] equal to or higher than the - * call in progress, the call in progress will be cancelled, throwing [CancellationException] and - * the new caller's [block] will be invoked. If the call in progress had a higher [priority] than - * the new caller, the new caller will throw [CancellationException] without invoking [block]. - * - * @param priority the priority of this mutation; [MutatePriority.Default] by default. Higher - * priority mutations will interrupt lower priority mutations. - * @param block mutation code to run mutually exclusive with any other call to [mutate], - * [mutateWith] or [tryMutate]. - */ - suspend fun mutate( - priority: MutatePriority = MutatePriority.Default, - block: suspend () -> R - ) = coroutineScope { - val mutator = Mutator(priority, coroutineContext[Job]!!) - - tryMutateOrCancel(mutator) - - mutex.withLock { - try { - block() - } finally { - currentMutator.compareAndSet(mutator, null) - } - } - } - - /** - * Enforce that only a single caller may be active at a time. - * - * If [mutateWith] is called while another call to [mutate] or [mutateWith] is in progress, their - * [priority] values are compared. If the new caller has a [priority] equal to or higher than the - * call in progress, the call in progress will be cancelled, throwing [CancellationException] and - * the new caller's [block] will be invoked. If the call in progress had a higher [priority] than - * the new caller, the new caller will throw [CancellationException] without invoking [block]. - * - * This variant of [mutate] calls its [block] with a [receiver], removing the need to create an - * additional capturing lambda to invoke it with a receiver object. This can be used to expose a - * mutable scope to the provided [block] while leaving the rest of the state object read-only. For - * example: - * - * @param receiver the receiver `this` that [block] will be called with - * @param priority the priority of this mutation; [MutatePriority.Default] by default. Higher - * priority mutations will interrupt lower priority mutations. - * @param block mutation code to run mutually exclusive with any other call to [mutate], - * [mutateWith] or [tryMutate]. - */ - suspend fun mutateWith( - receiver: T, - priority: MutatePriority = MutatePriority.Default, - block: suspend T.() -> R - ) = coroutineScope { - val mutator = Mutator(priority, coroutineContext[Job]!!) - - tryMutateOrCancel(mutator) - - mutex.withLock { - try { - receiver.block() - } finally { - currentMutator.compareAndSet(mutator, null) - } - } - } - - /** - * Attempt to mutate synchronously if there is no other active caller. If there is no other active - * caller, the [block] will be executed in a lock. If there is another active caller, this method - * will return false, indicating that the active caller needs to be cancelled through a [mutate] - * or [mutateWith] call with an equal or higher mutation priority. - * - * Calls to [mutate] and [mutateWith] will suspend until execution of the [block] has finished. - * - * @param block mutation code to run mutually exclusive with any other call to [mutate], - * [mutateWith] or [tryMutate]. - * @return true if the [block] was executed, false if there was another active caller and the - * [block] was not executed. - */ - fun tryMutate(block: () -> Unit): Boolean { - val didLock = mutex.tryLock() - if (didLock) { - try { - block() - } finally { - mutex.unlock() - } - } - return didLock - } -} diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedsBottomSheet.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedsBottomSheet.kt index 3f3104769..c904a472b 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedsBottomSheet.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedsBottomSheet.kt @@ -21,22 +21,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import app.cash.paging.compose.collectAsLazyPagingItems -import dev.sasikanth.rss.reader.components.bottomsheet.BottomSheetState import dev.sasikanth.rss.reader.feeds.FeedsEffect import dev.sasikanth.rss.reader.feeds.FeedsEvent import dev.sasikanth.rss.reader.feeds.FeedsPresenter import dev.sasikanth.rss.reader.feeds.ui.expanded.BottomSheetExpandedContent -import dev.sasikanth.rss.reader.feeds.ui.expanded.pinnedSources import dev.sasikanth.rss.reader.utils.inverse @Composable internal fun FeedsBottomSheet( feedsPresenter: FeedsPresenter, - bottomSheetState: BottomSheetState, + bottomSheetProgress: Float, closeSheet: () -> Unit, selectedFeedChanged: () -> Unit ) { @@ -51,9 +48,6 @@ internal fun FeedsBottomSheet( } } - val bottomSheetProgress = - remember(bottomSheetState.offsetProgress) { bottomSheetState.offsetProgress } - Column(modifier = Modifier.fillMaxSize()) { BottomSheetHandle(bottomSheetProgress) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomeEvent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomeEvent.kt index 0b071cd15..06bdc4b00 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomeEvent.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomeEvent.kt @@ -18,7 +18,7 @@ package dev.sasikanth.rss.reader.home import androidx.compose.material.ExperimentalMaterialApi -import dev.sasikanth.rss.reader.components.bottomsheet.BottomSheetValue +import androidx.compose.material3.SheetValue import dev.sasikanth.rss.reader.core.model.local.PostWithMetadata import dev.sasikanth.rss.reader.home.ui.PostsType @@ -32,7 +32,7 @@ sealed interface HomeEvent { data class OnPostSourceClicked(val feedId: String) : HomeEvent - data class FeedsSheetStateChanged(val feedsSheetState: BottomSheetValue) : HomeEvent + data class FeedsSheetStateChanged(val feedsSheetState: SheetValue) : HomeEvent data object OnHomeSelected : HomeEvent diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomePresenter.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomePresenter.kt index 9c3a4f451..865488b85 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomePresenter.kt @@ -18,6 +18,7 @@ package dev.sasikanth.rss.reader.home import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material3.SheetValue import app.cash.paging.cachedIn import app.cash.paging.createPager import app.cash.paging.createPagingConfig @@ -28,7 +29,6 @@ import com.arkivanov.essenty.backhandler.BackCallback import com.arkivanov.essenty.instancekeeper.InstanceKeeper import com.arkivanov.essenty.instancekeeper.getOrCreate import com.arkivanov.essenty.lifecycle.doOnCreate -import dev.sasikanth.rss.reader.components.bottomsheet.BottomSheetValue import dev.sasikanth.rss.reader.core.model.local.Feed import dev.sasikanth.rss.reader.core.model.local.FeedGroup import dev.sasikanth.rss.reader.core.model.local.PostWithMetadata @@ -119,7 +119,7 @@ class HomePresenter( return@BackCallback } - if (state.value.feedsSheetState == BottomSheetValue.Expanded) { + if (state.value.feedsSheetState == SheetValue.Expanded) { dispatch(HomeEvent.BackClicked) return@BackCallback } @@ -149,7 +149,7 @@ class HomePresenter( fun dispatch(event: HomeEvent) { when (event) { is HomeEvent.FeedsSheetStateChanged -> { - backCallback.isEnabled = event.feedsSheetState == BottomSheetValue.Expanded + backCallback.isEnabled = event.feedsSheetState == SheetValue.Expanded } is HomeEvent.SearchClicked -> openSearch() is HomeEvent.BookmarksClicked -> openBookmarks() @@ -321,10 +321,10 @@ class HomePresenter( .launchIn(coroutineScope) } - private fun feedsSheetStateChanged(feedsSheetState: BottomSheetValue) { + private fun feedsSheetStateChanged(feedsSheetState: SheetValue) { _state.update { // Clear search query once feeds sheet is collapsed - if (feedsSheetState == BottomSheetValue.Collapsed) { + if (feedsSheetState == SheetValue.PartiallyExpanded) { feedsPresenter.dispatch(FeedsEvent.ClearSearchQuery) } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomeState.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomeState.kt index eac11ebac..034010c69 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomeState.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomeState.kt @@ -18,10 +18,9 @@ package dev.sasikanth.rss.reader.home import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material3.SheetValue import androidx.compose.runtime.Immutable import app.cash.paging.PagingData -import dev.sasikanth.rss.reader.components.bottomsheet.BottomSheetValue -import dev.sasikanth.rss.reader.components.bottomsheet.BottomSheetValue.Collapsed import dev.sasikanth.rss.reader.core.model.local.PostWithMetadata import dev.sasikanth.rss.reader.core.model.local.Source import dev.sasikanth.rss.reader.home.HomeLoadingState.Loading @@ -34,7 +33,7 @@ internal data class HomeState( val featuredPosts: ImmutableList?, val posts: Flow>?, val loadingState: HomeLoadingState, - val feedsSheetState: BottomSheetValue, + val feedsSheetState: SheetValue, val activeSource: Source?, val featuredItemBlurEnabled: Boolean, val hasFeeds: Boolean?, @@ -48,7 +47,7 @@ internal data class HomeState( featuredPosts = null, posts = null, loadingState = HomeLoadingState.Idle, - feedsSheetState = Collapsed, + feedsSheetState = SheetValue.PartiallyExpanded, activeSource = null, featuredItemBlurEnabled = true, hasFeeds = null, diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedPostItem.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedPostItem.kt index ae030b8d6..361302efe 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedPostItem.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedPostItem.kt @@ -48,6 +48,7 @@ import dev.sasikanth.rss.reader.components.image.AsyncImage import dev.sasikanth.rss.reader.core.model.local.PostWithMetadata import dev.sasikanth.rss.reader.ui.AppTheme import dev.sasikanth.rss.reader.util.relativeDurationString +import dev.sasikanth.rss.reader.utils.Constants import dev.sasikanth.rss.reader.utils.LocalWindowSizeClass private val featuredImageAspectRatio: Float @@ -71,12 +72,14 @@ internal fun FeaturedPostItem( onCommentsClick: () -> Unit, onSourceClick: () -> Unit, onTogglePostReadClick: () -> Unit, + modifier: Modifier = Modifier, ) { Column( modifier = - Modifier.clip(MaterialTheme.shapes.extraLarge) + Modifier.then(modifier) + .clip(MaterialTheme.shapes.extraLarge) .clickable(onClick = onClick) - .alpha(if (item.read) 0.65f else 1f) + .alpha(if (item.read) Constants.ITEM_READ_ALPHA else Constants.ITEM_UNREAD_ALPHA) ) { val density = LocalDensity.current var descriptionBottomPadding by remember(item.link) { mutableStateOf(0.dp) } @@ -117,7 +120,7 @@ internal fun FeaturedPostItem( val lineBottom = textLayoutResult.getLineBottom(0) val lineHeight = with(density) { (lineTop + lineBottom).toDp() } - descriptionBottomPadding = lineHeight + descriptionBottomPadding = lineHeight * (3 - numberOfLines) } } ) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedSection.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedSection.kt index a07f9e1d9..116806610 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedSection.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedSection.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerDefaults @@ -50,7 +51,6 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastForEachIndexed import coil3.size.Size import dev.sasikanth.rss.reader.components.LocalDynamicColorState import dev.sasikanth.rss.reader.components.image.AsyncImage @@ -154,35 +154,47 @@ internal fun FeaturedSection( } } - FeaturedSectionBackground( - featuredPosts = featuredPosts, - pagerState = pagerState, - featuredItemBlurEnabled = featuredItemBlurEnabled - ) - HorizontalPager( state = pagerState, - contentPadding = pagerContentPadding, - pageSpacing = 16.dp, verticalAlignment = Alignment.Top, flingBehavior = PagerDefaults.flingBehavior( state = pagerState, snapAnimationSpec = spring(stiffness = Spring.StiffnessVeryLow) - ) + ), ) { page -> val featuredPost = featuredPosts.getOrNull(page) if (featuredPost != null) { - FeaturedPostItem( - item = featuredPost, - page = page, - pagerState = pagerState, - onClick = { onItemClick(featuredPost) }, - onBookmarkClick = { onPostBookmarkClick(featuredPost) }, - onCommentsClick = { onPostCommentsClick(featuredPost.commentsLink!!) }, - onSourceClick = { onPostSourceClick(featuredPost.sourceId) }, - onTogglePostReadClick = { onTogglePostReadClick(featuredPost.id, featuredPost.read) } - ) + Box { + FeaturedSectionBackground( + post = featuredPost, + featuredItemBlurEnabled = featuredItemBlurEnabled, + modifier = + Modifier.graphicsLayer { + val pageOffset = + if (page in 0..pagerState.pageCount) { + pagerState.getOffsetFractionForPage(page) + } else { + 0f + } + + translationX = size.width * pageOffset + alpha = (1f - pageOffset.absoluteValue) + } + ) + + FeaturedPostItem( + modifier = Modifier.padding(pagerContentPadding), + item = featuredPost, + page = page, + pagerState = pagerState, + onClick = { onItemClick(featuredPost) }, + onBookmarkClick = { onPostBookmarkClick(featuredPost) }, + onCommentsClick = { onPostCommentsClick(featuredPost.commentsLink!!) }, + onSourceClick = { onPostSourceClick(featuredPost.sourceId) }, + onTogglePostReadClick = { onTogglePostReadClick(featuredPost.id, featuredPost.read) } + ) + } } } } @@ -190,18 +202,13 @@ internal fun FeaturedSection( } @Composable -@OptIn(ExperimentalFoundationApi::class) private fun FeaturedSectionBackground( - pagerState: PagerState, - featuredPosts: ImmutableList, + post: PostWithMetadata, featuredItemBlurEnabled: Boolean, modifier: Modifier = Modifier, ) { - // We want the gradient overlay to be displayed above the blur and gradient background, and - // also when overscrolling happens. For that reason we are applying the gradient modifier, - // directly to the individual composables rather than the parent Box. val gradientOverlayModifier = - Modifier.drawWithCache { + Modifier.then(modifier).drawWithCache { val radialGradient = Brush.radialGradient( colors = @@ -221,13 +228,9 @@ private fun FeaturedSectionBackground( } } - Box(modifier = modifier) { + Box { if (canBlurImage && featuredItemBlurEnabled) { - FeaturedSectionBlurredBackground( - featuredPosts = featuredPosts, - pagerState = pagerState, - modifier = gradientOverlayModifier - ) + FeaturedSectionBlurredBackground(post = post, modifier = gradientOverlayModifier) } else { FeaturedSectionGradientBackground(modifier = gradientOverlayModifier) } @@ -257,47 +260,24 @@ private fun FeaturedSectionGradientBackground(modifier: Modifier = Modifier) { } @Composable -@OptIn(ExperimentalFoundationApi::class) private fun FeaturedSectionBlurredBackground( - featuredPosts: ImmutableList, - pagerState: PagerState, + post: PostWithMetadata, modifier: Modifier = Modifier ) { - // We are loading all featured posts images at once to avoid blinking issues that can occur - // due to state changes when we try to do this lazily. Since the alpha is set to 0 for images that - // don't need to be rendered, they are not drawn. If need more featured posts, we can convert this - // to a proper lazy layout. But for 6 items, this is the simplest approach to take. - featuredPosts.fastForEachIndexed { index, post -> - AsyncImage( - url = post.imageUrl!!, - modifier = - Modifier.aspectRatio(featuredImageBackgroundAspectRatio) - .graphicsLayer { - val offsetFraction = - if (index in 0..pagerState.pageCount) { - pagerState.getOffsetFractionForPage(index).absoluteValue.coerceIn(0f, 1f) - } else { - 0f - } - alpha = ((1f - offsetFraction) / 1f) - - val blurRadiusInPx = 100.dp.toPx() - // Since blur can be expensive memory wise, there is no point blurring images when not - // needed. - renderEffect = - if (index in pagerState.settledPage - 2..pagerState.settledPage + 2) { - BlurEffect(blurRadiusInPx, blurRadiusInPx, TileMode.Decal) - } else { - null - } - shape = RectangleShape - clip = false - } - .then(modifier), - contentDescription = null, - contentScale = ContentScale.Crop, - size = Size(128, 128), - backgroundColor = AppTheme.colorScheme.surfaceContainerLowest - ) - } + AsyncImage( + url = post.imageUrl!!, + modifier = + Modifier.aspectRatio(featuredImageBackgroundAspectRatio) + .graphicsLayer { + val blurRadiusInPx = 100.dp.toPx() + renderEffect = BlurEffect(blurRadiusInPx, blurRadiusInPx, TileMode.Decal) + shape = RectangleShape + clip = false + } + .then(modifier), + contentDescription = null, + contentScale = ContentScale.Crop, + size = Size(128, 128), + backgroundColor = AppTheme.colorScheme.surfaceContainerLowest + ) } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/HomeScreen.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/HomeScreen.kt index 6285ae76c..dc61c1705 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/HomeScreen.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/HomeScreen.kt @@ -23,18 +23,15 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -45,10 +42,16 @@ import androidx.compose.material.icons.rounded.KeyboardArrowUp import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -57,21 +60,19 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastMaxBy import androidx.paging.LoadState import app.cash.paging.compose.collectAsLazyPagingItems import dev.sasikanth.rss.reader.components.CompactFloatingActionButton import dev.sasikanth.rss.reader.components.LocalDynamicColorState -import dev.sasikanth.rss.reader.components.bottomsheet.BottomSheetScaffold -import dev.sasikanth.rss.reader.components.bottomsheet.rememberBottomSheetScaffoldState -import dev.sasikanth.rss.reader.components.bottomsheet.rememberBottomSheetState -import dev.sasikanth.rss.reader.core.model.local.PostWithMetadata import dev.sasikanth.rss.reader.feeds.ui.FeedsBottomSheet import dev.sasikanth.rss.reader.home.HomeEffect import dev.sasikanth.rss.reader.home.HomeEvent import dev.sasikanth.rss.reader.home.HomePresenter -import dev.sasikanth.rss.reader.home.HomeState import dev.sasikanth.rss.reader.platform.LocalLinkHandler import dev.sasikanth.rss.reader.resources.icons.Feed import dev.sasikanth.rss.reader.resources.icons.TwineIcons @@ -92,9 +93,9 @@ internal fun HomeScreen(homePresenter: HomePresenter, modifier: Modifier = Modif val feedsState by homePresenter.feedsPresenter.state.collectAsState() val bottomSheetState = - rememberBottomSheetState( - state.feedsSheetState, - confirmStateChange = { + rememberStandardBottomSheetState( + initialValue = state.feedsSheetState, + confirmValueChange = { homePresenter.dispatch(HomeEvent.FeedsSheetStateChanged(it)) true } @@ -105,159 +106,172 @@ internal fun HomeScreen(homePresenter: HomePresenter, modifier: Modifier = Modif val listState = rememberLazyListState() val featuredPostsPagerState = rememberPagerState(pageCount = { state.featuredPosts?.size ?: 0 }) - val bottomSheetCornerSize = remember { - BOTTOM_SHEET_CORNER_SIZE * bottomSheetState.offsetProgress.inverse() - } - val linkHandler = LocalLinkHandler.current LaunchedEffect(Unit) { homePresenter.effects.collectLatest { effect -> when (effect) { HomeEffect.MinimizeSheet -> { - bottomSheetState.collapse() + bottomSheetState.partialExpand() } } } } - Box(modifier = modifier) { - BottomSheetScaffold( - scaffoldState = bottomSheetScaffoldState, - topBar = { - HomeTopAppBar( - source = state.activeSource, - postsType = state.postsType, - listState = listState, - onSearchClicked = { homePresenter.dispatch(HomeEvent.SearchClicked) }, - onBookmarksClicked = { homePresenter.dispatch(HomeEvent.BookmarksClicked) }, - onSettingsClicked = { homePresenter.dispatch(HomeEvent.SettingsClicked) }, - onPostTypeChanged = { homePresenter.dispatch(HomeEvent.OnPostsTypeChanged(it)) } - ) - }, - content = { paddingValues -> - HomeScreenContent( - paddingValues = paddingValues, - state = state, - listState = listState, - featuredPostsPagerState = featuredPostsPagerState, - onSwipeToRefresh = { homePresenter.dispatch(HomeEvent.OnSwipeToRefresh) }, - onPostClicked = { homePresenter.dispatch(HomeEvent.OnPostClicked(it)) }, - onPostBookmarkClick = { homePresenter.dispatch(HomeEvent.OnPostBookmarkClick(it)) }, - onPostCommentsClick = { commentsLink -> - coroutineScope.launch { linkHandler.openLink(commentsLink) } - }, - onPostSourceClick = { feedId -> - homePresenter.dispatch(HomeEvent.OnPostSourceClicked(feedId)) - }, - onNoFeedsSwipeUp = { coroutineScope.launch { bottomSheetState.expand() } }, - onTogglePostReadStatus = { postId, postRead -> - homePresenter.dispatch(HomeEvent.TogglePostReadStatus(postId, postRead)) + val bottomSheetProgress by bottomSheetState.progress() + val showScrollToTop by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } + + val sheetPeekHeight = + BOTTOM_SHEET_PEEK_HEIGHT + + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + BottomSheetScaffold( + modifier = modifier, + scaffoldState = bottomSheetScaffoldState, + content = { _ -> + Box { + val featuredPosts = state.featuredPosts + val posts = state.posts?.collectAsLazyPagingItems() + val hasFeeds = state.hasFeeds + val dynamicColorState = LocalDynamicColorState.current + + LaunchedEffect(featuredPosts) { + if (featuredPosts.isNullOrEmpty()) { + dynamicColorState.reset() } - ) - }, - sheetContent = { - FeedsBottomSheet( - feedsPresenter = homePresenter.feedsPresenter, - bottomSheetState = bottomSheetState, - closeSheet = { coroutineScope.launch { bottomSheetState.collapse() } }, - selectedFeedChanged = { - coroutineScope.launch { - listState.scrollToItem(0) - featuredPostsPagerState.scrollToPage(0) + } + + val swipeRefreshState = + rememberPullRefreshState( + refreshing = state.isRefreshing, + onRefresh = { homePresenter.dispatch(HomeEvent.OnSwipeToRefresh) } + ) + val canSwipeToRefresh = hasFeeds == true + + HomeScreenContentLayout( + modifier = Modifier.pullRefresh(state = swipeRefreshState, enabled = canSwipeToRefresh), + homeTopAppBar = { + HomeTopAppBar( + source = state.activeSource, + postsType = state.postsType, + listState = listState, + onSearchClicked = { homePresenter.dispatch(HomeEvent.SearchClicked) }, + onBookmarksClicked = { homePresenter.dispatch(HomeEvent.BookmarksClicked) }, + onSettingsClicked = { homePresenter.dispatch(HomeEvent.SettingsClicked) }, + onPostTypeChanged = { homePresenter.dispatch(HomeEvent.OnPostsTypeChanged(it)) } + ) + }, + body = { paddingValues -> + Box { + when { + hasFeeds == null || (posts == null || featuredPosts == null) -> { + // no-op + } + featuredPosts.isNotEmpty() || + (posts.itemCount > 0 || posts.loadState.refresh == LoadState.Loading) -> { + PostsList( + paddingValues = paddingValues, + featuredPosts = featuredPosts, + posts = posts, + featuredItemBlurEnabled = state.featuredItemBlurEnabled, + listState = listState, + featuredPostsPagerState = featuredPostsPagerState, + onPostClicked = { homePresenter.dispatch(HomeEvent.OnPostClicked(it)) }, + onPostBookmarkClick = { + homePresenter.dispatch(HomeEvent.OnPostBookmarkClick(it)) + }, + onPostCommentsClick = { commentsLink -> + coroutineScope.launch { linkHandler.openLink(commentsLink) } + }, + onPostSourceClick = { feedId -> + homePresenter.dispatch(HomeEvent.OnPostSourceClicked(feedId)) + }, + onTogglePostReadClick = { postId, postRead -> + homePresenter.dispatch(HomeEvent.TogglePostReadStatus(postId, postRead)) + } + ) + } + !hasFeeds -> { + NoFeeds { coroutineScope.launch { bottomSheetState.expand() } } + } + featuredPosts.isEmpty() && posts.itemCount == 0 -> { + NoNewPosts() + } + } + + PullRefreshIndicator( + refreshing = state.isRefreshing, + state = swipeRefreshState, + modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars) + ) } - } + }, ) - }, - floatingActionButton = { - val showScrollToTop by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } CompactFloatingActionButton( label = LocalStrings.current.scrollToTop, visible = showScrollToTop, - modifier = - Modifier.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) - .padding(end = 16.dp, bottom = 16.dp), + modifier = Modifier.padding(end = 16.dp, bottom = sheetPeekHeight + 16.dp), ) { listState.animateScrollToItem(0) } - }, - backgroundColor = AppTheme.colorScheme.surfaceContainerLowest, - sheetBackgroundColor = AppTheme.colorScheme.tintedBackground, - sheetContentColor = AppTheme.colorScheme.tintedForeground, - sheetElevation = 0.dp, - sheetPeekHeight = BOTTOM_SHEET_PEEK_HEIGHT, - sheetShape = - RoundedCornerShape(topStart = bottomSheetCornerSize, topEnd = bottomSheetCornerSize), - sheetGesturesEnabled = !feedsState.isInMultiSelectMode - ) - } + } + }, + sheetContent = { + FeedsBottomSheet( + feedsPresenter = homePresenter.feedsPresenter, + bottomSheetProgress = bottomSheetProgress, + closeSheet = { coroutineScope.launch { bottomSheetState.partialExpand() } }, + selectedFeedChanged = { + coroutineScope.launch { + listState.scrollToItem(0) + featuredPostsPagerState.scrollToPage(0) + } + } + ) + }, + containerColor = AppTheme.colorScheme.surfaceContainerLowest, + sheetContainerColor = AppTheme.colorScheme.tintedBackground, + sheetContentColor = AppTheme.colorScheme.tintedForeground, + sheetShadowElevation = 0.dp, + sheetTonalElevation = 0.dp, + sheetPeekHeight = sheetPeekHeight, + sheetShape = + RoundedCornerShape( + topStart = BOTTOM_SHEET_CORNER_SIZE * bottomSheetProgress.inverse(), + topEnd = BOTTOM_SHEET_CORNER_SIZE * bottomSheetProgress.inverse() + ), + sheetSwipeEnabled = !feedsState.isInMultiSelectMode, + sheetDragHandle = null + ) } @Composable -@OptIn(ExperimentalFoundationApi::class) -private fun HomeScreenContent( - paddingValues: PaddingValues, - state: HomeState, - listState: LazyListState, - featuredPostsPagerState: PagerState, - onSwipeToRefresh: () -> Unit, - onPostClicked: (PostWithMetadata) -> Unit, - onPostBookmarkClick: (PostWithMetadata) -> Unit, - onPostCommentsClick: (String) -> Unit, - onPostSourceClick: (String) -> Unit, - onNoFeedsSwipeUp: () -> Unit, - onTogglePostReadStatus: (String, Boolean) -> Unit, +private fun HomeScreenContentLayout( + homeTopAppBar: @Composable () -> Unit, + body: @Composable (PaddingValues) -> Unit, + modifier: Modifier = Modifier, ) { - val featuredPosts = state.featuredPosts - val posts = state.posts?.collectAsLazyPagingItems() - val hasFeeds = state.hasFeeds - val dynamicColorState = LocalDynamicColorState.current + SubcomposeLayout( + modifier = Modifier.fillMaxSize().then(modifier), + ) { constraints -> + val layoutWidth = constraints.maxWidth + val layoutHeight = constraints.maxHeight + val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) - LaunchedEffect(featuredPosts) { - if (featuredPosts.isNullOrEmpty()) { - dynamicColorState.reset() - } - } + val topBarPlaceables = + subcompose("topBar") { homeTopAppBar() }.map { it.measure(looseConstraints) } + val topBarHeight = topBarPlaceables.fastMaxBy { it.height }?.height ?: 0 - val swipeRefreshState = - rememberPullRefreshState(refreshing = state.isRefreshing, onRefresh = onSwipeToRefresh) - val canSwipeToRefresh = hasFeeds == true + val bodyConstraints = looseConstraints.copy(maxHeight = layoutHeight) + val bodyPlaceables = + subcompose("body") { body(PaddingValues(top = topBarHeight.toDp())) } + .map { it.measure(bodyConstraints) } - Box(Modifier.fillMaxSize().pullRefresh(state = swipeRefreshState, enabled = canSwipeToRefresh)) { - when { - hasFeeds == null || (posts == null || featuredPosts == null) -> { - // no-op - } - featuredPosts.isNotEmpty() || - (posts.itemCount > 0 || posts.loadState.refresh == LoadState.Loading) -> { - PostsList( - paddingValues = paddingValues, - featuredPosts = featuredPosts, - posts = posts, - featuredItemBlurEnabled = state.featuredItemBlurEnabled, - listState = listState, - featuredPostsPagerState = featuredPostsPagerState, - onPostClicked = onPostClicked, - onPostBookmarkClick = onPostBookmarkClick, - onPostCommentsClick = onPostCommentsClick, - onPostSourceClick = onPostSourceClick, - onTogglePostReadClick = onTogglePostReadStatus - ) - } - !hasFeeds -> { - NoFeeds(onNoFeedsSwipeUp) - } - featuredPosts.isEmpty() && posts.itemCount == 0 -> { - NoNewPosts() - } + layout(layoutWidth, layoutHeight) { + bodyPlaceables.fastForEach { it.placeRelative(0, 0) } + topBarPlaceables.fastForEach { it.placeRelative(0, 0) } } - - PullRefreshIndicator( - refreshing = state.isRefreshing, - state = swipeRefreshState, - modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars).align(Alignment.TopCenter) - ) } } @@ -336,3 +350,19 @@ private fun NoNewPosts() { ) } } + +@Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") +private fun SheetState.progress(): State { + return derivedStateOf { + when { + currentValue == SheetValue.Expanded && targetValue == SheetValue.Expanded -> 1f + currentValue == SheetValue.Expanded && targetValue == SheetValue.PartiallyExpanded -> + 1f - anchoredDraggableState.progress + currentValue == SheetValue.PartiallyExpanded && targetValue == SheetValue.PartiallyExpanded -> + if (anchoredDraggableState.progress == 1f) 0f else anchoredDraggableState.progress + currentValue == SheetValue.PartiallyExpanded && targetValue == SheetValue.Expanded -> + anchoredDraggableState.progress + else -> 0f + } + } +} diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt index f2a30a9d4..c6b867b14 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt @@ -52,6 +52,7 @@ import dev.sasikanth.rss.reader.components.image.AsyncImage import dev.sasikanth.rss.reader.core.model.local.PostWithMetadata import dev.sasikanth.rss.reader.ui.AppTheme import dev.sasikanth.rss.reader.util.relativeDurationString +import dev.sasikanth.rss.reader.utils.Constants import dev.sasikanth.rss.reader.utils.LocalWindowSizeClass import kotlinx.collections.immutable.ImmutableList @@ -147,7 +148,10 @@ fun PostListItem( Modifier.clickable(onClick = onClick) .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) .padding(postListPadding) - .alpha(if (item.read && reduceReadItemAlpha) 0.65f else 1f) + .alpha( + if (item.read && reduceReadItemAlpha) Constants.ITEM_READ_ALPHA + else Constants.ITEM_UNREAD_ALPHA + ) ) { Row( modifier = Modifier.padding(start = 24.dp, top = 20.dp, end = 24.dp), diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ui/ReaderScreen.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ui/ReaderScreen.kt index 8ca78bf13..4e5e3b01f 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ui/ReaderScreen.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ui/ReaderScreen.kt @@ -66,6 +66,7 @@ import dev.sasikanth.rss.reader.resources.icons.Bookmarked import dev.sasikanth.rss.reader.resources.icons.Share import dev.sasikanth.rss.reader.resources.icons.TwineIcons import dev.sasikanth.rss.reader.resources.icons.Website +import dev.sasikanth.rss.reader.resources.strings.LocalStrings import dev.sasikanth.rss.reader.share.LocalShareHandler import dev.sasikanth.rss.reader.ui.AppTheme import dev.sasikanth.rss.reader.util.DispatchersProvider @@ -92,7 +93,7 @@ internal fun ReaderScreen( title = {}, navigationIcon = { IconButton(onClick = { presenter.dispatch(ReaderEvent.BackClicked) }) { - Icon(TwineIcons.ArrowBack, contentDescription = null) + Icon(TwineIcons.ArrowBack, contentDescription = LocalStrings.current.buttonGoBack) } }, colors = @@ -130,7 +131,7 @@ internal fun ReaderScreen( TwineIcons.Bookmark } IconButton(onClick = { presenter.dispatch(ReaderEvent.TogglePostBookmark) }) { - Icon(bookmarkIcon, contentDescription = null) + Icon(bookmarkIcon, contentDescription = LocalStrings.current.bookmark) } } @@ -159,7 +160,11 @@ internal fun ReaderScreen( coroutineScope.launch { presenter.dispatch(ReaderEvent.ArticleShortcutClicked) } } ) { - Icon(TwineIcons.ArticleShortcut, contentDescription = null, tint = iconTint) + Icon( + TwineIcons.ArticleShortcut, + contentDescription = LocalStrings.current.cdLoadFullArticle, + tint = iconTint + ) } } InProgress -> { @@ -176,14 +181,14 @@ internal fun ReaderScreen( Icon( modifier = Modifier.requiredSize(24.dp), imageVector = TwineIcons.Website, - contentDescription = null + contentDescription = LocalStrings.current.openWebsite ) } } Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { IconButton(onClick = { coroutineScope.launch { sharedHandler.share(state.link!!) } }) { - Icon(TwineIcons.Share, contentDescription = null) + Icon(TwineIcons.Share, contentDescription = LocalStrings.current.share) } } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/utils/Constants.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/utils/Constants.kt index 067c2f7f3..6409e3550 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/utils/Constants.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/utils/Constants.kt @@ -40,4 +40,7 @@ internal object Constants { const val MINIMUM_REQUIRED_SEARCH_CHARACTERS = 3 const val BADGE_COUNT_TRIM_LIMIT = 99 + + const val ITEM_READ_ALPHA = 0.65f + const val ITEM_UNREAD_ALPHA = 1f } diff --git a/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/components/bottomsheet/ActualIOS.kt b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/components/bottomsheet/ActualIOS.kt deleted file mode 100644 index 972e292e7..000000000 --- a/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/components/bottomsheet/ActualIOS.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2023 Sasikanth Miriyampalli - * - * 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 dev.sasikanth.rss.reader.components.bottomsheet - -import kotlinx.atomicfu.atomic - -actual class InternalAtomicReference actual constructor(value: V) { - - private val atomicReference = atomic(value) - - actual fun get(): V { - return atomicReference.value - } - - actual fun set(value: V) { - atomicReference.value = value - } - - actual fun compareAndSet(expect: V, newValue: V): Boolean { - return atomicReference.compareAndSet(expect, newValue) - } -}