Compare commits

..
Author SHA1 Message Date
95ad3a30cf Release v2.24.1 (#504)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Renovate / renovate (push) Successful in 1m20s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 19m27s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m58s
- Agenda widget: Cards / Classic style option, opacity slider for all Solid/Tinted widgets (#489)
- Month widget fits events into short week rows (#491)
- Week numbers follow the locale's rules and the row's first day (#493)
- Readable dimmed events in dark widgets, pinned Light/Dark theme matches system colours (#475)
- Dropped unused WorkManager permissions: network state, wake lock, foreground service (#448)
- Weblate translations (#434), incl. the Arabic string from after the 2.24.0 squash
- Version 2.24.1 (2240199), CHANGELOG, What's New in all store locales

#489 deviates from the issue: the classic look comes back as a style option instead of replacing the new design.

Closes #489
Closes #491
Closes #493
Closes #475
Closes #448

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Co-authored-by: Sup99999 <alelmnour1o234@proton.me>
Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Louis Leblanc <sandraev@posteo.dk>
Co-authored-by: Dmitri Gabinski <dkgabi@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/504
2026-11-13 11:17:48 +01:00
Jean-Luc Makiola 8ce4831efa Translations update from Weblate (#434)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 12s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 2m59s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/434
2026-11-13 11:07:42 +01:00
10 changed files with 189 additions and 451 deletions

No files matched your search

@@ -485,8 +485,9 @@ fun CalendarHost(
movableCalendarIds = movableCalendarIds,
dragEnabled = dragToReschedule,
move = { reschedule.move(it) },
drops = reschedule.drops,
inFlight = reschedule.inFlight,
undoStarted = reschedule.undoStarted,
abandoned = reschedule.abandoned,
edit = onEditEvent,
)
}
@@ -90,7 +90,6 @@ fun EventMoveHost(
MoveOutcome.Undone -> stringResource(R.string.event_move_undone)
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
MoveOutcome.Stale -> stringResource(R.string.event_move_stale)
MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end)
MoveOutcome.BlockedCancelledSeries -> stringResource(R.string.event_move_blocked_cancelled_series)
MoveOutcome.Failed -> stringResource(R.string.event_move_failed)
@@ -6,7 +6,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
@@ -16,7 +16,6 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.util.concurrent.atomic.AtomicLong
/**
* Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by
@@ -38,18 +37,24 @@ class EventMoveScope(
val dragEnabled: Boolean,
/**
* Hand a drop to the writer. Returns nothing on purpose: a drop is answered
* before its write resolves, so [drops] — not a return value — is what tells
* a held copy whether anything landed.
* before its write resolves, so [abandoned] — not a return value — is what
* tells a held copy that nothing landed.
*/
val move: (MoveRequest) -> Unit,
/**
* Each drop's progress by its token — see `RescheduleViewModel.drops`. A flow
* rather than a value so this scope stays the same object across a move —
* every visible block reads it as a composition local.
* True while a dropped event is being written, including the time its scope
* dialog is up. A flow rather than a value so this scope stays the same
* object across a move — every visible block reads it as a composition local.
*/
val drops: StateFlow<DropStates>,
/** The token of the undo write that just began — see `RescheduleViewModel.undoStarted`. */
val undoStarted: StateFlow<Long?>,
val inFlight: StateFlow<Boolean>,
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
val undoStarted: StateFlow<Int>,
/**
* Ticks when a drop ends with nothing landing — see
* `RescheduleViewModel.abandoned`. [move] answers before the write is
* resolved, so this is what tells a held copy to stop waiting.
*/
val abandoned: StateFlow<Int>,
/** Open an event in the edit form — the pointer-free route to the same change. */
val edit: (EventInstance) -> Unit,
) {
@@ -58,59 +63,46 @@ class EventMoveScope(
val LocalEventMove = compositionLocalOf<EventMoveScope?> { null }
private val moveTokens = AtomicLong()
private val NEVER_IN_FLIGHT = MutableStateFlow(false)
/** A fresh identity for one drop or undo, unique for the process. */
fun nextMoveToken(): Long = moveTokens.incrementAndGet()
private val NEVER_UNDONE = MutableStateFlow(0)
/** How many abandoned drops [DropStates] remembers — far more than can be on screen at once. */
private const val ABANDONED_KEPT = 32
/** Per-drop progress, by token — see `RescheduleViewModel.drops`. */
@Immutable
data class DropStates(
val pending: Set<Long> = emptySet(),
val abandoned: Set<Long> = emptySet(),
) {
fun started(token: Long): DropStates = copy(pending = pending + token)
fun ended(token: Long, landed: Boolean): DropStates = copy(
pending = pending - token,
// Insertion-ordered, so the oldest go first.
abandoned = if (landed) abandoned else (abandoned + token).let { kept ->
if (kept.size > ABANDONED_KEPT) kept.drop(kept.size - ABANDONED_KEPT).toSet() else kept
},
)
}
private val NO_DROPS = MutableStateFlow(DropStates())
private val NEVER_UNDONE = MutableStateFlow<Long?>(null)
private val NEVER_ABANDONED = MutableStateFlow(0)
/**
* Every drop's progress — empty wherever moving is off. A drag overlay keeps
* the token of its own drop and holds its copy while that token is pending.
* Whether a dropped event is still being written — false wherever moving is off.
* The drag overlays hold a landed block on its target for this window.
*/
@Composable
fun dropStates(): DropStates {
val flow = LocalEventMove.current?.drops ?: NO_DROPS
fun moveInFlight(): Boolean {
val flow = LocalEventMove.current?.inFlight ?: NEVER_IN_FLIGHT
return flow.collectAsStateWithLifecycle().value
}
/**
* Runs [onUndo] with the undo's token when an undo write begins, and never for
* one that began before this composable came on screen.
* How many drops have ended with nothing landing. Read as a plain count so a
* drag overlay can capture it at the moment of the drop and tell, later, whether
* *its own* drop was one of them.
*/
@Composable
fun OnUndoStarted(onUndo: (token: Long) -> Unit) {
fun abandonedMoves(): Int {
val flow = LocalEventMove.current?.abandoned ?: NEVER_ABANDONED
return flow.collectAsStateWithLifecycle().value
}
/**
* Runs [onUndo] when an undo write begins, and never for one that began before
* this composable came on screen.
*/
@Composable
fun OnUndoStarted(onUndo: () -> Unit) {
val flow = LocalEventMove.current?.undoStarted ?: NEVER_UNDONE
val token by flow.collectAsStateWithLifecycle()
var seen by remember { mutableStateOf(token) }
LaunchedEffect(token) {
val started = token
if (started == null || started == seen) return@LaunchedEffect
seen = started
onUndo(started)
val tick by flow.collectAsStateWithLifecycle()
var seen by remember { mutableIntStateOf(tick) }
LaunchedEffect(tick) {
if (tick == seen) return@LaunchedEffect
seen = tick
onUndo()
}
}
@@ -32,7 +32,6 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
@@ -47,9 +46,6 @@ import javax.inject.Inject
private const val MILLIS_PER_MINUTE = 60_000
private const val MINUTES_PER_DAY = 24 * 60
/** How many drops may wait behind the one in flight before more are refused. */
private const val MAX_QUEUED = 8
/** Where a dragged event should land. */
sealed interface MoveTarget {
/** A new start instant — a timeline drag, which moves time and day at once. */
@@ -78,8 +74,6 @@ data class MoveRequest(
val beginMillis: Long,
val endMillis: Long,
val target: MoveTarget,
/** The drop's own identity, from [nextMoveToken], which the view holding its copy waits on. */
val token: Long,
)
/**
@@ -121,13 +115,6 @@ sealed interface MoveOutcome {
/** The event vanished (sync, another device) between the read and the write. */
data object Gone : MoveOutcome
/**
* The drop's occurrence is no longer where it was picked up — an earlier
* write moved it — and a recurring one can't be followed there, so it is not
* written (#494).
*/
data object Stale : MoveOutcome
/** The drop would push the series past its own `UNTIL`, generating nothing. */
data object BlockedSeriesEnd : MoveOutcome
@@ -165,32 +152,36 @@ class RescheduleViewModel @Inject constructor(
private val _inFlight = MutableStateFlow(false)
/** True while any drop or undo runs or waits — what the confirmation chip holds for. */
/**
* True from the moment a drop is accepted until its write settles — the
* window the dropped block holds its landing position for.
*/
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
private val _drops = MutableStateFlow(DropStates())
private val _abandoned = MutableStateFlow(0)
/**
* Each drop's own state, by its [MoveRequest.token]: pending from the moment
* it is accepted until its write settles, and abandoned once it ends with
* nothing on the target day — refused, failed, or its scope dialog dismissed.
* Per drop, so a queued drop's held copy waits on its own write and is never
* let go by another drop's failure (#253, #494).
* Ticks when a drop ends with nothing on the target day: refused outright,
* refused before the write, written and failed, or its scope dialog
* dismissed. [move] answers before any of that is known, so without this
* signal the view that drew the drop held its copy out for the full settle
* timeout waiting for a grid that was never going to draw it (#253).
*/
val drops: StateFlow<DropStates> = _drops.asStateFlow()
val abandoned: StateFlow<Int> = _abandoned.asStateFlow()
private val _undoStarted = MutableStateFlow<Long?>(null)
private val _undoStarted = MutableStateFlow(0)
/**
* The token of an undo write the moment it begins, before the provider has
* anything to re-read, so the view that drew the drop can carry its chip back
* rather than let it reappear on the old day. Its fate then reads off [drops].
* Ticks the moment an undo write begins, before the provider has anything to
* re-read, so the view that drew the drop can carry its chip back rather than
* let it reappear on the old day.
*/
val undoStarted: StateFlow<Long?> = _undoStarted.asStateFlow()
val undoStarted: StateFlow<Int> = _undoStarted.asStateFlow()
/**
* Set while a drop is being prepared, waits on its scope dialog, or is
* written. Drops arriving in that window wait in [queue] and run in order.
* Set from the moment a drop is accepted until its write settles. Two drops
* of the same recurring event inside that window would compute their shift
* from the same pre-move occurrence, and the shifts would compound.
*/
private var busy = false
set(value) {
@@ -198,12 +189,6 @@ class RescheduleViewModel @Inject constructor(
_inFlight.value = value
}
/** Whether a drop since the pipeline was last idle failed — see [report]. */
private var failedThisRun = false
/** Drops that arrived while another was in flight, oldest first (#494). */
private val queue = ArrayDeque<MoveRequest>()
/**
* The calendars whose events may be dragged. Nothing below the UI guards
* this, so a block outside this set registers no drag gesture at all.
@@ -234,39 +219,36 @@ class RescheduleViewModel @Inject constructor(
}
/**
* Take a drop: run it now, or queue it behind the ones in flight. False means
* nothing will be written — the queue is full — and the drop is marked
* abandoned in [drops], so its held copy is let go by the one signal rather
* than by every caller remembering to.
* Take a drop, unless one is already being written. False means nothing will
* be written; [abandoned] ticks for that refusal too, so a held copy is let
* go by the one signal rather than by every caller remembering to.
*
* The Boolean is diagnostic — the tests read it, [EventMoveScope] does not
* hand it on. It answers before the write is resolved anyway.
* hand it on. A caller that gated on it would be back to releasing its copy
* from two places, and it answers before the write is resolved anyway.
*/
fun move(request: MoveRequest): Boolean {
if (queue.size >= MAX_QUEUED) {
_drops.update { it.ended(request.token, landed = false) }
if (busy || _scopePrompt.value != null) {
_abandoned.value += 1
return false
}
_drops.update { it.started(request.token) }
if (busy) queue.addLast(request) else run(request)
return true
}
private fun run(request: MoveRequest) {
busy = true
viewModelScope.launch {
val prepared = prepare(request)
if (prepared == null) {
settle(request.token, landed = false)
_abandoned.value += 1
busy = false
return@launch
}
// A cancelled series only moves as a whole (#388).
val seriesOnly = !prepared.original.canSaveApartFromSeries()
if (!prepared.isRecurring || (seriesOnly && prepared.canRealign)) {
settle(request.token, write(prepared, RecurringWriteScope.AllEvents))
write(prepared, RecurringWriteScope.AllEvents)
busy = false
} else if (seriesOnly) {
report(MoveOutcome.BlockedCancelledSeries)
settle(request.token, landed = false)
_outcome.value = MoveOutcome.BlockedCancelledSeries
_abandoned.value += 1
busy = false
} else {
// Still busy: the scope dialog is now the thing in flight.
pending = prepared
@@ -276,6 +258,7 @@ class RescheduleViewModel @Inject constructor(
)
}
}
return true
}
/** Answer the scope dialog. */
@@ -286,57 +269,35 @@ class RescheduleViewModel @Inject constructor(
pending = null
_scopePrompt.value = null
viewModelScope.launch {
settle(prepared.request.token, write(prepared, scope))
write(prepared, scope)
busy = false
}
}
/** Dismiss the scope dialog without writing. */
fun cancelScope() {
val prepared = pending ?: return
pending = null
busy = false
_scopePrompt.value = null
settle(prepared.request.token, landed = false)
}
/** End the drop [token], then run the next queued one, if any. */
private fun settle(token: Long, landed: Boolean) {
_drops.update { it.ended(token, landed) }
// Straight on to the next, so busy never reads false between two drops.
val next = queue.removeFirstOrNull()
if (next != null) {
run(next)
} else {
busy = false
failedThisRun = false
}
}
/**
* Show [outcome] on the chip. While drops run back to back, a failure stays
* up rather than being replaced by a later drop's success: the grid shows
* what moved, but only the chip says what didn't.
*/
private fun report(outcome: MoveOutcome) {
val failure = outcome !is MoveOutcome.Moved && outcome !is MoveOutcome.Undone
if (!failure && failedThisRun) return
if (failure) failedThisRun = true
_outcome.value = outcome
_abandoned.value += 1
}
/**
* Put a completed move back where it came from. The outcome stands until the
* inverse write reports back, so the one chip changes what it says rather
* than closing and reopening. False when another write is already running —
* the chip hides its action for that window, so this is the backstop. An
* undo is a journey of its own: [undoStarted] names its token, and [drops]
* reports whether it landed.
* the chip hides its action for that window, so this is the backstop.
*
* [abandoned] ticks once an inverse that *started* fails to land: [undoStarted]
* has sent the chip back on its journey by then and nothing else will seat it.
* A refusal ticks nothing — it launched no journey of its own, and the counter
* is global, so a tick here would release some other drop's held copy before
* the grid has drawn it.
*/
fun undo(undo: MoveUndo): Boolean {
if (busy) return false
val token = nextMoveToken()
_drops.update { it.started(token) }
busy = true
_undoStarted.value = token
_undoStarted.value += 1
viewModelScope.launch {
_outcome.value = try {
repository.updateEvent(undo.eventId, undo.moved, undo.restored)
@@ -350,7 +311,10 @@ class RescheduleViewModel @Inject constructor(
} catch (e: Exception) {
MoveOutcome.Failed
}
settle(token, landed = _outcome.value is MoveOutcome.Undone)
// Read off the outcome, the way [write] does — no second copy of the
// same fact to drift from it.
if (_outcome.value !is MoveOutcome.Undone) _abandoned.value += 1
busy = false
}
return true
}
@@ -360,27 +324,20 @@ class RescheduleViewModel @Inject constructor(
_outcome.value = null
}
private suspend fun prepare(dropped: MoveRequest): PreparedMove? {
val zone = zoneSource.current()
val (detail, request) = try {
val detail = repository.eventDetail(dropped.eventId)
// The drop carries the times the grid drew, which an earlier write
// may have moved on since (#494).
detail to if (occurrenceStillAt(dropped)) dropped else followed(dropped, detail, zone)
private suspend fun prepare(request: MoveRequest): PreparedMove? {
val detail = try {
repository.eventDetail(request.eventId)
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
report(MoveOutcome.Gone)
_outcome.value = MoveOutcome.Gone
return null
} catch (e: Exception) {
report(MoveOutcome.Failed)
return null
}
if (request == null) {
report(MoveOutcome.Stale)
_outcome.value = MoveOutcome.Failed
return null
}
val zone = zoneSource.current()
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
val shifted = when (val target = request.target) {
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
@@ -393,7 +350,7 @@ class RescheduleViewModel @Inject constructor(
// The UNTIL check is deferred to the write, which knows how far the move
// reaches and so which date has to clear it.
if ((shifted.problems(zone) - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
report(MoveOutcome.Failed)
_outcome.value = MoveOutcome.Failed
return null
}
@@ -438,14 +395,14 @@ class RescheduleViewModel @Inject constructor(
)
}
/** Write [prepared] in [scope]; true when it landed. */
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope): Boolean {
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) {
val request = prepared.request
if (endsBeforeItStarts(prepared, scope)) {
report(MoveOutcome.BlockedSeriesEnd)
return false
_outcome.value = MoveOutcome.BlockedSeriesEnd
_abandoned.value += 1
return
}
val outcome = try {
_outcome.value = try {
when {
!prepared.isRecurring || scope == RecurringWriteScope.AllEvents ->
repository.updateEvent(request.eventId, prepared.original, prepared.updated)
@@ -480,44 +437,9 @@ class RescheduleViewModel @Inject constructor(
} catch (e: Exception) {
MoveOutcome.Failed
}
report(outcome)
return outcome is MoveOutcome.Moved
}
/** Whether [request]'s occurrence is still exactly where it was picked up. */
private suspend fun occurrenceStillAt(request: MoveRequest): Boolean {
val begin = Instant.fromEpochMilliseconds(request.beginMillis)
val end = Instant.fromEpochMilliseconds(request.endMillis)
val range = begin..maxOf(end, Instant.fromEpochMilliseconds(request.beginMillis + 1))
return repository.eventOccurrences(request.eventId, range).any {
it.eventId == request.eventId && it.start == begin && it.end == end
}
}
/**
* [dropped] rebased onto where its event now is, so the last drop wins:
* absolute targets stand as they are, and a whole-day shift is re-aimed at
* the day it was dropped on. Null for a recurring event, whose moved
* occurrence can't be told apart from the rest of its series.
*/
private fun followed(dropped: MoveRequest, detail: EventDetail, zone: TimeZone): MoveRequest? {
if (detail.rrule != null && !detail.isException) return null
val now = detail.instance
val target = when (val target = dropped.target) {
is MoveTarget.ByDays -> {
val dayZone = if (now.isAllDay) TimeZone.UTC else zone
val aimed = Instant.fromEpochMilliseconds(dropped.beginMillis).toLocalDateTime(dayZone).date
.plus(target.days, DateTimeUnit.DAY)
val from = now.start.toLocalDateTime(dayZone).date
MoveTarget.ByDays((aimed.toEpochDays() - from.toEpochDays()).toInt())
}
else -> target
}
return dropped.copy(
beginMillis = now.start.toEpochMilliseconds(),
endMillis = now.end.toEpochMilliseconds(),
target = target,
)
// Nothing landed, so the copy the view is holding has nothing to fade
// into — let it go now rather than at the end of the settle timeout.
if (_outcome.value !is MoveOutcome.Moved) _abandoned.value += 1
}
/**
@@ -60,6 +60,7 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.atStartOfDayIn
@@ -138,17 +139,9 @@ data class StripDrag(val event: EventInstance, val dayShift: Int = 0, val overGr
sealed interface StripDrop {
val event: EventInstance
/** The drop's identity, which its held copy waits on. */
val token: Long
data class Days(override val event: EventInstance, val days: Int) : StripDrop
data class Days(override val event: EventInstance, val days: Int, override val token: Long) : StripDrop
data class Timed(
override val event: EventInstance,
val start: Instant,
val end: Instant,
override val token: Long,
) : StripDrop
data class Timed(override val event: EventInstance, val start: Instant, val end: Instant) : StripDrop
}
/** The event a long press left selected (#141). */
@@ -305,8 +298,6 @@ data class TimelineDrop(
val startMin: Int,
/** Minutes from the event's start to the dragged block's top edge. */
val clipOffsetMin: Int = 0,
/** The drop's identity, which its held copy waits on; set by the controller's finish. */
val token: Long = 0L,
)
/** The id a drafted block's stand-in event carries, which no provider row has (#153). */
@@ -350,13 +341,7 @@ internal fun draftBlock(draft: EventInstance, date: LocalDate, zone: TimeZone):
enum class ResizeEdge { Top, Bottom }
/** Where a finished resize asks its event to begin and end. */
data class TimelineResizeDrop(
val event: EventInstance,
val start: Instant,
val end: Instant,
/** The drop's identity, which its held copy waits on; set by the controller's finishResize. */
val token: Long = 0L,
)
data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant)
/**
* [event] resized by its [edge] to the span [startMin]..+[spanMin] from [date]'s
@@ -626,8 +611,14 @@ class TimelineDragController {
var settlingSelected: Boolean = false
private set
/** The token of the drop [settling] waits on. */
var settledToken: Long = 0L
/**
* The abandoned-move counter, read at the drop so the mark can't be a
* composition behind the tick it is compared against.
*/
var abandonedTicks: StateFlow<Int>? = null
/** What [abandonedTicks] stood at when the settling drop was let go. */
var settledAbandonedMark: Int = 0
private set
private var source: TimedBlock? = null
@@ -670,8 +661,8 @@ class TimelineDragController {
var stripSeated: Boolean by mutableStateOf(false)
private set
/** The token of the drop [stripSettling] waits on. */
var stripToken: Long = 0L
/** What [abandonedTicks] stood at when [stripSettling] was let go. */
var stripAbandonedMark: Int = 0
private set
/** The shown day the finger picked the strip bar up on. */
@@ -758,16 +749,16 @@ class TimelineDragController {
val zone = dragZone
cancel()
if (held.overGrid && landed != null && zone != null && !landed.edgeOnly) {
val token = settle(landed)
settle(landed)
val start = TimelineDrop(landed.event, landed.date, landed.startMin).startInstant(zone)
return StripDrop.Timed(held.event, start, start + draftMinutes.minutes, token)
return StripDrop.Timed(held.event, start, start + draftMinutes.minutes)
}
if (held.overGrid || held.dayShift == 0) return null
stripSettling = held
stripSeated = false
stripToken = nextMoveToken()
stripAbandonedMark = abandonedTicks?.value ?: 0
drops++
return StripDrop.Days(held.event, held.dayShift, stripToken)
return StripDrop.Days(held.event, held.dayShift)
}
/** Let the held strip drop go, once the strip shows the event at its new days or the move failed. */
@@ -864,15 +855,12 @@ class TimelineDragController {
if (landed == null || landed.slot == origin) return null
// A draft dropped on a day off screen would vanish, so it stays where it was (#153).
if (landed.edgeOnly && landed.event.isDraft) return null
val token = settle(landed)
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset, token)
settle(landed)
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
}
/**
* Keep [landed]'s copy on the target as [settling] until [release]; [end] is a
* resize's. Returns the drop's fresh token.
*/
private fun settle(landed: TimelineDrag, end: Instant? = null): Long {
/** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */
private fun settle(landed: TimelineDrag, end: Instant? = null) {
// Landed on midnight to midnight, the event moves to the all-day strip (#296).
val leavesGrid = !landed.event.isDraft && landed.eventSpanMin > 0 &&
landed.eventStartMin.mod(MINUTES_PER_DAY) == 0 &&
@@ -885,10 +873,9 @@ class TimelineDragController {
settling = if (landed.edgeOnly || leavesGrid) landed.copy(pieces = emptyList()) else landed
liftedInstanceId = landed.event.instanceId
settledOrigin = landed.event.eventId to landed.event.start
settledToken = nextMoveToken()
settledAbandonedMark = abandonedTicks?.value ?: 0
settledEnd = end
drops++
return settledToken
}
/**
@@ -957,7 +944,8 @@ class TimelineDragController {
cancel()
if (landed == null || landed.slot == origin) return null
val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
return drop.copy(token = settle(landed, end = drop.end))
settle(landed, end = drop.end)
return drop
}
/**
@@ -1211,6 +1199,7 @@ fun rememberTimelineDragController(): TimelineDragController {
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
controller.slopPx = LocalViewConfiguration.current.touchSlop
controller.abandonedTicks = LocalEventMove.current?.abandoned
return controller
}
@@ -1324,9 +1313,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val reduceMotion = rememberReduceMotion()
val drops = dropStates()
val moveInFlight = controller.settledToken in drops.pending
val stripInFlight = controller.stripToken in drops.pending
val moveInFlight = moveInFlight()
// Both live here rather than beside the controller: they read [drag], which
// changes every frame, and this composable is the one that is meant to.
@@ -1340,11 +1327,12 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
// recurring drop's scope dialog is up — and then until the grid draws the
// drop itself.
var handingOver by remember(controller.settling) { mutableStateOf(false) }
val abandoned = controller.settledToken in drops.abandoned
val abandoned = abandonedMoves()
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid, abandoned) {
if (controller.settling == null || moveInFlight) return@LaunchedEffect
// An abandoned drop: nothing will land and there is nothing to wait for.
if (!abandoned) {
// Past the mark this drop was let go on, it was abandoned: nothing will
// land and there is nothing to wait for.
if (abandoned == controller.settledAbandonedMark) {
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
}
handingOver = true
@@ -1354,10 +1342,9 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
}
// The same hold for a bar moved along the all-day strip.
val stripAbandoned = controller.stripToken in drops.abandoned
LaunchedEffect(controller.stripSettling, stripInFlight, controller.stripSeated, stripAbandoned) {
if (controller.stripSettling == null || stripInFlight) return@LaunchedEffect
if (!stripAbandoned) {
LaunchedEffect(controller.stripSettling, moveInFlight, controller.stripSeated, abandoned) {
if (controller.stripSettling == null || moveInFlight) return@LaunchedEffect
if (abandoned == controller.stripAbandonedMark) {
delay(if (controller.stripSeated) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
}
controller.releaseStrip()
@@ -14,7 +14,8 @@ import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.IntSize
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.nextMoveToken
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.LocalDate
import kotlin.math.abs
@@ -111,8 +112,6 @@ data class MonthChipDrop(
val event: EventInstance,
val grabDate: LocalDate,
val targetDate: LocalDate,
/** The drop's identity, which its held copy waits on — see [MonthDragController.settledToken]. */
val token: Long,
)
/**
@@ -173,8 +172,14 @@ class MonthDragController {
*/
private var undoable: MonthChipDrag? = null
/** The token of the drop (or undo) [settling] is waiting on. */
var settledToken: Long = 0L
/**
* The abandoned-move counter, read at the drop so the mark can't be a
* composition behind the tick it is compared against.
*/
var abandonedTicks: StateFlow<Int>? = null
/** What [abandonedTicks] stood at when the settling chip was let go. */
var settledAbandonedMark: Int = 0
private set
private var event: EventInstance? = null
@@ -260,7 +265,7 @@ class MonthDragController {
* there is no drop of this controller's left to undo, or the grid doesn't
* seat the moved event where the chip would have to start from.
*/
fun beginUndo(token: Long): Boolean {
fun beginUndo(): Boolean {
val last = undoable ?: return false
undoable = null
val from = last.targetDate ?: return false
@@ -269,7 +274,7 @@ class MonthDragController {
liftedInstanceId = last.event.instanceId
settledGhost = true
settledInRoot = null
settledToken = token
settledAbandonedMark = abandonedTicks?.value ?: 0
return true
}
@@ -290,9 +295,9 @@ class MonthDragController {
liftedInstanceId = landed.event.instanceId
settledGhost = true
undoable = settling
settledToken = nextMoveToken()
settledAbandonedMark = abandonedTicks?.value ?: 0
drops++
return MonthChipDrop(landed.event, landed.grabDate, target, settledToken)
return MonthChipDrop(landed.event, landed.grabDate, target)
}
/**
@@ -350,7 +355,9 @@ class MonthDragController {
@Composable
fun rememberMonthDragController(): MonthDragController {
return remember { MonthDragController() }
val controller = remember { MonthDragController() }
controller.abandonedTicks = LocalEventMove.current?.abandoned
return controller
}
val LocalMonthDrag = compositionLocalOf<MonthDragController?> { null }
@@ -125,7 +125,8 @@ import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.dropStates
import de.jeanlucmakiola.calendula.ui.common.moveInFlight
import de.jeanlucmakiola.calendula.ui.common.abandonedMoves
import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
@@ -514,7 +515,7 @@ fun MonthScreen(
val chipDrag = rememberMonthDragController()
// Undo moves the event back, so the chip travels back too. The
// signal comes from the write, not from the chip that offers it.
OnUndoStarted { token -> chipDrag.beginUndo(token) }
OnUndoStarted { chipDrag.beginUndo() }
Box(
modifier = Modifier
.padding(innerPadding)
@@ -594,9 +595,7 @@ private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Bo
val density = LocalDensity.current
val timeFit = rememberMonthChipTimeFit(showStartTimes)
val reduceMotion = rememberReduceMotion()
val drops = dropStates()
val moveInFlight = controller.settledToken in drops.pending
val abandoned = controller.settledToken in drops.abandoned
val moveInFlight = moveInFlight()
// Here rather than beside the controller: this reads [drag], which changes
// every frame, and this composable is the one that is meant to.
DragSnapHaptics(controller.drag?.targetDate, controller.drops)
@@ -609,12 +608,14 @@ private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Bo
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
val seat = controller.settledInRoot
val abandoned = abandonedMoves()
LaunchedEffect(controller.settling, moveInFlight, seat, abandoned) {
val landed = controller.settling
if (landed == null || moveInFlight) return@LaunchedEffect
if (seat == null) {
// An abandoned drop has no seat coming.
if (!abandoned) delay(MONTH_SETTLE_TIMEOUT_MILLIS)
// Past the mark this drop was let go on, it was abandoned and no
// seat is ever coming.
if (abandoned == controller.settledAbandonedMark) delay(MONTH_SETTLE_TIMEOUT_MILLIS)
} else {
if (!gliding) glide.snapTo(landed.topLeftInRoot)
gliding = true
@@ -2690,7 +2691,6 @@ private fun monthChipDragModifier(
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.ByDays(delta),
token = drop.token,
),
)
}
@@ -592,7 +592,6 @@ private fun WeekContent(
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.Start(start),
token = drop.token,
),
)
}
@@ -612,7 +611,6 @@ private fun WeekContent(
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.Span(drop.start, drop.end),
token = drop.token,
),
)
}
@@ -628,7 +626,6 @@ private fun WeekContent(
is StripDrop.Days -> MoveTarget.ByDays(drop.days)
is StripDrop.Timed -> MoveTarget.Span(drop.start, drop.end)
},
token = drop.token,
),
)
}
-1
View File
@@ -110,7 +110,6 @@
<string name="event_move_failed">Couldn\'t move the event</string>
<string name="event_move_write_denied">Calendula needs write access to move events</string>
<string name="event_move_gone">That event no longer exists</string>
<string name="event_move_stale">That event moved before your drop could be saved</string>
<string name="event_move_blocked_series_end">Can\'t move an event past the end of its series</string>
<string name="event_move_blocked_cancelled_series">A cancelled series can only move as a whole, and this would change which days it falls on</string>
@@ -13,12 +13,10 @@ import de.jeanlucmakiola.calendula.testEvent
import de.jeanlucmakiola.calendula.testPrefs
import de.jeanlucmakiola.calendula.testRepository
import de.jeanlucmakiola.calendula.testSettings
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.LocalDate
@@ -81,43 +79,15 @@ class RescheduleViewModelTest {
status = status,
)
/**
* [present] are the events whose occurrence the provider still reports where
* a drop picked it up; [io] a [StandardTestDispatcher] keeps a drop in flight.
*/
private fun viewModel(
tempDir: Path,
fake: FakeCalendarDataSource,
present: List<Long> = listOf(42L, 7L),
io: CoroutineDispatcher = dispatcher,
): RescheduleViewModel {
fake.occurrencesAt(present)
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel {
val repo = testRepository(
fake, tempDir, io,
fake, tempDir, dispatcher,
prefs = testPrefs(tempDir, CoroutineScope(dispatcher)),
settings = testSettings(tempDir, CoroutineScope(dispatcher)),
)
return RescheduleViewModel(repo, io, FakeZoneSource())
return RescheduleViewModel(repo, dispatcher, FakeZoneSource())
}
private fun slowViewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel =
viewModel(tempDir, fake, io = StandardTestDispatcher(dispatcher.scheduler))
/** Every query answers with an occurrence of each of [ids] exactly at the queried span. */
private fun FakeCalendarDataSource.occurrencesAt(ids: List<Long>) {
instancesResult = { begin, end ->
ids.map { id ->
testEvent(
id = id,
start = Instant.fromEpochMilliseconds(begin),
end = Instant.fromEpochMilliseconds(end),
)
}
}
}
private val RescheduleViewModel.abandonedCount: Int get() = drops.value.abandoned.size
private fun CoroutineScope.activate(vm: RescheduleViewModel): Job =
launch { vm.movableCalendarIds.collect {} }
@@ -127,7 +97,6 @@ class RescheduleViewModelTest {
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.ByDays(2),
token = nextMoveToken(),
)
/** A drop an hour later, expressed the way a timeline drag expresses one. */
@@ -136,7 +105,6 @@ class RescheduleViewModelTest {
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis + 3_600_000L)),
token = nextMoveToken(),
)
/** The end dragged an hour later, the start left where it is. */
@@ -148,7 +116,6 @@ class RescheduleViewModelTest {
Instant.fromEpochMilliseconds(beginMillis),
Instant.fromEpochMilliseconds(endMillis + 3_600_000L),
),
token = nextMoveToken(),
)
@Test
@@ -292,148 +259,21 @@ class RescheduleViewModelTest {
}
@Test
fun `a drop of another event waits for the one in flight, then lands`(
fun `a second drop is refused while the first is still in flight`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail() }
}
val vm = slowViewModel(tempDir, fake)
val first = oneHourLater()
val second = toWednesday().copy(eventId = 7L, token = nextMoveToken())
assertThat(vm.move(first)).isTrue()
assertThat(vm.move(second)).isTrue()
assertThat(vm.drops.value.pending).containsExactly(first.token, second.token)
advanceUntilIdle()
assertThat(fake.updatedEvents.map { it.first }).containsExactly(42L, 7L).inOrder()
assertThat(vm.drops.value).isEqualTo(DropStates())
assertThat(vm.inFlight.value).isFalse()
}
@Test
fun `a failed drop lets go of its own copy only, not the one queued behind it`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail() }
}
val vm = slowViewModel(tempDir, fake)
// Zero distance: refused in prepare.
val nowhere = oneHourLater().copy(
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
token = nextMoveToken(),
)
val queued = toWednesday().copy(eventId = 7L, token = nextMoveToken())
vm.move(nowhere)
vm.move(queued)
advanceUntilIdle()
assertThat(vm.drops.value.abandoned).containsExactly(nowhere.token)
assertThat(fake.updatedEvents.map { it.first }).containsExactly(7L)
}
@Test
fun `the same event dropped twice ends up where it was dropped last`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val hour = 3_600_000L
val fake = FakeCalendarDataSource()
val vm = slowViewModel(tempDir, fake)
// Once the first drop is written, the event sits an hour later.
fake.eventDetailResult = {
if (fake.updatedEvents.isEmpty()) detail() else detail(anchorMillis = beginMillis + hour)
}
fake.instancesResult = { _, _ ->
val shift = if (fake.updatedEvents.isEmpty()) 0L else hour
listOf(
testEvent(
id = 42L,
start = Instant.fromEpochMilliseconds(beginMillis + shift),
end = Instant.fromEpochMilliseconds(endMillis + shift),
),
)
}
vm.move(oneHourLater())
// Picked up from where the grid still drew it, and dropped two days on.
vm.move(toWednesday())
advanceUntilIdle()
assertThat(fake.updatedEvents).hasSize(2)
val last = fake.updatedEvents.last().third
assertThat(last.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 10), LocalTime(13, 0)))
}
@Test
fun `a recurring occurrence that moved since the drop is not written`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail(rrule = "FREQ=WEEKLY") } }
val vm = viewModel(tempDir, fake, present = emptyList())
val drop = toWednesday()
vm.move(drop)
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isNull()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Stale)
assertThat(vm.drops.value.abandoned).containsExactly(drop.token)
assertThat(fake.updatedEvents).isEmpty()
}
@Test
fun `a deleted event reports itself as gone, not moved`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { null } }
val vm = viewModel(tempDir, fake, present = emptyList())
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Gone)
}
@Test
fun `a failure stays on the chip while later drops land`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { id ->
if (id == 42L) detail(rrule = "FREQ=WEEKLY;BYDAY=MO,WE", status = EventStatus.Cancelled) else detail()
}
}
val vm = slowViewModel(tempDir, fake)
vm.move(toWednesday())
vm.move(toWednesday().copy(eventId = 7L, token = nextMoveToken()))
advanceUntilIdle()
assertThat(fake.updatedEvents.map { it.first }).containsExactly(7L)
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedCancelledSeries)
}
@Test
fun `a drop made while a scope dialog is up runs once it is answered`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { id -> if (id == 42L) detail(rrule = "FREQ=WEEKLY") else detail() }
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
}
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
assertThat(vm.move(toWednesday())).isTrue()
advanceUntilIdle()
val queued = toWednesday().copy(eventId = 7L, token = nextMoveToken())
assertThat(vm.move(queued)).isTrue()
// Parked on the scope dialog, so the first drop still owns the pipeline.
assertThat(vm.move(oneHourLater())).isFalse()
advanceUntilIdle()
assertThat(fake.updatedEvents).isEmpty()
assertThat(vm.drops.value.pending).contains(queued.token)
vm.moveWithScope(RecurringWriteScope.AllEvents)
advanceUntilIdle()
assertThat(fake.updatedEvents.map { it.first }).containsExactly(42L, 7L).inOrder()
assertThat(vm.drops.value.pending).isEmpty()
}
@Test
@@ -449,14 +289,14 @@ class RescheduleViewModelTest {
vm.move(toWednesday())
advanceUntilIdle()
val abandoned = vm.abandonedCount
val abandoned = vm.abandoned.value
assertThat(vm.undo(undo)).isFalse()
advanceUntilIdle()
assertThat(fake.updatedEvents).hasSize(1)
// Nothing ticks: the refusal started no journey of its own, and the
// counter is global — a tick here would release the copy the drop that
// *is* in flight is holding, before the grid has drawn it.
assertThat(vm.abandonedCount).isEqualTo(abandoned)
assertThat(vm.abandoned.value).isEqualTo(abandoned)
}
@Test
@@ -471,13 +311,13 @@ class RescheduleViewModelTest {
advanceUntilIdle()
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
val abandoned = vm.abandonedCount
val abandoned = vm.abandoned.value
fake.writeError = SecurityException("permission revoked")
assertThat(vm.undo(undo)).isTrue()
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
assertThat(vm.abandonedCount).isEqualTo(abandoned + 1)
assertThat(vm.abandoned.value).isEqualTo(abandoned + 1)
}
@Test
@@ -490,12 +330,12 @@ class RescheduleViewModelTest {
advanceUntilIdle()
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
val abandoned = vm.abandonedCount
val abandoned = vm.abandoned.value
vm.undo(undo)
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Undone)
assertThat(vm.abandonedCount).isEqualTo(abandoned)
assertThat(vm.abandoned.value).isEqualTo(abandoned)
}
@Test
@@ -575,7 +415,6 @@ class RescheduleViewModelTest {
target = MoveTarget.Start(
Instant.fromEpochMilliseconds(beginMillis + 26 * 3_600_000L),
),
token = nextMoveToken(),
),
)
advanceUntilIdle()
@@ -596,7 +435,6 @@ class RescheduleViewModelTest {
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
token = nextMoveToken(),
),
)
advanceUntilIdle()
@@ -718,18 +556,17 @@ class RescheduleViewModelTest {
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
token = nextMoveToken(),
),
)).isTrue()
advanceUntilIdle()
assertThat(vm.abandonedCount).isEqualTo(1)
assertThat(vm.abandoned.value).isEqualTo(1)
// Refused by the provider, after the write was attempted.
fake.writeError = SecurityException("revoked")
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
assertThat(vm.abandonedCount).isEqualTo(2)
assertThat(vm.abandoned.value).isEqualTo(2)
}
@Test
@@ -743,11 +580,11 @@ class RescheduleViewModelTest {
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.abandonedCount).isEqualTo(0)
assertThat(vm.abandoned.value).isEqualTo(0)
vm.cancelScope()
advanceUntilIdle()
assertThat(vm.abandonedCount).isEqualTo(1)
assertThat(vm.abandoned.value).isEqualTo(1)
}
@Test
@@ -759,7 +596,7 @@ class RescheduleViewModelTest {
advanceUntilIdle()
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
assertThat(vm.abandonedCount).isEqualTo(0)
assertThat(vm.abandoned.value).isEqualTo(0)
}
@Test
@@ -831,7 +668,6 @@ class RescheduleViewModelTest {
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Span(sundayEvening, Instant.fromEpochMilliseconds(endMillis)),
token = nextMoveToken(),
),
)
advanceUntilIdle()
@@ -873,7 +709,7 @@ class RescheduleViewModelTest {
assertThat(vm.scopePrompt.value).isNull()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedCancelledSeries)
assertThat(vm.abandonedCount).isEqualTo(1)
assertThat(vm.abandoned.value).isEqualTo(1)
assertThat(fake.updatedEvents).isEmpty()
assertThat(fake.updatedOccurrences).isEmpty()
}
@@ -903,7 +739,6 @@ class RescheduleViewModelTest {
beginMillis = dayStart,
endMillis = dayStart + 86_400_000L,
target = MoveTarget.Span(start, Instant.fromEpochMilliseconds(endMillis)),
token = nextMoveToken(),
),
)
advanceUntilIdle()
@@ -940,7 +775,6 @@ class RescheduleViewModelTest {
beginMillis = dayStart,
endMillis = dayStart + 86_400_000L,
target = MoveTarget.Span(eight, eight + 1.hours),
token = nextMoveToken(),
),
)
advanceUntilIdle()