From bf3da3249d456bba477dbc918caad9e71de1c0d0 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Fri, 5 Jul 2024 01:42:39 -0500 Subject: [PATCH 1/2] Move menu search to bottom app bar --- .../todoroo/astrid/activity/MainActivity.kt | 202 +++-- .../astrid/activity/MainActivityViewModel.kt | 24 +- .../astrid/activity/TaskListFragment.kt | 2 +- .../tasks/compose/drawer/AnchoredDraggable.kt | 791 ------------------ .../org/tasks/compose/drawer/DrawerItem.kt | 17 +- .../compose/drawer/InternalAtomicReference.kt | 25 - .../compose/drawer/InternalMutatorMutex.kt | 160 ---- .../tasks/compose/drawer/ModalBottomSheet.kt | 663 --------------- .../org/tasks/compose/drawer/SheetState.kt | 502 ----------- .../tasks/compose/drawer/TaskListDrawer.kt | 125 +-- .../org/tasks/compose/drawer/TasksMenu.kt | 127 --- 11 files changed, 253 insertions(+), 2385 deletions(-) delete mode 100644 app/src/main/java/org/tasks/compose/drawer/AnchoredDraggable.kt delete mode 100644 app/src/main/java/org/tasks/compose/drawer/InternalAtomicReference.kt delete mode 100644 app/src/main/java/org/tasks/compose/drawer/InternalMutatorMutex.kt delete mode 100644 app/src/main/java/org/tasks/compose/drawer/ModalBottomSheet.kt delete mode 100644 app/src/main/java/org/tasks/compose/drawer/SheetState.kt delete mode 100644 app/src/main/java/org/tasks/compose/drawer/TasksMenu.kt diff --git a/app/src/main/java/com/todoroo/astrid/activity/MainActivity.kt b/app/src/main/java/com/todoroo/astrid/activity/MainActivity.kt index 859a91b11..b64ecd7ab 100644 --- a/app/src/main/java/com/todoroo/astrid/activity/MainActivity.kt +++ b/app/src/main/java/com/todoroo/astrid/activity/MainActivity.kt @@ -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 { diff --git a/app/src/main/java/com/todoroo/astrid/activity/MainActivityViewModel.kt b/app/src/main/java/com/todoroo/astrid/activity/MainActivityViewModel.kt index 7fbbe13cf..b1f2bad35 100644 --- a/app/src/main/java/com/todoroo/astrid/activity/MainActivityViewModel.kt +++ b/app/src/main/java/com/todoroo/astrid/activity/MainActivityViewModel.kt @@ -60,12 +60,14 @@ class MainActivityViewModel @Inject constructor( val begForMoney: Boolean = false, val filter: Filter, val task: Task? = null, - val drawerOpen: Boolean = false, val drawerItems: ImmutableList = persistentListOf(), val searchItems: ImmutableList = persistentListOf(), val menuQuery: String = "", ) + private val _drawerOpen = MutableStateFlow(false) + val drawerOpen = _drawerOpen.asStateFlow() + private val _state = MutableStateFlow( State( filter = savedStateHandle.get(OPEN_FILTER) @@ -108,13 +110,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 { @@ -145,7 +147,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( @@ -153,7 +155,7 @@ class MainActivityViewModel @Inject constructor( collapsed = item.isCollapsed, hasError = item.error, canAdd = item.addIntentRc != 0, - type = { item }, + header = item, ) else -> throw IllegalArgumentException() } @@ -176,7 +178,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()) } } diff --git a/app/src/main/java/com/todoroo/astrid/activity/TaskListFragment.kt b/app/src/main/java/com/todoroo/astrid/activity/TaskListFragment.kt index 1a27ff6d8..24a7038bb 100644 --- a/app/src/main/java/com/todoroo/astrid/activity/TaskListFragment.kt +++ b/app/src/main/java/com/todoroo/astrid/activity/TaskListFragment.kt @@ -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 { diff --git a/app/src/main/java/org/tasks/compose/drawer/AnchoredDraggable.kt b/app/src/main/java/org/tasks/compose/drawer/AnchoredDraggable.kt deleted file mode 100644 index 10a918f0c..000000000 --- a/app/src/main/java/org/tasks/compose/drawer/AnchoredDraggable.kt +++ /dev/null @@ -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 { - - /** - * 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 { - - internal val anchors = mutableMapOf() - - /** - * 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 DraggableAnchors( - builder: DraggableAnchorsConfig.() -> Unit -): DraggableAnchors = MapDraggableAnchors(DraggableAnchorsConfig().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 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. - */ -@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( - initialValue: T, - internal val positionalThreshold: (totalDistance: Float) -> Float, - internal val velocityThreshold: () -> Float, - val animationSpec: AnimationSpec, - 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, - positionalThreshold: (totalDistance: Float) -> Float, - velocityThreshold: () -> Float, - animationSpec: AnimationSpec, - 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 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. - * - * 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 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, - 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 not 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. - * - * If the [anchors] change while the [block] is being executed, it will be cancelled and - * re-executed with the latest anchors and target. 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) -> 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. - * - * 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. 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, 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 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 - ) - } - ) - } -} - -/** - * 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 AnchoredDraggableState.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 AnchoredDraggableState.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() -} - -private class AnchoredDragFinishedSignal : CancellationException() { - override fun fillInStackTrace(): Throwable { - stackTrace = emptyArray() - return this - } -} - -private suspend fun 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 emptyDraggableAnchors() = MapDraggableAnchors(emptyMap()) - -@OptIn(ExperimentalMaterial3Api::class) -private class MapDraggableAnchors(private val anchors: Map) : DraggableAnchors { - - 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)" -} diff --git a/app/src/main/java/org/tasks/compose/drawer/DrawerItem.kt b/app/src/main/java/org/tasks/compose/drawer/DrawerItem.kt index d107f0e80..92a335782 100644 --- a/app/src/main/java/org/tasks/compose/drawer/DrawerItem.kt +++ b/app/src/main/java/org/tasks/compose/drawer/DrawerItem.kt @@ -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 } diff --git a/app/src/main/java/org/tasks/compose/drawer/InternalAtomicReference.kt b/app/src/main/java/org/tasks/compose/drawer/InternalAtomicReference.kt deleted file mode 100644 index 2e4417739..000000000 --- a/app/src/main/java/org/tasks/compose/drawer/InternalAtomicReference.kt +++ /dev/null @@ -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 = - java.util.concurrent.atomic.AtomicReference \ No newline at end of file diff --git a/app/src/main/java/org/tasks/compose/drawer/InternalMutatorMutex.kt b/app/src/main/java/org/tasks/compose/drawer/InternalMutatorMutex.kt deleted file mode 100644 index 2008da078..000000000 --- a/app/src/main/java/org/tasks/compose/drawer/InternalMutatorMutex.kt +++ /dev/null @@ -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(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/app/src/main/java/org/tasks/compose/drawer/ModalBottomSheet.kt b/app/src/main/java/org/tasks/compose/drawer/ModalBottomSheet.kt deleted file mode 100644 index ec771a10e..000000000 --- a/app/src/main/java/org/tasks/compose/drawer/ModalBottomSheet.kt +++ /dev/null @@ -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 - -/** - * Material Design modal bottom sheet. - * - * 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 - } -} diff --git a/app/src/main/java/org/tasks/compose/drawer/SheetState.kt b/app/src/main/java/org/tasks/compose/drawer/SheetState.kt deleted file mode 100644 index ffb493a22..000000000 --- a/app/src/main/java/org/tasks/compose/drawer/SheetState.kt +++ /dev/null @@ -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( - 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( - 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 diff --git a/app/src/main/java/org/tasks/compose/drawer/TaskListDrawer.kt b/app/src/main/java/org/tasks/compose/drawer/TaskListDrawer.kt index 2016df31b..36871ebb0 100644 --- a/app/src/main/java/org/tasks/compose/drawer/TaskListDrawer.kt +++ b/app/src/main/java/org/tasks/compose/drawer/TaskListDrawer.kt @@ -8,6 +8,7 @@ import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween 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 @@ -16,9 +17,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 @@ -34,13 +35,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 @@ -50,6 +54,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate 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.painterResource import androidx.compose.ui.res.stringResource @@ -63,7 +69,9 @@ import org.tasks.extensions.formatNumber import org.tasks.filters.FilterImpl import org.tasks.filters.NavigationDrawerSubheader import org.tasks.themes.TasksTheme +import kotlin.math.roundToInt +@OptIn(ExperimentalMaterial3Api::class) @Composable fun TaskListDrawer( begForMoney: Boolean, @@ -75,29 +83,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 } @@ -108,8 +111,7 @@ fun TaskListDrawer( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium ) - ) - , + ), text = query, onTextChange = { onQueryChange(it) }, placeHolder = stringResource(id = R.string.TLA_menu_search), @@ -145,24 +147,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, + ) + } } } } @@ -170,11 +185,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) @@ -250,13 +266,16 @@ private fun DrawerIcon(icon: Int, 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), @@ -331,22 +350,20 @@ fun MenuPreview() { DrawerItem.Filter( title = "My Tasks", icon = R.drawable.ic_outline_all_inbox_24px, - 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 = {}, diff --git a/app/src/main/java/org/tasks/compose/drawer/TasksMenu.kt b/app/src/main/java/org/tasks/compose/drawer/TasksMenu.kt deleted file mode 100644 index edab854d8..000000000 --- a/app/src/main/java/org/tasks/compose/drawer/TasksMenu.kt +++ /dev/null @@ -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, - 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, - ) - } -} \ No newline at end of file From 053cffd389ab503c105627490c122c592bfc660a Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Fri, 5 Jul 2024 01:44:24 -0500 Subject: [PATCH 2/2] Update version and changelog --- CHANGELOG.md | 2 +- app/build.gradle.kts | 2 +- .../android/en-US/changelogs/{131006.txt => 131007.txt} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename fastlane/metadata/android/en-US/changelogs/{131006.txt => 131007.txt} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59307d15c..ab246c74c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -### 13.10 (2024-06-30) +### 13.10 (2024-07-05) * Add search bar to drawer * Add search bar to list picker diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 03cd237f6..a31c80996 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -51,7 +51,7 @@ android { defaultConfig { testApplicationId = "org.tasks.test" applicationId = "org.tasks" - versionCode = 131006 + versionCode = 131007 versionName = "13.10" targetSdk = libs.versions.android.targetSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt() diff --git a/fastlane/metadata/android/en-US/changelogs/131006.txt b/fastlane/metadata/android/en-US/changelogs/131007.txt similarity index 100% rename from fastlane/metadata/android/en-US/changelogs/131006.txt rename to fastlane/metadata/android/en-US/changelogs/131007.txt