From de58a8ff96ea8e9040dec98d740f1b883a1fdfc5 Mon Sep 17 00:00:00 2001 From: konstantiniiv Date: Mon, 24 Nov 2025 11:08:36 +0100 Subject: [PATCH 1/6] DROID-4113 composite widget keys --- .../anytype/ui/home/WidgetSection.kt | 17 +- .../anytype/ui/home/WidgetsScreen.kt | 6 +- .../presentation/widgets/WidgetView.kt | 25 ++ .../widgets/WidgetViewExtensionTest.kt | 302 ++++++++++++++++++ 4 files changed, 340 insertions(+), 10 deletions(-) create mode 100644 presentation/src/test/java/com/anytypeio/anytype/presentation/widgets/WidgetViewExtensionTest.kt diff --git a/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetSection.kt b/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetSection.kt index 23942e4800..27d6d981f2 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetSection.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetSection.kt @@ -59,6 +59,7 @@ import com.anytypeio.anytype.presentation.widgets.TreePath import com.anytypeio.anytype.presentation.widgets.ViewId import com.anytypeio.anytype.presentation.widgets.WidgetId import com.anytypeio.anytype.presentation.widgets.WidgetView +import com.anytypeio.anytype.presentation.widgets.compositeKey import com.anytypeio.anytype.ui.widgets.menu.getWidgetMenuItems import com.anytypeio.anytype.ui.widgets.types.AllContentWidgetCard import com.anytypeio.anytype.ui.widgets.types.BinWidgetCard @@ -97,7 +98,7 @@ fun LazyListScope.renderWidgetSection( ) { itemsIndexed( items = widgets, - key = { _, item -> item.id }, + key = { _, item -> item.compositeKey() }, contentType = { _, item -> sectionType } ) { index, item -> val animateItemModifier = Modifier.animateItem() @@ -117,7 +118,7 @@ fun LazyListScope.renderWidgetSection( ReorderableItem( enabled = isReorderEnabled, state = reorderableState, - key = item.id, + key = item.compositeKey(), animateItemModifier = animateItemModifier ) { isDragged -> val hasStartedDragging = remember { mutableStateOf(false) } @@ -179,7 +180,7 @@ fun LazyListScope.renderWidgetSection( ReorderableItem( enabled = isReorderEnabled, state = reorderableState, - key = item.id, + key = item.compositeKey(), animateItemModifier = animateItemModifier ) { isDragged -> val hasStartedDragging = remember { mutableStateOf(false) } @@ -232,7 +233,7 @@ fun LazyListScope.renderWidgetSection( ReorderableItem( enabled = isReorderEnabled, state = reorderableState, - key = item.id, + key = item.compositeKey(), animateItemModifier = animateItemModifier ) { isDragged -> val hasStartedDragging = remember { mutableStateOf(false) } @@ -294,7 +295,7 @@ fun LazyListScope.renderWidgetSection( ReorderableItem( enabled = isReorderEnabled, state = reorderableState, - key = item.id, + key = item.compositeKey(), animateItemModifier = animateItemModifier ) { isDragged -> val hasStartedDragging = remember { mutableStateOf(false) } @@ -356,7 +357,7 @@ fun LazyListScope.renderWidgetSection( ReorderableItem( enabled = isReorderEnabled, state = reorderableState, - key = item.id, + key = item.compositeKey(), animateItemModifier = animateItemModifier ) { isDragged -> val hasStartedDragging = remember { mutableStateOf(false) } @@ -418,7 +419,7 @@ fun LazyListScope.renderWidgetSection( ReorderableItem( enabled = isReorderEnabled, state = reorderableState, - key = item.id, + key = item.compositeKey(), animateItemModifier = animateItemModifier ) { isDragged -> val hasStartedDragging = remember { mutableStateOf(false) } @@ -489,7 +490,7 @@ fun LazyListScope.renderWidgetSection( ReorderableItem( enabled = isReorderEnabled, state = reorderableState, - key = item.id, + key = item.compositeKey(), animateItemModifier = animateItemModifier ) { isDragged -> val hasStartedDragging = remember { mutableStateOf(false) } diff --git a/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetsScreen.kt b/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetsScreen.kt index 74fba744b0..897cfe694a 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetsScreen.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetsScreen.kt @@ -32,6 +32,7 @@ import com.anytypeio.anytype.presentation.widgets.Widget.Source.Companion.SECTIO import com.anytypeio.anytype.presentation.widgets.Widget.Source.Companion.SECTION_PINNED import com.anytypeio.anytype.presentation.widgets.Widget.Source.Companion.WIDGET_BIN_ID import com.anytypeio.anytype.presentation.widgets.WidgetView +import com.anytypeio.anytype.presentation.widgets.extractWidgetId import com.anytypeio.anytype.ui.widgets.types.AddWidgetButton import com.anytypeio.anytype.ui.widgets.types.BinWidgetCard import sh.calvin.reorderable.ReorderableItem @@ -95,8 +96,9 @@ fun WidgetsScreen( val fromType = from.contentType as? SectionType - val fromId = from.key as? Id - val toId = to.key as? Id + // Extract widget IDs from composite keys using extractWidgetId() extension + val fromId = (from.key as? String)?.extractWidgetId() + val toId = (to.key as? String)?.extractWidgetId() when (fromType) { SectionType.PINNED -> { diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/widgets/WidgetView.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/widgets/WidgetView.kt index 1db89ef838..1a80764b77 100644 --- a/presentation/src/main/java/com/anytypeio/anytype/presentation/widgets/WidgetView.kt +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/widgets/WidgetView.kt @@ -259,6 +259,31 @@ sealed class WidgetView { ) } +/** + * Generates a composite key for use in Compose lazy lists to ensure uniqueness + * across different sections (PINNED, TYPES, etc.). + * Format: "SECTION_widgetId" + */ +fun WidgetView.compositeKey(): String = "${sectionType}_${id}" + +/** + * Extracts the widget ID from a composite key generated by [compositeKey]. + * + * Expected format: "SECTION_widgetId" (e.g., "PINNED_abc123" or "TYPES_xyz789") + * + * @return The widget ID portion of the composite key, or null if the format is invalid + * (e.g., no underscore present or empty result after extraction) + * + * Examples: + * - "PINNED_abc123".extractWidgetId() → "abc123" + * - "TYPES_xyz".extractWidgetId() → "xyz" + * - "PINNED_id_with_underscores".extractWidgetId() → "id_with_underscores" + * - "INVALIDKEY".extractWidgetId() → null + * - "".extractWidgetId() → null + */ +fun String.extractWidgetId(): String? = + substringAfter("_", "").takeIf { it.isNotEmpty() } + sealed class DropDownMenuAction { data object ChangeWidgetType : DropDownMenuAction() data object RemoveWidget : DropDownMenuAction() diff --git a/presentation/src/test/java/com/anytypeio/anytype/presentation/widgets/WidgetViewExtensionTest.kt b/presentation/src/test/java/com/anytypeio/anytype/presentation/widgets/WidgetViewExtensionTest.kt new file mode 100644 index 0000000000..2772ac8e8a --- /dev/null +++ b/presentation/src/test/java/com/anytypeio/anytype/presentation/widgets/WidgetViewExtensionTest.kt @@ -0,0 +1,302 @@ +package com.anytypeio.anytype.presentation.widgets + +import kotlin.test.assertEquals +import kotlin.test.assertNull +import org.junit.Test + +/** + * Unit tests for WidgetView extension functions: + * - [compositeKey]: Generates unique keys for widgets across sections + * - [extractWidgetId]: Extracts widget IDs from composite keys + */ +class WidgetViewExtensionTest { + + // ======================================== + // Tests for compositeKey() + // ======================================== + + @Test + fun `compositeKey should generate key with PINNED section`() { + // Given + val widget = WidgetView.Tree( + id = "widget123", + name = WidgetView.Name.Empty, + source = Widget.Source.Bundled.Favorites, + sectionType = SectionType.PINNED + ) + + // When + val result = widget.compositeKey() + + // Then + assertEquals("PINNED_widget123", result) + } + + @Test + fun `compositeKey should generate key with TYPES section`() { + // Given + val widget = WidgetView.Tree( + id = "typeWidget456", + name = WidgetView.Name.Empty, + source = Widget.Source.Bundled.Favorites, + sectionType = SectionType.TYPES + ) + + // When + val result = widget.compositeKey() + + // Then + assertEquals("TYPES_typeWidget456", result) + } + + @Test + fun `compositeKey should generate key with NONE section`() { + // Given + val widget = WidgetView.Tree( + id = "noneWidget789", + name = WidgetView.Name.Empty, + source = Widget.Source.Bundled.Favorites, + sectionType = SectionType.NONE + ) + + // When + val result = widget.compositeKey() + + // Then + assertEquals("NONE_noneWidget789", result) + } + + @Test + fun `compositeKey should generate key with null section`() { + // Given + val widget = WidgetView.Tree( + id = "nullSectionWidget", + name = WidgetView.Name.Empty, + source = Widget.Source.Bundled.Favorites, + sectionType = null + ) + + // When + val result = widget.compositeKey() + + // Then + assertEquals("null_nullSectionWidget", result) + } + + @Test + fun `compositeKey should handle widget ID with underscores`() { + // Given + val widget = WidgetView.Tree( + id = "widget_id_with_underscores", + name = WidgetView.Name.Empty, + source = Widget.Source.Bundled.Favorites, + sectionType = SectionType.PINNED + ) + + // When + val result = widget.compositeKey() + + // Then + assertEquals("PINNED_widget_id_with_underscores", result) + } + + // ======================================== + // Tests for extractWidgetId() + // ======================================== + + @Test + fun `extractWidgetId should extract ID from valid PINNED composite key`() { + // Given + val compositeKey = "PINNED_abc123" + + // When + val result = compositeKey.extractWidgetId() + + // Then + assertEquals("abc123", result) + } + + @Test + fun `extractWidgetId should extract ID from valid TYPES composite key`() { + // Given + val compositeKey = "TYPES_xyz789" + + // When + val result = compositeKey.extractWidgetId() + + // Then + assertEquals("xyz789", result) + } + + @Test + fun `extractWidgetId should extract ID from valid NONE composite key`() { + // Given + val compositeKey = "NONE_def456" + + // When + val result = compositeKey.extractWidgetId() + + // Then + assertEquals("def456", result) + } + + @Test + fun `extractWidgetId should handle widget ID with underscores`() { + // Given + val compositeKey = "PINNED_widget_id_with_underscores" + + // When + val result = compositeKey.extractWidgetId() + + // Then + assertEquals("widget_id_with_underscores", result) + } + + @Test + fun `extractWidgetId should extract everything after first underscore when multiple underscores`() { + // Given + val compositeKey = "TYPES_multi_underscore_id" + + // When + val result = compositeKey.extractWidgetId() + + // Then + assertEquals("multi_underscore_id", result) + } + + @Test + fun `extractWidgetId should return null for key without underscore`() { + // Given + val invalidKey = "INVALIDKEY" + + // When + val result = invalidKey.extractWidgetId() + + // Then + assertNull(result) + } + + @Test + fun `extractWidgetId should return null for empty string`() { + // Given + val emptyKey = "" + + // When + val result = emptyKey.extractWidgetId() + + // Then + assertNull(result) + } + + @Test + fun `extractWidgetId should return null for key with underscore but no ID`() { + // Given + val invalidKey = "SECTION_" + + // When + val result = invalidKey.extractWidgetId() + + // Then + assertNull(result) + } + + @Test + fun `extractWidgetId should handle key with only underscore`() { + // Given + val invalidKey = "_" + + // When + val result = invalidKey.extractWidgetId() + + // Then + assertNull(result) + } + + // ======================================== + // Roundtrip Tests + // ======================================== + + @Test + fun `roundtrip should work for PINNED widget`() { + // Given + val widget = WidgetView.Tree( + id = "roundtrip123", + name = WidgetView.Name.Empty, + source = Widget.Source.Bundled.Favorites, + sectionType = SectionType.PINNED + ) + + // When: Generate composite key and extract ID back + val compositeKey = widget.compositeKey() + val extractedId = compositeKey.extractWidgetId() + + // Then: Extracted ID should match original ID + assertEquals(widget.id, extractedId) + } + + @Test + fun `roundtrip should work for TYPES widget`() { + // Given + val widget = WidgetView.Tree( + id = "typeRoundtrip456", + name = WidgetView.Name.Empty, + source = Widget.Source.Bundled.Favorites, + sectionType = SectionType.TYPES + ) + + // When: Generate composite key and extract ID back + val compositeKey = widget.compositeKey() + val extractedId = compositeKey.extractWidgetId() + + // Then: Extracted ID should match original ID + assertEquals(widget.id, extractedId) + } + + @Test + fun `roundtrip should work for widget with underscores in ID`() { + // Given + val widget = WidgetView.Tree( + id = "widget_with_many_underscores", + name = WidgetView.Name.Empty, + source = Widget.Source.Bundled.Favorites, + sectionType = SectionType.PINNED + ) + + // When: Generate composite key and extract ID back + val compositeKey = widget.compositeKey() + val extractedId = compositeKey.extractWidgetId() + + // Then: Extracted ID should match original ID + assertEquals(widget.id, extractedId) + } + + @Test + fun `roundtrip should work for all section types`() { + val testCases = listOf( + SectionType.PINNED to "pinned_widget", + SectionType.TYPES to "types_widget", + SectionType.NONE to "none_widget" + ) + + testCases.forEach { (sectionType, widgetId) -> + // Given + val widget = WidgetView.Tree( + id = widgetId, + name = WidgetView.Name.Empty, + source = Widget.Source.Bundled.Favorites, + sectionType = sectionType + ) + + // When + val compositeKey = widget.compositeKey() + val extractedId = compositeKey.extractWidgetId() + + // Then + assertEquals( + widget.id, + extractedId, + "Roundtrip failed for section type: $sectionType" + ) + } + } +} From 7a7de4556d70c0c1733f33c3d77bc2930f16dde3 Mon Sep 17 00:00:00 2001 From: konstantiniiv Date: Mon, 24 Nov 2025 11:32:13 +0100 Subject: [PATCH 2/6] DROID-4113 drag start fix --- .../com/anytypeio/anytype/ui/home/WidgetSection.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetSection.kt b/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetSection.kt index 27d6d981f2..aa8200acb6 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetSection.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetSection.kt @@ -750,8 +750,9 @@ private fun ReorderableCollectionItemScope.WidgetCardModifier( if (mode is InteractionMode.ReadOnly) { Modifier.noRippleClickable { onWidgetClicked() } } else { - if (shouldEnableLongClick && dragModifier != null) { + if (dragModifier != null) { // When drag is enabled, use simple click + custom drag detector + // Menu is only shown if shouldEnableLongClick is true Modifier .clickable( indication = null, @@ -767,8 +768,11 @@ private fun ReorderableCollectionItemScope.WidgetCardModifier( dragGestureDetector = LongPressWithSlopDetector( touchSlop = touchSlop, onMenuTrigger = { - longPressConsumed = true - onWidgetLongClicked() + // Only show menu if widget has menu items + if (shouldEnableLongClick) { + longPressConsumed = true + onWidgetLongClicked() + } }, haptic = haptic, onDragStarted = { From 59e3add65e846f61062e39d5a171783c032444d4 Mon Sep 17 00:00:00 2001 From: konstantiniiv Date: Mon, 24 Nov 2025 11:42:31 +0100 Subject: [PATCH 3/6] DROID-4113 refactoring --- .../anytype/ui/home/WidgetSection.kt | 123 +++++++++--------- 1 file changed, 62 insertions(+), 61 deletions(-) diff --git a/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetSection.kt b/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetSection.kt index aa8200acb6..8e97a72205 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetSection.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetSection.kt @@ -731,12 +731,9 @@ private fun ReorderableCollectionItemScope.WidgetCardModifier( var longPressConsumed by remember { mutableStateOf(false) } - var modifier = Modifier - .then( - with(lazyItemScope) { - Modifier.animateItem(placementSpec = null) - } - ) + val baseModifier = with(lazyItemScope) { + Modifier.animateItem(placementSpec = null) + } .fillMaxWidth() .padding(start = 20.dp, end = 20.dp, top = 6.dp, bottom = 6.dp) .alpha(if (isMenuExpanded) 0.8f else 1f) @@ -745,66 +742,70 @@ private fun ReorderableCollectionItemScope.WidgetCardModifier( color = colorResource(id = R.color.dashboard_card_background) ) - // Apply click and drag modifiers based on mode - modifier = modifier.then( - if (mode is InteractionMode.ReadOnly) { + val interactionModifier = when { + + mode is InteractionMode.ReadOnly -> { Modifier.noRippleClickable { onWidgetClicked() } - } else { - if (dragModifier != null) { - // When drag is enabled, use simple click + custom drag detector - // Menu is only shown if shouldEnableLongClick is true - Modifier - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - if (longPressConsumed) { - longPressConsumed = false - } else { - onWidgetClicked() - } + } + + dragModifier != null -> { + // When drag is enabled, use simple click + custom drag detector. + // Menu is only shown if shouldEnableLongClick is true. + Modifier + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + if (longPressConsumed) { + longPressConsumed = false + } else { + onWidgetClicked() } - .draggableHandle( - dragGestureDetector = LongPressWithSlopDetector( - touchSlop = touchSlop, - onMenuTrigger = { - // Only show menu if widget has menu items - if (shouldEnableLongClick) { - longPressConsumed = true - onWidgetLongClicked() - } - }, - haptic = haptic, - onDragStarted = { - ViewCompat.performHapticFeedback( - view, - HapticFeedbackConstantsCompat.GESTURE_START - ) - }, - onDragStopped = { - ViewCompat.performHapticFeedback( - view, - HapticFeedbackConstantsCompat.GESTURE_END - ) + } + .draggableHandle( + dragGestureDetector = LongPressWithSlopDetector( + touchSlop = touchSlop, + onMenuTrigger = { + // Only show menu if widget has menu items + if (shouldEnableLongClick) { + longPressConsumed = true + onWidgetLongClicked() } - ) + }, + haptic = haptic, + onDragStarted = { + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.GESTURE_START + ) + }, + onDragStopped = { + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.GESTURE_END + ) + } ) - } else if (shouldEnableLongClick) { - // When drag is not enabled, use standard combinedClickable - Modifier.combinedClickable( - onClick = { onWidgetClicked() }, - onLongClick = { - onWidgetLongClicked() - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - }, - indication = null, - interactionSource = remember { MutableInteractionSource() } ) - } else { - Modifier.noRippleClickable { onWidgetClicked() } - } } - ) - return modifier + shouldEnableLongClick -> { + // When drag is not enabled, use standard combinedClickable + Modifier.combinedClickable( + onClick = { onWidgetClicked() }, + onLongClick = { + onWidgetLongClicked() + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + }, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) + } + + else -> { + Modifier.noRippleClickable { onWidgetClicked() } + } + } + + return baseModifier.then(interactionModifier) } From a1598962e734ccb1003aa26a91f2febdb0496916 Mon Sep 17 00:00:00 2001 From: konstantiniiv Date: Mon, 24 Nov 2025 11:57:29 +0100 Subject: [PATCH 4/6] DROID-4113 refactoring --- .../anytype/ui/home/WidgetSection.kt | 222 ++---------------- .../gestures/LongPressWithSlopDetector.kt | 87 +++++++ .../gestures/ReorderableItemModifier.kt | 125 ++++++++++ 3 files changed, 227 insertions(+), 207 deletions(-) create mode 100644 core-ui/src/main/java/com/anytypeio/anytype/core_ui/gestures/LongPressWithSlopDetector.kt create mode 100644 core-ui/src/main/java/com/anytypeio/anytype/core_ui/gestures/ReorderableItemModifier.kt diff --git a/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetSection.kt b/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetSection.kt index 8e97a72205..7df0c39492 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetSection.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetSection.kt @@ -2,53 +2,33 @@ package com.anytypeio.anytype.ui.home import android.view.View import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.core.view.HapticFeedbackConstantsCompat -import androidx.core.view.ViewCompat import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.awaitLongPressOrCancellation -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Text 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.hapticfeedback.HapticFeedback -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.pointer.PointerInputChange -import androidx.compose.ui.input.pointer.PointerInputScope -import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import kotlin.math.abs import com.anytypeio.anytype.R import com.anytypeio.anytype.core_models.Id import com.anytypeio.anytype.core_models.ObjectWrapper import com.anytypeio.anytype.core_ui.foundation.noRippleClickable +import com.anytypeio.anytype.core_ui.gestures.ReorderableItemModifier import com.anytypeio.anytype.core_ui.views.Caption1Medium import com.anytypeio.anytype.core_ui.views.UXBody import com.anytypeio.anytype.core_ui.widgets.dv.DefaultDragAndDropModifier @@ -72,8 +52,6 @@ import com.anytypeio.anytype.ui.widgets.types.ListWidgetCard import com.anytypeio.anytype.ui.widgets.types.SpaceChatWidgetCard import com.anytypeio.anytype.ui.widgets.types.TreeWidgetCard import kotlinx.coroutines.delay -import sh.calvin.reorderable.DragGestureDetector -import sh.calvin.reorderable.ReorderableCollectionItemScope import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableLazyListState @@ -135,10 +113,10 @@ fun LazyListScope.renderWidgetSection( } } - val modifier = WidgetCardModifier( + val modifier = ReorderableItemModifier( lazyItemScope = this@itemsIndexed, isMenuExpanded = isCardMenuExpanded.value, - mode = mode, + isReadOnly = mode is InteractionMode.ReadOnly, view = view, onWidgetClicked = { onWidgetSourceClicked(item.id) }, onWidgetLongClicked = { @@ -197,10 +175,10 @@ fun LazyListScope.renderWidgetSection( } } - val modifier = WidgetCardModifier( + val modifier = ReorderableItemModifier( lazyItemScope = this@itemsIndexed, isMenuExpanded = isCardMenuExpanded.value, - mode = mode, + isReadOnly = mode is InteractionMode.ReadOnly, view = view, onWidgetClicked = { onWidgetSourceClicked(item.id) }, onWidgetLongClicked = { @@ -250,10 +228,10 @@ fun LazyListScope.renderWidgetSection( } } - val modifier = WidgetCardModifier( + val modifier = ReorderableItemModifier( lazyItemScope = this@itemsIndexed, isMenuExpanded = isCardMenuExpanded.value, - mode = mode, + isReadOnly = mode is InteractionMode.ReadOnly, view = view, onWidgetClicked = { onWidgetSourceClicked(item.id) }, onWidgetLongClicked = { @@ -312,10 +290,10 @@ fun LazyListScope.renderWidgetSection( } } - val modifier = WidgetCardModifier( + val modifier = ReorderableItemModifier( lazyItemScope = this@itemsIndexed, isMenuExpanded = isCardMenuExpanded.value, - mode = mode, + isReadOnly = mode is InteractionMode.ReadOnly, view = view, onWidgetClicked = { onWidgetSourceClicked(item.id) }, onWidgetLongClicked = { @@ -374,10 +352,10 @@ fun LazyListScope.renderWidgetSection( } } - val modifier = WidgetCardModifier( + val modifier = ReorderableItemModifier( lazyItemScope = this@itemsIndexed, isMenuExpanded = isCardMenuExpanded.value, - mode = mode, + isReadOnly = mode is InteractionMode.ReadOnly, view = view, onWidgetClicked = { onWidgetSourceClicked(item.id) }, onWidgetLongClicked = { @@ -436,10 +414,10 @@ fun LazyListScope.renderWidgetSection( } } - val modifier = WidgetCardModifier( + val modifier = ReorderableItemModifier( lazyItemScope = this@itemsIndexed, isMenuExpanded = isCardMenuExpanded.value, - mode = mode, + isReadOnly = mode is InteractionMode.ReadOnly, view = view, onWidgetClicked = { onWidgetSourceClicked(item.id) }, onWidgetLongClicked = { @@ -507,10 +485,10 @@ fun LazyListScope.renderWidgetSection( } } - val modifier = WidgetCardModifier( + val modifier = ReorderableItemModifier( lazyItemScope = this@itemsIndexed, isMenuExpanded = isCardMenuExpanded.value, - mode = mode, + isReadOnly = mode is InteractionMode.ReadOnly, view = view, onWidgetClicked = { onWidgetSourceClicked(item.id) }, onWidgetLongClicked = { @@ -639,173 +617,3 @@ fun PinnedSectionHeader( ) } } - -/** - * Custom DragGestureDetector that uses touch slop to differentiate between: - * - Long-press without movement (shows menu) - * - Long-press with drag movement (starts dragging) - * - * Based on approach from https://github.com/Calvin-LL/Reorderable/issues/55 - */ -private class LongPressWithSlopDetector( - private val touchSlop: Float, - private val onMenuTrigger: () -> Unit, - private val haptic: HapticFeedback, - private val onDragStarted: () -> Unit = {}, - private val onDragStopped: () -> Unit = {} -) : DragGestureDetector { - override suspend fun PointerInputScope.detect( - onDragStart: (Offset) -> Unit, - onDragEnd: () -> Unit, - onDragCancel: () -> Unit, - onDrag: (PointerInputChange, Offset) -> Unit - ) { - awaitEachGesture { - val down = awaitFirstDown() - - // Wait for a long-press. If it gets cancelled (pointer up or moved too far), - // we stop handling this gesture. - val longPress = awaitLongPressOrCancellation(down.id) ?: run { - onDragCancel() - return@awaitEachGesture - } - - var isDragging = false - val pointerId = longPress.id - val dragStartOffset = longPress.position - - // After long-press is recognized, watch for movement. - while (true) { - val event = awaitPointerEvent() - val change = event.changes.firstOrNull { it.id == pointerId } ?: continue - - if (!change.pressed) { - // Pointer was released; decide whether to trigger menu or end drag. - if (isDragging) { - onDragEnd() - onDragStopped() - } else { - onMenuTrigger() - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - } - break - } - - val dragDelta = change.positionChange() - val verticalDelta = dragDelta.y - - if (!isDragging) { - // Check if we've moved far enough from the long-press position to start a drag. - val verticalOffset = change.position.y - dragStartOffset.y - if (abs(verticalOffset) > touchSlop) { - isDragging = true - onDragStarted() - onDragStart(dragStartOffset) - change.consume() - } - } - - if (isDragging && verticalDelta != 0f) { - onDrag(change, Offset(0f, verticalDelta)) - change.consume() - } - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun ReorderableCollectionItemScope.WidgetCardModifier( - lazyItemScope: LazyItemScope, - isMenuExpanded: Boolean, - mode: InteractionMode, - view: View, - onWidgetClicked: () -> Unit, - onWidgetLongClicked: () -> Unit, - dragModifier: Modifier? = null, - shouldEnableLongClick: Boolean = true -): Modifier { - val haptic = LocalHapticFeedback.current - val touchSlop = LocalViewConfiguration.current.touchSlop - - var longPressConsumed by remember { mutableStateOf(false) } - - val baseModifier = with(lazyItemScope) { - Modifier.animateItem(placementSpec = null) - } - .fillMaxWidth() - .padding(start = 20.dp, end = 20.dp, top = 6.dp, bottom = 6.dp) - .alpha(if (isMenuExpanded) 0.8f else 1f) - .background( - shape = RoundedCornerShape(16.dp), - color = colorResource(id = R.color.dashboard_card_background) - ) - - val interactionModifier = when { - - mode is InteractionMode.ReadOnly -> { - Modifier.noRippleClickable { onWidgetClicked() } - } - - dragModifier != null -> { - // When drag is enabled, use simple click + custom drag detector. - // Menu is only shown if shouldEnableLongClick is true. - Modifier - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - if (longPressConsumed) { - longPressConsumed = false - } else { - onWidgetClicked() - } - } - .draggableHandle( - dragGestureDetector = LongPressWithSlopDetector( - touchSlop = touchSlop, - onMenuTrigger = { - // Only show menu if widget has menu items - if (shouldEnableLongClick) { - longPressConsumed = true - onWidgetLongClicked() - } - }, - haptic = haptic, - onDragStarted = { - ViewCompat.performHapticFeedback( - view, - HapticFeedbackConstantsCompat.GESTURE_START - ) - }, - onDragStopped = { - ViewCompat.performHapticFeedback( - view, - HapticFeedbackConstantsCompat.GESTURE_END - ) - } - ) - ) - } - - shouldEnableLongClick -> { - // When drag is not enabled, use standard combinedClickable - Modifier.combinedClickable( - onClick = { onWidgetClicked() }, - onLongClick = { - onWidgetLongClicked() - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - }, - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) - } - - else -> { - Modifier.noRippleClickable { onWidgetClicked() } - } - } - - return baseModifier.then(interactionModifier) -} diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/gestures/LongPressWithSlopDetector.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/gestures/LongPressWithSlopDetector.kt new file mode 100644 index 0000000000..ed2b482859 --- /dev/null +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/gestures/LongPressWithSlopDetector.kt @@ -0,0 +1,87 @@ +package com.anytypeio.anytype.core_ui.gestures + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitLongPressOrCancellation +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.positionChange +import kotlin.math.abs +import sh.calvin.reorderable.DragGestureDetector + +/** + * Custom DragGestureDetector that uses touch slop to differentiate between: + * - Long-press without movement (shows menu) + * - Long-press with drag movement (starts dragging) + * + * Based on approach from https://github.com/Calvin-LL/Reorderable/issues/55 + */ +class LongPressWithSlopDetector( + private val touchSlop: Float, + private val onMenuTrigger: () -> Unit, + private val haptic: HapticFeedback, + private val onDragStarted: () -> Unit = {}, + private val onDragStopped: () -> Unit = {} +) : DragGestureDetector { + override suspend fun PointerInputScope.detect( + onDragStart: (Offset) -> Unit, + onDragEnd: () -> Unit, + onDragCancel: () -> Unit, + onDrag: (PointerInputChange, Offset) -> Unit + ) { + awaitEachGesture { + val down = awaitFirstDown() + + // Wait for a long-press. If it gets cancelled (pointer up or moved too far), + // we stop handling this gesture. + val longPress = awaitLongPressOrCancellation(down.id) ?: run { + onDragCancel() + return@awaitEachGesture + } + + var isDragging = false + val pointerId = longPress.id + val dragStartOffset = longPress.position + + // After long-press is recognized, watch for movement. + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == pointerId } ?: continue + + if (!change.pressed) { + // Pointer was released; decide whether to trigger menu or end drag. + if (isDragging) { + onDragEnd() + onDragStopped() + } else { + onMenuTrigger() + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + break + } + + val dragDelta = change.positionChange() + val verticalDelta = dragDelta.y + + if (!isDragging) { + // Check if we've moved far enough from the long-press position to start a drag. + val verticalOffset = change.position.y - dragStartOffset.y + if (abs(verticalOffset) > touchSlop) { + isDragging = true + onDragStarted() + onDragStart(dragStartOffset) + change.consume() + } + } + + if (isDragging && verticalDelta != 0f) { + onDrag(change, Offset(0f, verticalDelta)) + change.consume() + } + } + } + } +} \ No newline at end of file diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/gestures/ReorderableItemModifier.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/gestures/ReorderableItemModifier.kt new file mode 100644 index 0000000000..b5eda5a684 --- /dev/null +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/gestures/ReorderableItemModifier.kt @@ -0,0 +1,125 @@ +package com.anytypeio.anytype.core_ui.gestures + +import android.view.View +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.dp +import androidx.core.view.HapticFeedbackConstantsCompat +import androidx.core.view.ViewCompat +import com.anytypeio.anytype.core_ui.R +import com.anytypeio.anytype.core_ui.foundation.noRippleClickable +import sh.calvin.reorderable.ReorderableCollectionItemScope + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ReorderableCollectionItemScope.ReorderableItemModifier( + lazyItemScope: LazyItemScope, + isMenuExpanded: Boolean, + isReadOnly: Boolean, + view: View, + onWidgetClicked: () -> Unit, + onWidgetLongClicked: () -> Unit, + dragModifier: Modifier? = null, + shouldEnableLongClick: Boolean = true +): Modifier { + val haptic = LocalHapticFeedback.current + val touchSlop = LocalViewConfiguration.current.touchSlop + + var longPressConsumed by remember { mutableStateOf(false) } + + val baseModifier = with(lazyItemScope) { + Modifier.animateItem(placementSpec = null) + } + .fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, top = 6.dp, bottom = 6.dp) + .alpha(if (isMenuExpanded) 0.8f else 1f) + .background( + shape = RoundedCornerShape(16.dp), + color = colorResource(id = R.color.dashboard_card_background) + ) + + val interactionModifier = when { + + isReadOnly -> { + Modifier.noRippleClickable { onWidgetClicked() } + } + + dragModifier != null -> { + // When drag is enabled, use simple click + custom drag detector. + // Menu is only shown if shouldEnableLongClick is true. + Modifier + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + if (longPressConsumed) { + longPressConsumed = false + } else { + onWidgetClicked() + } + } + .draggableHandle( + dragGestureDetector = LongPressWithSlopDetector( + touchSlop = touchSlop, + onMenuTrigger = { + // Only show menu if widget has menu items + if (shouldEnableLongClick) { + longPressConsumed = true + onWidgetLongClicked() + } + }, + haptic = haptic, + onDragStarted = { + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.GESTURE_START + ) + }, + onDragStopped = { + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.GESTURE_END + ) + } + ) + ) + } + + shouldEnableLongClick -> { + // When drag is not enabled, use standard combinedClickable + Modifier.combinedClickable( + onClick = { onWidgetClicked() }, + onLongClick = { + onWidgetLongClicked() + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + }, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) + } + + else -> { + Modifier.noRippleClickable { onWidgetClicked() } + } + } + + return baseModifier.then(interactionModifier) +} \ No newline at end of file From 10c57626d50facb0a3b105f7fd72f02978c92c28 Mon Sep 17 00:00:00 2001 From: konstantiniiv Date: Mon, 24 Nov 2025 12:06:49 +0100 Subject: [PATCH 5/6] DROID-4113 refactoring --- .../anytype/ui/home/WidgetSection.kt | 28 +++++++++---------- .../gestures/ReorderableItemModifier.kt | 16 +++++------ 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetSection.kt b/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetSection.kt index 7df0c39492..090a592299 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetSection.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/home/WidgetSection.kt @@ -118,8 +118,8 @@ fun LazyListScope.renderWidgetSection( isMenuExpanded = isCardMenuExpanded.value, isReadOnly = mode is InteractionMode.ReadOnly, view = view, - onWidgetClicked = { onWidgetSourceClicked(item.id) }, - onWidgetLongClicked = { + onItemClicked = { onWidgetSourceClicked(item.id) }, + onItemLongClicked = { isCardMenuExpanded.value = !isCardMenuExpanded.value }, dragModifier = if (isReorderEnabled) DefaultDragAndDropModifier(view, {}) else null, @@ -180,8 +180,8 @@ fun LazyListScope.renderWidgetSection( isMenuExpanded = isCardMenuExpanded.value, isReadOnly = mode is InteractionMode.ReadOnly, view = view, - onWidgetClicked = { onWidgetSourceClicked(item.id) }, - onWidgetLongClicked = { + onItemClicked = { onWidgetSourceClicked(item.id) }, + onItemLongClicked = { isCardMenuExpanded.value = !isCardMenuExpanded.value }, dragModifier = if (isReorderEnabled) DefaultDragAndDropModifier(view, {}) else null, @@ -233,8 +233,8 @@ fun LazyListScope.renderWidgetSection( isMenuExpanded = isCardMenuExpanded.value, isReadOnly = mode is InteractionMode.ReadOnly, view = view, - onWidgetClicked = { onWidgetSourceClicked(item.id) }, - onWidgetLongClicked = { + onItemClicked = { onWidgetSourceClicked(item.id) }, + onItemLongClicked = { isCardMenuExpanded.value = !isCardMenuExpanded.value }, dragModifier = if (isReorderEnabled) DefaultDragAndDropModifier(view, {}) else null, @@ -295,8 +295,8 @@ fun LazyListScope.renderWidgetSection( isMenuExpanded = isCardMenuExpanded.value, isReadOnly = mode is InteractionMode.ReadOnly, view = view, - onWidgetClicked = { onWidgetSourceClicked(item.id) }, - onWidgetLongClicked = { + onItemClicked = { onWidgetSourceClicked(item.id) }, + onItemLongClicked = { isCardMenuExpanded.value = !isCardMenuExpanded.value }, dragModifier = if (isReorderEnabled) DefaultDragAndDropModifier(view, {}) else null, @@ -357,8 +357,8 @@ fun LazyListScope.renderWidgetSection( isMenuExpanded = isCardMenuExpanded.value, isReadOnly = mode is InteractionMode.ReadOnly, view = view, - onWidgetClicked = { onWidgetSourceClicked(item.id) }, - onWidgetLongClicked = { + onItemClicked = { onWidgetSourceClicked(item.id) }, + onItemLongClicked = { isCardMenuExpanded.value = !isCardMenuExpanded.value }, dragModifier = if (isReorderEnabled) DefaultDragAndDropModifier(view, {}) else null, @@ -419,8 +419,8 @@ fun LazyListScope.renderWidgetSection( isMenuExpanded = isCardMenuExpanded.value, isReadOnly = mode is InteractionMode.ReadOnly, view = view, - onWidgetClicked = { onWidgetSourceClicked(item.id) }, - onWidgetLongClicked = { + onItemClicked = { onWidgetSourceClicked(item.id) }, + onItemLongClicked = { isCardMenuExpanded.value = !isCardMenuExpanded.value }, dragModifier = if (isReorderEnabled) DefaultDragAndDropModifier(view, {}) else null, @@ -490,8 +490,8 @@ fun LazyListScope.renderWidgetSection( isMenuExpanded = isCardMenuExpanded.value, isReadOnly = mode is InteractionMode.ReadOnly, view = view, - onWidgetClicked = { onWidgetSourceClicked(item.id) }, - onWidgetLongClicked = { + onItemClicked = { onWidgetSourceClicked(item.id) }, + onItemLongClicked = { isCardMenuExpanded.value = !isCardMenuExpanded.value }, dragModifier = if (isReorderEnabled) DefaultDragAndDropModifier(view, {}) else null, diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/gestures/ReorderableItemModifier.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/gestures/ReorderableItemModifier.kt index b5eda5a684..372682a972 100644 --- a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/gestures/ReorderableItemModifier.kt +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/gestures/ReorderableItemModifier.kt @@ -35,8 +35,8 @@ fun ReorderableCollectionItemScope.ReorderableItemModifier( isMenuExpanded: Boolean, isReadOnly: Boolean, view: View, - onWidgetClicked: () -> Unit, - onWidgetLongClicked: () -> Unit, + onItemClicked: () -> Unit, + onItemLongClicked: () -> Unit, dragModifier: Modifier? = null, shouldEnableLongClick: Boolean = true ): Modifier { @@ -59,7 +59,7 @@ fun ReorderableCollectionItemScope.ReorderableItemModifier( val interactionModifier = when { isReadOnly -> { - Modifier.noRippleClickable { onWidgetClicked() } + Modifier.noRippleClickable { onItemClicked() } } dragModifier != null -> { @@ -73,7 +73,7 @@ fun ReorderableCollectionItemScope.ReorderableItemModifier( if (longPressConsumed) { longPressConsumed = false } else { - onWidgetClicked() + onItemClicked() } } .draggableHandle( @@ -83,7 +83,7 @@ fun ReorderableCollectionItemScope.ReorderableItemModifier( // Only show menu if widget has menu items if (shouldEnableLongClick) { longPressConsumed = true - onWidgetLongClicked() + onItemLongClicked() } }, haptic = haptic, @@ -106,9 +106,9 @@ fun ReorderableCollectionItemScope.ReorderableItemModifier( shouldEnableLongClick -> { // When drag is not enabled, use standard combinedClickable Modifier.combinedClickable( - onClick = { onWidgetClicked() }, + onClick = { onItemClicked() }, onLongClick = { - onWidgetLongClicked() + onItemLongClicked() haptic.performHapticFeedback(HapticFeedbackType.LongPress) }, indication = null, @@ -117,7 +117,7 @@ fun ReorderableCollectionItemScope.ReorderableItemModifier( } else -> { - Modifier.noRippleClickable { onWidgetClicked() } + Modifier.noRippleClickable { onItemClicked() } } } From c6cc7c0c63a4215fdfc0eeecc6341bea6a6f1e1d Mon Sep 17 00:00:00 2001 From: konstantiniiv Date: Mon, 24 Nov 2025 13:07:57 +0100 Subject: [PATCH 6/6] DROID-4113 optimistic widgets update after drag --- .../presentation/home/HomeScreenViewModel.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/home/HomeScreenViewModel.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/home/HomeScreenViewModel.kt index b9fcd9b7d8..f72a80dc9b 100644 --- a/presentation/src/main/java/com/anytypeio/anytype/presentation/home/HomeScreenViewModel.kt +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/home/HomeScreenViewModel.kt @@ -2921,6 +2921,16 @@ class HomeScreenViewModel( viewModelScope.launch { Timber.d("DROID-3965, Persisting type widget order: ${newOrder.map { it.takeLast(4) + "..." }}") + // Store the current order for potential rollback + val previousOrder = typeWidgets.value.toList() + + // Optimistically update typeWidgets immediately to keep UI in sync + val reorderedWidgets = newOrder.mapNotNull { id -> + typeWidgets.value.find { it.id == id } + } + typeWidgets.value = reorderedWidgets + Timber.d("DROID-4113, Optimistically updated typeWidgets to new order") + // Activate event lock before sending to middleware to prevent race conditions activateTypeWidgetEventLock() @@ -2932,6 +2942,9 @@ class HomeScreenViewModel( ).fold( onFailure = { error -> Timber.e(error, "DROID-3965, Failed to reorder type widgets: $newOrder") + // Rollback to previous order + typeWidgets.value = previousOrder + Timber.d("DROID-4113, Rolled back typeWidgets to previous order") clearTypeWidgetDragState() }, onSuccess = { finalOrder ->