Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
262 changes: 38 additions & 224 deletions app/src/main/java/com/anytypeio/anytype/ui/home/WidgetSection.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 -> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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,
onItemClicked: () -> Unit,
onItemLongClicked: () -> 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 { onItemClicked() }
}

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 {
onItemClicked()
}
}
.draggableHandle(
dragGestureDetector = LongPressWithSlopDetector(
touchSlop = touchSlop,
onMenuTrigger = {
// Only show menu if widget has menu items
if (shouldEnableLongClick) {
longPressConsumed = true
onItemLongClicked()
}
},
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 = { onItemClicked() },
onLongClick = {
onItemLongClicked()
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
},
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
}

else -> {
Modifier.noRippleClickable { onItemClicked() }
}
}

return baseModifier.then(interactionModifier)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading