Keep the move action and default off while unknown (#173)

Switching drag off gated the whole move scope, which also removed the
TalkBack move action — a menu item can't cause an accidental drag, so it
stays; only the gesture is gated now, via EventMoveScope.dragEnabled.

The host flow also started at true, re-arming the gesture for the first
frames of a cold start for exactly the user who turned it off.
This commit is contained in:
2026-08-11 17:38:40 +02:00
parent 4fca1e5530
commit 0c1d38099c
6 changed files with 35 additions and 9 deletions

View File

@@ -312,12 +312,13 @@ fun CalendarHost(
heldEditKey = key heldEditKey = key
editKey = key editKey = key
} }
// Off by preference (#173) means no scope at all: every block then registers // Off by preference (#173) switches off the gesture, not the scope: blocks
// no drag gesture, and the edit form stays the way to reschedule. // register no drag, while the edit form and its TalkBack action stay put.
val dragToReschedule = viewModel.dragToReschedule.collectAsStateWithLifecycle().value val dragToReschedule = viewModel.dragToReschedule.collectAsStateWithLifecycle().value
val moveScope = remember(movableCalendarIds, reschedule) { val moveScope = remember(movableCalendarIds, reschedule, dragToReschedule) {
EventMoveScope( EventMoveScope(
movableCalendarIds = movableCalendarIds, movableCalendarIds = movableCalendarIds,
dragEnabled = dragToReschedule,
move = reschedule::move, move = reschedule::move,
inFlight = reschedule.inFlight, inFlight = reschedule.inFlight,
undoStarted = reschedule.undoStarted, undoStarted = reschedule.undoStarted,
@@ -342,7 +343,7 @@ fun CalendarHost(
// navigation, so it fades through rather than sliding — paging *within* a // navigation, so it fades through rather than sliding — paging *within* a
// view keeps the directional slide. AnimatedContent keyed on the view type. // view keeps the directional slide. AnimatedContent keyed on the view type.
val viewSwitch = fadeThrough() val viewSwitch = fadeThrough()
CompositionLocalProvider(LocalEventMove provides moveScope.takeIf { dragToReschedule }) { CompositionLocalProvider(LocalEventMove provides moveScope) {
AnimatedContent( AnimatedContent(
targetState = view, targetState = view,
transitionSpec = { viewSwitch }, transitionSpec = { viewSwitch },

View File

@@ -54,11 +54,17 @@ class CalendarHostViewModel @Inject constructor(
initialValue = false, initialValue = false,
) )
/** Whether events may be dragged to another slot to reschedule them (#68, #173). */ /**
* Whether events may be dragged to another slot to reschedule them (#68, #173).
* Off until the stored value arrives, even though the preference itself defaults
* on: the wrong guess costs an enabled user a gesture for the few frames a cold
* start takes to read DataStore, but re-arms the one thing a disabled user
* switched off to be rid of.
*/
val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L), started = SharingStarted.WhileSubscribed(5_000L),
initialValue = true, initialValue = false,
) )
} }

View File

@@ -29,6 +29,12 @@ class EventMoveScope(
* the repository writes whatever it is handed — so this gate is load-bearing. * the repository writes whatever it is handed — so this gate is load-bearing.
*/ */
val movableCalendarIds: Set<Long>, val movableCalendarIds: Set<Long>,
/**
* Whether the *gesture* is on (#173). Off gates the drag alone: [edit] and the
* TalkBack action below stay, since invoking a menu item can't cause the
* accidental drags the setting exists to stop.
*/
val dragEnabled: Boolean,
/** False when the drop was refused outright, so nothing will be written. */ /** False when the drop was refused outright, so nothing will be written. */
val move: (MoveRequest) -> Boolean, val move: (MoveRequest) -> Boolean,
/** /**
@@ -91,6 +97,17 @@ fun ghostAlpha(lifted: Boolean): Float = animateFloatAsState(
label = "ghost-alpha", label = "ghost-alpha",
).value ).value
/**
* Whether [event] can be picked up and dragged — its calendar allows moving and
* the gesture is switched on. Not the same question as [eventMoveAction], which
* survives the setting.
*/
@Composable
fun eventDragAllowed(event: EventInstance): Boolean {
val move = LocalEventMove.current ?: return false
return move.dragEnabled && move.allows(event)
}
/** /**
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't * A TalkBack action that opens [event] in the edit form, so rescheduling isn't
* pointer-only. Null when this event can't be moved. * pointer-only. Null when this event can't be moved.

View File

@@ -92,6 +92,7 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.beginsOn import de.jeanlucmakiola.calendula.ui.common.beginsOn
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
@@ -737,7 +738,7 @@ private fun EventBlock(
val moveAction = eventMoveAction(block.event) val moveAction = eventMoveAction(block.event)
// A block clipped at the top continues from the previous day: its top edge // A block clipped at the top continues from the previous day: its top edge
// is midnight, not the event's start, so dragging it would invent a time. // is midnight, not the event's start, so dragging it would invent a time.
val draggable = moveAction != null && block.beginsOn(date, zone) val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone)
val dragModifier = rememberEventDragSource( val dragModifier = rememberEventDragSource(
enabled = draggable, enabled = draggable,
key = block.event.instanceId, key = block.event.instanceId,

View File

@@ -2172,7 +2172,7 @@ private fun monthChipDragModifier(
rowHeightPx: Float, rowHeightPx: Float,
isRtl: Boolean, isRtl: Boolean,
): Modifier = rememberDragSurface( ): Modifier = rememberDragSurface(
enabled = moveScope != null && controller != null, enabled = moveScope?.dragEnabled == true && controller != null,
key = week.days.first(), key = week.days.first(),
onPickUp = { local, pointerInRoot, nodeInRoot, size -> onPickUp = { local, pointerInRoot, nodeInRoot, size ->
val bandTop = band[0]?.takeIf { it.isAttached }?.positionInRoot()?.y val bandTop = band[0]?.takeIf { it.isAttached }?.positionInRoot()?.y

View File

@@ -100,6 +100,7 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.beginsOn import de.jeanlucmakiola.calendula.ui.common.beginsOn
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
@@ -905,7 +906,7 @@ private fun EventBlock(
val moveAction = eventMoveAction(block.event) val moveAction = eventMoveAction(block.event)
// A block clipped at the top continues from the previous day: its top edge // A block clipped at the top continues from the previous day: its top edge
// is midnight, not the event's start, so dragging it would invent a time. // is midnight, not the event's start, so dragging it would invent a time.
val draggable = moveAction != null && block.beginsOn(date, zone) val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone)
val dragModifier = rememberEventDragSource( val dragModifier = rememberEventDragSource(
enabled = draggable, enabled = draggable,
key = block.event.instanceId, key = block.event.instanceId,