Merge tag '13.10'

pull/2945/head
Alex Baker 3 months ago
commit 33798b3255

@ -2,7 +2,7 @@
* New icon picker with over 2,100 icons! (pro feature)
### 13.10 (2024-06-30)
### 13.10 (2024-07-05)
* Add search bar to drawer
* Add search bar to list picker

@ -8,9 +8,17 @@ package com.todoroo.astrid.activity
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.IntentCompat.getParcelableExtra
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -34,12 +42,16 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.tasks.BuildConfig
import org.tasks.R
import org.tasks.Tasks
import org.tasks.activities.GoogleTaskListSettingsActivity
import org.tasks.activities.TagSettingsActivity
import org.tasks.analytics.Firebase
import org.tasks.billing.Inventory
import org.tasks.billing.PurchaseActivity
import org.tasks.caldav.BaseCaldavCalendarSettingsActivity
import org.tasks.compose.drawer.TasksMenu
import org.tasks.compose.drawer.DrawerAction
import org.tasks.compose.drawer.DrawerItem
import org.tasks.compose.drawer.TaskListDrawer
import org.tasks.data.dao.AlarmDao
import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.LocationDao
@ -51,7 +63,9 @@ import org.tasks.data.listSettingsClass
import org.tasks.databinding.TaskListActivityBinding
import org.tasks.dialogs.NewFilterDialog
import org.tasks.dialogs.WhatsNewDialog
import org.tasks.extensions.Context.findActivity
import org.tasks.extensions.Context.nightMode
import org.tasks.extensions.Context.openUri
import org.tasks.extensions.hideKeyboard
import org.tasks.filters.Filter
import org.tasks.filters.FilterProvider
@ -60,6 +74,8 @@ import org.tasks.filters.PlaceFilter
import org.tasks.location.LocationPickerActivity
import org.tasks.location.LocationPickerActivity.Companion.EXTRA_PLACE
import org.tasks.preferences.DefaultFilterProvider
import org.tasks.preferences.HelpAndFeedback
import org.tasks.preferences.MainPreferences
import org.tasks.preferences.Preferences
import org.tasks.themes.ColorProvider
import org.tasks.themes.TasksTheme
@ -94,6 +110,7 @@ class MainActivity : AppCompatActivity() {
/** @see android.app.Activity.onCreate
*/
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
theme.applyTheme(this)
@ -105,59 +122,150 @@ class MainActivity : AppCompatActivity() {
handleIntent()
binding.composeView.setContent {
val state = viewModel.state.collectAsStateWithLifecycle().value
if (state.drawerOpen) {
if (viewModel.drawerOpen.collectAsStateWithLifecycle().value) {
TasksTheme {
TasksMenu(
items = if (state.menuQuery.isNotEmpty()) state.searchItems else state.drawerItems,
begForMoney = state.begForMoney,
isTopAppBar = preferences.isTopAppBar,
setFilter = { viewModel.setFilter(it) },
toggleCollapsed = { viewModel.toggleCollapsed(it) },
addFilter = {
when (it.addIntentRc) {
FilterProvider.REQUEST_NEW_FILTER ->
NewFilterDialog.newFilterDialog().show(
supportFragmentManager,
SubheaderClickHandler.FRAG_TAG_NEW_FILTER
)
REQUEST_NEW_PLACE ->
startActivityForResult(
Intent(this, LocationPickerActivity::class.java),
REQUEST_NEW_PLACE
)
REQUEST_NEW_TAGS ->
startActivityForResult(
Intent(this, TagSettingsActivity::class.java),
REQUEST_NEW_LIST
)
REQUEST_NEW_LIST -> lifecycleScope.launch {
val account = caldavDao.getAccount(it.id) ?: return@launch
when (it.subheaderType) {
NavigationDrawerSubheader.SubheaderType.GOOGLE_TASKS ->
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true,
confirmValueChange = { true },
)
ModalBottomSheet(
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
onDismissRequest = { viewModel.closeDrawer() },
) {
val state = viewModel.state.collectAsStateWithLifecycle().value
val context = LocalContext.current
val settingsRequest = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
context.findActivity()?.recreate()
}
val scope = rememberCoroutineScope()
TaskListDrawer(
begForMoney = state.begForMoney,
filters = if (state.menuQuery.isNotEmpty()) state.searchItems else state.drawerItems,
onClick = {
when (it) {
is DrawerItem.Filter -> {
viewModel.setFilter(it.filter)
scope.launch(Dispatchers.Default) {
sheetState.hide()
viewModel.closeDrawer()
}
}
is DrawerItem.Header -> {
viewModel.toggleCollapsed(it.header)
}
}
},
onAddClick = {
scope.launch(Dispatchers.Default) {
sheetState.hide()
viewModel.closeDrawer()
when (it.header.addIntentRc) {
FilterProvider.REQUEST_NEW_FILTER ->
NewFilterDialog.newFilterDialog().show(
supportFragmentManager,
SubheaderClickHandler.FRAG_TAG_NEW_FILTER
)
REQUEST_NEW_PLACE ->
startActivityForResult(
Intent(this@MainActivity, GoogleTaskListSettingsActivity::class.java)
.putExtra(GoogleTaskListSettingsActivity.EXTRA_ACCOUNT, account),
REQUEST_NEW_LIST
Intent(
this@MainActivity,
LocationPickerActivity::class.java
),
REQUEST_NEW_PLACE
)
NavigationDrawerSubheader.SubheaderType.CALDAV,
NavigationDrawerSubheader.SubheaderType.TASKS,
NavigationDrawerSubheader.SubheaderType.ETESYNC ->
REQUEST_NEW_TAGS ->
startActivityForResult(
Intent(this@MainActivity, account.listSettingsClass())
.putExtra(BaseCaldavCalendarSettingsActivity.EXTRA_CALDAV_ACCOUNT, account),
Intent(
this@MainActivity,
TagSettingsActivity::class.java
),
REQUEST_NEW_LIST
)
else -> {}
REQUEST_NEW_LIST -> lifecycleScope.launch {
val account =
caldavDao.getAccount(it.header.id) ?: return@launch
when (it.header.subheaderType) {
NavigationDrawerSubheader.SubheaderType.GOOGLE_TASKS ->
startActivityForResult(
Intent(
this@MainActivity,
GoogleTaskListSettingsActivity::class.java
)
.putExtra(
GoogleTaskListSettingsActivity.EXTRA_ACCOUNT,
account
),
REQUEST_NEW_LIST
)
NavigationDrawerSubheader.SubheaderType.CALDAV,
NavigationDrawerSubheader.SubheaderType.TASKS,
NavigationDrawerSubheader.SubheaderType.ETESYNC ->
startActivityForResult(
Intent(
this@MainActivity,
account.listSettingsClass()
)
.putExtra(
BaseCaldavCalendarSettingsActivity.EXTRA_CALDAV_ACCOUNT,
account
),
REQUEST_NEW_LIST
)
else -> {}
}
}
else -> Timber.e("Unhandled request code: $it")
}
}
else -> Timber.e("Unhandled request code: $it")
}
},
dismiss = { viewModel.setDrawerOpen(false) },
query = state.menuQuery,
onQueryChange = { viewModel.queryMenu(it) },
)
},
onDrawerAction = {
viewModel.closeDrawer()
when (it) {
DrawerAction.PURCHASE ->
if (Tasks.IS_GENERIC)
context.openUri(R.string.url_donate)
else
context.startActivity(
Intent(
context,
PurchaseActivity::class.java
)
)
DrawerAction.SETTINGS ->
settingsRequest.launch(
Intent(
context,
MainPreferences::class.java
)
)
DrawerAction.HELP_AND_FEEDBACK ->
context.startActivity(
Intent(
context,
HelpAndFeedback::class.java
)
)
}
},
onErrorClick = {
context.startActivity(Intent(context, MainPreferences::class.java))
},
query = state.menuQuery,
onQueryChange = { viewModel.queryMenu(it) },
)
}
}
}
}
@ -272,7 +380,7 @@ class MainActivity : AppCompatActivity() {
private fun clearUi() {
actionMode?.finish()
actionMode = null
viewModel.setDrawerOpen(false)
viewModel.closeDrawer()
}
private suspend fun getTaskToLoad(filter: Filter?): Task? = when {

@ -55,12 +55,14 @@ class MainActivityViewModel @Inject constructor(
val begForMoney: Boolean = false,
val filter: Filter,
val task: Task? = null,
val drawerOpen: Boolean = false,
val drawerItems: ImmutableList<DrawerItem> = persistentListOf(),
val searchItems: ImmutableList<DrawerItem> = persistentListOf(),
val menuQuery: String = "",
)
private val _drawerOpen = MutableStateFlow(false)
val drawerOpen = _drawerOpen.asStateFlow()
private val _state = MutableStateFlow(
State(
filter = savedStateHandle.get<Filter>(OPEN_FILTER)
@ -103,13 +105,13 @@ class MainActivityViewModel @Inject constructor(
defaultFilterProvider.setLastViewedFilter(filter)
}
fun setDrawerOpen(open: Boolean) {
_state.update {
it.copy(
drawerOpen = open,
menuQuery = if (!open) "" else it.menuQuery,
)
}
fun closeDrawer() {
_drawerOpen.update { false }
_state.update { it.copy(menuQuery = "") }
}
fun openDrawer() {
_drawerOpen.update { true }
}
init {
@ -140,7 +142,7 @@ class MainActivityViewModel @Inject constructor(
},
selected = item.areItemsTheSame(selected),
shareCount = if (item is CaldavFilter) item.principals else 0,
type = { item },
filter = item,
)
is NavigationDrawerSubheader ->
DrawerItem.Header(
@ -148,7 +150,7 @@ class MainActivityViewModel @Inject constructor(
collapsed = item.isCollapsed,
hasError = item.error,
canAdd = item.addIntentRc != 0,
type = { item },
header = item,
)
else -> throw IllegalArgumentException()
}
@ -171,7 +173,7 @@ class MainActivityViewModel @Inject constructor(
},
selected = item.areItemsTheSame(selected),
shareCount = if (item is CaldavFilter) item.principals else 0,
type = { item },
filter = item,
)
}
.let { filters -> _state.update { it.copy(searchItems = filters.toPersistentList()) } }

@ -353,7 +353,7 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
toolbar.setOnMenuItemClickListener(this)
toolbar.setNavigationOnClickListener {
activity?.hideKeyboard()
mainViewModel.setDrawerOpen(true)
mainViewModel.openDrawer()
}
setupMenu(toolbar)
binding.banner.setContent {

@ -1,791 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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 org.tasks.compose.drawer
/**
* This is a copy of androidx.compose.foundation.gestures.AnchoredDraggable until that API is
* promoted to stable in foundation. Any changes there should be replicated here.
*/
import androidx.annotation.FloatRange
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.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.Modifier
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlin.math.abs
/**
* Structure that represents the anchors of a [AnchoredDraggableState].
*
* See the DraggableAnchors factory method to construct drag anchors using a default implementation.
*/
@ExperimentalMaterial3Api
internal interface DraggableAnchors<T> {
/**
* Get the anchor position for an associated [value]
*
* @return The position of the anchor, or [Float.NaN] if the anchor does not exist
*/
fun positionOf(value: T): Float
/**
* Whether there is an anchor position associated with the [value]
*
* @param value The value to look up
* @return true if there is an anchor for this value, false if there is no anchor for this value
*/
fun hasAnchorFor(value: T): Boolean
/**
* Find the closest anchor to the [position].
*
* @param position The position to start searching from
*
* @return The closest anchor or null if the anchors are empty
*/
fun closestAnchor(position: Float): T?
/**
* Find the closest anchor to the [position], in the specified direction.
*
* @param position The position to start searching from
* @param searchUpwards Whether to search upwards from the current position or downwards
*
* @return The closest anchor or null if the anchors are empty
*/
fun closestAnchor(position: Float, searchUpwards: Boolean): T?
/**
* The smallest anchor, or [Float.NEGATIVE_INFINITY] if the anchors are empty.
*/
fun minAnchor(): Float
/**
* The biggest anchor, or [Float.POSITIVE_INFINITY] if the anchors are empty.
*/
fun maxAnchor(): Float
/**
* The amount of anchors
*/
val size: Int
}
/**
* [DraggableAnchorsConfig] stores a mutable configuration anchors, comprised of values of [T] and
* corresponding [Float] positions. This [DraggableAnchorsConfig] is used to construct an immutable
* [DraggableAnchors] instance later on.
*/
@ExperimentalMaterial3Api
internal class DraggableAnchorsConfig<T> {
internal val anchors = mutableMapOf<T, Float>()
/**
* Set the anchor position for [this] anchor.
*
* @param position The anchor position.
*/
@Suppress("BuilderSetStyle")
infix fun T.at(position: Float) {
anchors[this] = position
}
}
/**
* Create a new [DraggableAnchors] instance using a builder function.
*
* @param builder A function with a [DraggableAnchorsConfig] that offers APIs to configure anchors
* @return A new [DraggableAnchors] instance with the anchor positions set by the `builder`
* function.
*/
@ExperimentalMaterial3Api
internal fun <T : Any> DraggableAnchors(
builder: DraggableAnchorsConfig<T>.() -> Unit
): DraggableAnchors<T> = MapDraggableAnchors(DraggableAnchorsConfig<T>().apply(builder).anchors)
/**
* 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].
*/
@ExperimentalMaterial3Api
internal fun <T> Modifier.anchoredDraggable(
state: AnchoredDraggableState<T>,
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.
*/
@ExperimentalMaterial3Api
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.
* Use the constructor overload with anchors if the anchors are defined in composition, or update
* the anchors using [updateAnchors].
*
* This contains necessary information about any ongoing drag or animation and provides methods
* to change the state either immediately or by starting an animation.
*
* @param initialValue The initial value of the state.
* @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.
* @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.
*/
@Stable
@ExperimentalMaterial3Api
internal class AnchoredDraggableState<T>(
initialValue: T,
internal val positionalThreshold: (totalDistance: Float) -> Float,
internal val velocityThreshold: () -> Float,
val animationSpec: AnimationSpec<Float>,
internal val confirmValueChange: (newValue: T) -> Boolean = { true }
) {
/**
* Construct an [AnchoredDraggableState] instance with anchors.
*
* @param initialValue The initial value of the state.
* @param anchors The anchors of the state. Use [updateAnchors] to update the anchors later.
* @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.
*/
@ExperimentalMaterial3Api
constructor(
initialValue: T,
anchors: DraggableAnchors<T>,
positionalThreshold: (totalDistance: Float) -> Float,
velocityThreshold: () -> Float,
animationSpec: AnimationSpec<Float>,
confirmValueChange: (newValue: T) -> Boolean = { true }
) : this(
initialValue,
positionalThreshold,
velocityThreshold,
animationSpec,
confirmValueChange
) {
this.anchors = anchors
trySnapTo(initialValue)
}
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 {
dragTarget ?: run {
val currentOffset = offset
if (!currentOffset.isNaN()) {
computeTarget(currentOffset, currentValue, velocity = 0f)
} else currentValue
}
}
/**
* The closest value in the swipe direction from the current offset, not considering thresholds.
* If an [anchoredDrag] is in progress, this will be the target of that anchoredDrag (if
* specified).
*/
internal val closestValue: T by derivedStateOf {
dragTarget ?: run {
val currentOffset = offset
if (!currentOffset.isNaN()) {
computeTargetWithoutThresholds(currentOffset, currentValue)
} 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 mutableFloatStateOf(Float.NaN)
private set
/**
* Require the current offset.
*
* @see offset
*
* @throws IllegalStateException If the offset has not been initialized yet
*/
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() = dragTarget != null
/**
* The fraction of the progress going from [currentValue] to [closestValue], within [0f..1f]
* bounds, or 1f if the [AnchoredDraggableState] is in a settled state.
*/
@get:FloatRange(from = 0.0, to = 1.0)
val progress: Float by derivedStateOf(structuralEqualityPolicy()) {
val a = anchors.positionOf(currentValue)
val b = anchors.positionOf(closestValue)
val distance = abs(b - a)
if (!distance.isNaN() && 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 mutableFloatStateOf(0f)
private set
private var dragTarget: T? by mutableStateOf(null)
var anchors: DraggableAnchors<T> by mutableStateOf(emptyDraggableAnchors())
private set
/**
* Update the anchors. If there is no ongoing [anchoredDrag] operation, snap to the [newTarget],
* otherwise restart the ongoing [anchoredDrag] operation (e.g. an animation) with the new
* anchors.
*
* <b>If your anchors depend on the size of the layout, updateAnchors should be called in the
* layout (placement) phase, e.g. through Modifier.onSizeChanged.</b> 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 from side effects or layout.
*
* @param newAnchors The new anchors.
* @param newTarget The new target, by default the closest anchor or the current target if there
* are no anchors.
*/
fun updateAnchors(
newAnchors: DraggableAnchors<T>,
newTarget: T = if (!offset.isNaN()) {
newAnchors.closestAnchor(offset) ?: targetValue
} else targetValue
) {
if (anchors != newAnchors) {
anchors = newAnchors
// Attempt to snap. If nobody is holding the lock, we can immediately update the offset.
// If anybody is holding the lock, we send a signal to restart the ongoing work with the
// updated anchors.
val snapSuccessful = trySnapTo(newTarget)
if (!snapSuccessful) {
dragTarget = newTarget
}
}
}
/**
* Find the closest anchor, taking into account the [velocityThreshold] and
* [positionalThreshold], and settle at it with an animation.
*
* If the [velocity] is lower than the [velocityThreshold], the closest anchor by distance and
* [positionalThreshold] will be the target. If the [velocity] is higher than the
* [velocityThreshold], the [positionalThreshold] will <b>not</b> be considered and the next
* anchor in the direction indicated by the sign of the [velocity] will be the target.
*/
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 currentAnchorPosition = currentAnchors.positionOf(currentValue)
val velocityThresholdPx = velocityThreshold()
return if (currentAnchorPosition == offset || currentAnchorPosition.isNaN()) {
currentValue
} else if (currentAnchorPosition < 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.positionOf(upper) - currentAnchorPosition)
val relativeThreshold = abs(positionalThreshold(distance))
val absoluteThreshold = abs(currentAnchorPosition + 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(currentAnchorPosition - currentAnchors.positionOf(lower))
val relativeThreshold = abs(positionalThreshold(distance))
val absoluteThreshold = abs(currentAnchorPosition - 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 fun computeTargetWithoutThresholds(
offset: Float,
currentValue: T,
): T {
val currentAnchors = anchors
val currentAnchorPosition = currentAnchors.positionOf(currentValue)
return if (currentAnchorPosition == offset || currentAnchorPosition.isNaN()) {
currentValue
} else if (currentAnchorPosition < offset) {
currentAnchors.closestAnchor(offset, true) ?: currentValue
} else {
currentAnchors.closestAnchor(offset, false) ?: currentValue
}
}
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 with the latest
* anchors.
*
* 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, the ongoing drag will be cancelled.
*
* <b>If the [anchors] change while the [block] is being executed, it will be cancelled and
* re-executed with the latest anchors and target.</b> This allows you to target the correct
* state.
*
* @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: DraggableAnchors<T>) -> Unit
) {
try {
dragMutex.mutate(dragPriority) {
restartable(inputs = { anchors }) { latestAnchors ->
anchoredDragScope.block(latestAnchors)
}
}
} finally {
val closest = anchors.closestAnchor(offset)
if (closest != null &&
abs(offset - anchors.positionOf(closest)) <= 0.5f &&
confirmValueChange.invoke(closest)
) {
currentValue = closest
}
}
}
/**
* Call this function to take control of drag logic and perform anchored drag with the latest
* anchors and target.
*
* 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.
*
* <b>If the [anchors] or [AnchoredDraggableState.targetValue] change while the [block] is being
* executed, it will be cancelled and re-executed with the latest anchors and target.</b> This
* allows you to target the correct state.
*
* If [anchoredDrag] is called from elsewhere with the [dragPriority] higher or equal to ongoing
* drag, the ongoing drag will be cancelled.
*
* @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: DraggableAnchors<T>, targetValue: T) -> Unit
) {
if (anchors.hasAnchorFor(targetValue)) {
try {
dragMutex.mutate(dragPriority) {
dragTarget = targetValue
restartable(
inputs = { anchors to this@AnchoredDraggableState.targetValue }
) { (latestAnchors, latestTarget) ->
anchoredDragScope.block(latestAnchors, latestTarget)
}
}
} finally {
dragTarget = null
val closest = anchors.closestAnchor(offset)
if (closest != null &&
abs(offset - anchors.positionOf(closest)) <= 0.5f &&
confirmValueChange.invoke(closest)
) {
currentValue = closest
}
}
} else {
// Todo: b/283467401, revisit this behavior
currentValue = targetValue
}
}
internal fun newOffsetForDelta(delta: Float) =
((if (offset.isNaN()) 0f else offset) + delta)
.coerceIn(anchors.minAnchor(), anchors.maxAnchor())
/**
* 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
*/
private fun trySnapTo(targetValue: T): Boolean = dragMutex.tryMutate {
with(anchoredDragScope) {
val targetOffset = anchors.positionOf(targetValue)
if (!targetOffset.isNaN()) {
dragTo(targetOffset)
dragTarget = null
}
currentValue = targetValue
}
}
companion object {
/**
* The default [Saver] implementation for [AnchoredDraggableState].
*/
@ExperimentalMaterial3Api
fun <T : Any> Saver(
animationSpec: AnimationSpec<Float>,
confirmValueChange: (T) -> Boolean,
positionalThreshold: (distance: Float) -> Float,
velocityThreshold: () -> Float,
) = Saver<AnchoredDraggableState<T>, T>(
save = { it.currentValue },
restore = {
AnchoredDraggableState(
initialValue = it,
animationSpec = animationSpec,
confirmValueChange = confirmValueChange,
positionalThreshold = positionalThreshold,
velocityThreshold = velocityThreshold
)
}
)
}
}
/**
* 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.
*
* @throws CancellationException if the interaction interrupted by another interaction like a
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
*
* @param targetValue The target value of the animation
*/
@ExperimentalMaterial3Api
internal suspend fun <T> AnchoredDraggableState<T>.snapTo(targetValue: T) {
anchoredDrag(targetValue = targetValue) { anchors, latestTarget ->
val targetOffset = anchors.positionOf(latestTarget)
if (!targetOffset.isNaN()) 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.
*
* @throws CancellationException if the interaction interrupted by another interaction like a
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
*
* @param targetValue The target value of the animation
* @param velocity The velocity the animation should start with
*/
@ExperimentalMaterial3Api
internal suspend fun <T> AnchoredDraggableState<T>.animateTo(
targetValue: T,
velocity: Float = this.lastVelocity,
) {
anchoredDrag(targetValue = targetValue) { anchors, latestTarget ->
val targetOffset = anchors.positionOf(latestTarget)
if (!targetOffset.isNaN()) {
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
}
}
}
}
/**
* Contains useful defaults for [anchoredDraggable] and [AnchoredDraggableState].
*/
@Stable
@ExperimentalMaterial3Api
internal object AnchoredDraggableDefaults {
/**
* The default animation used by [AnchoredDraggableState].
*/
@get:ExperimentalMaterial3Api
@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
@ExperimentalMaterial3Api
val AnimationSpec = SpringSpec<Float>()
}
private class AnchoredDragFinishedSignal : CancellationException() {
override fun fillInStackTrace(): Throwable {
stackTrace = emptyArray()
return this
}
}
private suspend fun <I> restartable(inputs: () -> I, block: suspend (I) -> Unit) {
try {
coroutineScope {
var previousDrag: Job? = null
snapshotFlow(inputs)
.collect { latestInputs ->
previousDrag?.apply {
cancel(AnchoredDragFinishedSignal())
join()
}
previousDrag = launch(start = CoroutineStart.UNDISPATCHED) {
block(latestInputs)
this@coroutineScope.cancel(AnchoredDragFinishedSignal())
}
}
}
} catch (anchoredDragFinished: AnchoredDragFinishedSignal) {
// Ignored
}
}
private fun <T> emptyDraggableAnchors() = MapDraggableAnchors<T>(emptyMap())
@OptIn(ExperimentalMaterial3Api::class)
private class MapDraggableAnchors<T>(private val anchors: Map<T, Float>) : DraggableAnchors<T> {
override fun positionOf(value: T): Float = anchors[value] ?: Float.NaN
override fun hasAnchorFor(value: T) = anchors.containsKey(value)
override fun closestAnchor(position: Float): T? = anchors.minByOrNull {
abs(position - it.value)
}?.key
override fun closestAnchor(
position: Float,
searchUpwards: Boolean
): T? {
return anchors.minByOrNull { (_, anchor) ->
val delta = if (searchUpwards) anchor - position else position - anchor
if (delta < 0) Float.POSITIVE_INFINITY else delta
}?.key
}
override fun minAnchor() = anchors.values.minOrNull() ?: Float.NaN
override fun maxAnchor() = anchors.values.maxOrNull() ?: Float.NaN
override val size: Int
get() = anchors.size
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is MapDraggableAnchors<*>) return false
return anchors == other.anchors
}
override fun hashCode() = 31 * anchors.hashCode()
override fun toString() = "MapDraggableAnchors($anchors)"
}

@ -1,25 +0,0 @@
// ktlint-disable filename
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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 org.tasks.compose.drawer
/* Copy of androidx.compose.material.ActualJvm, mirrored from Foundation. This is used for the
M2/M3-internal copy of MutatorMutex.
*/
internal typealias InternalAtomicReference<V> =
java.util.concurrent.atomic.AtomicReference<V>

@ -1,160 +0,0 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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 org.tasks.compose.drawer
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
/**
* 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<Mutator?>(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 <R> 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 <T, R> 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
}
}

@ -1,663 +0,0 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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 org.tasks.compose.drawer
import android.content.Context
import android.graphics.PixelFormat
import android.os.Build
import android.view.Gravity
import android.view.KeyEvent
import android.view.View
import android.view.ViewTreeObserver
import android.view.WindowManager
import android.window.OnBackInvokedCallback
import android.window.OnBackInvokedDispatcher
import androidx.annotation.DoNotInline
import androidx.annotation.RequiresApi
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Surface
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionContext
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCompositionContext
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.ViewRootForInspector
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.popup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.findViewTreeViewModelStoreOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import kotlinx.coroutines.launch
import org.tasks.compose.drawer.SheetValue.Expanded
import org.tasks.compose.drawer.SheetValue.Hidden
import org.tasks.compose.drawer.SheetValue.PartiallyExpanded
import java.util.UUID
import kotlin.math.max
/**
* <a href="https://m3.material.io/components/bottom-sheets/overview" class="external" target="_blank">Material Design modal bottom sheet</a>.
*
* Modal bottom sheets are used as an alternative to inline menus or simple dialogs on mobile,
* especially when offering a long list of action items, or when items require longer descriptions
* and icons. Like dialogs, modal bottom sheets appear in front of app content, disabling all other
* app functionality when they appear, and remaining on screen until confirmed, dismissed, or a
* required action has been taken.
*
* ![Bottom sheet image](https://developer.android.com/images/reference/androidx/compose/material3/bottom_sheet.png)
*
* A simple example of a modal bottom sheet looks like this:
*
* @sample androidx.compose.material3.samples.ModalBottomSheetSample
*
* @param onDismissRequest Executes when the user clicks outside of the bottom sheet, after sheet
* animates to [Hidden].
* @param modifier Optional [Modifier] for the bottom sheet.
* @param sheetState The state of the bottom sheet.
* @param sheetMaxWidth [Dp] that defines what the maximum width the sheet will take.
* Pass in [Dp.Unspecified] for a sheet that spans the entire screen width.
* @param shape The shape of the bottom sheet.
* @param containerColor The color used for the background of this bottom sheet
* @param contentColor The preferred color for content inside this bottom sheet. Defaults to either
* the matching content color for [containerColor], or to the current [LocalContentColor] if
* [containerColor] is not a color from the theme.
* @param tonalElevation The tonal elevation of this bottom sheet.
* @param scrimColor Color of the scrim that obscures content when the bottom sheet is open.
* @param dragHandle Optional visual marker to swipe the bottom sheet.
* @param windowInsets window insets to be passed to the bottom sheet window via [PaddingValues]
* params.
* @param properties [ModalBottomSheetProperties] for further customization of this
* modal bottom sheet's behavior.
* @param content The content to be displayed inside the bottom sheet.
*/
@Composable
@ExperimentalMaterial3Api
fun ModalBottomSheet(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(),
sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
shape: Shape = BottomSheetDefaults.ExpandedShape,
containerColor: Color = BottomSheetDefaults.ContainerColor,
contentColor: Color = contentColorFor(containerColor),
tonalElevation: Dp = BottomSheetDefaults.Elevation,
scrimColor: Color = BottomSheetDefaults.ScrimColor,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
windowInsets: WindowInsets = BottomSheetDefaults.windowInsets,
properties: ModalBottomSheetProperties = ModalBottomSheetDefaults.properties(),
content: @Composable ColumnScope.() -> Unit,
) {
// b/291735717 Remove this once deprecated methods without density are removed
val density = LocalDensity.current
SideEffect {
sheetState.density = density
}
val scope = rememberCoroutineScope()
val animateToDismiss: () -> Unit = {
if (sheetState.anchoredDraggableState.confirmValueChange(Hidden)) {
scope.launch { sheetState.hide() }.invokeOnCompletion {
if (!sheetState.isVisible) {
onDismissRequest()
}
}
}
}
val settleToDismiss: (velocity: Float) -> Unit = {
scope.launch { sheetState.settle(it) }.invokeOnCompletion {
if (!sheetState.isVisible) onDismissRequest()
}
}
ModalBottomSheetPopup(
properties = properties,
onDismissRequest = {
// if (sheetState.currentValue == Expanded && sheetState.hasPartiallyExpandedState) {
// scope.launch { sheetState.partialExpand() }
// } else { // Is expanded without collapsed state or is collapsed.
scope.launch { sheetState.hide() }.invokeOnCompletion { onDismissRequest() }
// }
},
windowInsets = windowInsets,
) {
BoxWithConstraints(Modifier.fillMaxSize()) {
val fullHeight = constraints.maxHeight
Scrim(
color = scrimColor,
onDismissRequest = animateToDismiss,
visible = sheetState.targetValue != Hidden
)
Surface(
modifier = modifier
.widthIn(max = sheetMaxWidth)
.fillMaxWidth()
.align(Alignment.TopCenter)
.offset {
IntOffset(
0,
sheetState
.requireOffset()
.toInt()
)
}
.nestedScroll(
remember(sheetState) {
ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
sheetState = sheetState,
orientation = Orientation.Vertical,
onFling = settleToDismiss
)
}
)
.draggable(
state = sheetState.anchoredDraggableState.draggableState,
orientation = Orientation.Vertical,
enabled = sheetState.isVisible,
startDragImmediately = sheetState.anchoredDraggableState.isAnimationRunning,
onDragStopped = { settleToDismiss(it) }
)
.modalBottomSheetAnchors(
sheetState = sheetState,
fullHeight = fullHeight.toFloat()
),
shape = shape,
color = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation,
) {
Column(Modifier.fillMaxWidth()) {
if (dragHandle != null) {
Box(
Modifier
.align(Alignment.CenterHorizontally)
) {
dragHandle()
}
}
content()
}
}
}
}
if (sheetState.hasExpandedState) {
LaunchedEffect(sheetState) {
sheetState.show()
}
}
}
/**
* Properties used to customize the behavior of a [ModalBottomSheet].
*
* @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the bottom
* sheet's window.
* @param isFocusable Whether the modal bottom sheet is focusable. When true,
* the modal bottom sheet will receive IME events and key presses, such as when
* the back button is pressed.
* @param shouldDismissOnBackPress Whether the modal bottom sheet can be dismissed by pressing
* the back button. If true, pressing the back button will call onDismissRequest.
* Note that [isFocusable] must be set to true in order to receive key events such as
* the back button - if the modal bottom sheet is not focusable then this property does nothing.
*/
@ExperimentalMaterial3Api
class ModalBottomSheetProperties(
val securePolicy: SecureFlagPolicy,
val isFocusable: Boolean,
val shouldDismissOnBackPress: Boolean
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ModalBottomSheetProperties) return false
if (securePolicy != other.securePolicy) return false
if (isFocusable != other.isFocusable) return false
if (shouldDismissOnBackPress != other.shouldDismissOnBackPress) return false
return true
}
override fun hashCode(): Int {
var result = securePolicy.hashCode()
result = 31 * result + isFocusable.hashCode()
result = 31 * result + shouldDismissOnBackPress.hashCode()
return result
}
}
/**
* Default values for [ModalBottomSheet]
*/
@Immutable
@ExperimentalMaterial3Api
object ModalBottomSheetDefaults {
/**
* Properties used to customize the behavior of a [ModalBottomSheet].
*
* @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the bottom
* sheet's window.
* @param isFocusable Whether the modal bottom sheet is focusable. When true,
* the modal bottom sheet will receive IME events and key presses, such as when
* the back button is pressed.
* @param shouldDismissOnBackPress Whether the modal bottom sheet can be dismissed by pressing
* the back button. If true, pressing the back button will call onDismissRequest.
* Note that [isFocusable] must be set to true in order to receive key events such as
* the back button - if the modal bottom sheet is not focusable then this property does nothing.
*/
fun properties(
securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit,
isFocusable: Boolean = true,
shouldDismissOnBackPress: Boolean = true
) = ModalBottomSheetProperties(securePolicy, isFocusable, shouldDismissOnBackPress)
}
/**
* Create and [remember] a [SheetState] for [ModalBottomSheet].
*
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is tall enough,
* should be skipped. If true, the sheet will always expand to the [Expanded] state and move to the
* [Hidden] state when hiding the sheet, either programmatically or by user interaction.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
*/
@Composable
@ExperimentalMaterial3Api
fun rememberModalBottomSheetState(
skipPartiallyExpanded: Boolean = false,
confirmValueChange: (SheetValue) -> Boolean = { true },
) = rememberSheetState(skipPartiallyExpanded, confirmValueChange, Hidden)
@Composable
private fun Scrim(
color: Color,
onDismissRequest: () -> Unit,
visible: Boolean
) {
if (color.isSpecified) {
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = TweenSpec()
)
val dismissSheet = if (visible) {
Modifier
.pointerInput(onDismissRequest) {
detectTapGestures {
onDismissRequest()
}
}
.clearAndSetSemantics {}
} else {
Modifier
}
Canvas(
Modifier
.fillMaxSize()
.then(dismissSheet)
) {
drawRect(color = color, alpha = alpha)
}
}
}
@ExperimentalMaterial3Api
private fun Modifier.modalBottomSheetAnchors(
sheetState: SheetState,
fullHeight: Float
) = onSizeChanged { sheetSize ->
val newAnchors = DraggableAnchors {
Hidden at fullHeight
if (sheetSize.height > (fullHeight / 2) && !sheetState.skipPartiallyExpanded) {
PartiallyExpanded at fullHeight / 2f
}
if (sheetSize.height != 0) {
Expanded at max(0f, fullHeight - sheetSize.height)
}
}
val newTarget = when (sheetState.anchoredDraggableState.targetValue) {
Hidden -> Hidden
PartiallyExpanded, Expanded -> {
val hasPartiallyExpandedState = newAnchors.hasAnchorFor(PartiallyExpanded)
val newTarget = if (hasPartiallyExpandedState) PartiallyExpanded
else if (newAnchors.hasAnchorFor(Expanded)) Expanded else Hidden
newTarget
}
}
sheetState.anchoredDraggableState.updateAnchors(newAnchors, newTarget)
}
/**
* Popup specific for modal bottom sheet.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ModalBottomSheetPopup(
properties: ModalBottomSheetProperties,
onDismissRequest: () -> Unit,
windowInsets: WindowInsets,
content: @Composable () -> Unit,
) {
val view = LocalView.current
val id = rememberSaveable { UUID.randomUUID() }
val parentComposition = rememberCompositionContext()
val currentContent by rememberUpdatedState(content)
val layoutDirection = LocalLayoutDirection.current
val modalBottomSheetWindow = remember {
ModalBottomSheetWindow(
properties = properties,
onDismissRequest = onDismissRequest,
composeView = view,
saveId = id
).apply {
setCustomContent(
parent = parentComposition,
content = {
Box(
Modifier
.semantics { this.popup() }
.windowInsetsPadding(windowInsets)
.then(
// TODO(b/290893168): Figure out a solution for APIs < 30.
if (Build.VERSION.SDK_INT >= 33)
Modifier.imePadding()
else Modifier
)
) {
currentContent()
}
}
)
}
}
DisposableEffect(modalBottomSheetWindow) {
modalBottomSheetWindow.show()
modalBottomSheetWindow.superSetLayoutDirection(layoutDirection)
onDispose {
modalBottomSheetWindow.disposeComposition()
modalBottomSheetWindow.dismiss()
}
}
}
/** Custom compose view for [ModalBottomSheet] */
@OptIn(ExperimentalMaterial3Api::class)
private class ModalBottomSheetWindow(
private val properties: ModalBottomSheetProperties,
private var onDismissRequest: () -> Unit,
private val composeView: View,
saveId: UUID
) :
AbstractComposeView(composeView.context),
ViewTreeObserver.OnGlobalLayoutListener,
ViewRootForInspector {
private var backCallback: Any? = null
init {
id = android.R.id.content
// Set up view owners
setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner())
setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner())
setViewTreeSavedStateRegistryOwner(composeView.findViewTreeSavedStateRegistryOwner())
setTag(androidx.compose.ui.R.id.compose_view_saveable_id_tag, "Popup:$saveId")
// Enable children to draw their shadow by not clipping them
clipChildren = false
}
private val windowManager =
composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private val displayWidth: Int
get() = context.resources.displayMetrics.widthPixels
private val params: WindowManager.LayoutParams =
WindowManager.LayoutParams().apply {
// Position bottom sheet from the bottom of the screen
gravity = Gravity.BOTTOM or Gravity.START
// Application panel window
type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL
// Fill up the entire app view
width = displayWidth
height = WindowManager.LayoutParams.MATCH_PARENT
// Format of screen pixels
format = PixelFormat.TRANSLUCENT
// Title used as fallback for a11y services
// TODO: Provide bottom sheet window resource
title = composeView.context.resources.getString(
androidx.compose.ui.R.string.default_popup_window_title
)
// Get the Window token from the parent view
token = composeView.applicationWindowToken
// Flags specific to modal bottom sheet.
flags = flags and (
WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES or
WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM
).inv()
flags = flags or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
// Security flag
val secureFlagEnabled =
properties.securePolicy.shouldApplySecureFlag(composeView.isFlagSecureEnabled())
if (secureFlagEnabled) {
flags = flags or WindowManager.LayoutParams.FLAG_SECURE
} else {
flags = flags and (WindowManager.LayoutParams.FLAG_SECURE.inv())
}
// Focusable
if (!properties.isFocusable) {
flags = flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
} else {
flags = flags and (WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE.inv())
}
}
private var content: @Composable () -> Unit by mutableStateOf({})
override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
private set
@Composable
override fun Content() {
content()
}
fun setCustomContent(
parent: CompositionContext? = null,
content: @Composable () -> Unit
) {
parent?.let { setParentCompositionContext(it) }
this.content = content
shouldCreateCompositionOnAttachedToWindow = true
}
fun show() {
windowManager.addView(this, params)
}
fun dismiss() {
setViewTreeLifecycleOwner(null)
setViewTreeSavedStateRegistryOwner(null)
composeView.viewTreeObserver.removeOnGlobalLayoutListener(this)
windowManager.removeViewImmediate(this)
}
/**
* Taken from PopupWindow. Calls [onDismissRequest] when back button is pressed.
*/
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.keyCode == KeyEvent.KEYCODE_BACK && properties.shouldDismissOnBackPress) {
if (keyDispatcherState == null) {
return super.dispatchKeyEvent(event)
}
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
val state = keyDispatcherState
state?.startTracking(event, this)
return true
} else if (event.action == KeyEvent.ACTION_UP) {
val state = keyDispatcherState
if (state != null && state.isTracking(event) && !event.isCanceled) {
onDismissRequest()
return true
}
}
}
return super.dispatchKeyEvent(event)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
maybeRegisterBackCallback()
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
maybeUnregisterBackCallback()
}
private fun maybeRegisterBackCallback() {
if (!properties.shouldDismissOnBackPress || Build.VERSION.SDK_INT < 33) {
return
}
if (backCallback == null) {
backCallback = Api33Impl.createBackCallback(onDismissRequest)
}
Api33Impl.maybeRegisterBackCallback(this, backCallback)
}
private fun maybeUnregisterBackCallback() {
if (Build.VERSION.SDK_INT >= 33) {
Api33Impl.maybeUnregisterBackCallback(this, backCallback)
}
backCallback = null
}
override fun onGlobalLayout() {
// No-op
}
override fun setLayoutDirection(layoutDirection: Int) {
// Do nothing. ViewRootImpl will call this method attempting to set the layout direction
// from the context's locale, but we have one already from the parent composition.
}
// Sets the "real" layout direction for our content that we obtain from the parent composition.
fun superSetLayoutDirection(layoutDirection: LayoutDirection) {
val direction = when (layoutDirection) {
LayoutDirection.Ltr -> android.util.LayoutDirection.LTR
LayoutDirection.Rtl -> android.util.LayoutDirection.RTL
}
super.setLayoutDirection(direction)
}
@RequiresApi(33)
private object Api33Impl {
@JvmStatic
@DoNotInline
fun createBackCallback(onDismissRequest: () -> Unit) =
OnBackInvokedCallback(onDismissRequest)
@JvmStatic
@DoNotInline
fun maybeRegisterBackCallback(view: View, backCallback: Any?) {
if (backCallback is OnBackInvokedCallback) {
view.findOnBackInvokedDispatcher()?.registerOnBackInvokedCallback(
OnBackInvokedDispatcher.PRIORITY_OVERLAY,
backCallback
)
}
}
@JvmStatic
@DoNotInline
fun maybeUnregisterBackCallback(view: View, backCallback: Any?) {
if (backCallback is OnBackInvokedCallback) {
view.findOnBackInvokedDispatcher()?.unregisterOnBackInvokedCallback(backCallback)
}
}
}
}
// Taken from AndroidPopup.android.kt
private fun View.isFlagSecureEnabled(): Boolean {
val windowParams = rootView.layoutParams as? WindowManager.LayoutParams
if (windowParams != null) {
return (windowParams.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0
}
return false
}
// Taken from AndroidPopup.android.kt
private fun SecureFlagPolicy.shouldApplySecureFlag(isSecureFlagSetOnParent: Boolean): Boolean {
return when (this) {
SecureFlagPolicy.SecureOff -> false
SecureFlagPolicy.SecureOn -> true
SecureFlagPolicy.Inherit -> isSecureFlagSetOnParent
}
}

@ -1,502 +0,0 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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 org.tasks.compose.drawer
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ShapeDefaults
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
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.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CancellationException
import org.tasks.compose.drawer.SheetValue.Expanded
import org.tasks.compose.drawer.SheetValue.Hidden
import org.tasks.compose.drawer.SheetValue.PartiallyExpanded
/**
* State of a sheet composable, such as [ModalBottomSheet]
*
* Contains states relating to its swipe position as well as animations between state values.
*
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
* enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
* to the [Hidden] state if available when hiding the sheet, either programmatically or by user
* interaction.
* @param initialValue The initial value of the state.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
* @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
* expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
* programmatically or by user interaction.
*/
@Stable
@ExperimentalMaterial3Api
class SheetState @Deprecated(
message = "This constructor is deprecated. " +
"Please use the constructor that provides a [Density]",
replaceWith = ReplaceWith(
"SheetState(" +
"skipPartiallyExpanded, LocalDensity.current, initialValue, " +
"confirmValueChange, skipHiddenState)"
)
) constructor(
internal val skipPartiallyExpanded: Boolean,
initialValue: SheetValue = Hidden,
confirmValueChange: (SheetValue) -> Boolean = { true },
internal val skipHiddenState: Boolean = false,
) {
/**
* State of a sheet composable, such as [ModalBottomSheet]
*
* Contains states relating to its swipe position as well as animations between state values.
*
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
* enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
* to the [Hidden] state if available when hiding the sheet, either programmatically or by user
* interaction.
* @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 confirmValueChange Optional callback invoked to confirm or veto a pending state change.
* @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
* expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
* programmatically or by user interaction.
*/
@ExperimentalMaterial3Api
@Suppress("Deprecation")
constructor(
skipPartiallyExpanded: Boolean,
density: Density,
initialValue: SheetValue = Hidden,
confirmValueChange: (SheetValue) -> Boolean = { true },
skipHiddenState: Boolean = false,
) : this(skipPartiallyExpanded, initialValue, confirmValueChange, skipHiddenState) {
this.density = density
}
init {
if (skipPartiallyExpanded) {
require(initialValue != PartiallyExpanded) {
"The initial value must not be set to PartiallyExpanded if skipPartiallyExpanded " +
"is set to true."
}
}
if (skipHiddenState) {
require(initialValue != Hidden) {
"The initial value must not be set to Hidden if skipHiddenState is set to true."
}
}
}
/**
* The current value of the state.
*
* If no swipe or animation is in progress, this corresponds to the state the bottom sheet is
* currently in. If a swipe or an animation is in progress, this corresponds the state the sheet
* was in before the swipe or animation started.
*/
val currentValue: SheetValue get() = anchoredDraggableState.currentValue
/**
* The target value of the bottom sheet state.
*
* If a swipe is in progress, this is the value that the sheet would animate to if the
* swipe finishes. If an animation is running, this is the target value of that animation.
* Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
*/
val targetValue: SheetValue get() = anchoredDraggableState.targetValue
/**
* Whether the modal bottom sheet is visible.
*/
val isVisible: Boolean
get() = anchoredDraggableState.currentValue != Hidden
/**
* Require the current offset (in pixels) of the bottom sheet.
*
* The offset will be initialized during the first measurement phase of the provided sheet
* content.
*
* These are the phases:
* Composition { -> Effects } -> Layout { Measurement -> Placement } -> Drawing
*
* During the first composition, an [IllegalStateException] is thrown. In subsequent
* compositions, the offset will be derived from the anchors of the previous pass. Always prefer
* accessing the offset from a LaunchedEffect as it will be scheduled to be executed the next
* frame, after layout.
*
* @throws IllegalStateException If the offset has not been initialized yet
*/
fun requireOffset(): Float = anchoredDraggableState.requireOffset()
/**
* Whether the sheet has an expanded state defined.
*/
val hasExpandedState: Boolean
get() = anchoredDraggableState.anchors.hasAnchorFor(Expanded)
/**
* Whether the modal bottom sheet has a partially expanded state defined.
*/
val hasPartiallyExpandedState: Boolean
get() = anchoredDraggableState.anchors.hasAnchorFor(PartiallyExpanded)
/**
* Fully expand the bottom sheet with animation and suspend until it is fully expanded or
* animation has been cancelled.
* *
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun expand() {
anchoredDraggableState.animateTo(Expanded)
}
/**
* Animate the bottom sheet and suspend until it is partially expanded or animation has been
* cancelled.
* @throws [CancellationException] if the animation is interrupted
* @throws [IllegalStateException] if [skipPartiallyExpanded] is set to true
*/
suspend fun partialExpand() {
check(!skipPartiallyExpanded) {
"Attempted to animate to partial expanded when skipPartiallyExpanded was enabled. Set" +
" skipPartiallyExpanded to false to use this function."
}
animateTo(PartiallyExpanded)
}
/**
* Expand the bottom sheet with animation and suspend until it is [PartiallyExpanded] if defined
* else [Expanded].
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun show() {
val targetValue = when {
hasPartiallyExpandedState -> PartiallyExpanded
else -> Expanded
}
animateTo(targetValue)
}
/**
* Hide the bottom sheet with animation and suspend until it is fully hidden or animation has
* been cancelled.
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun hide() {
check(!skipHiddenState) {
"Attempted to animate to hidden when skipHiddenState was enabled. Set skipHiddenState" +
" to false to use this function."
}
animateTo(Hidden)
}
/**
* Animate to a [targetValue].
* If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the
* [targetValue] without updating the offset.
*
* @throws CancellationException if the interaction interrupted by another interaction like a
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
*
* @param targetValue The target value of the animation
*/
internal suspend fun animateTo(
targetValue: SheetValue,
velocity: Float = anchoredDraggableState.lastVelocity
) {
anchoredDraggableState.animateTo(targetValue, velocity)
}
/**
* Snap to a [targetValue] without any animation.
*
* @throws CancellationException if the interaction interrupted by another interaction like a
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
*
* @param targetValue The target value of the animation
*/
internal suspend fun snapTo(targetValue: SheetValue) {
anchoredDraggableState.snapTo(targetValue)
}
/**
* Find the closest anchor taking into account the velocity and settle at it with an animation.
*/
internal suspend fun settle(velocity: Float) {
anchoredDraggableState.settle(velocity)
}
internal var anchoredDraggableState = AnchoredDraggableState(
initialValue = initialValue,
animationSpec = AnchoredDraggableDefaults.AnimationSpec,
confirmValueChange = confirmValueChange,
positionalThreshold = { with(requireDensity()) { 56.dp.toPx() } },
velocityThreshold = { with(requireDensity()) { 125.dp.toPx() } }
)
internal val offset: Float? get() = anchoredDraggableState.offset
internal var density: Density? = null
private fun requireDensity() = requireNotNull(density) {
"SheetState did not have a density attached. Are you using SheetState with " +
"BottomSheetScaffold or ModalBottomSheet component?"
}
companion object {
/**
* The default [Saver] implementation for [SheetState].
*/
fun Saver(
skipPartiallyExpanded: Boolean,
confirmValueChange: (SheetValue) -> Boolean,
density: Density
) = Saver<SheetState, SheetValue>(
save = { it.currentValue },
restore = { savedValue ->
SheetState(skipPartiallyExpanded, density, savedValue, confirmValueChange)
}
)
/**
* The default [Saver] implementation for [SheetState].
*/
@Deprecated(
message = "This function is deprecated. Please use the overload where Density is" +
" provided.",
replaceWith = ReplaceWith(
"Saver(skipPartiallyExpanded, confirmValueChange, LocalDensity.current)"
)
)
@Suppress("Deprecation")
fun Saver(
skipPartiallyExpanded: Boolean,
confirmValueChange: (SheetValue) -> Boolean
) = Saver<SheetState, SheetValue>(
save = { it.currentValue },
restore = { savedValue ->
SheetState(skipPartiallyExpanded, savedValue, confirmValueChange)
}
)
}
}
/**
* Possible values of [SheetState].
*/
@ExperimentalMaterial3Api
enum class SheetValue {
/**
* The sheet is not visible.
*/
Hidden,
/**
* The sheet is visible at full height.
*/
Expanded,
/**
* The sheet is partially visible.
*/
PartiallyExpanded,
}
/**
* Contains the default values used by [ModalBottomSheet] and [BottomSheetScaffold].
*/
@Stable
@ExperimentalMaterial3Api
object BottomSheetDefaults {
/** The default shape for bottom sheets in a [Hidden] state. */
val HiddenShape: Shape
@Composable get() = RectangleShape
/** The default shape for a bottom sheets in [PartiallyExpanded] and [Expanded] states. */
val ExpandedShape: Shape
@Composable get() = ShapeDefaults.ExtraLarge
.copy(bottomStart = CornerSize(0.0.dp), bottomEnd = CornerSize(0.0.dp))
/** The default container color for a bottom sheet. */
val ContainerColor: Color
@Composable get() = MaterialTheme.colorScheme.surface
/** The default elevation for a bottom sheet. */
val Elevation = 1.dp
/** The default color of the scrim overlay for background content. */
val ScrimColor: Color
@Composable get() = MaterialTheme.colorScheme.scrim.copy(
alpha = 0.32f
)
/**
* The default peek height used by [BottomSheetScaffold].
*/
val SheetPeekHeight = 56.dp
/**
* The default max width used by [ModalBottomSheet] and [BottomSheetScaffold]
*/
val SheetMaxWidth = 640.dp
/**
* Default insets to be used and consumed by the [ModalBottomSheet] window.
*/
val windowInsets: WindowInsets
@Composable
get() = WindowInsets.systemBars.only(WindowInsetsSides.Vertical)
/**
* The optional visual marker placed on top of a bottom sheet to indicate it may be dragged.
*/
@Composable
fun DragHandle(
modifier: Modifier = Modifier,
width: Dp = 32.dp,
height: Dp = 4.dp,
shape: Shape = MaterialTheme.shapes.extraLarge,
color: Color = MaterialTheme.colorScheme.onSurfaceVariant
.copy(alpha = .4f),
) {
Surface(
modifier = modifier
.padding(vertical = DragHandleVerticalPadding),
color = color,
shape = shape
) {
Box(
Modifier
.size(
width = width,
height = height
)
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
sheetState: SheetState,
orientation: Orientation,
onFling: (velocity: Float) -> Unit
): NestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.toFloat()
return if (delta < 0 && source == NestedScrollSource.Drag) {
sheetState.anchoredDraggableState.dispatchRawDelta(delta).toOffset()
} else {
Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return if (source == NestedScrollSource.Drag) {
sheetState.anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset()
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
val toFling = available.toFloat()
val currentOffset = sheetState.requireOffset()
val minAnchor = sheetState.anchoredDraggableState.anchors.minAnchor()
return if (toFling < 0 && currentOffset > minAnchor) {
onFling(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 {
onFling(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
}
@Composable
@ExperimentalMaterial3Api
internal fun rememberSheetState(
skipPartiallyExpanded: Boolean = false,
confirmValueChange: (SheetValue) -> Boolean = { true },
initialValue: SheetValue = Hidden,
skipHiddenState: Boolean = false,
): SheetState {
val density = LocalDensity.current
return rememberSaveable(
skipPartiallyExpanded, confirmValueChange,
saver = SheetState.Saver(
skipPartiallyExpanded = skipPartiallyExpanded,
confirmValueChange = confirmValueChange,
density = density
)
) {
SheetState(
skipPartiallyExpanded,
density,
initialValue,
confirmValueChange,
skipHiddenState
)
}
}
private val DragHandleVerticalPadding = 22.dp

@ -6,6 +6,7 @@ import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@ -14,9 +15,9 @@ import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.mandatorySystemGestures
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -31,13 +32,16 @@ import androidx.compose.material.icons.outlined.PeopleOutline
import androidx.compose.material.icons.outlined.PermIdentity
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SyncProblem
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.BottomAppBarDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -46,6 +50,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
@ -61,7 +67,9 @@ import org.tasks.filters.FilterImpl
import org.tasks.filters.NavigationDrawerSubheader
import org.tasks.themes.TasksIcons
import org.tasks.themes.TasksTheme
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskListDrawer(
begForMoney: Boolean,
@ -73,29 +81,24 @@ fun TaskListDrawer(
query: String,
onQueryChange: (String) -> Unit,
) {
val searching by remember (query) {
derivedStateOf {
query.isNotBlank()
}
}
var hasFocus by remember { mutableStateOf(false) }
LazyColumn(
val bottomAppBarScrollBehavior = BottomAppBarDefaults.exitAlwaysScrollBehavior()
Scaffold(
modifier = Modifier
.animateContentSize(
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium
)
)
.imePadding(),
contentPadding = PaddingValues(bottom = WindowInsets.mandatorySystemGestures
.asPaddingValues()
.calculateBottomPadding()),
) {
item {
Row(
verticalAlignment = Alignment.CenterVertically,
.nestedScroll(bottomAppBarScrollBehavior.nestedScrollConnection),
bottomBar = {
BottomAppBar(
modifier = Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
bottomAppBarScrollBehavior.state.heightOffsetLimit = -placeable.height.toFloat()
val height = placeable.height + bottomAppBarScrollBehavior.state.heightOffset
layout(placeable.width, height.roundToInt().coerceAtLeast(0)) {
placeable.place(0, 0)
}
},
containerColor = MaterialTheme.colorScheme.surface,
scrollBehavior = bottomAppBarScrollBehavior
) {
var hasFocus by remember { mutableStateOf(false) }
SearchBar(
modifier = Modifier
.onFocusChanged { hasFocus = it.hasFocus }
@ -106,8 +109,7 @@ fun TaskListDrawer(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium
)
)
,
),
text = query,
onTextChange = { onQueryChange(it) },
placeHolder = stringResource(id = R.string.TLA_menu_search),
@ -143,24 +145,37 @@ fun TaskListDrawer(
}
}
}
items(items = filters) {
when (it) {
is DrawerItem.Filter -> FilterItem(
item = it,
onClick = { onClick(it) }
)
is DrawerItem.Header -> HeaderItem(
item = it,
canAdd = it.canAdd,
toggleCollapsed = { onClick(it) },
onAddClick = { onAddClick(it) },
onErrorClick = onErrorClick,
) { contentPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize(),
contentPadding = PaddingValues(
bottom = maxOf(
WindowInsets.mandatorySystemGestures
.asPaddingValues()
.calculateBottomPadding(),
contentPadding.calculateBottomPadding()
)
}
}
if (!searching) {
item {
Divider(modifier = Modifier.fillMaxWidth())
),
verticalArrangement = Arrangement.Bottom,
) {
items(items = filters, key = { it.key() }) {
when (it) {
is DrawerItem.Filter -> FilterItem(
// modifier = Modifier.animateItemPlacement(),
item = it,
onClick = { onClick(it) }
)
is DrawerItem.Header -> HeaderItem(
// modifier = Modifier.animateItemPlacement(),
item = it,
canAdd = it.canAdd,
toggleCollapsed = { onClick(it) },
onAddClick = { onAddClick(it) },
onErrorClick = onErrorClick,
)
}
}
}
}
@ -168,11 +183,12 @@ fun TaskListDrawer(
@Composable
internal fun FilterItem(
modifier: Modifier = Modifier,
item: DrawerItem.Filter,
onClick: () -> Unit,
) {
MenuRow(
modifier = Modifier
modifier = modifier
.background(
if (item.selected)
MaterialTheme.colorScheme.onSurface.copy(alpha = .1f)
@ -229,13 +245,16 @@ private fun DrawerIcon(icon: String, color: Int = 0) {
@Composable
internal fun HeaderItem(
modifier: Modifier = Modifier,
item: DrawerItem.Header,
canAdd: Boolean,
toggleCollapsed: () -> Unit,
onAddClick: () -> Unit,
onErrorClick: () -> Unit,
) {
Column {
Column(
modifier = modifier,
) {
Divider(modifier = Modifier.fillMaxWidth())
MenuRow(
padding = PaddingValues(start = 16.dp),
@ -300,22 +319,20 @@ fun MenuPreview() {
DrawerItem.Filter(
title = "My Tasks",
icon = TasksIcons.ALL_INBOX,
type = { FilterImpl() },
filter = FilterImpl(),
),
DrawerItem.Header(
title = "Filters",
collapsed = false,
canAdd = true,
hasError = false,
type = {
NavigationDrawerSubheader(
null,
false,
false,
NavigationDrawerSubheader.SubheaderType.PREFERENCE,
0L,
)
},
header = NavigationDrawerSubheader(
null,
false,
false,
NavigationDrawerSubheader.SubheaderType.PREFERENCE,
0L,
),
)
),
onClick = {},

@ -1,127 +0,0 @@
package org.tasks.compose.drawer
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.tasks.R
import org.tasks.Tasks
import org.tasks.billing.PurchaseActivity
import org.tasks.extensions.Context.findActivity
import org.tasks.extensions.Context.openUri
import org.tasks.filters.Filter
import org.tasks.filters.NavigationDrawerSubheader
import org.tasks.preferences.HelpAndFeedback
import org.tasks.preferences.MainPreferences
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TasksMenu(
items: ImmutableList<DrawerItem>,
isTopAppBar: Boolean,
begForMoney: Boolean,
setFilter: (Filter) -> Unit,
toggleCollapsed: (NavigationDrawerSubheader) -> Unit,
addFilter: (NavigationDrawerSubheader) -> Unit,
dismiss: () -> Unit,
query: String,
onQueryChange: (String) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val skipPartiallyExpanded = remember(expanded) {
expanded || isTopAppBar
}
val density = LocalDensity.current
val sheetState = rememberSaveable(
skipPartiallyExpanded,
saver = SheetState.Saver(
skipPartiallyExpanded = skipPartiallyExpanded,
confirmValueChange = { true },
density = density,
)
) {
SheetState(
skipPartiallyExpanded = skipPartiallyExpanded,
initialValue = if (skipPartiallyExpanded) SheetValue.Expanded else SheetValue.PartiallyExpanded,
confirmValueChange = { true },
skipHiddenState = false,
density = density,
)
}
LaunchedEffect(sheetState.currentValue) {
if (sheetState.currentValue == SheetValue.Expanded) {
expanded = true
}
}
val context = LocalContext.current
val settingsRequest = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
context.findActivity()?.recreate()
}
ModalBottomSheet(
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
onDismissRequest = { dismiss() }
) {
val scope = rememberCoroutineScope()
TaskListDrawer(
begForMoney = begForMoney,
filters = items,
onClick = {
when (it) {
is DrawerItem.Filter -> {
setFilter(it.type())
scope.launch(Dispatchers.Default) {
sheetState.hide()
dismiss()
}
}
is DrawerItem.Header -> {
toggleCollapsed(it.type())
}
}
},
onAddClick = {
scope.launch(Dispatchers.Default) {
sheetState.hide()
dismiss()
addFilter(it.type())
}
},
onDrawerAction = {
dismiss()
when (it) {
DrawerAction.PURCHASE ->
if (Tasks.IS_GENERIC)
context.openUri(R.string.url_donate)
else
context.startActivity(Intent(context, PurchaseActivity::class.java))
DrawerAction.SETTINGS ->
settingsRequest.launch(Intent(context, MainPreferences::class.java))
DrawerAction.HELP_AND_FEEDBACK ->
context.startActivity(Intent(context, HelpAndFeedback::class.java))
}
},
onErrorClick = {
context.startActivity(Intent(context, MainPreferences::class.java))
},
query = query,
onQueryChange = onQueryChange,
)
}
}

@ -10,13 +10,22 @@ sealed interface DrawerItem {
val count: Int = 0,
val shareCount: Int = 0,
val selected: Boolean = false,
val type: () -> org.tasks.filters.Filter,
) : DrawerItem
val filter: org.tasks.filters.Filter,
) : DrawerItem {
override fun key(): String {
return "filter_${hashCode()}"
}
}
data class Header(
val title: String,
val collapsed: Boolean,
val hasError: Boolean,
val canAdd: Boolean,
val type: () -> NavigationDrawerSubheader,
) : DrawerItem
val header: NavigationDrawerSubheader,
) : DrawerItem {
override fun key(): String {
return "header_${header.subheaderType}_${header.id}"
}
}
fun key(): String
}

Loading…
Cancel
Save