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
37 changed files with 328 additions and 1179 deletions

No files matched your search

+1 -4
View File
@@ -318,16 +318,13 @@ jobs:
# not provide node inside custom job containers, so JavaScript actions
# (checkout, download-artifact) can't run there. The Renovate job gets away
# with a container because its only step is a shell command. Ruby is installed
# the same way sshpass, jq and fdroidserver are in the job above, from trixie:
# fastlane >= 2.241 needs Ruby 3.2+, and bookworm's apt only has 3.1.
# the same way sshpass, jq and fdroidserver are in the job above.
play:
needs: [detect, release]
# workflow_dispatch is the F-Droid re-sign recovery path — it must never
# touch Play, so gate on a real release only.
if: needs.detect.outputs.is_release == 'true'
runs-on: docker
container:
image: node:22-trixie
env:
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
# directly drops the docker-in-docker requirement entirely.
# Full tag pinned; Renovate's github-actions manager keeps it bumped.
container:
image: ghcr.io/renovatebot/renovate:44.237.3
image: ghcr.io/renovatebot/renovate:43.232.0
steps:
- name: Run Renovate
run: renovate
-12
View File
@@ -7,16 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.24.2] — 2026-11-13
### Fixed
- **Quick moves no longer get lost.** Dropping an event while an earlier move was still saving used to snap it back without a word. Drops now wait in line and are saved in order, and a moved event stays where you dropped it until the calendar shows it there. If an event changed before a drop could be saved, or the line is full, a message says so instead ([#494]).
- **Undo after a run of moves** keeps the first move's Undo, and each failed move gets its own message rather than replacing the last one ([#494]).
### Changed
- **The day view's title names the month**, like the week and 3-day views. Weekday and date already sit in the column header below it ([#501]).
- Library updates (kotlinx-datetime, AppCompat).
## [2.24.1] — 2026-11-12
### Added
@@ -1983,5 +1973,3 @@ automatically, with zero telemetry and no internet permission.
[#489]: https://codeberg.org/jlmakiola/calendula/issues/489
[#491]: https://codeberg.org/jlmakiola/calendula/issues/491
[#493]: https://codeberg.org/jlmakiola/calendula/issues/493
[#494]: https://codeberg.org/jlmakiola/calendula/issues/494
[#501]: https://codeberg.org/jlmakiola/calendula/issues/501
+1 -1
View File
@@ -7,4 +7,4 @@ source "https://rubygems.org"
# Pinned exactly; Renovate's bundler manager keeps it bumped. No Gemfile.lock is
# committed on purpose: this resolves an uploader's transitive deps, not the
# app's, and none of it affects the reproducible release build.
gem "fastlane", "2.241.1"
gem "fastlane", "2.237.0"
+2 -2
View File
@@ -32,8 +32,8 @@ android {
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md.
versionCode = 2240299
versionName = "2.24.2"
versionCode = 2240199
versionName = "2.24.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -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,
)
}
@@ -8,9 +8,9 @@ import java.util.Locale
* out in [locale]'s own order, with the year shown only when [date] falls outside
* [currentYear].
*
* Pass [skeleton] *without* a year field, e.g. "LLLL" for a month. A skeleton
* is a field list, so wanting the year is just asking for one more field; the
* locale still decides where it lands ("July 2026" vs "2026年7月").
* Pass [skeleton] *without* a year field — "LLLL" for a month, "EEEdMMM" for a
* day. A skeleton is a field list, so wanting the year is just asking for one
* more field; the locale still decides where it lands ("July 2026" vs "2026年7月").
*
* Dropping the year in the current year is the one bit of policy here: the title
* sits directly above a grid that already says which year it is, and the year's
@@ -90,9 +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.Skipped -> stringResource(R.string.event_move_skipped)
MoveOutcome.QueueFull -> stringResource(R.string.event_move_queue_full)
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)
@@ -1,15 +1,12 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.animateFloatAsState
import kotlinx.coroutines.delay
import androidx.compose.animation.core.tween
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.Composable
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
@@ -19,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
@@ -41,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 undo write that just began — see `RescheduleViewModel.undoStarted`. */
val undoStarted: StateFlow<UndoStart?>,
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,
) {
@@ -61,42 +63,30 @@ 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<UndoStart?>(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
}
/**
* 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 abandonedMoves(): Int {
val flow = LocalEventMove.current?.abandoned ?: NEVER_ABANDONED
return flow.collectAsStateWithLifecycle().value
}
@@ -105,53 +95,17 @@ fun dropStates(): DropStates {
* this composable came on screen.
*/
@Composable
fun OnUndoStarted(onUndo: (UndoStart) -> Unit) {
fun OnUndoStarted(onUndo: () -> Unit) {
val flow = LocalEventMove.current?.undoStarted ?: NEVER_UNDONE
val start by flow.collectAsStateWithLifecycle()
var seen by remember { mutableStateOf(start) }
LaunchedEffect(start) {
val started = start
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()
}
}
/**
* The life of a drop parked by a newer pickup (#494): held while its [token]
* is pending, then a beat after the grid [seated] it — or [timeoutMillis] if it
* never does — handed over and faded, then released. Returns the copy's alpha.
* An abandoned drop skips the wait; nothing is coming to seat it.
*/
@Composable
fun rememberHeldSettle(
token: Long,
seated: Boolean,
timeoutMillis: Long,
onHandOver: () -> Unit,
onRelease: () -> Unit,
): Float {
val drops = dropStates()
val pending = token in drops.pending
val abandoned = token in drops.abandoned
val handOver by rememberUpdatedState(onHandOver)
val release by rememberUpdatedState(onRelease)
var fading by remember(token) { mutableStateOf(false) }
LaunchedEffect(token, pending, abandoned, seated) {
if (pending) return@LaunchedEffect
if (!abandoned) delay(if (seated) SETTLE_GRACE_MILLIS else timeoutMillis)
fading = true
handOver()
delay(SETTLE_FADE_MILLIS.toLong())
release()
}
return animateFloatAsState(
targetValue = if (fading) 0f else 1f,
animationSpec = tween(SETTLE_FADE_MILLIS),
label = "held-settle",
).value
}
/** Opacity the source block keeps while its floating copy travels. */
const val GHOST_ALPHA: Float = 0.3f
@@ -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
@@ -41,21 +40,12 @@ import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
import kotlin.time.TimeMark
import kotlin.time.TimeSource
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
/** How long after a write its event's drawn times may still be the old ones. */
private val RECENT_WRITE = 3.seconds
/** Where a dragged event should land. */
sealed interface MoveTarget {
/** A new start instant — a timeline drag, which moves time and day at once. */
@@ -84,10 +74,6 @@ data class MoveRequest(
val beginMillis: Long,
val endMillis: Long,
val target: MoveTarget,
/** Whether the dragged occurrence is all-day, which decides the zone its day is read in. */
val isAllDay: Boolean,
/** The drop's own identity, from [nextMoveToken], which the view holding its copy waits on. */
val token: Long,
)
/**
@@ -106,13 +92,8 @@ data class MoveUndo(
val moved: EventForm,
/** The form as it stood before the move. */
val restored: EventForm,
/** The token of the drop this undoes, so the view that drew it carries the right chip back. */
val dropToken: Long,
)
/** An undo write that just began: its own [token], and the [dropToken] of the drop it undoes. */
data class UndoStart(val token: Long, val dropToken: Long)
sealed interface MoveOutcome {
/**
* Written. [startMillis] is where the dragged occurrence now begins, for the
@@ -134,19 +115,6 @@ sealed interface MoveOutcome {
/** The event vanished (sync, another device) between the read and the write. */
data object Gone : MoveOutcome
/** Drops that waited behind a scope dialog, cancelled rather than run after it (#494). */
data object Skipped : MoveOutcome
/** More drops waiting than the queue holds; this one was not taken. */
data object QueueFull : MoveOutcome
/**
* The drop's recurring occurrence may no longer be where it was picked up —
* its series was just written — and 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
@@ -184,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<UndoStart?>(null)
private val _undoStarted = MutableStateFlow(0)
/**
* An undo write the moment it begins, before the provider has anything to
* 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. Its fate then reads off [drops].
* let it reappear on the old day.
*/
val undoStarted: StateFlow<UndoStart?> = _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) {
@@ -217,22 +189,6 @@ class RescheduleViewModel @Inject constructor(
_inFlight.value = value
}
/** Whether a move has been announced since the pipeline was last idle — see [report]. */
private var movedThisRun = false
/** Outcomes waiting for the chip, shown one after another as each is consumed. */
private val notices = ArrayDeque<MoveOutcome>()
/**
* When each event's series (by id) or single occurrence (by id and begin)
* was last written. The grid draws the old times until it re-reads, so a
* drop on a recurring event written moments ago may carry stale times (#494).
*/
private val written = mutableMapOf<Pair<Long, Long?>, TimeMark>()
/** 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.
@@ -263,50 +219,38 @@ 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) }
report(MoveOutcome.QueueFull)
if (busy || _scopePrompt.value != null) {
_abandoned.value += 1
return false
}
if (_scopePrompt.value != null) {
_drops.update { it.ended(request.token, landed = false) }
report(MoveOutcome.Skipped)
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. Drops
// waiting behind it are cancelled, not held behind a question
// about another event.
skipQueued()
// Still busy: the scope dialog is now the thing in flight.
pending = prepared
_scopePrompt.value = MoveScopePrompt(
occurrenceOnly = !prepared.canRealign,
@@ -314,6 +258,7 @@ class RescheduleViewModel @Inject constructor(
)
}
}
return true
}
/** Answer the scope dialog. */
@@ -324,92 +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
movedThisRun = false
}
}
private fun skipQueued() {
if (queue.isEmpty()) return
val skipped = queue.map { it.token }
queue.clear()
_drops.update { states -> skipped.fold(states) { acc, token -> acc.ended(token, landed = false) } }
report(MoveOutcome.Skipped)
}
/**
* Queue [outcome] for the chip. While drops run back to back only the first
* move is announced, so its Undo stays on offer; every failure gets its own
* turn after it, since the grid shows what moved but only the chip says what
* didn't. A later write to the announced move's event takes that Undo away,
* as putting the event back would undo the later move too.
*/
private fun report(outcome: MoveOutcome, eventId: Long? = null) {
if (outcome is MoveOutcome.Moved) {
if (movedThisRun) {
dropUndoFor(eventId)
return
}
movedThisRun = true
} else if (outcome == (notices.lastOrNull() ?: _outcome.value)) {
return
}
if (_outcome.value == null) _outcome.value = outcome else notices.addLast(outcome)
}
private fun dropUndoFor(eventId: Long?) {
fun MoveOutcome.withoutUndo(): MoveOutcome =
if (this is MoveOutcome.Moved && undo?.eventId == eventId) copy(undo = null) else this
_outcome.value = _outcome.value?.withoutUndo()
val rest = notices.map { it.withoutUndo() }
notices.clear()
notices.addAll(rest)
}
private fun markWritten(eventId: Long, beginMillis: Long?) {
written[eventId to beginMillis] = TimeSource.Monotonic.markNow()
}
private fun writtenRecently(eventId: Long, beginMillis: Long): Boolean {
written.entries.removeAll { it.value.elapsedNow() > RECENT_WRITE }
return (eventId to null) in written || (eventId to beginMillis) in written
_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 = UndoStart(token, undo.dropToken)
markWritten(undo.eventId, null)
_undoStarted.value += 1
viewModelScope.launch {
_outcome.value = try {
repository.updateEvent(undo.eventId, undo.moved, undo.restored)
@@ -423,40 +311,33 @@ 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
}
/** Clear the outcome once the screen has shown it, bringing on the next one waiting. */
/** Clear the outcome once the screen has shown it. */
fun consumeOutcome() {
_outcome.value = notices.removeFirstOrNull()
_outcome.value = null
}
private suspend fun prepare(dropped: MoveRequest): PreparedMove? {
val zone = zoneSource.current()
private suspend fun prepare(request: MoveRequest): PreparedMove? {
val detail = try {
repository.eventDetail(dropped.eventId)
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)
_outcome.value = MoveOutcome.Failed
return null
}
// The drop carries the times the grid drew, which an earlier write may
// have moved on since (#494).
val request = if (detail.rrule == null || detail.isException) {
followed(dropped, detail, zone)
} else if (writtenRecently(dropped.eventId, dropped.beginMillis)) {
report(MoveOutcome.Stale)
return null
} else {
dropped
}
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)
@@ -469,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
}
@@ -514,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)
@@ -556,37 +437,9 @@ class RescheduleViewModel @Inject constructor(
} catch (e: Exception) {
MoveOutcome.Failed
}
if (outcome is MoveOutcome.Moved) {
markWritten(request.eventId, request.beginMillis.takeIf { scope == RecurringWriteScope.ThisEvent && prepared.isRecurring })
}
report(outcome, request.eventId)
return outcome is MoveOutcome.Moved
}
/**
* A one-off [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. Unchanged when the event hasn't moved.
*/
private fun followed(dropped: MoveRequest, detail: EventDetail, zone: TimeZone): MoveRequest {
val now = detail.instance
val target = when (val target = dropped.target) {
is MoveTarget.ByDays -> {
// Each day read the way its own kind anchors it: all-day at UTC.
val aimed = Instant.fromEpochMilliseconds(dropped.beginMillis)
.toLocalDateTime(if (dropped.isAllDay) TimeZone.UTC else zone).date
.plus(target.days, DateTimeUnit.DAY)
val from = now.start.toLocalDateTime(if (now.isAllDay) TimeZone.UTC else zone).date
MoveTarget.ByDays((aimed.toEpochDays() - from.toEpochDays()).toInt())
}
else -> target
}
return dropped.copy(
beginMillis = now.start.toEpochMilliseconds(),
endMillis = now.end.toEpochMilliseconds(),
target = target,
isAllDay = now.isAllDay,
)
// 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
}
/**
@@ -653,7 +506,6 @@ class RescheduleViewModel @Inject constructor(
eventId = prepared.request.eventId,
moved = prepared.updated,
restored = prepared.original,
dropToken = prepared.request.token,
)
} else {
null
@@ -20,7 +20,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -61,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
@@ -139,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,9 +297,7 @@ data class TimelineDrop(
val date: LocalDate,
val startMin: Int,
/** Minutes from the event's start to the dragged block's top edge. */
val clipOffsetMin: Int,
/** The drop's identity, which its held copy waits on. */
val token: Long,
val clipOffsetMin: Int = 0,
)
/** The id a drafted block's stand-in event carries, which no provider row has (#153). */
@@ -350,37 +340,8 @@ internal fun draftBlock(draft: EventInstance, date: LocalDate, zone: TimeZone):
/** Which end of an event a resize handle moves (#141). */
enum class ResizeEdge { Top, Bottom }
/** A drop parked by a newer pickup while its write still runs — see [TimelineDragController.held]. */
data class HeldDrop(
val copy: TimelineDrag,
val token: Long,
val instanceId: Long,
/** The event row and start the drop came from, as [TimelineDragController.ghosts] matches them. */
val origin: Pair<Long, Instant>,
/** A resize's new end, which has to have landed too before the drop counts as seated. */
val end: Instant?,
/** Whether the grid now draws the drop at its landing slot. */
val seated: Boolean = false,
val handedOver: Boolean = false,
) {
fun ghosts(block: TimedBlock): Boolean = block.event.instanceId == instanceId ||
(block.event.eventId == origin.first && block.event.start == origin.second)
}
/** A strip drop parked by a newer one while its write still runs — see [TimelineDragController.heldStrips]. */
data class HeldStrip(val drag: StripDrag, val token: Long, val seated: Boolean = false)
/** An event's new start and end, as a resize leaves them. */
data class ResizedSpan(val start: Instant, val end: Instant)
/** 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. */
val token: Long,
)
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
@@ -391,16 +352,17 @@ data class TimelineResizeDrop(
* it was drawn (see [repicked]). An end takes a pass the grid draws at the drop
* point, so the block settles under the finger.
*/
internal fun resizedSpan(
internal fun resizedDrop(
event: EventInstance,
edge: ResizeEdge?,
date: LocalDate,
startMin: Int,
spanMin: Int,
zone: TimeZone,
): ResizedSpan {
): TimelineResizeDrop {
val drawn = wallClockSpan(event, zone)
return ResizedSpan(
return TimelineResizeDrop(
event = event,
start = if (edge == ResizeEdge.Top) {
event.start.toFormTime(zone).repicked(
wallClockTime(date, startMin),
@@ -551,14 +513,6 @@ class TimelineDragController {
*/
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
/**
* Earlier drops still being written when another block was picked up. They
* keep their copy and their ghost until their own token settles, rather than
* vanishing and flashing back at the old slot (#494).
*/
var held: List<HeldDrop> by mutableStateOf(emptyList())
private set
/** How long a block drafted on an empty slot starts out, in minutes (#153). */
var draftMinutes: Int = SettingsPrefs.DEFAULT_EVENT_DURATION
@@ -657,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
@@ -701,12 +661,8 @@ class TimelineDragController {
var stripSeated: Boolean by mutableStateOf(false)
private set
/** Strip drops parked by a newer strip drop while their writes still run (#494). */
var heldStrips: List<HeldStrip> by mutableStateOf(emptyList())
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. */
@@ -731,7 +687,7 @@ class TimelineDragController {
dragDuration = (block.event.end - block.event.start).coerceAtLeast(Duration.ZERO)
dragKept = zone.repeatedHourOffsetAt(block.event.start)
this.titleLines = titleLines
parkSettling()
settling = null
isDragging = true
liftedInstanceId = block.event.instanceId
grab = pointerInRoot - blockInRoot
@@ -770,7 +726,7 @@ class TimelineDragController {
dragDuration = draftMinutes.minutes
dragKept = null
titleLines = 1
parkSettling()
settling = null
isDragging = true
liftedInstanceId = event.instanceId
grab = Offset(0f, geometry.hourPx * STRIP_GRAB_MINUTES / 60f)
@@ -793,18 +749,16 @@ class TimelineDragController {
val zone = dragZone
cancel()
if (held.overGrid && landed != null && zone != null && !landed.edgeOnly) {
val token = settle(landed)
val start = dropStartInstant(landed.event, landed.date, landed.startMin, clipOffsetMin = 0, zone)
return StripDrop.Timed(held.event, start, start + draftMinutes.minutes, token)
settle(landed)
val start = TimelineDrop(landed.event, landed.date, landed.startMin).startInstant(zone)
return StripDrop.Timed(held.event, start, start + draftMinutes.minutes)
}
if (held.overGrid || held.dayShift == 0) return null
// One still settling keeps its place on the strip until its own write lands (#494).
stripSettling?.let { heldStrips = heldStrips + HeldStrip(it, stripToken, stripSeated) }
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. */
@@ -818,21 +772,6 @@ class TimelineDragController {
if (stripSettling != null) stripSeated = true
}
/** Note that the strip draws the parked strip drop [token] at its new days. */
fun seatHeldStrip(token: Long) {
if (heldStrips.any { it.token == token && !it.seated }) {
heldStrips = heldStrips.map { if (it.token == token) it.copy(seated = true) else it }
}
}
/** Let the parked strip drop [token] go. */
fun releaseHeldStrip(token: Long) {
heldStrips = heldStrips.filterNot { it.token == token }
}
/** Every strip drop still shown at its new days: the parked ones, then the latest. */
fun stripPreviews(): List<StripDrag> = heldStrips.map { it.drag } + listOfNotNull(stripDrag ?: stripSettling)
fun move(pointerInRoot: Offset) {
pointer = pointerInRoot
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
@@ -867,36 +806,12 @@ class TimelineDragController {
* an event that crosses midnight (#253).
*/
fun ghosts(block: TimedBlock): Boolean {
if (held.any { !it.handedOver && it.ghosts(block) }) return true
if (liftedInstanceId == null) return false
if (block.event.instanceId == liftedInstanceId) return true
val (eventId, start) = settledOrigin ?: return false
return block.event.eventId == eventId && block.event.start == start
}
/** Move a still-settling drop to [held] before a new pickup takes the slot. */
private fun parkSettling() {
val landed = settling
val origin = settledOrigin
if (landed != null && origin != null && !landed.event.isDraft) {
held = held + HeldDrop(landed, settledToken, landed.event.instanceId, origin, settledEnd, settledOnGrid)
}
settling = null
settledOnGrid = false
settledOrigin = null
settledEnd = null
}
/** Stop ghosting the held drop [token]'s source while its copy fades. */
fun handOverHeld(token: Long) {
held = held.map { if (it.token == token) it.copy(handedOver = true) else it }
}
/** Drop the held copy [token] once it has faded. */
fun releaseHeld(token: Long) {
held = held.filterNot { it.token == token }
}
/**
* What a day column now holds, so a settled drop can tell when the grid has
* caught up with it. Matched on the landing slot plus either the event row
@@ -904,23 +819,16 @@ class TimelineDragController {
* blank title matches nothing: every untitled event has one (#253).
*/
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
if (held.any { !it.seated && drawsDrop(it.copy, it.end, date, blocks) }) {
held = held.map { if (!it.seated && drawsDrop(it.copy, it.end, date, blocks)) it.copy(seated = true) else it }
}
val landed = settling ?: return
if (settledOnGrid) return
settledOnGrid = drawsDrop(landed, settledEnd, date, blocks)
}
/** Whether [blocks], [date]'s column, draws the drop [landed] where it landed. */
private fun drawsDrop(landed: TimelineDrag, end: Instant?, date: LocalDate, blocks: List<TimedBlock>): Boolean {
// Where this day would draw it, which for a drop clipped at midnight is
// the day's own start rather than the edge the finger held (#253) — and
// which for a tail dragged clear of its day is the day before it.
val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt()
val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset)
.firstOrNull() ?: return false
return blocks.any { block ->
.firstOrNull() ?: return
val end = settledEnd
settledOnGrid = blocks.any { block ->
block.startMin == slice.startMin &&
// A resize may keep its start, so its end has to have landed too.
(end == null || block.event.end == end) &&
@@ -947,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 &&
@@ -968,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
}
/**
@@ -991,7 +895,7 @@ class TimelineDragController {
resize = session
clipOffsetMin = 0
this.titleLines = titleLines
parkSettling()
settling = null
isDragging = true
pointerMoved = true
liftedInstanceId = block.event.instanceId
@@ -1022,10 +926,10 @@ class TimelineDragController {
val event = source?.event ?: return null
val edge = resize?.edge
if (edge != null) {
val drop = resizedSpan(event, edge, date, startMin, spanMin, zone)
val drop = resizedDrop(event, edge, date, startMin, spanMin, zone)
return event.copy(start = drop.start, end = drop.end)
}
val start = dropStartInstant(event, date, startMin, clipOffsetMin, zone)
val start = TimelineDrop(event, date, startMin, clipOffsetMin).startInstant(zone)
return event.copy(start = start, end = start + dragDuration)
}
@@ -1039,8 +943,9 @@ class TimelineDragController {
val edge = resize?.edge
cancel()
if (landed == null || landed.slot == origin) return null
val span = resizedSpan(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
return TimelineResizeDrop(landed.event, span.start, span.end, settle(landed, end = span.end))
val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
settle(landed, end = drop.end)
return drop
}
/**
@@ -1294,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
}
@@ -1323,16 +1229,7 @@ fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean =
* visibly settles there.
*/
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
dropStartInstant(event, date, startMin, clipOffsetMin, zone)
/** Where [event] starts when its block's top edge lands [startMin] past [date]'s midnight — see [TimelineDrop]. */
internal fun dropStartInstant(
event: EventInstance,
date: LocalDate,
startMin: Int,
clipOffsetMin: Int,
zone: TimeZone,
): Instant = wallClockInstant(date, startMin - clipOffsetMin, zone, kept = zone.repeatedHourOffsetAt(event.start))
wallClockInstant(date, startMin - clipOffsetMin, zone, kept = zone.repeatedHourOffsetAt(event.start))
/**
* The wall-clock minutes an event lasting [duration] covers when it starts
@@ -1404,7 +1301,6 @@ private const val SETTLE_TIMEOUT_MILLIS = 900L
*/
const val SETTLE_FADE_MILLIS: Int = 250
/**
* The floating block, drawn over the whole calendar so it is free of the day
* column's clip and of the scroll viewport's rounded corners.
@@ -1412,10 +1308,12 @@ const val SETTLE_FADE_MILLIS: Int = 250
@Composable
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
var origin by remember { mutableStateOf(Offset.Zero) }
val density = LocalDensity.current
val dark = LocalFloretDarkTheme.current
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.
@@ -1429,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
@@ -1443,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()
@@ -1460,12 +1358,6 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
.clearAndSetSemantics { }
.onGloballyPositioned { origin = it.positionInRoot() },
) {
controller.held.forEach { parked ->
key(parked.token) { HeldTimelineCopy(controller, parked, origin) }
}
controller.heldStrips.forEach { parked ->
key(parked.token) { HeldStripSettle(controller, parked) }
}
val drag = controller.drag ?: controller.settling ?: return@Box
// Landed: the copy sinks back to the grid's own plane while the write runs.
val landed = controller.drag == null
@@ -1479,58 +1371,6 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
animationSpec = tween(SETTLE_FADE_MILLIS),
label = "drag-handover",
)
TimelineCopy(drag, controller.geometry, origin, lift, copyAlpha)
}
}
/**
* [TimelineDragController.ghosts] for [block], derived so the block recomposes
* only when its own answer changes rather than on every lift, park and release.
*/
@Composable
fun TimelineDragController.ghostsDerived(block: TimedBlock): Boolean {
val ghosted by remember(this, block) { derivedStateOf { ghosts(block) } }
return ghosted
}
/** A drop parked by a newer pickup: held at its landing slot until its own write settles. */
@Composable
private fun HeldTimelineCopy(controller: TimelineDragController, parked: HeldDrop, origin: Offset) {
val alpha = rememberHeldSettle(
token = parked.token,
seated = parked.seated,
timeoutMillis = SETTLE_TIMEOUT_MILLIS,
onHandOver = { controller.handOverHeld(parked.token) },
onRelease = { controller.releaseHeld(parked.token) },
)
TimelineCopy(parked.copy, controller.geometry, origin, lift = 0f, copyAlpha = alpha)
}
/** A strip drop parked by a newer one: shown at its new days until its own write settles. */
@Composable
private fun HeldStripSettle(controller: TimelineDragController, parked: HeldStrip) {
rememberHeldSettle(
token = parked.token,
seated = parked.seated,
timeoutMillis = SETTLE_TIMEOUT_MILLIS,
onHandOver = {},
onRelease = { controller.releaseHeldStrip(parked.token) },
)
}
/** [drag]'s pieces drawn over the calendar, clipped to the timeline's viewport. */
@Composable
private fun TimelineCopy(
drag: TimelineDrag,
geometry: TimelineGeometry,
origin: Offset,
lift: Float,
copyAlpha: Float,
) {
val density = LocalDensity.current
val dark = LocalFloretDarkTheme.current
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val paint = eventPaint(drag.event, dark)
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
// Read off the event rather than the held block, so grabbing either half
@@ -1546,7 +1386,7 @@ private fun TimelineCopy(
// so no column clip or rounded corner cuts it, but it still belongs to
// the timeline: a piece a whole day tall reaches far past both ends of
// the viewport, and unclipped it paints over the headers above (#267).
val port = geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
val port = controller.geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
// How much of each piece the viewport actually shows. A multi-day event
// has a piece per day, and the widest one is a full 24 hours — taller
// than the screen, its top at a midnight scrolled out of sight. Picking
@@ -1598,6 +1438,7 @@ private fun TimelineCopy(
)
}
}
}
}
/** One piece of a dragged block, floating over the calendar. */
@@ -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
@@ -106,22 +107,11 @@ data class MonthChipDrag(
val time: String? = null,
)
/** A drop parked by a newer pickup while its write still runs — see [MonthDragController.held]. */
data class HeldChip(
val copy: MonthChipDrag,
val token: Long,
val handedOver: Boolean = false,
/** Where the grid now draws the drop, in root coordinates, once a row seats it. */
val seat: Offset? = null,
)
/** Where a finished chip drag asks its event to go, as a whole-day shift. */
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,
)
/**
@@ -177,22 +167,19 @@ class MonthDragController {
private set
/**
* This controller's recent drops by token, kept past [release] for
* [beginUndo] — the confirmation chip carries Undo long after the copy is
* gone, and it may be any of them the chip offers to undo.
* The last drop this controller made, kept past [release] for [beginUndo] —
* the confirmation chip carries Undo long after the copy is gone.
*/
private val undoable = LinkedHashMap<Long, MonthChipDrag>()
private var undoable: MonthChipDrag? = null
/**
* Earlier drops still being written when another chip was picked up. They
* keep their copy and their ghost until their own token settles, rather than
* vanishing and flashing back on the old day (#494).
* The abandoned-move counter, read at the drop so the mark can't be a
* composition behind the tick it is compared against.
*/
var held: List<HeldChip> by mutableStateOf(emptyList())
private set
var abandonedTicks: StateFlow<Int>? = null
/** The token of the drop (or undo) [settling] is waiting on. */
var settledToken: Long = 0L
/** What [abandonedTicks] stood at when the settling chip was let go. */
var settledAbandonedMark: Int = 0
private set
private var event: EventInstance? = null
@@ -221,7 +208,7 @@ class MonthDragController {
this.event = event
this.grabDate = grabDate
this.time = time
parkSettling()
settling = null
isDragging = true
liftedInstanceId = event.instanceId
grab = pointerInRoot - chipInRoot
@@ -247,39 +234,6 @@ class MonthDragController {
settledGhost = false
}
/**
* Whether [event]'s chip belongs to a held drop and must stay a ghost: its
* source, or once a row seats it, the chip the grid draws on its target day.
*/
fun isHeldChip(event: EventInstance, days: List<LocalDate>?): Boolean = held.any { parked ->
!parked.handedOver && (
parked.copy.event.instanceId == event.instanceId ||
(
parked.seat != null && days != null && parked.copy.targetDate in days &&
isSameEvent(event, parked.copy.event)
)
)
}
/** Move a still-settling drop to [held] before a new pickup takes the slot. */
private fun parkSettling() {
val landed = settling
if (landed != null) held = held + HeldChip(landed, settledToken)
settling = null
settledInRoot = null
settledGhost = false
}
/** Stop ghosting the held drop [token]'s source while its copy fades. */
fun handOverHeld(token: Long) {
held = held.map { if (it.token == token) it.copy(handedOver = true) else it }
}
/** Drop the held copy [token] once it has faded. */
fun releaseHeld(token: Long) {
held = held.filterNot { it.token == token }
}
/**
* Whether [days] holds the chip the grid has drawn for the settled drop —
* the seat the copy is on its way to, which must ghost until it gets there.
@@ -296,13 +250,6 @@ class MonthDragController {
* nearest where the chip was let go is the one the finger was over.
*/
fun noteSettled(token: Any) {
val row = rows[token]
if (row != null && held.any { it.seat == null }) {
held = held.map { parked ->
val target = parked.copy.targetDate
if (parked.seat != null || target == null) parked else parked.copy(seat = row.seatOf(parked.copy.event, target))
}
}
val landed = settling ?: return
val target = landed.targetDate ?: return
val at = rows[token]?.seatOf(landed.event, target) ?: return
@@ -318,16 +265,16 @@ 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, dropToken: Long): Boolean {
val last = undoable.remove(dropToken) ?: return false
parkSettling()
fun beginUndo(): Boolean {
val last = undoable ?: return false
undoable = null
val from = last.targetDate ?: return false
val at = rows.values.firstNotNullOfOrNull { it.seatOf(last.event, from) } ?: return false
settling = last.copy(grabDate = from, targetDate = last.grabDate, topLeftInRoot = at)
liftedInstanceId = last.event.instanceId
settledGhost = true
settledInRoot = null
settledToken = token
settledAbandonedMark = abandonedTicks?.value ?: 0
return true
}
@@ -347,11 +294,10 @@ class MonthDragController {
)
liftedInstanceId = landed.event.instanceId
settledGhost = true
settledToken = nextMoveToken()
undoable[settledToken] = requireNotNull(settling)
if (undoable.size > UNDOABLE_KEPT) undoable.remove(undoable.keys.first())
undoable = settling
settledAbandonedMark = abandonedTicks?.value ?: 0
drops++
return MonthChipDrop(landed.event, landed.grabDate, target, settledToken)
return MonthChipDrop(landed.event, landed.grabDate, target)
}
/**
@@ -409,10 +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 }
/** How many recent drops a month controller keeps for an undo to carry back. */
private const val UNDOABLE_KEPT = 8
@@ -125,11 +125,11 @@ 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
import de.jeanlucmakiola.calendula.ui.common.rememberHeldSettle
import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS
import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
@@ -515,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 { start -> chipDrag.beginUndo(start.token, start.dropToken) }
OnUndoStarted { chipDrag.beginUndo() }
Box(
modifier = Modifier
.padding(innerPadding)
@@ -591,11 +591,11 @@ private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L
@Composable
private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Boolean) {
var origin by remember { mutableStateOf(Offset.Zero) }
val dark = LocalFloretDarkTheme.current
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)
@@ -608,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
@@ -633,9 +635,6 @@ private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Bo
.clearAndSetSemantics { }
.onGloballyPositioned { origin = it.positionInRoot() },
) {
controller.held.forEach { parked ->
key(parked.token) { HeldMonthChip(controller, parked, origin, timeFit) }
}
val drag = controller.drag ?: controller.settling ?: return@Box
// Landed: the copy sinks back to the grid's own plane while the write runs.
val lift by animateFloatAsState(
@@ -647,93 +646,42 @@ private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Bo
animationSpec = tween(SETTLE_FADE_MILLIS),
label = "chip-handover",
)
MonthChipCopy(
drag = drag,
// Read at layout, so the glide doesn't recompose every frame.
at = { if (gliding) glide.value else drag.topLeftInRoot },
origin = origin,
lift = lift,
alpha = copyAlpha,
MonthBar(
event = drag.event,
dark = dark,
continuesLeft = false,
continuesRight = false,
// The time the source chip carried, drawn only if this one-column
// cut-out of it has the room — which a slice of a multi-day bar has
// not, however wide the bar was.
time = drag.time,
timeFit = timeFit,
chipWidth = with(density) { drag.sizePx.width.toDp() },
modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware
// offset would mirror them across the screen in an RTL layout.
.absoluteOffset {
val at = if (gliding) glide.value else drag.topLeftInRoot
IntOffset(
(at.x - origin.x).roundToInt(),
(at.y - origin.y).roundToInt(),
)
}
.width(with(density) { drag.sizePx.width.toDp() })
.height(with(density) { drag.sizePx.height.toDp() })
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp)
.graphicsLayer {
scaleX = 1f + 0.04f * lift
scaleY = 1f + 0.04f * lift
shadowElevation = 8.dp.toPx() * lift
alpha = copyAlpha
shape = EventChipShape
clip = false
},
)
}
}
/** A drop parked by a newer pickup: held on its landing day until its own write settles. */
@Composable
private fun HeldMonthChip(
controller: MonthDragController,
parked: HeldChip,
origin: Offset,
timeFit: MonthChipTimeFit?,
) {
val reduceMotion = rememberReduceMotion()
val glide = remember { Animatable(parked.copy.topLeftInRoot, Offset.VectorConverter) }
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
val seat = parked.seat
val landed = parked.token !in dropStates().pending
// Carried onto its seat once the write is in, as the settling chip is.
LaunchedEffect(seat, landed) {
if (seat == null || !landed) return@LaunchedEffect
if (reduceMotion) glide.snapTo(seat) else glide.animateTo(seat, glideSpec)
}
val alpha = rememberHeldSettle(
token = parked.token,
seated = seat != null,
timeoutMillis = MONTH_SETTLE_TIMEOUT_MILLIS,
onHandOver = { controller.handOverHeld(parked.token) },
onRelease = { controller.releaseHeld(parked.token) },
)
MonthChipCopy(parked.copy, { glide.value }, origin, lift = 0f, alpha = alpha, timeFit = timeFit)
}
/** [drag]'s chip floating over the grid with its top left at [at], in root coordinates. */
@Composable
private fun MonthChipCopy(
drag: MonthChipDrag,
at: () -> Offset,
origin: Offset,
lift: Float,
alpha: Float,
timeFit: MonthChipTimeFit?,
) {
val density = LocalDensity.current
val dark = LocalFloretDarkTheme.current
MonthBar(
event = drag.event,
dark = dark,
continuesLeft = false,
continuesRight = false,
// The time the source chip carried, drawn only if this one-column
// cut-out of it has the room — which a slice of a multi-day bar has
// not, however wide the bar was.
time = drag.time,
timeFit = timeFit,
chipWidth = with(density) { drag.sizePx.width.toDp() },
modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware
// offset would mirror them across the screen in an RTL layout.
.absoluteOffset {
val topLeft = at()
IntOffset(
(topLeft.x - origin.x).roundToInt(),
(topLeft.y - origin.y).roundToInt(),
)
}
.width(with(density) { drag.sizePx.width.toDp() })
.height(with(density) { drag.sizePx.height.toDp() })
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp)
.graphicsLayer {
scaleX = 1f + 0.04f * lift
scaleY = 1f + 0.04f * lift
shadowElevation = 8.dp.toPx() * lift
this.alpha = alpha
shape = EventChipShape
clip = false
},
)
}
/**
* Content for both scrolling styles. No swipe detector and no [AnimatedContent]:
* vertical scrolling owns the gesture, and the list never swaps wholesale — it
@@ -2310,7 +2258,7 @@ private fun MonthWeekRow(
// Once the grid holds the settled chip, tell the controller where this row
// has seated it, so the copy in flight can glide onto it. Keyed on the week,
// which is what changes when the re-read lands.
LaunchedEffect(week, dragController?.settling, dragController?.held) {
LaunchedEffect(week, dragController?.settling) {
dragController?.noteSettled(rowToken)
}
@@ -2743,8 +2691,6 @@ private fun monthChipDragModifier(
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.ByDays(delta),
isAllDay = drop.event.isAllDay,
token = drop.token,
),
)
}
@@ -2890,17 +2836,8 @@ private fun MonthBar(
val moveAction = eventMoveAction(event)
// The source stays put as a ghost while its floating copy travels.
val monthDrag = LocalMonthDrag.current
// Derived, so a chip recomposes only when its own answer changes rather than
// on every lift, park and release anywhere in the grid.
val lifted by remember(monthDrag, event, days) {
derivedStateOf {
monthDrag != null && (
monthDrag.liftedInstanceId == event.instanceId ||
monthDrag.isSettledChip(event, days) ||
monthDrag.isHeldChip(event, days)
)
}
}
val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
monthDrag?.isSettledChip(event, days) == true
val ghost = ghostAlpha(lifted)
val shape = monthBarShape(continuesLeft, continuesRight)
Box(
@@ -157,7 +157,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.ghostsDerived
import de.jeanlucmakiola.calendula.ui.common.StripDrag
import de.jeanlucmakiola.calendula.ui.common.StripDrop
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
@@ -311,6 +310,14 @@ internal enum class TimelineKind(
// A run of days often straddles two weeks, so no single number fits it.
MultiDay(showWeekNumber = false, todayText = R.string.week_today_action, headerBias = 0f),
Day(showWeekNumber = false, todayText = R.string.day_today_action, headerBias = -1f),
;
fun title(start: LocalDate, end: LocalDate, locale: Locale, currentYear: Int, abbreviated: Boolean): String =
if (this == Day) {
formatDayTitle(start, locale, currentYear, abbreviated)
} else {
formatWeekTitle(start, end, locale, currentYear, abbreviated)
}
}
internal val CalendarView.timelineKind: TimelineKind
@@ -485,10 +492,9 @@ private fun PagedTimelineScreen(
val jumpToToday = { jumpToDate(today) }
val locale = currentLocale()
// The day view names the month too: its header already shows weekday and date (#501).
val (title, shortTitle) = remember(weekStart, weekEnd, locale, today.year) {
formatTimelineTitle(weekStart, weekEnd, locale, today.year, abbreviated = false) to
formatTimelineTitle(weekStart, weekEnd, locale, today.year, abbreviated = true)
val (title, shortTitle) = remember(titleKind, weekStart, weekEnd, locale, today.year) {
titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = false) to
titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = true)
}
PublishChrome(
ScreenChrome(
@@ -585,9 +591,7 @@ private fun WeekContent(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
isAllDay = drop.event.isAllDay,
target = MoveTarget.Start(start),
token = drop.token,
),
)
}
@@ -606,9 +610,7 @@ private fun WeekContent(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
isAllDay = drop.event.isAllDay,
target = MoveTarget.Span(drop.start, drop.end),
token = drop.token,
),
)
}
@@ -620,12 +622,10 @@ private fun WeekContent(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
isAllDay = drop.event.isAllDay,
target = when (drop) {
is StripDrop.Days -> MoveTarget.ByDays(drop.days)
is StripDrop.Timed -> MoveTarget.Span(drop.start, drop.end)
},
token = drop.token,
),
)
}
@@ -767,7 +767,8 @@ private fun WeekPager(
// Until its days arrive a page shows their frame without events.
val empty = remember(pageSpan, today) { emptySpan(pageSpan, today) }
// A bar held over other days in the strip makes room there before it is dropped.
val held = dragController.stripPreviews().takeIf { page == pagerState.currentPage }.orEmpty()
val held = (dragController.stripDrag ?: dragController.stripSettling)
?.takeIf { page == pagerState.currentPage }
val zone = LocalTimelineZone.current
val shown = remember(loaded, held, zone) { loaded?.withStripPreview(held, zone) }
val stripHeight = shown?.allDayStripHeight()
@@ -792,24 +793,17 @@ private fun WeekPager(
}
/**
* [this] with the strip bars [held] sideways laid out at the days they are over,
* so the bars there move aside live. Each bar keeps its own event, and with it
* its key and the gesture on it; one moved off the page keeps a lane it is not
* seen in.
* [this] with the strip bar [held] sideways laid out at the days it is over, so
* the bars there move aside live. The bar keeps its own event, and with it its
* key and the gesture on it; one moved off the page keeps a lane it is not seen in.
*/
internal fun WeekUiState.Success.withStripPreview(held: List<StripDrag>, zone: TimeZone): WeekUiState.Success {
val shifts = held.filter { !it.overGrid && it.dayShift != 0 }.associate { it.event.instanceId to it.dayShift }
if (shifts.isEmpty()) return this
val originals = allDaySpans.filter { it.event.instanceId in shifts }.associateBy { it.event.instanceId }
if (originals.isEmpty()) return this
val events = allDaySpans.map { span ->
shifts[span.event.instanceId]?.let { span.event.shiftedDays(it, zone) } ?: span.event
}
val spans = layoutAllDay(events, days, zone).map { span ->
originals[span.event.instanceId]?.let { span.copy(event = it.event) } ?: span
}
val laidOut = spans.mapTo(HashSet()) { it.event.instanceId }
return copy(allDaySpans = spans + originals.values.filter { it.event.instanceId !in laidOut }.map { it.copy(lane = 0) })
internal fun WeekUiState.Success.withStripPreview(held: StripDrag?, zone: TimeZone): WeekUiState.Success {
if (held == null || held.overGrid || held.dayShift == 0) return this
val id = held.event.instanceId
val original = allDaySpans.firstOrNull { it.event.instanceId == id } ?: return this
val events = allDaySpans.map { if (it.event.instanceId == id) it.event.shiftedDays(held.dayShift, zone) else it.event }
val spans = layoutAllDay(events, days, zone).map { if (it.event.instanceId == id) it.copy(event = original.event) else it }
return copy(allDaySpans = if (spans.any { it.event.instanceId == id }) spans else spans + original.copy(lane = 0))
}
private fun EventInstance.shiftedDays(days: Int, zone: TimeZone): EventInstance {
@@ -817,17 +811,6 @@ private fun EventInstance.shiftedDays(days: Int, zone: TimeZone): EventInstance
return copy(start = start.plus(days, DateTimeUnit.DAY, dates), end = end.plus(days, DateTimeUnit.DAY, dates))
}
/** Whether these bars draw the strip drop [drop] at the days it was dropped on. */
private fun List<PlacedBar>.drawStripDrop(drop: StripDrag, zone: TimeZone): Boolean {
val target = drop.event.spanFirstDay(zone).plus(drop.dayShift, DateTimeUnit.DAY)
return any { bar ->
bar.event.instanceId != drop.event.instanceId &&
(bar.event.eventId == drop.event.eventId ||
(drop.event.title.isNotBlank() && bar.event.title == drop.event.title)) &&
bar.event.spanFirstDay(zone) == target
}
}
/** A page with no events yet, for one whose days are still loading. */
private fun emptySpan(span: DaySpan, today: LocalDate): WeekUiState.Success {
val days = (0 until span.count).map { span.start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) }
@@ -1210,11 +1193,17 @@ private fun AllDayStrip(
val sideways = held?.takeIf { !it.overGrid }
val lifted = dragController.liftedInstanceId?.takeIf { active && dragController.stripDrag?.overGrid != false }
val settling = dragController.stripSettling?.takeIf { active }
val heldStrips = dragController.heldStrips.takeIf { active }.orEmpty()
// Seated once the re-read draws the event at the days it was dropped on.
LaunchedEffect(settling, heldStrips, bars) {
if (settling != null && bars.drawStripDrop(settling, zone)) dragController.seatStrip()
heldStrips.forEach { if (!it.seated && bars.drawStripDrop(it.drag, zone)) dragController.seatHeldStrip(it.token) }
LaunchedEffect(settling, bars) {
if (settling == null) return@LaunchedEffect
val target = settling.event.spanFirstDay(zone).plus(settling.dayShift, DateTimeUnit.DAY)
val landed = bars.any { bar ->
bar.event.instanceId != settling.event.instanceId &&
(bar.event.eventId == settling.event.eventId ||
(settling.event.title.isNotBlank() && bar.event.title == settling.event.title)) &&
bar.event.spanFirstDay(zone) == target
}
if (landed) dragController.seatStrip()
}
Row(
@@ -1599,7 +1588,7 @@ private fun DayColumnCard(
widthFraction = 1f / block.laneCount,
y = top,
height = height,
landing = dragController.ghostsDerived(block),
landing = dragController.ghosts(block),
)
val topInset = hourCellBlockInset(showHourGrid, cuts.top)
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
@@ -1767,7 +1756,7 @@ private fun EventBlock(
onCancel = dragController::cancel,
onRelease = { dragController.select(block.event, fromLift = draggable) },
)
val lifted = draggable && dragController.ghostsDerived(block)
val lifted = draggable && dragController.ghosts(block)
// Released in place, the block takes over the copy's lift without a dip.
val takesOverLift = selected && dragController.selection?.fromLift == true
val ghostFade = ghostAlpha(lifted)
@@ -2027,7 +2016,7 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
formatMinuteOfDay(min, is24Hour, locale)
/**
* Every timeline's title, the day view's included: just the month [weekStart] falls in.
* The week's title: just the month [weekStart] falls in.
*
* The day numbers are already in the column headers directly below, so spelling
* out "13.–19. Jul" restates them in the widest string in the bar. Naming the
@@ -2042,7 +2031,7 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
* "December" with no year would be actively misleading while the reader is
* looking straight at January dates.
*/
private fun formatTimelineTitle(
private fun formatWeekTitle(
weekStart: LocalDate,
weekEnd: LocalDate,
locale: Locale,
@@ -2057,3 +2046,17 @@ private fun formatTimelineTitle(
forceYear = weekEnd.year != weekStart.year,
)
}
/** The day's title: its weekday and date, abbreviated to the date alone when short of room. */
private fun formatDayTitle(
date: LocalDate,
locale: Locale,
currentYear: Int,
abbreviated: Boolean = false,
): String =
formatCalendarTitle(
date = date.toJavaLocalDate(),
locale = locale,
currentYear = currentYear,
skeleton = if (abbreviated) "dMMM" else "EEEdMMM",
)
-3
View File
@@ -110,9 +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_queue_full">Too many moves at once, so that one wasn\'t saved</string>
<string name="event_move_skipped">Moves made while the question was open weren\'t 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,8 +97,6 @@ class RescheduleViewModelTest {
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.ByDays(2),
isAllDay = false,
token = nextMoveToken(),
)
/** A drop an hour later, expressed the way a timeline drag expresses one. */
@@ -137,8 +105,6 @@ class RescheduleViewModelTest {
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis + 3_600_000L)),
isAllDay = false,
token = nextMoveToken(),
)
/** The end dragged an hour later, the start left where it is. */
@@ -150,8 +116,6 @@ class RescheduleViewModelTest {
Instant.fromEpochMilliseconds(beginMillis),
Instant.fromEpochMilliseconds(endMillis + 3_600_000L),
),
isAllDay = false,
token = nextMoveToken(),
)
@Test
@@ -295,228 +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() }
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
}
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 drop on a series written moments ago is not written`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
// A whole-series write shifts every occurrence; the grid still draws the
// old ones, and another occurrence may now sit at the dragged time.
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail(rrule = "FREQ=DAILY") } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
vm.moveWithScope(RecurringWriteScope.AllEvents)
advanceUntilIdle()
vm.consumeOutcome()
val again = toWednesday()
vm.move(again)
assertThat(vm.move(toWednesday())).isTrue()
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isNull()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Stale)
assertThat(vm.drops.value.abandoned).containsExactly(again.token)
assertThat(fake.updatedEvents).hasSize(1)
}
@Test
fun `a single occurrence written leaves the rest of its series movable`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail(rrule = "FREQ=WEEKLY") } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
// Parked on the scope dialog, so the first drop still owns the pipeline.
assertThat(vm.move(oneHourLater())).isFalse()
advanceUntilIdle()
vm.moveWithScope(RecurringWriteScope.ThisEvent)
advanceUntilIdle()
// Another occurrence, a week on.
val week = 7 * 86_400_000L
vm.move(oneHourLater().copy(beginMillis = beginMillis + week, endMillis = endMillis + week, token = nextMoveToken()))
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isNotNull()
}
@Test
fun `chip messages wait their turn instead of replacing each other`(
@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(vm.outcome.value).isEqualTo(MoveOutcome.BlockedCancelledSeries)
vm.consumeOutcome()
assertThat((vm.outcome.value as MoveOutcome.Moved).undo?.eventId).isEqualTo(7L)
vm.consumeOutcome()
assertThat(vm.outcome.value).isNull()
}
@Test
fun `a full queue says so`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = slowViewModel(tempDir, fake)
repeat(9) { i -> vm.move(toWednesday().copy(eventId = 100L + i, token = nextMoveToken())) }
val refused = toWednesday().copy(eventId = 200L, token = nextMoveToken())
assertThat(vm.move(refused)).isFalse()
assertThat(vm.drops.value.abandoned).containsExactly(refused.token)
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.QueueFull)
}
@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 `drops waiting when a scope dialog opens are cancelled, not held behind it`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { id -> if (id == 42L) detail(rrule = "FREQ=WEEKLY") else detail() }
}
val vm = slowViewModel(tempDir, fake)
vm.move(oneHourLater())
val queued = toWednesday().copy(eventId = 7L, token = nextMoveToken())
vm.move(queued)
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isNotNull()
assertThat(vm.drops.value.abandoned).containsExactly(queued.token)
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Skipped)
// One made while the dialog is up is refused the same way.
val during = toWednesday().copy(eventId = 7L, token = nextMoveToken())
assertThat(vm.move(during)).isFalse()
vm.moveWithScope(RecurringWriteScope.AllEvents)
advanceUntilIdle()
assertThat(fake.updatedEvents.map { it.first }).containsExactly(42L)
// The skip has its turn, then the move it waited on, with its Undo.
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Skipped)
vm.consumeOutcome()
assertThat((vm.outcome.value as MoveOutcome.Moved).undo?.eventId).isEqualTo(42L)
}
@Test
fun `the first move of a run keeps the chip and its undo`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = slowViewModel(tempDir, fake)
vm.move(oneHourLater())
vm.move(toWednesday().copy(eventId = 7L, token = nextMoveToken()))
advanceUntilIdle()
val shown = vm.outcome.value as MoveOutcome.Moved
assertThat(fake.updatedEvents).hasSize(2)
assertThat(shown.undo?.eventId).isEqualTo(42L)
}
@Test
fun `a later move of the same event takes the first move's undo away`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = slowViewModel(tempDir, fake)
vm.move(oneHourLater())
vm.move(toWednesday())
advanceUntilIdle()
assertThat(fake.updatedEvents).hasSize(2)
assertThat((vm.outcome.value as MoveOutcome.Moved).undo).isNull()
assertThat(fake.updatedEvents).isEmpty()
}
@Test
@@ -527,19 +284,19 @@ class RescheduleViewModelTest {
advanceUntilIdle()
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
// A second drop, of another event, parks on its scope dialog, which holds the pipeline.
// A second drop parks on its scope dialog, which holds the pipeline.
fake.eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
vm.move(toWednesday().copy(eventId = 7L, token = nextMoveToken()))
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
@@ -554,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
@@ -573,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
@@ -658,8 +415,6 @@ class RescheduleViewModelTest {
target = MoveTarget.Start(
Instant.fromEpochMilliseconds(beginMillis + 26 * 3_600_000L),
),
isAllDay = false,
token = nextMoveToken(),
),
)
advanceUntilIdle()
@@ -680,8 +435,6 @@ class RescheduleViewModelTest {
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
isAllDay = false,
token = nextMoveToken(),
),
)
advanceUntilIdle()
@@ -803,19 +556,17 @@ class RescheduleViewModelTest {
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
isAllDay = false,
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
@@ -829,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
@@ -845,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
@@ -917,8 +668,6 @@ class RescheduleViewModelTest {
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Span(sundayEvening, Instant.fromEpochMilliseconds(endMillis)),
isAllDay = false,
token = nextMoveToken(),
),
)
advanceUntilIdle()
@@ -960,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()
}
@@ -990,8 +739,6 @@ class RescheduleViewModelTest {
beginMillis = dayStart,
endMillis = dayStart + 86_400_000L,
target = MoveTarget.Span(start, Instant.fromEpochMilliseconds(endMillis)),
isAllDay = true,
token = nextMoveToken(),
),
)
advanceUntilIdle()
@@ -1028,8 +775,6 @@ class RescheduleViewModelTest {
beginMillis = dayStart,
endMillis = dayStart + 86_400_000L,
target = MoveTarget.Span(eight, eight + 1.hours),
isAllDay = true,
token = nextMoveToken(),
),
)
advanceUntilIdle()
@@ -53,7 +53,7 @@ class TimelineDropTest {
@Test
fun `dropping the head asks for exactly where it landed`() {
val target = LocalDate(2026, 9, 2)
val drop = TimelineDrop(overnight, target, 20 * 60, clipOffsetMin = 0, token = 0L)
val drop = TimelineDrop(overnight, target, 20 * 60, clipOffsetMin = 0)
assertThat(drop.startInstant(zone)).isEqualTo(target.atTime(20, 0).toInstant(zone))
}
@@ -63,7 +63,7 @@ class TimelineDropTest {
val tail = blockOn(sun)
val offset = tail.clipOffsetMinutes(sun, zone)
// Dragged three days back, same height in the column: Sun 6 -> Thu 3.
val drop = TimelineDrop(overnight, LocalDate(2026, 9, 3), 0, offset, token = 0L)
val drop = TimelineDrop(overnight, LocalDate(2026, 9, 3), 0, offset)
// The event began Sat 5 Sep 20:00, so it now begins Wed 2 Sep 20:00 —
// the whole event moved three days, and no time of day was invented.
@@ -75,7 +75,7 @@ class TimelineDropTest {
fun `dragging the tail down the column re-times the event across midnight`() {
val offset = blockOn(sun).clipOffsetMinutes(sun, zone)
// Tail top pulled from 00:00 to 03:00 on its own day.
val drop = TimelineDrop(overnight, sun, 3 * 60, offset, token = 0L)
val drop = TimelineDrop(overnight, sun, 3 * 60, offset)
assertThat(drop.startInstant(zone)).isEqualTo(sat.atTime(23, 0).toInstant(zone))
}
@@ -84,7 +84,7 @@ class TimelineDropTest {
fun `pulling the tail above its own midnight moves the event earlier`() {
val offset = blockOn(sun).clipOffsetMinutes(sun, zone)
// The tail's top edge dragged an hour above the midnight it rests on.
val drop = TimelineDrop(overnight, sun, -60, offset, token = 0L)
val drop = TimelineDrop(overnight, sun, -60, offset)
// Event was Sat 20:00 to Sun 08:00; it now begins an hour earlier.
assertThat(drop.startInstant(zone)).isEqualTo(sat.atTime(19, 0).toInstant(zone))
@@ -92,7 +92,7 @@ class TimelineDropTest {
@Test
fun `a top edge dragged past the next midnight resolves onto the day after`() {
val drop = TimelineDrop(overnight, sat, 25 * 60, clipOffsetMin = 0, token = 0L)
val drop = TimelineDrop(overnight, sat, 25 * 60, clipOffsetMin = 0)
assertThat(drop.startInstant(zone)).isEqualTo(sun.atTime(1, 0).toInstant(zone))
}
@@ -130,7 +130,7 @@ class TimelineDropTest {
val tail = layoutDay(listOf(overnightIntoDst), sun, berlin).single()
val offset = tail.clipOffsetMinutes(sun, berlin)
// Dropped back onto its own slot a week earlier, clear of the boundary.
val drop = TimelineDrop(overnightIntoDst, LocalDate(2026, 3, 22), 0, offset, token = 0L)
val drop = TimelineDrop(overnightIntoDst, LocalDate(2026, 3, 22), 0, offset)
assertThat(drop.startInstant(berlin))
.isEqualTo(LocalDate(2026, 3, 21).atTime(22, 0).toInstant(berlin))
@@ -155,7 +155,7 @@ class TimelineDropTest {
)
val block = layoutDay(listOf(event), fallBack, brussels).single()
assertThat(block.clipOffsetMinutes(fallBack, brussels)).isEqualTo(0)
val drop = TimelineDrop(event, fallBack, block.startMin, clipOffsetMin = 0, token = 0L)
val drop = TimelineDrop(event, fallBack, block.startMin, clipOffsetMin = 0)
assertThat(drop.startInstant(brussels)).isEqualTo(event.start)
}
@@ -168,7 +168,7 @@ class TimelineDropTest {
val tail = layoutDay(listOf(event), fallBack, brussels).single()
val offset = tail.clipOffsetMinutes(fallBack, brussels)
assertThat(offset).isEqualTo(2 * 60)
val drop = TimelineDrop(event, fallBack, tail.startMin, clipOffsetMin = offset, token = 0L)
val drop = TimelineDrop(event, fallBack, tail.startMin, clipOffsetMin = offset)
assertThat(drop.startInstant(brussels)).isEqualTo(event.start)
}
@@ -189,7 +189,7 @@ class TimelineDropTest {
end = Instant.parse("2026-10-25T02:30:00Z"),
)
val block = layoutDay(listOf(event), fallBack, brussels).single()
val drop = TimelineDrop(event, fallBack, block.startMin + 15, clipOffsetMin = 0, token = 0L)
val drop = TimelineDrop(event, fallBack, block.startMin + 15, clipOffsetMin = 0)
assertThat(drop.startInstant(brussels)).isEqualTo(Instant.parse("2026-10-25T01:45:00Z"))
}
@@ -234,7 +234,7 @@ class TimelineDropTest {
start = Instant.parse("2026-10-25T01:30:00Z"),
end = Instant.parse("2026-10-25T03:00:00Z"),
)
val drop = resizedSpan(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 30, 15, brussels)
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 30, 15, brussels)
assertThat(drop.start).isEqualTo(event.start)
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T01:45:00Z"))
}
@@ -246,7 +246,7 @@ class TimelineDropTest {
start = Instant.parse("2026-10-25T00:30:00Z"),
end = Instant.parse("2026-10-25T01:15:00Z"),
)
val drop = resizedSpan(event, ResizeEdge.Top, fallBack, 2 * 60, 60, brussels)
val drop = resizedDrop(event, ResizeEdge.Top, fallBack, 2 * 60, 60, brussels)
assertThat(drop.start).isEqualTo(Instant.parse("2026-10-25T00:00:00Z"))
assertThat(drop.end).isEqualTo(event.end)
}
@@ -258,7 +258,7 @@ class TimelineDropTest {
start = Instant.parse("2026-10-25T00:20:00Z"),
end = Instant.parse("2026-10-25T01:10:00Z"),
)
val drop = resizedSpan(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 20, 30, brussels)
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 20, 30, brussels)
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T00:50:00Z"))
}
@@ -270,7 +270,7 @@ class TimelineDropTest {
start = Instant.parse("2026-10-24T23:00:00Z"),
end = Instant.parse("2026-10-25T03:00:00Z"),
)
val drop = resizedSpan(event, ResizeEdge.Bottom, fallBack, 60, 75, brussels)
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 60, 75, brussels)
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T00:15:00Z"))
}
}
@@ -356,12 +356,12 @@ class WeekLayoutTest {
)
assertThat(state.allDaySpans.map { it.lane }).containsExactly(0, 0)
val preview = state.withStripPreview(listOf(StripDrag(held, dayShift = 2)), zone).allDaySpans
val preview = state.withStripPreview(StripDrag(held, dayShift = 2), zone).allDaySpans
val moved = preview.single { it.event.instanceId == held.instanceId }
assertThat(moved.event).isEqualTo(held)
assertThat(moved.startCol).isEqualTo(2)
assertThat(preview.map { it.lane }).containsExactly(0, 1)
assertThat(state.withStripPreview(listOf(StripDrag(held, dayShift = 2, overGrid = true)), zone)).isSameInstanceAs(state)
assertThat(state.withStripPreview(StripDrag(held, dayShift = 2, overGrid = true), zone)).isSameInstanceAs(state)
}
}
@@ -1,6 +0,0 @@
الإصلاحات
• لم تعد الأحداث المنقولة بسرعة متتالية تضيع: تُحفظ عمليات النقل بالترتيب.
• تظهر رسالة عند تعذّر حفظ عملية نقل.
التغييرات
• يعرض عنوان عرض اليوم الشهر.
@@ -1,6 +0,0 @@
Выпраўлена
• Падзеі, перанесеныя хутка адна за адной, больш не губляюцца: пераносы захоўваюцца па чарзе.
• Паведамленне паказвае, калі перанос не ўдалося захаваць.
Зменена
• Загаловак дзённага прагляду паказвае месяц.
@@ -1,6 +0,0 @@
Opraveno
• Události rychle přesunuté za sebou se už neztrácí: přesuny se ukládají postupně.
• Zpráva upozorní, když přesun nešel uložit.
Změněno
• Název denního zobrazení ukazuje měsíc.
@@ -1,6 +0,0 @@
Behoben
• Schnell hintereinander verschobene Termine gehen nicht mehr verloren: Verschiebungen werden der Reihe nach gespeichert.
• Eine Meldung sagt, wenn eine Verschiebung nicht gespeichert werden konnte.
Geändert
• Der Titel der Tagesansicht zeigt den Monat.
@@ -1,6 +0,0 @@
Fixed
• Events moved in quick succession are no longer lost: moves are saved in order.
• A message says when a move could not be saved.
Changed
• The day view title shows the month.
@@ -1,6 +0,0 @@
Correcciones
• Los eventos movidos rápidamente uno tras otro ya no se pierden: los cambios se guardan en orden.
• Un mensaje avisa cuando no se pudo guardar un cambio.
Cambios
• El título de la vista diaria muestra el mes.
@@ -1,6 +0,0 @@
Corrections
• Les événements déplacés rapidement à la suite ne sont plus perdus : les déplacements sont enregistrés dans l’ordre.
• Un message indique quand un déplacement n’a pas pu être enregistré.
Modifications
• Le titre de la vue jour affiche le mois.
@@ -1,6 +0,0 @@
Javítva
• A gyors egymásutánban áthelyezett események többé nem vesznek el: az áthelyezések sorban mentődnek.
• Üzenet jelzi, ha egy áthelyezést nem sikerült menteni.
Változások
• A napi nézet címe a hónapot mutatja.
@@ -1,6 +0,0 @@
Correzioni
• Gli eventi spostati rapidamente uno dopo l’altro non vanno più persi: gli spostamenti vengono salvati in ordine.
• Un messaggio avvisa quando uno spostamento non è stato salvato.
Modifiche
• Il titolo della vista giornaliera mostra il mese.
@@ -1,6 +0,0 @@
修正
• 続けてすばやく移動した予定が失われなくなりました。移動は順番に保存されます。
• 移動を保存できなかったときはメッセージが表示されます。
変更
• 日表示のタイトルに月が表示されるようになりました。
@@ -1,6 +0,0 @@
Opgelost
• Snel na elkaar verplaatste afspraken gaan niet meer verloren: verplaatsingen worden op volgorde opgeslagen.
• Een melding laat weten wanneer een verplaatsing niet kon worden opgeslagen.
Gewijzigd
• De titel van de dagweergave toont de maand.
@@ -1,6 +0,0 @@
Poprawki
• Wydarzenia szybko przenoszone jedno po drugim już nie giną: przeniesienia są zapisywane po kolei.
• Komunikat informuje, gdy przeniesienia nie udało się zapisać.
Zmiany
• Tytuł widoku dnia pokazuje miesiąc.
@@ -1,6 +0,0 @@
Correções
• Eventos movidos rapidamente um após o outro não se perdem mais: as mudanças são salvas em ordem.
• Uma mensagem avisa quando uma mudança não pôde ser salva.
Alterações
• O título da visualização diária mostra o mês.
@@ -1,6 +0,0 @@
Correções
• Os eventos movidos rapidamente uns após os outros já não se perdem: as alterações são guardadas por ordem.
• Uma mensagem avisa quando uma alteração não pôde ser guardada.
Alterações
• O título da vista diária mostra o mês.
@@ -1,6 +0,0 @@
Исправлено
• События, быстро перенесённые одно за другим, больше не теряются: переносы сохраняются по очереди.
• Сообщение показывает, если перенос не удалось сохранить.
Изменено
• Заголовок дневного вида показывает месяц.
@@ -1,6 +0,0 @@
Opravené
• Udalosti rýchlo presunuté za sebou sa už nestrácajú: presuny sa ukladajú postupne.
• Správa upozorní, keď sa presun nepodarilo uložiť.
Zmenené
• Názov denného zobrazenia ukazuje mesiac.
@@ -1,6 +0,0 @@
修复
• 快速连续移动的日程不再丢失:移动会按顺序保存。
• 移动无法保存时会显示提示。
变更
• 日视图标题显示月份。
@@ -1,6 +0,0 @@
修正
• 快速連續移動的活動不再遺失:移動會依序儲存。
• 移動無法儲存時會顯示訊息。
變更
• 日檢視標題會顯示月份。
+2 -2
View File
@@ -4,7 +4,7 @@ kotlin = "2.4.20"
ksp = "2.3.11"
hilt = "2.60.1"
coreKtx = "1.19.0"
appcompat = "1.8.0"
appcompat = "1.7.1"
lifecycleRuntime = "2.11.0"
activityCompose = "1.13.0"
composeBom = "2026.09.00"
@@ -18,7 +18,7 @@ junitPlatform = "6.1.3"
truth = "1.4.5"
androidxJunit = "1.3.0"
espressoCore = "3.7.0"
kotlinxDatetime = "0.8.0-0.6.x-compat"
kotlinxDatetime = "0.7.0"
kotlinxCoroutines = "1.11.0"
turbine = "1.2.1"
hiltNavigationCompose = "1.4.0"