Compare commits
3 Commits
19b86936e6
...
v2.18.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66dbdc8913 | ||
|
|
6a125b7c73 | ||
|
|
44233cff35 |
10
CHANGELOG.md
10
CHANGELOG.md
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.18.1] — 2026-08-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Tapping an empty slot in week or day view creates the event at the hour you
|
||||||
|
tapped, after the timeline has been zoomed. Pinching the day taller or shorter
|
||||||
|
— or changing **Hour height** in Settings — left the tap still being measured
|
||||||
|
against the old spacing, so the new event landed at some other hour entirely
|
||||||
|
([#148]).
|
||||||
|
|
||||||
## [2.18.0] — 2026-07-31
|
## [2.18.0] — 2026-07-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1323,3 +1332,4 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#57]: https://codeberg.org/jlmakiola/calendula/issues/57
|
[#57]: https://codeberg.org/jlmakiola/calendula/issues/57
|
||||||
[#56]: https://codeberg.org/jlmakiola/calendula/issues/56
|
[#56]: https://codeberg.org/jlmakiola/calendula/issues/56
|
||||||
[#114]: https://codeberg.org/jlmakiola/calendula/issues/114
|
[#114]: https://codeberg.org/jlmakiola/calendula/issues/114
|
||||||
|
[#148]: https://codeberg.org/jlmakiola/calendula/issues/148
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 21800
|
versionCode = 21801
|
||||||
versionName = "2.18.0"
|
versionName = "2.18.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||||
eventColor = eventColor,
|
eventColor = eventColor,
|
||||||
eventColorKey = eventColorKey,
|
eventColorKey = eventColorKey,
|
||||||
isException = !isNull(EventDetailProjection.IDX_ORIGINAL_ID),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,11 +89,6 @@ internal object EventDetailProjection {
|
|||||||
// Recurring rows carry DURATION instead of DTEND; the detail screen
|
// Recurring rows carry DURATION instead of DTEND; the detail screen
|
||||||
// needs it to render a series opened without a named occurrence.
|
// needs it to render a series opened without a named occurrence.
|
||||||
CalendarContract.Events.DURATION,
|
CalendarContract.Events.DURATION,
|
||||||
// Non-null on a modified-occurrence exception row (it points at the
|
|
||||||
// series). "No RRULE" alone can't tell an exception from a master — a
|
|
||||||
// sync adapter that leaves the rule on the exception would otherwise
|
|
||||||
// get an exception written against an exception (#68).
|
|
||||||
CalendarContract.Events.ORIGINAL_ID,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_EVENT_ID = 0
|
const val IDX_EVENT_ID = 0
|
||||||
@@ -115,7 +110,6 @@ internal object EventDetailProjection {
|
|||||||
const val IDX_SELF_ATTENDEE_STATUS = 16
|
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||||
const val IDX_EVENT_COLOR_KEY = 17
|
const val IDX_EVENT_COLOR_KEY = 17
|
||||||
const val IDX_DURATION = 18
|
const val IDX_DURATION = 18
|
||||||
const val IDX_ORIGINAL_ID = 19
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -44,16 +44,6 @@ val CalendarSource.hasVisibilitySwitch: Boolean
|
|||||||
val CalendarSource.isEventTarget: Boolean
|
val CalendarSource.isEventTarget: Boolean
|
||||||
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
|
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this calendar's events may have their times rewritten by a drag (#68).
|
|
||||||
* Deliberately not [isEventTarget]: an event living in a switched-off or
|
|
||||||
* non-syncing calendar isn't rendered anyway, while a *managed* event is
|
|
||||||
* editable (reminders, notes) yet must never move — the next contacts sync would
|
|
||||||
* put it back.
|
|
||||||
*/
|
|
||||||
val CalendarSource.allowsEventMove: Boolean
|
|
||||||
get() = canModifyContents && !isManaged
|
|
||||||
|
|
||||||
/** Every state worth naming on this calendar's row, in reading order. */
|
/** Every state worth naming on this calendar's row, in reading order. */
|
||||||
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
|
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
|
||||||
if (isManaged) add(CalendarStateLabel.MANAGED)
|
if (isManaged) add(CalendarStateLabel.MANAGED)
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
|
||||||
|
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.LocalDateTime
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.plus
|
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The zone this form's wall-clock times mean, matching what the data layer
|
|
||||||
* resolves them in at write time: the form's own pinned zone, else [deviceZone].
|
|
||||||
* An unparseable pinned id falls back to the device, like the write path does.
|
|
||||||
*/
|
|
||||||
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
|
|
||||||
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The form moved so it starts at [newStart], keeping its length. The wall-clock
|
|
||||||
* values are re-derived in the event's own zone, not the device's, so dragging a
|
|
||||||
* pinned event still means what the event means.
|
|
||||||
*
|
|
||||||
* The **instant** duration is preserved, not the wall-clock span: a recurring
|
|
||||||
* event's length travels to the provider as `DURATION`, so preserving wall clock
|
|
||||||
* would rewrite a whole series' length whenever a shift crosses a DST boundary.
|
|
||||||
*
|
|
||||||
* All-day events carry placeholder times and are date-anchored — move them with
|
|
||||||
* [shiftedByDays] instead; this returns them untouched.
|
|
||||||
*/
|
|
||||||
fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
|
|
||||||
if (isAllDay) return this
|
|
||||||
val zone = resolvedZone(deviceZone)
|
|
||||||
val span = end.toInstant(zone) - start.toInstant(zone)
|
|
||||||
return copy(
|
|
||||||
start = newStart.toLocalDateTime(zone),
|
|
||||||
end = (newStart + span).toLocalDateTime(zone),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The form moved [days] calendar days, keeping its time of day and its length.
|
|
||||||
* A multi-day event keeps its span rather than collapsing to a single day.
|
|
||||||
*
|
|
||||||
* An all-day event is pure date arithmetic — both ends move, and the placeholder
|
|
||||||
* times ride along untouched. A timed one re-derives its end from the preserved
|
|
||||||
* **instant** duration, for the same reason [shiftedTo] does: the length travels
|
|
||||||
* to a recurring row as `DURATION`, so a move onto a DST changeover that kept
|
|
||||||
* wall clock would silently rewrite the whole series' length.
|
|
||||||
*/
|
|
||||||
fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
|
|
||||||
if (days == 0) return this
|
|
||||||
val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time)
|
|
||||||
if (isAllDay) {
|
|
||||||
return copy(
|
|
||||||
start = newStart,
|
|
||||||
end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val zone = resolvedZone(deviceZone)
|
|
||||||
val span = end.toInstant(zone) - start.toInstant(zone)
|
|
||||||
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone))
|
|
||||||
}
|
|
||||||
@@ -124,13 +124,6 @@ data class EventDetail(
|
|||||||
val eventColor: Int? = null,
|
val eventColor: Int? = null,
|
||||||
/** The event's `Events.EVENT_COLOR_KEY` (a calendar-palette key), or null. */
|
/** The event's `Events.EVENT_COLOR_KEY` (a calendar-palette key), or null. */
|
||||||
val eventColorKey: String? = null,
|
val eventColorKey: String? = null,
|
||||||
/**
|
|
||||||
* True when this row is a modified occurrence of a series (`ORIGINAL_ID` is
|
|
||||||
* set), not a master. Such a row stands alone — writing an exception against
|
|
||||||
* it would nest one exception inside another — so a reschedule always takes
|
|
||||||
* the plain whole-row path, whatever [rrule] a sync adapter left on it.
|
|
||||||
*/
|
|
||||||
val isException: Boolean = false,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
private val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
||||||
DayOfWeek.MONDAY to "MO",
|
DayOfWeek.MONDAY to "MO",
|
||||||
DayOfWeek.TUESDAY to "TU",
|
DayOfWeek.TUESDAY to "TU",
|
||||||
DayOfWeek.WEDNESDAY to "WE",
|
DayOfWeek.WEDNESDAY to "WE",
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
|
||||||
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [rrule] re-anchored from an occurrence on [oldStart] to one on [newStart].
|
|
||||||
*
|
|
||||||
* `Events.RRULE` is written verbatim while DTSTART moves, so a rule that names
|
|
||||||
* its own day — `FREQ=WEEKLY;BYDAY=MO`, what Google and CalDAV write for nearly
|
|
||||||
* every weekly series — would keep pointing at Monday after the anchor became a
|
|
||||||
* Wednesday, and the series would not move at all. `BYDAY` is therefore
|
|
||||||
* re-derived from [newStart].
|
|
||||||
*
|
|
||||||
* **Only weekly `BYDAY` is realigned, and only for a whole-day move.** The rule
|
|
||||||
* has to agree with the series *anchor*, which is not the occurrence being
|
|
||||||
* dragged: the anchor moves by the same wall-clock shift, and only weekday
|
|
||||||
* arithmetic survives that unchanged, because it is uniform mod 7 and every
|
|
||||||
* anchor time-of-day crosses the same number of midnights. Day-of-month does not
|
|
||||||
* — `BYMONTHDAY=28` on a January anchor dragged from Feb 28 to Mar 1 gives an
|
|
||||||
* anchor of Jan 29 under a `BYMONTHDAY=1` rule, which is not an instance of its
|
|
||||||
* own rule and materialises a phantom occurrence on any client that trusts
|
|
||||||
* DTSTART. Those rules return null instead. The caller must also refuse when the
|
|
||||||
* shift is not a whole number of days, for the same reason (see
|
|
||||||
* `RescheduleViewModel`).
|
|
||||||
*
|
|
||||||
* Null also covers rules one moved occurrence cannot resolve at all
|
|
||||||
* (`BYDAY=MO,WE`, an ordinal `2TH`, `BYSETPOS`, …) — dropping the extra days
|
|
||||||
* would delete occurrences. The accepted parts are deliberately a subset of what
|
|
||||||
* [parseSimpleRecurrence] understands, so anything realignable is also a rule
|
|
||||||
* [problems] can check the `UNTIL` of.
|
|
||||||
*/
|
|
||||||
fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? {
|
|
||||||
if (oldStart == newStart) return rrule
|
|
||||||
val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else ""
|
|
||||||
val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }
|
|
||||||
if (parts.isEmpty()) return null
|
|
||||||
var weekly = false
|
|
||||||
val rebuilt = parts.map { part ->
|
|
||||||
val eq = part.indexOf('=')
|
|
||||||
if (eq <= 0) return null
|
|
||||||
val key = part.substring(0, eq).uppercase()
|
|
||||||
val value = part.substring(eq + 1).trim()
|
|
||||||
when (key) {
|
|
||||||
"FREQ" -> {
|
|
||||||
weekly = value.equals("WEEKLY", ignoreCase = true)
|
|
||||||
part
|
|
||||||
}
|
|
||||||
"BYDAY" -> {
|
|
||||||
val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null
|
|
||||||
if (!value.equals(old, ignoreCase = true)) return null
|
|
||||||
"BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}"
|
|
||||||
}
|
|
||||||
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part
|
|
||||||
else -> return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// BYDAY is only simple on a weekly rule (matching parseSimpleRecurrence);
|
|
||||||
// "every Monday of the month" is a shape this has not been reasoned about.
|
|
||||||
if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null
|
|
||||||
if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null
|
|
||||||
return prefix + rebuilt.joinToString(";")
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import androidx.compose.animation.slideOutHorizontally
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -28,10 +27,6 @@ import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
|
|||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.selectView
|
import de.jeanlucmakiola.calendula.ui.common.selectView
|
||||||
@@ -299,27 +294,6 @@ fun CalendarHost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag to reschedule (#68). Hosted here so one instance serves every view and
|
|
||||||
// survives view switches; the calendar surfaces read it out of LocalEventMove.
|
|
||||||
val reschedule: RescheduleViewModel = hiltViewModel()
|
|
||||||
val movableCalendarIds by reschedule.movableCalendarIds.collectAsStateWithLifecycle()
|
|
||||||
val onEditEvent: (EventInstance) -> Unit = { event ->
|
|
||||||
val key = longArrayOf(
|
|
||||||
event.eventId,
|
|
||||||
event.start.toEpochMilliseconds(),
|
|
||||||
event.end.toEpochMilliseconds(),
|
|
||||||
)
|
|
||||||
heldEditKey = key
|
|
||||||
editKey = key
|
|
||||||
}
|
|
||||||
val moveScope = remember(movableCalendarIds, reschedule) {
|
|
||||||
EventMoveScope(
|
|
||||||
movableCalendarIds = movableCalendarIds,
|
|
||||||
move = reschedule::move,
|
|
||||||
edit = onEditEvent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
|
||||||
// Base-level back: pop the view stack while no overlay covers it (each overlay
|
// Base-level back: pop the view stack while no overlay covers it (each overlay
|
||||||
@@ -337,7 +311,6 @@ fun CalendarHost(
|
|||||||
// navigation, so it fades through rather than sliding — paging *within* a
|
// navigation, so it fades through rather than sliding — paging *within* a
|
||||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||||
val viewSwitch = fadeThrough()
|
val viewSwitch = fadeThrough()
|
||||||
CompositionLocalProvider(LocalEventMove provides moveScope) {
|
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = view,
|
targetState = view,
|
||||||
transitionSpec = { viewSwitch },
|
transitionSpec = { viewSwitch },
|
||||||
@@ -394,12 +367,6 @@ fun CalendarHost(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Scope prompt + confirmation/undo snackbar for a dropped event. Declared
|
|
||||||
// right after the calendar views, so any overlay opened afterwards covers
|
|
||||||
// the snackbar rather than the other way round.
|
|
||||||
EventMoveHost(reschedule, modifier = Modifier.fillMaxSize())
|
|
||||||
|
|
||||||
// Search overlay — below detail/edit in the Box so a tapped result's
|
// Search overlay — below detail/edit in the Box so a tapped result's
|
||||||
// detail screen draws on top, and closing it returns to the results.
|
// detail screen draws on top, and closing it returns to the results.
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.layout.LayoutCoordinates
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
|
||||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
|
||||||
import androidx.compose.ui.unit.IntSize
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How far the finger may wander during the hold. Deliberately well under touch
|
|
||||||
* slop (~18dp): past that an ancestor — the vertical scroll, the page swipe —
|
|
||||||
* claims the gesture, and a hold that survived to there would have opened a dead
|
|
||||||
* zone where neither the drag nor the page turn happens.
|
|
||||||
*/
|
|
||||||
private val PICKUP_TOLERANCE = 6.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pick an event block up with a long press and drag it, without
|
|
||||||
* `detectDragGesturesAfterLongPress`.
|
|
||||||
*
|
|
||||||
* The stock detector cancels the press as soon as an ancestor consumes — and
|
|
||||||
* during the hold the block itself consumes nothing, so the scroll and the page
|
|
||||||
* swipe are free to claim at their own slop — and it also cancels when the finger
|
|
||||||
* leaves the block, which a `MIN_EVENT_FRACTION`-tall block loses immediately.
|
|
||||||
* This one keeps its own timeout and its own (much smaller) tolerance, and never
|
|
||||||
* cancels on leaving the bounds.
|
|
||||||
*
|
|
||||||
* Movement before the timeout is deliberately **not** consumed: consuming it
|
|
||||||
* would kill the ancestor's gesture outright, so a scroll that happens to start
|
|
||||||
* on top of a block would die. Fast movement means the user meant to scroll —
|
|
||||||
* abandon quietly. A second finger means a pinch, which owns the gesture.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun rememberEventDragSource(
|
|
||||||
enabled: Boolean,
|
|
||||||
key: Any?,
|
|
||||||
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> Unit,
|
|
||||||
onMove: (pointerInRoot: Offset) -> Unit,
|
|
||||||
onDrop: () -> Unit,
|
|
||||||
onCancel: () -> Unit,
|
|
||||||
): Modifier = rememberDragSurface(
|
|
||||||
enabled = enabled,
|
|
||||||
key = key,
|
|
||||||
onPickUp = { _, pointerInRoot, nodeInRoot, size ->
|
|
||||||
onPickUp(pointerInRoot, nodeInRoot, size)
|
|
||||||
true
|
|
||||||
},
|
|
||||||
onMove = onMove,
|
|
||||||
onDrop = onDrop,
|
|
||||||
onCancel = onCancel,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The same pickup, for a surface that carries many draggable pieces rather than
|
|
||||||
* being one itself — the month grid, whose chips are covered by a full-bleed tap
|
|
||||||
* layer and so can never take pointer input of their own. [onPickUp] receives the
|
|
||||||
* press position local to this node and answers whether anything is there;
|
|
||||||
* returning false abandons the gesture as if the hold had never completed.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun rememberDragSurface(
|
|
||||||
enabled: Boolean,
|
|
||||||
key: Any?,
|
|
||||||
onPickUp: (local: Offset, pointerInRoot: Offset, nodeInRoot: Offset, size: IntSize) -> Boolean,
|
|
||||||
onMove: (pointerInRoot: Offset) -> Unit,
|
|
||||||
onDrop: () -> Unit,
|
|
||||||
onCancel: () -> Unit,
|
|
||||||
): Modifier {
|
|
||||||
if (!enabled) return Modifier
|
|
||||||
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
|
|
||||||
val tolerance = with(LocalDensity.current) { PICKUP_TOLERANCE.toPx() }
|
|
||||||
val coordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
|
||||||
val currentPickUp by rememberUpdatedState(onPickUp)
|
|
||||||
val currentMove by rememberUpdatedState(onMove)
|
|
||||||
val currentDrop by rememberUpdatedState(onDrop)
|
|
||||||
val currentCancel by rememberUpdatedState(onCancel)
|
|
||||||
|
|
||||||
return Modifier
|
|
||||||
.onGloballyPositioned { coordinates[0] = it }
|
|
||||||
.pointerInput(key) {
|
|
||||||
awaitEachGesture {
|
|
||||||
val down = awaitFirstDown(requireUnconsumed = false)
|
|
||||||
val origin = down.position
|
|
||||||
val heldStill = withTimeoutOrNull(holdMillis) {
|
|
||||||
while (true) {
|
|
||||||
val main = awaitPointerEvent()
|
|
||||||
val change = main.changes.firstOrNull { it.id == down.id } ?: break
|
|
||||||
if (!change.pressed) break
|
|
||||||
if (main.changes.count { it.pressed } > 1) break
|
|
||||||
if ((change.position - origin).getDistance() > tolerance) break
|
|
||||||
// An ancestor's claim only becomes visible once the whole
|
|
||||||
// main pass has run, so look again on the final pass.
|
|
||||||
val final = awaitPointerEvent(PointerEventPass.Final)
|
|
||||||
if (final.changes.any { it.isConsumed }) break
|
|
||||||
}
|
|
||||||
} == null
|
|
||||||
if (!heldStill) return@awaitEachGesture
|
|
||||||
|
|
||||||
val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture
|
|
||||||
val took = currentPickUp(
|
|
||||||
down.position,
|
|
||||||
layout.localToRoot(down.position),
|
|
||||||
layout.positionInRoot(),
|
|
||||||
layout.size,
|
|
||||||
)
|
|
||||||
if (!took) return@awaitEachGesture
|
|
||||||
var dropped = false
|
|
||||||
try {
|
|
||||||
// Once the block is lifted the gesture is ours, so it is
|
|
||||||
// driven on the initial pass and consumed there. That pass
|
|
||||||
// runs parent → child, which gets both halves right: an
|
|
||||||
// ancestor that outranks us (the pinch, which claims the
|
|
||||||
// moment a second finger lands anywhere in the timeline) has
|
|
||||||
// already consumed by the time we look, and everything below
|
|
||||||
// us — the month grid's full-bleed tap layer, which is a
|
|
||||||
// descendant and would otherwise open the day on lift — sees
|
|
||||||
// ours. Consumption persists across passes, so the scroll and
|
|
||||||
// the page swipe stand down on main as well.
|
|
||||||
while (true) {
|
|
||||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
|
||||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
|
||||||
if (change.isConsumed) break
|
|
||||||
if (!change.pressed) {
|
|
||||||
change.consume()
|
|
||||||
dropped = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (event.changes.count { it.pressed } > 1) break
|
|
||||||
change.consume()
|
|
||||||
coordinates[0]?.takeIf { it.isAttached }
|
|
||||||
?.let { currentMove(it.localToRoot(change.position)) }
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// Also reached when the pointer node is disposed mid-drag (the
|
|
||||||
// page swapping out under the finger) — that is a cancel, not a
|
|
||||||
// drop, and must never write.
|
|
||||||
if (dropped) currentDrop() else currentCancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The app's first haptics: a lift on pickup, then a tick every time the drop
|
|
||||||
* target snaps to a different slot, so the granularity is felt rather than read.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun DragSnapHaptics(slot: Any?) {
|
|
||||||
val haptics = LocalHapticFeedback.current
|
|
||||||
val previous = remember { arrayOfNulls<Any>(1) }
|
|
||||||
LaunchedEffect(slot) {
|
|
||||||
val had = previous[0]
|
|
||||||
previous[0] = slot
|
|
||||||
when {
|
|
||||||
slot == null -> Unit
|
|
||||||
had == null -> haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
|
||||||
had != slot -> haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.SnackbarDuration
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.SnackbarResult
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
|
||||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.ZoneOffset
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The two surfaces a drag-and-drop reschedule needs on top of the calendar: the
|
|
||||||
* recurring-scope prompt, and the confirmation snackbar carrying Undo.
|
|
||||||
*
|
|
||||||
* None of the four calendar screens sets a `snackbarHost` on its Scaffold, so
|
|
||||||
* this hosts its own — bottom-centre inside the host's root Box, above every
|
|
||||||
* view. It forgoes the Scaffold's FAB avoidance in exchange for not threading a
|
|
||||||
* `SnackbarHostState` through all four screens.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) {
|
|
||||||
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
|
|
||||||
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
|
|
||||||
val snackbars = remember { SnackbarHostState() }
|
|
||||||
val locale = currentLocale()
|
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
|
||||||
|
|
||||||
prompt?.let { pending ->
|
|
||||||
RecurringScopeDialog(
|
|
||||||
title = stringResource(R.string.event_move_recurring_title),
|
|
||||||
onSelect = viewModel::moveWithScope,
|
|
||||||
onDismiss = viewModel::cancelScope,
|
|
||||||
allowSeries = !pending.occurrenceOnly,
|
|
||||||
reason = stringResource(R.string.event_move_occurrence_only)
|
|
||||||
.takeIf { pending.occurrenceOnly },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val moved = outcome as? MoveOutcome.Moved
|
|
||||||
val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) }
|
|
||||||
val message = when (outcome) {
|
|
||||||
null -> null
|
|
||||||
is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty())
|
|
||||||
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.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end)
|
|
||||||
MoveOutcome.Failed -> stringResource(R.string.event_move_failed)
|
|
||||||
}
|
|
||||||
val undoLabel = stringResource(R.string.event_move_undo)
|
|
||||||
|
|
||||||
LaunchedEffect(outcome) {
|
|
||||||
val text = message ?: return@LaunchedEffect
|
|
||||||
val undo = moved?.undo
|
|
||||||
val result = snackbars.showSnackbar(
|
|
||||||
message = text,
|
|
||||||
actionLabel = undoLabel.takeIf { undo != null },
|
|
||||||
withDismissAction = undo == null,
|
|
||||||
duration = SnackbarDuration.Short,
|
|
||||||
)
|
|
||||||
if (result == SnackbarResult.ActionPerformed && undo != null) {
|
|
||||||
viewModel.undo(undo)
|
|
||||||
} else {
|
|
||||||
viewModel.consumeOutcome()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.BottomCenter) {
|
|
||||||
SnackbarHost(
|
|
||||||
hostState = snackbars,
|
|
||||||
modifier = Modifier.navigationBarsPadding().padding(16.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "Fri, 7 Aug, 09:00" — the day, plus the time for a timed event. All-day events
|
|
||||||
* are read back on the UTC calendar day they are anchored to (#65, #82).
|
|
||||||
*/
|
|
||||||
private fun formatMovedTo(
|
|
||||||
startMillis: Long,
|
|
||||||
isAllDay: Boolean,
|
|
||||||
use24Hour: Boolean,
|
|
||||||
locale: Locale,
|
|
||||||
): String {
|
|
||||||
val zone: ZoneId = if (isAllDay) ZoneOffset.UTC else ZoneId.systemDefault()
|
|
||||||
val skeleton = when {
|
|
||||||
isAllDay -> "EEEdMMM"
|
|
||||||
use24Hour -> "EEEdMMMHm"
|
|
||||||
else -> "EEEdMMMhm"
|
|
||||||
}
|
|
||||||
return localizedDateFormatter(locale, skeleton)
|
|
||||||
.format(Instant.ofEpochMilli(startMillis).atZone(zone))
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by
|
|
||||||
* whichever event block is being composed. A composition local rather than six
|
|
||||||
* layers of parameters: every calendar surface needs the same three things, and
|
|
||||||
* the blocks that need them sit deep inside private composables.
|
|
||||||
*
|
|
||||||
* Null means moving is off entirely (no host provided it) — a block then
|
|
||||||
* registers no drag gesture at all, so a long press keeps its old meaning of
|
|
||||||
* nothing happening.
|
|
||||||
*/
|
|
||||||
@Immutable
|
|
||||||
class EventMoveScope(
|
|
||||||
/**
|
|
||||||
* Calendars whose events may be moved. Nothing below the UI enforces this —
|
|
||||||
* the repository writes whatever it is handed — so this gate is load-bearing.
|
|
||||||
*/
|
|
||||||
val movableCalendarIds: Set<Long>,
|
|
||||||
val move: (MoveRequest) -> Unit,
|
|
||||||
/** Open an event in the edit form — the pointer-free route to the same change. */
|
|
||||||
val edit: (EventInstance) -> Unit,
|
|
||||||
) {
|
|
||||||
fun allows(event: EventInstance): Boolean = event.calendarId in movableCalendarIds
|
|
||||||
}
|
|
||||||
|
|
||||||
val LocalEventMove = compositionLocalOf<EventMoveScope?> { null }
|
|
||||||
|
|
||||||
/** Opacity the source block keeps while its floating copy travels. */
|
|
||||||
const val GHOST_ALPHA: Float = 0.3f
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't
|
|
||||||
* pointer-only. Null when this event can't be moved — the block then carries no
|
|
||||||
* action and registers no drag either.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun eventMoveAction(event: EventInstance): CustomAccessibilityAction? {
|
|
||||||
val move = LocalEventMove.current ?: return null
|
|
||||||
if (!move.allows(event)) return null
|
|
||||||
val label = stringResource(R.string.event_move_action)
|
|
||||||
return remember(event.instanceId, label, move) {
|
|
||||||
CustomAccessibilityAction(label) {
|
|
||||||
move.edit(event)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.foundation.ScrollState
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
|
||||||
|
|
||||||
/** Width of the hour gutter down the start edge of the day and week timelines. */
|
|
||||||
val GUTTER_WIDTH = 48.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start inset for the gutter's content (week badge + hour labels) so it centres
|
|
||||||
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp,
|
|
||||||
* matching the icon button's centre.
|
|
||||||
*/
|
|
||||||
val GUTTER_CONTENT_START_INSET = 8.dp
|
|
||||||
|
|
||||||
private val BADGE_HEIGHT = 20.dp
|
|
||||||
|
|
||||||
/** How far the fixed hour labels recede while a block is being dragged. */
|
|
||||||
private const val DIMMED_HOUR_ALPHA = 0.3f
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The timeline's hour gutter. Scrolls in sync with the day columns through the
|
|
||||||
* shared [scrollState], and while [dragController] holds a lifted block it dims
|
|
||||||
* the hour labels and floats a badge with the drag's current start time at the
|
|
||||||
* row the block would land on.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun HourGutter(
|
|
||||||
scrollState: ScrollState,
|
|
||||||
hourHeight: Dp,
|
|
||||||
dragController: TimelineDragController,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
|
||||||
val locale = currentLocale()
|
|
||||||
// Derived, not read directly: the drag is rewritten every frame, while its
|
|
||||||
// snapped start only changes once per slot — which is all the gutter shows.
|
|
||||||
val dragStartMin by remember(dragController) {
|
|
||||||
derivedStateOf { dragController.drag?.startMin }
|
|
||||||
}
|
|
||||||
val hourAlpha by animateFloatAsState(
|
|
||||||
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
|
|
||||||
label = "hourLabelAlpha",
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.width(GUTTER_WIDTH)
|
|
||||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
|
||||||
.fillMaxHeight()
|
|
||||||
.verticalScroll(scrollState),
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
(0 until 24).forEach { h ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(hourHeight),
|
|
||||||
) {
|
|
||||||
if (h > 0) {
|
|
||||||
Text(
|
|
||||||
text = formatHourLabel(h, use24Hour, locale),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
.copy(alpha = hourAlpha),
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.offset(y = (-6).dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dragStartMin?.let { startMin ->
|
|
||||||
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.offset(y = top)
|
|
||||||
.height(BADGE_HEIGHT)
|
|
||||||
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
|
||||||
.padding(horizontal = 4.dp)
|
|
||||||
// The dragged block behind it already carries this time; a
|
|
||||||
// second copy would only duplicate the announcement.
|
|
||||||
.clearAndSetSemantics { },
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = formatGutterTime(startMin, use24Hour, locale),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
maxLines = 1,
|
|
||||||
softWrap = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|
||||||
import de.jeanlucmakiola.floret.components.OptionCard
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How far a write to a recurring event should reach: this occurrence, it and
|
|
||||||
* everything after (a series split), or the whole series.
|
|
||||||
*
|
|
||||||
* One of the two carve-outs from the full-screen picker rule — a two-or-three
|
|
||||||
* option decision taken mid-action reads better as a popup than as a near-empty
|
|
||||||
* screen. Shared by the edit screen's save and a drag-and-drop reschedule, which
|
|
||||||
* want the same three options for the same reasons.
|
|
||||||
*
|
|
||||||
* [allowOccurrence] drops "only this event" (an exception row can't carry its own
|
|
||||||
* rule, so a changed recurrence rules it out); [allowSeries] drops the two wider
|
|
||||||
* options, for a rule whose days can't be recalculated from one moved
|
|
||||||
* occurrence — [reason] then says why.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun RecurringScopeDialog(
|
|
||||||
title: String,
|
|
||||||
onSelect: (RecurringWriteScope) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
allowOccurrence: Boolean = true,
|
|
||||||
allowSeries: Boolean = true,
|
|
||||||
reason: String? = null,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(title) },
|
|
||||||
text = {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
if (reason != null) {
|
|
||||||
Text(
|
|
||||||
text = reason,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (allowOccurrence) {
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_delete_option_occurrence),
|
|
||||||
onClick = { onSelect(RecurringWriteScope.ThisEvent) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (allowSeries) {
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_delete_option_following),
|
|
||||||
onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) },
|
|
||||||
)
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_delete_option_series),
|
|
||||||
onClick = { onSelect(RecurringWriteScope.AllEvents) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|
||||||
import de.jeanlucmakiola.calendula.domain.allowsEventMove
|
|
||||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
|
||||||
import de.jeanlucmakiola.calendula.domain.problems
|
|
||||||
import de.jeanlucmakiola.calendula.domain.realignRecurrence
|
|
||||||
import de.jeanlucmakiola.calendula.domain.resolvedZone
|
|
||||||
import de.jeanlucmakiola.calendula.domain.shiftedByDays
|
|
||||||
import de.jeanlucmakiola.calendula.domain.shiftedTo
|
|
||||||
import de.jeanlucmakiola.calendula.domain.toEditForm
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.flowOn
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
|
||||||
import kotlinx.datetime.plus
|
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
|
||||||
import kotlin.time.Instant
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
/** Where a dragged event should land. */
|
|
||||||
sealed interface MoveTarget {
|
|
||||||
/** A new start instant — a timeline drag, which moves time and day at once. */
|
|
||||||
data class Start(val instant: Instant) : MoveTarget
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A whole-day shift, keeping the time of day — a month-grid or all-day drag.
|
|
||||||
* A delta rather than a target date on purpose: the grid already knows how
|
|
||||||
* many columns the finger crossed, and re-deriving that from a date would
|
|
||||||
* mean the screen and this view model each resolving the event's first day
|
|
||||||
* in their own zone, which can disagree by a day.
|
|
||||||
*/
|
|
||||||
data class ByDays(val days: Int) : MoveTarget
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One dropped event. [beginMillis]/[endMillis] are the dragged *occurrence's*
|
|
||||||
* own times (`Instances.BEGIN`/`END`), exactly as the detail and edit screens
|
|
||||||
* pass them, so a recurring series resolves the right occurrence.
|
|
||||||
*/
|
|
||||||
data class MoveRequest(
|
|
||||||
val eventId: Long,
|
|
||||||
val beginMillis: Long,
|
|
||||||
val endMillis: Long,
|
|
||||||
val target: MoveTarget,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A recurring drop waiting for the user to pick how far it reaches.
|
|
||||||
* [occurrenceOnly] means the rule names days that a single moved occurrence
|
|
||||||
* can't re-derive (`BYDAY=MO,WE`, `2TH`, …), so the only honest option left is
|
|
||||||
* this one occurrence — see [realignRecurrence].
|
|
||||||
*/
|
|
||||||
data class MoveScopePrompt(val occurrenceOnly: Boolean)
|
|
||||||
|
|
||||||
/** The inverse of a completed move, for the undo action. */
|
|
||||||
data class MoveUndo(
|
|
||||||
val eventId: Long,
|
|
||||||
/** The form as it now stands (the moved state). */
|
|
||||||
val moved: EventForm,
|
|
||||||
/** The form as it stood before the move. */
|
|
||||||
val restored: EventForm,
|
|
||||||
)
|
|
||||||
|
|
||||||
sealed interface MoveOutcome {
|
|
||||||
/**
|
|
||||||
* Written. [startMillis] is where the dragged occurrence now begins, for the
|
|
||||||
* confirmation message; [undo] is null when the write has no clean inverse.
|
|
||||||
*/
|
|
||||||
data class Moved(
|
|
||||||
val startMillis: Long,
|
|
||||||
val isAllDay: Boolean,
|
|
||||||
val undo: MoveUndo?,
|
|
||||||
) : MoveOutcome
|
|
||||||
|
|
||||||
data object Undone : MoveOutcome
|
|
||||||
|
|
||||||
/** `WRITE_CALENDAR` was revoked between the drop and the provider call. */
|
|
||||||
data object WriteDenied : MoveOutcome
|
|
||||||
|
|
||||||
/** The event vanished (sync, another device) between the read and the write. */
|
|
||||||
data object Gone : MoveOutcome
|
|
||||||
|
|
||||||
/** The drop would push the series past its own `UNTIL`, generating nothing. */
|
|
||||||
data object BlockedSeriesEnd : MoveOutcome
|
|
||||||
|
|
||||||
data object Failed : MoveOutcome
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes a drag-and-drop reschedule (#68) through the same repository calls the
|
|
||||||
* edit screen's save uses, so recurring writes, reminder reconciliation and
|
|
||||||
* attendee preservation behave identically. Hosted at `CalendarHost` so one
|
|
||||||
* instance serves every calendar view and survives view switches.
|
|
||||||
*
|
|
||||||
* The full prefilled form is carried through the write — never a stripped one —
|
|
||||||
* because the occurrence-exception path reconciles reminders and attendees onto
|
|
||||||
* the new row, and a partial form would wipe them.
|
|
||||||
*/
|
|
||||||
@HiltViewModel
|
|
||||||
class RescheduleViewModel @Inject constructor(
|
|
||||||
private val repository: CalendarRepository,
|
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val _scopePrompt = MutableStateFlow<MoveScopePrompt?>(null)
|
|
||||||
val scopePrompt: StateFlow<MoveScopePrompt?> = _scopePrompt.asStateFlow()
|
|
||||||
|
|
||||||
private val _outcome = MutableStateFlow<MoveOutcome?>(null)
|
|
||||||
val outcome: StateFlow<MoveOutcome?> = _outcome.asStateFlow()
|
|
||||||
|
|
||||||
private var pending: PreparedMove? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set from the moment a drop is accepted until its write settles. Two drops
|
|
||||||
* of the same recurring event landing inside that window would each compute
|
|
||||||
* their shift from the same pre-move occurrence, and the data layer applies
|
|
||||||
* both to the re-read anchor — so the shifts would compound.
|
|
||||||
*/
|
|
||||||
private var busy = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The calendars whose events may be dragged. Nothing below the UI guards
|
|
||||||
* this — the repository and data source attempt any write handed to them —
|
|
||||||
* so a block outside this set registers no drag gesture at all.
|
|
||||||
*/
|
|
||||||
val movableCalendarIds: StateFlow<Set<Long>> = repository.calendars()
|
|
||||||
.map { calendars -> calendars.filter { it.allowsEventMove }.map { it.id }.toSet() }
|
|
||||||
.catch { emit(emptySet<Long>()) }
|
|
||||||
.flowOn(io)
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = emptySet(),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Everything a drop needs, resolved before anything is written. */
|
|
||||||
private data class PreparedMove(
|
|
||||||
val request: MoveRequest,
|
|
||||||
val original: EventForm,
|
|
||||||
val updated: EventForm,
|
|
||||||
/** True for a series master with a rule — an exception row is not one. */
|
|
||||||
val isRecurring: Boolean,
|
|
||||||
/**
|
|
||||||
* False when the rule names days this move can't re-derive, so anything
|
|
||||||
* wider than the single occurrence would leave rule and anchor disagreeing.
|
|
||||||
*/
|
|
||||||
val canRealign: Boolean,
|
|
||||||
/** The series row's DTSTART date after the move — what its `UNTIL` must clear. */
|
|
||||||
val newAnchorDate: LocalDate,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun move(request: MoveRequest) {
|
|
||||||
if (busy || _scopePrompt.value != null) return
|
|
||||||
busy = true
|
|
||||||
viewModelScope.launch {
|
|
||||||
val prepared = prepare(request)
|
|
||||||
if (prepared == null) {
|
|
||||||
busy = false
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
if (!prepared.isRecurring) {
|
|
||||||
write(prepared, RecurringWriteScope.AllEvents)
|
|
||||||
busy = false
|
|
||||||
} else {
|
|
||||||
// Still busy: the scope dialog is now the thing in flight.
|
|
||||||
pending = prepared
|
|
||||||
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Answer the scope dialog. */
|
|
||||||
fun moveWithScope(scope: RecurringWriteScope) {
|
|
||||||
val prepared = pending ?: return
|
|
||||||
// Belt and braces against the dialog ever offering a scope the rule
|
|
||||||
// can't carry: writing it would leave anchor and rule disagreeing.
|
|
||||||
if (!prepared.canRealign && scope != RecurringWriteScope.ThisEvent) return
|
|
||||||
pending = null
|
|
||||||
_scopePrompt.value = null
|
|
||||||
viewModelScope.launch {
|
|
||||||
write(prepared, scope)
|
|
||||||
busy = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Dismiss the scope dialog without writing. */
|
|
||||||
fun cancelScope() {
|
|
||||||
pending = null
|
|
||||||
busy = false
|
|
||||||
_scopePrompt.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Put a completed move back where it came from. */
|
|
||||||
fun undo(undo: MoveUndo) {
|
|
||||||
if (busy) return
|
|
||||||
busy = true
|
|
||||||
_outcome.value = null
|
|
||||||
viewModelScope.launch {
|
|
||||||
_outcome.value = try {
|
|
||||||
repository.updateEvent(undo.eventId, undo.moved, undo.restored)
|
|
||||||
MoveOutcome.Undone
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
MoveOutcome.WriteDenied
|
|
||||||
} catch (e: NoSuchEventException) {
|
|
||||||
MoveOutcome.Gone
|
|
||||||
} catch (e: Exception) {
|
|
||||||
MoveOutcome.Failed
|
|
||||||
}
|
|
||||||
busy = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clear the outcome once the screen has shown it. */
|
|
||||||
fun consumeOutcome() {
|
|
||||||
_outcome.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun prepare(request: MoveRequest): PreparedMove? {
|
|
||||||
val detail = try {
|
|
||||||
repository.eventDetail(request.eventId)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: NoSuchEventException) {
|
|
||||||
_outcome.value = MoveOutcome.Gone
|
|
||||||
return null
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_outcome.value = MoveOutcome.Failed
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val zone = TimeZone.currentSystemDefault()
|
|
||||||
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
|
|
||||||
val shifted = when (val target = request.target) {
|
|
||||||
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
|
|
||||||
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
|
|
||||||
}
|
|
||||||
// A zero-distance drop is not a write. Matches the edit form's own
|
|
||||||
// pristine-form no-op, and keeps a mis-aimed long press harmless.
|
|
||||||
if (shifted == original) return null
|
|
||||||
|
|
||||||
// The UNTIL check is deferred to the write: how far the move reaches
|
|
||||||
// decides which date has to clear it, and "only this event" writes an
|
|
||||||
// exception row that no UNTIL constrains at all.
|
|
||||||
if ((shifted.problems() - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
|
|
||||||
_outcome.value = MoveOutcome.Failed
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// An exception row stands alone whatever rule a sync adapter left on it.
|
|
||||||
val isRecurring = original.rrule != null && !detail.isException
|
|
||||||
val movedDay = shifted.start.date != original.start.date
|
|
||||||
// The series anchor moves by the same *wall-clock* shift as the dragged
|
|
||||||
// occurrence, so only a whole-day shift moves it by a predictable number
|
|
||||||
// of days — a drag that also changes the time of day would carry some
|
|
||||||
// anchor times of day across an extra midnight and leave the rule naming
|
|
||||||
// the wrong weekday. All-day forms shift as bare dates, so they always
|
|
||||||
// qualify.
|
|
||||||
val wholeDayShift = original.isAllDay || shifted.start.time == original.start.time
|
|
||||||
val realigned = if (isRecurring && movedDay) {
|
|
||||||
if (wholeDayShift) {
|
|
||||||
realignRecurrence(
|
|
||||||
requireNotNull(original.rrule),
|
|
||||||
original.start.date,
|
|
||||||
shifted.start.date,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
original.rrule
|
|
||||||
}
|
|
||||||
return PreparedMove(
|
|
||||||
request = request,
|
|
||||||
original = original,
|
|
||||||
// A rule we can't re-derive stays verbatim: the only scope offered
|
|
||||||
// then is the single occurrence, whose exception row carries no rule.
|
|
||||||
updated = shifted.copy(rrule = realigned ?: original.rrule),
|
|
||||||
isRecurring = isRecurring,
|
|
||||||
canRealign = !isRecurring || !movedDay || realigned != null,
|
|
||||||
// Where the series row's own DTSTART lands, given the anchor moves by
|
|
||||||
// the same shift. Only meaningful under [wholeDayShift], which is the
|
|
||||||
// only case a wider-than-one-occurrence write is offered in.
|
|
||||||
newAnchorDate = anchorDate(detail, original, zone)
|
|
||||||
.plus(shifted.start.date.toEpochDays() - original.start.date.toEpochDays(), DateTimeUnit.DAY),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) {
|
|
||||||
val request = prepared.request
|
|
||||||
if (endsBeforeItStarts(prepared, scope)) {
|
|
||||||
_outcome.value = MoveOutcome.BlockedSeriesEnd
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_outcome.value = try {
|
|
||||||
when {
|
|
||||||
!prepared.isRecurring || scope == RecurringWriteScope.AllEvents ->
|
|
||||||
repository.updateEvent(request.eventId, prepared.original, prepared.updated)
|
|
||||||
|
|
||||||
scope == RecurringWriteScope.ThisEvent ->
|
|
||||||
repository.updateOccurrence(
|
|
||||||
request.eventId,
|
|
||||||
request.beginMillis,
|
|
||||||
prepared.updated,
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> repository.updateEventFromOccurrence(
|
|
||||||
eventId = request.eventId,
|
|
||||||
beginMillis = request.beginMillis,
|
|
||||||
original = prepared.original,
|
|
||||||
updated = prepared.updated,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
MoveOutcome.Moved(
|
|
||||||
startMillis = movedStartMillis(prepared),
|
|
||||||
isAllDay = prepared.updated.isAllDay,
|
|
||||||
undo = undoFor(prepared, scope),
|
|
||||||
)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
MoveOutcome.WriteDenied
|
|
||||||
} catch (e: NoSuchEventException) {
|
|
||||||
MoveOutcome.Gone
|
|
||||||
} catch (e: Exception) {
|
|
||||||
MoveOutcome.Failed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this write would leave a rule whose `UNTIL` precedes the first day
|
|
||||||
* it now applies to — the provider then generates nothing and the event
|
|
||||||
* silently disappears from every view.
|
|
||||||
*
|
|
||||||
* Which date has to clear `UNTIL` depends on how far the write reaches: a
|
|
||||||
* whole-series move carries the series *anchor*, a split starts a new series
|
|
||||||
* at the moved occurrence, and a single occurrence becomes an exception row
|
|
||||||
* that no `UNTIL` constrains. Testing the occurrence in every case would
|
|
||||||
* refuse the perfectly ordinary drag of a bounded series' last occurrence.
|
|
||||||
*/
|
|
||||||
private fun endsBeforeItStarts(prepared: PreparedMove, scope: RecurringWriteScope): Boolean {
|
|
||||||
if (!prepared.isRecurring || scope == RecurringWriteScope.ThisEvent) return false
|
|
||||||
val rule = prepared.updated.rrule ?: return false
|
|
||||||
val end = parseSimpleRecurrence(rule)?.end as? RecurrenceEnd.Until ?: return false
|
|
||||||
val firstDay = if (scope == RecurringWriteScope.AllEvents) {
|
|
||||||
prepared.newAnchorDate
|
|
||||||
} else {
|
|
||||||
prepared.updated.start.date
|
|
||||||
}
|
|
||||||
return end.date < firstDay
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The series row's own start date — for a recurring master, `EventDetail`
|
|
||||||
* carries the row's DTSTART rather than the tapped occurrence's. Read in the
|
|
||||||
* same anchoring the write path uses: UTC for an all-day series.
|
|
||||||
*/
|
|
||||||
private fun anchorDate(detail: EventDetail, original: EventForm, zone: TimeZone): LocalDate =
|
|
||||||
detail.instance.start
|
|
||||||
.toLocalDateTime(if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone))
|
|
||||||
.date
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Undo is offered only where the inverse is one symmetric write: a
|
|
||||||
* non-recurring event (absolute DTSTART/DTEND) and a whole-series move (the
|
|
||||||
* −Δ wall-clock shift lands on the re-read anchor). "This event" leaves an
|
|
||||||
* exception row behind and "this and following" splits the series with an
|
|
||||||
* UNTIL truncation — neither is undone by shifting back.
|
|
||||||
*/
|
|
||||||
private fun undoFor(prepared: PreparedMove, scope: RecurringWriteScope): MoveUndo? =
|
|
||||||
if (!prepared.isRecurring || scope == RecurringWriteScope.AllEvents) {
|
|
||||||
MoveUndo(
|
|
||||||
eventId = prepared.request.eventId,
|
|
||||||
moved = prepared.updated,
|
|
||||||
restored = prepared.original,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where the dragged occurrence now begins, in the same anchoring the views
|
|
||||||
* read dates back in: a UTC midnight for an all-day event, the real instant
|
|
||||||
* for a timed one.
|
|
||||||
*/
|
|
||||||
private fun movedStartMillis(prepared: PreparedMove): Long {
|
|
||||||
val form = prepared.updated
|
|
||||||
return if (form.isAllDay) {
|
|
||||||
form.start.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
|
|
||||||
} else {
|
|
||||||
form.start.toInstant(form.resolvedZone(TimeZone.currentSystemDefault()))
|
|
||||||
.toEpochMilliseconds()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,6 @@ val LocalUse24HourFormat = staticCompositionLocalOf { true }
|
|||||||
private const val PATTERN_24 = "HH:mm"
|
private const val PATTERN_24 = "HH:mm"
|
||||||
private const val PATTERN_12 = "h:mm a"
|
private const val PATTERN_12 = "h:mm a"
|
||||||
private const val HOUR_PATTERN_12 = "h a"
|
private const val HOUR_PATTERN_12 = "h a"
|
||||||
private const val HOUR_MINUTE_PATTERN_12 = "h:mm"
|
|
||||||
|
|
||||||
/** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */
|
/** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */
|
||||||
fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter =
|
fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter =
|
||||||
@@ -43,18 +42,6 @@ fun formatMinuteOfDay(minutes: Int, is24Hour: Boolean, locale: Locale): String =
|
|||||||
else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale)
|
else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The time shown in the timeline gutter while a block is dragged: 24h →
|
|
||||||
* "09:15", 12h → "9:15". The meridiem is dropped on purpose — the hour labels
|
|
||||||
* around it already carry it, and the gutter is too narrow to hold it.
|
|
||||||
*/
|
|
||||||
fun formatGutterTime(minutes: Int, is24Hour: Boolean, locale: Locale): String {
|
|
||||||
val clamped = minutes.coerceIn(0, MINUTES_PER_DAY - 1)
|
|
||||||
val pattern = if (is24Hour) PATTERN_24 else HOUR_MINUTE_PATTERN_12
|
|
||||||
return LocalTime.of(clamped / 60, clamped % 60)
|
|
||||||
.format(DateTimeFormatter.ofPattern(pattern, locale))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded,
|
* The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded,
|
||||||
* the prior look); 12h → "1 PM".
|
* the prior look); 12h → "1 PM".
|
||||||
|
|||||||
@@ -1,354 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.ScrollState
|
|
||||||
import androidx.compose.foundation.MutatePriority
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.absoluteOffset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.Stable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.runtime.withFrameNanos
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.layout.LayoutCoordinates
|
|
||||||
import androidx.compose.ui.layout.boundsInRoot
|
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.compose.ui.unit.IntSize
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.LocalDateTime
|
|
||||||
import kotlinx.datetime.LocalTime
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/** Start granularity a dragged block snaps to, matching the big calendar apps. */
|
|
||||||
const val DRAG_SNAP_MINUTES: Int = 15
|
|
||||||
|
|
||||||
/** How close to a timeline edge the finger must get before the view scrolls. */
|
|
||||||
private val AUTO_SCROLL_EDGE = 64.dp
|
|
||||||
|
|
||||||
/** Fastest auto-scroll step, per frame, right at the edge. */
|
|
||||||
private val AUTO_SCROLL_STEP = 16.dp
|
|
||||||
|
|
||||||
/** A block being dragged, in root coordinates so it can be drawn in an overlay. */
|
|
||||||
data class TimelineDrag(
|
|
||||||
val event: EventInstance,
|
|
||||||
val date: LocalDate,
|
|
||||||
val startMin: Int,
|
|
||||||
val endMin: Int,
|
|
||||||
val topLeftInRoot: Offset,
|
|
||||||
val sizePx: IntSize,
|
|
||||||
) {
|
|
||||||
/** What the snap haptics key off: one tick per changed slot, not per frame. */
|
|
||||||
val slot: Pair<LocalDate, Int> get() = date to startMin
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Where a finished drag asks its event to go. */
|
|
||||||
data class TimelineDrop(val event: EventInstance, val date: LocalDate, val startMin: Int)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The timeline's live geometry, republished on every layout. Held in plain
|
|
||||||
* fields rather than snapshot state on purpose: it changes on every scroll
|
|
||||||
* frame, and recomposing the screen that often would cost far more than the
|
|
||||||
* drag loop's own per-frame read of it.
|
|
||||||
*/
|
|
||||||
class TimelineGeometry {
|
|
||||||
/** The day-columns row — scrolling *content*, so its root position folds in the scroll. */
|
|
||||||
var grid: LayoutCoordinates? = null
|
|
||||||
|
|
||||||
/** The scroll viewport, for the edge that triggers auto-scrolling. */
|
|
||||||
var viewport: LayoutCoordinates? = null
|
|
||||||
var scroll: ScrollState? = null
|
|
||||||
var hourPx: Float = 0f
|
|
||||||
|
|
||||||
/** Column pitch — one column plus the gap after it. */
|
|
||||||
var columnWidthPx: Float = 0f
|
|
||||||
var columnGapPx: Float = 0f
|
|
||||||
var edgePx: Float = 0f
|
|
||||||
var stepPx: Float = 0f
|
|
||||||
var days: List<LocalDate> = emptyList()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the columns are laid out right-to-left. Pointer coordinates are
|
|
||||||
* never mirrored, but the grid is, so the leftmost column is the *last* day
|
|
||||||
* in Arabic — the mapping has to flip with it.
|
|
||||||
*/
|
|
||||||
var isRtl: Boolean = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hoisted drag state for one timeline (#68). It lives above the per-page
|
|
||||||
* `AnimatedContent` — a page change mid-drag would otherwise strand a ghost —
|
|
||||||
* and the block it renders is drawn in an overlay: a `Card` column ends its
|
|
||||||
* modifier chain with a clip, so a block offset toward the neighbouring column
|
|
||||||
* would simply be cut off in place.
|
|
||||||
*/
|
|
||||||
@Stable
|
|
||||||
class TimelineDragController {
|
|
||||||
val geometry = TimelineGeometry()
|
|
||||||
|
|
||||||
var drag: TimelineDrag? by mutableStateOf(null)
|
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which block is lifted, and whether anything is. Separate snapshot state
|
|
||||||
* from [drag] on purpose: [drag] changes on every frame, and the blocks that
|
|
||||||
* only need to know "am I the ghost" must not recompose that often.
|
|
||||||
*/
|
|
||||||
var liftedInstanceId: Long? by mutableStateOf(null)
|
|
||||||
private set
|
|
||||||
|
|
||||||
private var source: TimedBlock? = null
|
|
||||||
private var grab = Offset.Zero
|
|
||||||
private var pointer = Offset.Zero
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The slot the block already occupied when it was picked up. A long press
|
|
||||||
* that never moves must write nothing — and because the target snaps to the
|
|
||||||
* grid, "nothing moved" is not the same as "the start is unchanged": an
|
|
||||||
* event at 09:07 resolves to 09:00 the instant it lifts.
|
|
||||||
*/
|
|
||||||
private var originSlot: Pair<LocalDate, Int>? = null
|
|
||||||
|
|
||||||
val isDragging: Boolean get() = liftedInstanceId != null
|
|
||||||
|
|
||||||
fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) {
|
|
||||||
source = block
|
|
||||||
liftedInstanceId = block.event.instanceId
|
|
||||||
grab = pointerInRoot - blockInRoot
|
|
||||||
pointer = pointerInRoot
|
|
||||||
originSlot = null
|
|
||||||
recompute()
|
|
||||||
originSlot = drag?.slot
|
|
||||||
}
|
|
||||||
|
|
||||||
fun move(pointerInRoot: Offset) {
|
|
||||||
pointer = pointerInRoot
|
|
||||||
recompute()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancel() {
|
|
||||||
source = null
|
|
||||||
liftedInstanceId = null
|
|
||||||
originSlot = null
|
|
||||||
drag = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End the drag, handing back where it landed — null when it never resolved,
|
|
||||||
* or when it landed back on the slot it started from.
|
|
||||||
*/
|
|
||||||
fun finish(): TimelineDrop? {
|
|
||||||
val landed = drag
|
|
||||||
val origin = originSlot
|
|
||||||
cancel()
|
|
||||||
if (landed == null || landed.slot == origin) return null
|
|
||||||
return TimelineDrop(landed.event, landed.date, landed.startMin)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-derive the target from the pointer's *root* position. Recomputed rather
|
|
||||||
* than accumulated from `positionChange()`, because a stationary finger emits
|
|
||||||
* no move events while auto-scroll walks the content underneath it.
|
|
||||||
*/
|
|
||||||
fun recompute() {
|
|
||||||
val block = source ?: return
|
|
||||||
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
|
|
||||||
val hourPx = geometry.hourPx
|
|
||||||
val columnPx = geometry.columnWidthPx
|
|
||||||
val days = geometry.days
|
|
||||||
if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return
|
|
||||||
|
|
||||||
val origin = grid.positionInRoot()
|
|
||||||
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
|
|
||||||
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
|
||||||
// Clamp the start into the target day; a tail running past midnight is
|
|
||||||
// fine and stays visible on the next day.
|
|
||||||
val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
|
|
||||||
val span = block.endMin - block.startMin
|
|
||||||
// The column the finger is over on screen, and the day that column shows.
|
|
||||||
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
|
|
||||||
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
|
||||||
val height = maxOf(span / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
|
|
||||||
drag = TimelineDrag(
|
|
||||||
event = block.event,
|
|
||||||
date = days[dayIndex],
|
|
||||||
startMin = startMin,
|
|
||||||
endMin = startMin + span,
|
|
||||||
topLeftInRoot = Offset(
|
|
||||||
x = origin.x + column * columnPx,
|
|
||||||
y = origin.y + startMin / 60f * hourPx,
|
|
||||||
),
|
|
||||||
sizePx = IntSize(
|
|
||||||
(columnPx - geometry.columnGapPx).roundToInt(),
|
|
||||||
height.roundToInt(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hold the scroll for the whole drag and nudge it once per frame while the
|
|
||||||
* finger sits near an edge. One `scroll` call, not a loop of
|
|
||||||
* `animateScrollBy`: each of those re-acquires the mutex and cancels the last.
|
|
||||||
*/
|
|
||||||
suspend fun autoScroll() {
|
|
||||||
val scroll = geometry.scroll ?: return
|
|
||||||
scroll.scroll(MutatePriority.UserInput) {
|
|
||||||
while (true) {
|
|
||||||
withFrameNanos { }
|
|
||||||
recompute()
|
|
||||||
val step = edgeStep()
|
|
||||||
if (step != 0f) scrollBy(step)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun edgeStep(): Float {
|
|
||||||
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return 0f
|
|
||||||
val edge = geometry.edgePx
|
|
||||||
if (edge <= 0f) return 0f
|
|
||||||
val bounds = viewport.boundsInRoot()
|
|
||||||
val fromTop = pointer.y - bounds.top
|
|
||||||
val fromBottom = bounds.bottom - pointer.y
|
|
||||||
return when {
|
|
||||||
fromTop < edge -> -(edge - fromTop) / edge * geometry.stepPx
|
|
||||||
fromBottom < edge -> (edge - fromBottom) / edge * geometry.stepPx
|
|
||||||
else -> 0f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun rememberTimelineDragController(): TimelineDragController {
|
|
||||||
val controller = remember { TimelineDragController() }
|
|
||||||
val density = LocalDensity.current
|
|
||||||
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
|
|
||||||
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
|
|
||||||
return controller
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when [block] actually begins on [day] rather than being the tail of an
|
|
||||||
* event that started earlier. A clipped block's top edge is midnight, not the
|
|
||||||
* event's start, so dragging it would move the event to a time it never had.
|
|
||||||
*/
|
|
||||||
fun TimedBlock.beginsOn(day: LocalDate, zone: TimeZone): Boolean =
|
|
||||||
event.start.toLocalDateTime(zone).date == day
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The instant a drop asks for, read in the zone the timeline is drawn in. A drop
|
|
||||||
* into a spring-forward gap has no instant of its own; `toInstant` resolves it
|
|
||||||
* forward by the missing hour, and the block visibly settles there.
|
|
||||||
*/
|
|
||||||
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
|
|
||||||
LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
|
|
||||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
|
||||||
val dark = isSystemInDarkTheme()
|
|
||||||
val soften = LocalSoftenColors.current
|
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
|
||||||
val locale = currentLocale()
|
|
||||||
val reduceMotion = rememberReduceMotion()
|
|
||||||
val density = LocalDensity.current
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
DragSnapHaptics(controller.drag?.slot)
|
|
||||||
LaunchedEffect(controller.isDragging) {
|
|
||||||
if (controller.isDragging) controller.autoScroll()
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
// A copy of a block that is still in the tree behind it; announcing
|
|
||||||
// it again would just duplicate the event for the drag's duration.
|
|
||||||
.clearAndSetSemantics { }
|
|
||||||
.onGloballyPositioned { origin = it.positionInRoot() },
|
|
||||||
) {
|
|
||||||
val drag = controller.drag ?: return@Box
|
|
||||||
val fill = eventFill(drag.event.color, dark, soften)
|
|
||||||
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
|
||||||
val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}–" +
|
|
||||||
formatMinuteOfDay(drag.endMin.coerceAtMost(MINUTES_PER_DAY), use24Hour, locale)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
// Absolute: these are root coordinates, and the direction-aware
|
|
||||||
// offset would mirror them across the screen in an RTL layout.
|
|
||||||
.absoluteOffset {
|
|
||||||
IntOffset(
|
|
||||||
(drag.topLeftInRoot.x - origin.x).roundToInt(),
|
|
||||||
(drag.topLeftInRoot.y - origin.y).roundToInt(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.size(
|
|
||||||
width = with(density) { drag.sizePx.width.toDp() },
|
|
||||||
height = with(density) { drag.sizePx.height.toDp() },
|
|
||||||
)
|
|
||||||
.padding(horizontal = 1.dp)
|
|
||||||
.graphicsLayer {
|
|
||||||
if (!reduceMotion) {
|
|
||||||
scaleX = 1.02f
|
|
||||||
scaleY = 1.02f
|
|
||||||
}
|
|
||||||
shadowElevation = 8.dp.toPx()
|
|
||||||
shape = RoundedCornerShape(4.dp)
|
|
||||||
clip = false
|
|
||||||
}
|
|
||||||
.background(fill, RoundedCornerShape(4.dp))
|
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
color = eventInk(fill, alpha = 0.85f),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -76,6 +76,18 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
|
|||||||
.coerceAtLeast(fillHourHeight(viewportHeight))
|
.coerceAtLeast(fillHourHeight(viewportHeight))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The minute of the day a tap [offsetY] px down a timeline column means, snapped
|
||||||
|
* to the hour it landed in.
|
||||||
|
*
|
||||||
|
* [hourPx] must be the hour height the column is drawing at *now* — a tap
|
||||||
|
* detector that captured it when it was installed maps taps to a pre-pinch grid
|
||||||
|
* (#148). A zero or negative height (a viewport measured at nothing) has no grid
|
||||||
|
* to read, so it answers midnight rather than dividing by it.
|
||||||
|
*/
|
||||||
|
fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
|
||||||
|
if (hourPx <= 0f) 0 else (offsetY / hourPx).toInt().coerceIn(0, 23) * 60
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shortest an event block may render, as a fraction of an hour. Blocks keep a
|
* Shortest an event block may render, as a fraction of an hour. Blocks keep a
|
||||||
* floor so a 15-minute event stays tappable, but the floor scales with the hour
|
* floor so a 15-minute event stays tappable, but the floor scales with the hour
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -53,19 +54,14 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.customActions
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -79,19 +75,7 @@ import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GHOST_ALPHA
|
|
||||||
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.NowLine
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.beginsOn
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
@@ -110,10 +94,10 @@ import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -124,6 +108,11 @@ import kotlin.time.Clock
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private val GUTTER_WIDTH = 48.dp
|
||||||
|
/** Start inset for the gutter's hour labels so they centre on the top bar's
|
||||||
|
* hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's
|
||||||
|
* 4dp inset + 24dp half icon button), matching the week view. */
|
||||||
|
private val GUTTER_CONTENT_START_INSET = 8.dp
|
||||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
|
|
||||||
@@ -311,51 +300,31 @@ private fun DayContent(
|
|||||||
// drag is consumed by the inner scroll first — the two gestures coexist.
|
// drag is consumed by the inner scroll first — the two gestures coexist.
|
||||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||||
|
|
||||||
// Above the AnimatedContent: a page change mid-drag would strand the
|
AnimatedContent(
|
||||||
// floating block inside the outgoing page.
|
targetState = state,
|
||||||
val dragController = rememberTimelineDragController()
|
modifier = modifier.then(swipeModifier),
|
||||||
val move = LocalEventMove.current
|
contentKey = { s ->
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
|
||||||
|
|
||||||
Box(modifier = modifier) {
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = state,
|
|
||||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
|
||||||
contentKey = { s ->
|
|
||||||
when (s) {
|
|
||||||
is DayUiState.Success -> "success-${s.date}"
|
|
||||||
is DayUiState.Failure -> "failure-${s.reason}"
|
|
||||||
DayUiState.Loading -> "loading"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
|
||||||
label = "day-transition",
|
|
||||||
) { s ->
|
|
||||||
when (s) {
|
when (s) {
|
||||||
DayUiState.Loading -> DayLoading()
|
is DayUiState.Success -> "success-${s.date}"
|
||||||
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
is DayUiState.Failure -> "failure-${s.reason}"
|
||||||
is DayUiState.Success -> DaySuccess(
|
DayUiState.Loading -> "loading"
|
||||||
state = s,
|
|
||||||
topSectionColor = topSectionColor,
|
|
||||||
scrollState = scrollState,
|
|
||||||
allDayHeight = allDayHeight,
|
|
||||||
dragController = dragController,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
onCreateAt = onCreateAt,
|
|
||||||
onDrop = { drop ->
|
|
||||||
move?.move(
|
|
||||||
MoveRequest(
|
|
||||||
eventId = drop.event.eventId,
|
|
||||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
|
||||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
|
||||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||||
|
label = "day-transition",
|
||||||
|
) { s ->
|
||||||
|
when (s) {
|
||||||
|
DayUiState.Loading -> DayLoading()
|
||||||
|
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
|
is DayUiState.Success -> DaySuccess(
|
||||||
|
state = s,
|
||||||
|
topSectionColor = topSectionColor,
|
||||||
|
scrollState = scrollState,
|
||||||
|
allDayHeight = allDayHeight,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
TimelineDragOverlay(dragController)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,10 +334,8 @@ private fun DaySuccess(
|
|||||||
topSectionColor: Color,
|
topSectionColor: Color,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
dragController: TimelineDragController,
|
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// All-day strip collapses to nothing when the day has no all-day events,
|
// All-day strip collapses to nothing when the day has no all-day events,
|
||||||
@@ -387,10 +354,8 @@ private fun DaySuccess(
|
|||||||
Timeline(
|
Timeline(
|
||||||
state = state,
|
state = state,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
dragController = dragController,
|
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -524,15 +489,13 @@ private fun AllDayBar(
|
|||||||
private fun Timeline(
|
private fun Timeline(
|
||||||
state: DayUiState.Success,
|
state: DayUiState.Success,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
dragController: TimelineDragController,
|
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
val zoom = LocalTimelineZoom.current
|
val zoom = LocalTimelineZoom.current
|
||||||
val density = LocalDensity.current
|
|
||||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
|
||||||
|
|
||||||
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
|
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
|
||||||
// timeline's own viewport height, which is only known here — below the top
|
// timeline's own viewport height, which is only known here — below the top
|
||||||
@@ -551,19 +514,39 @@ private fun Timeline(
|
|||||||
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
||||||
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
||||||
// labels centre on the top bar hamburger, matching the week view.
|
// labels centre on the top bar hamburger, matching the week view.
|
||||||
HourGutter(
|
Column(
|
||||||
scrollState = scrollState,
|
modifier = Modifier
|
||||||
hourHeight = hourHeight,
|
.width(GUTTER_WIDTH)
|
||||||
dragController = dragController,
|
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||||
)
|
.fillMaxHeight()
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
) {
|
||||||
|
(0 until 24).forEach { h ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(hourHeight),
|
||||||
|
) {
|
||||||
|
if (h > 0) {
|
||||||
|
Text(
|
||||||
|
text = formatHourLabel(h, use24Hour, locale),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.offset(y = (-6).dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Day column: rounded, clipped scroll viewport (permanent corners).
|
// Day column: rounded, clipped scroll viewport (permanent corners).
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState),
|
||||||
.onGloballyPositioned { dragController.geometry.viewport = it },
|
|
||||||
) {
|
) {
|
||||||
DayColumnCard(
|
DayColumnCard(
|
||||||
blocks = state.timed,
|
blocks = state.timed,
|
||||||
@@ -571,26 +554,11 @@ private fun Timeline(
|
|||||||
date = state.date,
|
date = state.date,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
hourHeight = hourHeight,
|
hourHeight = hourHeight,
|
||||||
dragController = dragController,
|
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(totalHeight)
|
.height(totalHeight),
|
||||||
// The scrolling content itself, so its root position
|
|
||||||
// already folds in the scroll offset.
|
|
||||||
.onGloballyPositioned { coords ->
|
|
||||||
dragController.geometry.let {
|
|
||||||
it.grid = coords
|
|
||||||
it.scroll = scrollState
|
|
||||||
it.hourPx = with(density) { hourHeight.toPx() }
|
|
||||||
it.columnGapPx = 0f
|
|
||||||
it.columnWidthPx = coords.size.width.toFloat()
|
|
||||||
it.days = listOf(state.date)
|
|
||||||
it.isRtl = isRtl
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -604,15 +572,19 @@ private fun DayColumnCard(
|
|||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
hourHeight: Dp,
|
hourHeight: Dp,
|
||||||
dragController: TimelineDragController,
|
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||||
val showHourLines = LocalShowHourLines.current
|
val showHourLines = LocalShowHourLines.current
|
||||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||||
|
// The tap detector outlives the composition that installed it — a pinch or a
|
||||||
|
// Settings change moves the hour height without restarting it — so it reads
|
||||||
|
// the height and the callback through state handles instead of capturing
|
||||||
|
// them, or taps land on the scale the column had before the zoom (#148).
|
||||||
|
val currentHourPx = rememberUpdatedState(hourPx)
|
||||||
|
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
|
||||||
Card(
|
Card(
|
||||||
// Plain rectangular column — the soft corners come from the outer
|
// Plain rectangular column — the soft corners come from the outer
|
||||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
@@ -633,8 +605,10 @@ private fun DayColumnCard(
|
|||||||
// only fires on the column background. Snaps to the tapped hour.
|
// only fires on the column background. Snaps to the tapped hour.
|
||||||
.pointerInput(date) {
|
.pointerInput(date) {
|
||||||
detectTapGestures { offset ->
|
detectTapGestures { offset ->
|
||||||
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
|
currentOnCreateAt.value(
|
||||||
onCreateAt(date, hour * 60)
|
date,
|
||||||
|
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -649,10 +623,7 @@ private fun DayColumnCard(
|
|||||||
block = block,
|
block = block,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
height = height,
|
height = height,
|
||||||
date = date,
|
|
||||||
dragController = dragController,
|
|
||||||
onClick = { onEventClick(block.event) },
|
onClick = { onEventClick(block.event) },
|
||||||
onDrop = onDrop,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = laneWidth * block.lane, y = top)
|
.offset(x = laneWidth * block.lane, y = top)
|
||||||
.width(laneWidth)
|
.width(laneWidth)
|
||||||
@@ -673,10 +644,7 @@ private fun EventBlock(
|
|||||||
block: TimedBlock,
|
block: TimedBlock,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
height: Dp,
|
height: Dp,
|
||||||
date: LocalDate,
|
|
||||||
dragController: TimelineDragController,
|
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
@@ -700,34 +668,12 @@ private fun EventBlock(
|
|||||||
val showTitle = available >= titleLineHeight
|
val showTitle = available >= titleLineHeight
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
val fill = eventFill(block.event.color, dark, soften)
|
val fill = eventFill(block.event.color, dark, soften)
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
|
||||||
val moveAction = eventMoveAction(block.event)
|
|
||||||
// A block clipped at the top continues from the previous day: its top edge is
|
|
||||||
// midnight, not the event's start, so dragging it would invent a time.
|
|
||||||
val draggable = moveAction != null && block.beginsOn(date, zone)
|
|
||||||
val dragModifier = rememberEventDragSource(
|
|
||||||
enabled = draggable,
|
|
||||||
key = block.event.instanceId,
|
|
||||||
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
|
|
||||||
onMove = dragController::move,
|
|
||||||
onDrop = { dragController.finish()?.let(onDrop) },
|
|
||||||
onCancel = dragController::cancel,
|
|
||||||
)
|
|
||||||
val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
// The source stays put as a ghost while its floating copy travels.
|
|
||||||
.then(if (lifted) Modifier.alpha(GHOST_ALPHA) else Modifier)
|
|
||||||
.background(fill, RoundedCornerShape(4.dp))
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
// After clickable, so it is the inner node and wins the main pass;
|
|
||||||
// the tap still works, since a drag consumes the up.
|
|
||||||
.then(dragModifier)
|
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
.semantics {
|
.semantics { contentDescription = "$title, $timeLabel" },
|
||||||
contentDescription = "$title, $timeLabel"
|
|
||||||
if (moveAction != null) customActions = listOf(moveAction)
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
if (showTitle) {
|
if (showTitle) {
|
||||||
|
|||||||
@@ -127,7 +127,6 @@ import de.jeanlucmakiola.floret.identity.collapseExit
|
|||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
@@ -453,11 +452,30 @@ private fun SaveScopeDialog(
|
|||||||
onSelect: (RecurringWriteScope) -> Unit,
|
onSelect: (RecurringWriteScope) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
RecurringScopeDialog(
|
AlertDialog(
|
||||||
title = stringResource(R.string.event_edit_recurring_title),
|
onDismissRequest = onDismiss,
|
||||||
onSelect = onSelect,
|
title = { Text(stringResource(R.string.event_edit_recurring_title)) },
|
||||||
onDismiss = onDismiss,
|
text = {
|
||||||
allowOccurrence = !recurrenceChanged,
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
if (!recurrenceChanged) {
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_delete_option_occurrence),
|
||||||
|
onClick = { onSelect(RecurringWriteScope.ThisEvent) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_delete_option_following),
|
||||||
|
onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) },
|
||||||
|
)
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_delete_option_series),
|
||||||
|
onClick = { onSelect(RecurringWriteScope.AllEvents) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.month
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.Stable
|
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.layout.LayoutCoordinates
|
|
||||||
import androidx.compose.ui.layout.boundsInRoot
|
|
||||||
import androidx.compose.ui.unit.IntSize
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
|
|
||||||
/** One week row's live geometry, republished on every layout while it is on screen. */
|
|
||||||
class MonthRowGeometry(
|
|
||||||
val days: List<LocalDate>,
|
|
||||||
/** The row's day-column box — the space chip offsets are measured in. */
|
|
||||||
val cell: LayoutCoordinates,
|
|
||||||
val columnWidthPx: Float,
|
|
||||||
/**
|
|
||||||
* Whether the columns are laid out right-to-left. Pointer coordinates are
|
|
||||||
* never mirrored, but the grid is, so the leftmost column is the *last* day
|
|
||||||
* in Arabic.
|
|
||||||
*/
|
|
||||||
val isRtl: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** A chip in flight, in root coordinates so it can be drawn in an overlay. */
|
|
||||||
data class MonthChipDrag(
|
|
||||||
val event: EventInstance,
|
|
||||||
/** The day whose column the chip was grabbed in — the drag is a delta from it. */
|
|
||||||
val grabDate: LocalDate,
|
|
||||||
val targetDate: LocalDate?,
|
|
||||||
val topLeftInRoot: Offset,
|
|
||||||
val sizePx: IntSize,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hoisted drag state for the month grid (#68).
|
|
||||||
*
|
|
||||||
* Every visible week row registers itself, so a chip can be carried across rows:
|
|
||||||
* the pointer node that took the press keeps receiving events after the finger
|
|
||||||
* has left its own bounds, and the target is then resolved against whichever
|
|
||||||
* registered row the finger is actually over. Rows are keyed by an identity token
|
|
||||||
* rather than by their date, because the continuous style can show the same week
|
|
||||||
* twice — once in each adjoining month.
|
|
||||||
*/
|
|
||||||
@Stable
|
|
||||||
class MonthDragController {
|
|
||||||
private val rows = LinkedHashMap<Any, MonthRowGeometry>()
|
|
||||||
|
|
||||||
var drag: MonthChipDrag? by mutableStateOf(null)
|
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which chip is lifted, and whether anything is. Separate snapshot state from
|
|
||||||
* [drag] on purpose: [drag] changes on every frame, while the chips and rows
|
|
||||||
* that only need "am I the ghost" / "must I stop clipping" must not.
|
|
||||||
*/
|
|
||||||
var liftedInstanceId: Long? by mutableStateOf(null)
|
|
||||||
private set
|
|
||||||
|
|
||||||
private var event: EventInstance? = null
|
|
||||||
private var grabDate: LocalDate? = null
|
|
||||||
private var grab = Offset.Zero
|
|
||||||
private var pointer = Offset.Zero
|
|
||||||
private var sizePx = IntSize.Zero
|
|
||||||
|
|
||||||
val isDragging: Boolean get() = liftedInstanceId != null
|
|
||||||
|
|
||||||
fun putRow(token: Any, geometry: MonthRowGeometry) {
|
|
||||||
rows[token] = geometry
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeRow(token: Any) {
|
|
||||||
rows.remove(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun begin(
|
|
||||||
event: EventInstance,
|
|
||||||
grabDate: LocalDate,
|
|
||||||
pointerInRoot: Offset,
|
|
||||||
chipInRoot: Offset,
|
|
||||||
size: IntSize,
|
|
||||||
) {
|
|
||||||
this.event = event
|
|
||||||
this.grabDate = grabDate
|
|
||||||
liftedInstanceId = event.instanceId
|
|
||||||
grab = pointerInRoot - chipInRoot
|
|
||||||
pointer = pointerInRoot
|
|
||||||
sizePx = size
|
|
||||||
recompute()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun move(pointerInRoot: Offset) {
|
|
||||||
pointer = pointerInRoot
|
|
||||||
recompute()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancel() {
|
|
||||||
event = null
|
|
||||||
grabDate = null
|
|
||||||
liftedInstanceId = null
|
|
||||||
drag = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun finish(): MonthChipDrop? {
|
|
||||||
val landed = drag
|
|
||||||
cancel()
|
|
||||||
val target = landed?.targetDate ?: return null
|
|
||||||
return MonthChipDrop(landed.event, landed.grabDate, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun recompute() {
|
|
||||||
val event = event ?: return
|
|
||||||
val grabbed = grabDate ?: return
|
|
||||||
val resolved = rows.values.firstNotNullOfOrNull { row ->
|
|
||||||
val bounds = row.cell.takeIf { it.isAttached }?.boundsInRoot()
|
|
||||||
?: return@firstNotNullOfOrNull null
|
|
||||||
if (!bounds.contains(pointer) || row.columnWidthPx <= 0f) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
val column = ((pointer.x - bounds.left) / row.columnWidthPx)
|
|
||||||
.toInt()
|
|
||||||
.coerceIn(0, row.days.lastIndex)
|
|
||||||
row.days[if (row.isRtl) row.days.lastIndex - column else column]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
drag = MonthChipDrag(
|
|
||||||
event = event,
|
|
||||||
grabDate = grabbed,
|
|
||||||
// Off the grid entirely (the gutter, the header): keep the last cell
|
|
||||||
// it was over, so a wobble past the edge doesn't drop the target.
|
|
||||||
targetDate = resolved ?: drag?.targetDate,
|
|
||||||
topLeftInRoot = pointer - grab,
|
|
||||||
sizePx = sizePx,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun rememberMonthDragController(): MonthDragController = remember { MonthDragController() }
|
|
||||||
|
|
||||||
val LocalMonthDrag = compositionLocalOf<MonthDragController?> { null }
|
|
||||||
@@ -62,7 +62,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
@@ -78,28 +77,7 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
import androidx.compose.foundation.layout.absoluteOffset
|
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
|
||||||
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.GHOST_ALPHA
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
|
||||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
|
||||||
import androidx.compose.ui.unit.IntSize
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.compose.ui.semantics.customActions
|
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.compose.ui.layout.LayoutCoordinates
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
@@ -406,21 +384,13 @@ fun MonthScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
// Hoisted above every style's grid: a dragged chip is drawn in an
|
Column(
|
||||||
// overlay so it is free of the week row's clip and, in the scrolling
|
|
||||||
// styles, of the list viewport's.
|
|
||||||
val chipDrag = rememberMonthDragController()
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
|
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||||
LocalDimCutoff provides dimCutoff,
|
|
||||||
LocalMonthDrag provides chipDrag,
|
|
||||||
) {
|
|
||||||
if (scrolling) {
|
if (scrolling) {
|
||||||
ContinuousMonthContent(
|
ContinuousMonthContent(
|
||||||
state = continuousState,
|
state = continuousState,
|
||||||
@@ -457,61 +427,10 @@ fun MonthScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MonthDragOverlay(chipDrag)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The chip in flight, drawn over the grid and following the finger. */
|
|
||||||
@Composable
|
|
||||||
private fun MonthDragOverlay(controller: MonthDragController) {
|
|
||||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
|
||||||
val dark = isSystemInDarkTheme()
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val reduceMotion = rememberReduceMotion()
|
|
||||||
// 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)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
// A copy of a chip that is still in the tree behind it; announcing it
|
|
||||||
// again would just duplicate the event for the drag's duration.
|
|
||||||
.clearAndSetSemantics { }
|
|
||||||
.onGloballyPositioned { origin = it.positionInRoot() },
|
|
||||||
) {
|
|
||||||
val drag = controller.drag ?: return@Box
|
|
||||||
MonthBar(
|
|
||||||
event = drag.event,
|
|
||||||
dark = dark,
|
|
||||||
continuesLeft = false,
|
|
||||||
continuesRight = false,
|
|
||||||
modifier = Modifier
|
|
||||||
// Absolute: these are root coordinates, and the direction-aware
|
|
||||||
// offset would mirror them across the screen in an RTL layout.
|
|
||||||
.absoluteOffset {
|
|
||||||
IntOffset(
|
|
||||||
(drag.topLeftInRoot.x - origin.x).roundToInt(),
|
|
||||||
(drag.topLeftInRoot.y - origin.y).roundToInt(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.width(with(density) { drag.sizePx.width.toDp() })
|
|
||||||
.height(with(density) { drag.sizePx.height.toDp() })
|
|
||||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
|
|
||||||
.graphicsLayer {
|
|
||||||
if (!reduceMotion) {
|
|
||||||
scaleX = 1.04f
|
|
||||||
scaleY = 1.04f
|
|
||||||
}
|
|
||||||
shadowElevation = 8.dp.toPx()
|
|
||||||
shape = RoundedCornerShape(4.dp)
|
|
||||||
clip = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthContent(
|
private fun MonthContent(
|
||||||
state: MonthUiState,
|
state: MonthUiState,
|
||||||
@@ -1801,22 +1720,6 @@ private fun MonthWeekRow(
|
|||||||
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
||||||
val morphing = morphInFlight()
|
val morphing = morphInFlight()
|
||||||
|
|
||||||
// Drag to reschedule (#68). The chips can take no pointer input of their own —
|
|
||||||
// the full-bleed tap layer below sits on top of them, and Compose stops
|
|
||||||
// sibling hit-testing at the topmost hit — so one detector on the row's day-
|
|
||||||
// column box hit-tests them geometrically instead.
|
|
||||||
val moveScope = LocalEventMove.current
|
|
||||||
val dragController = LocalMonthDrag.current
|
|
||||||
val rowToken = remember { Any() }
|
|
||||||
val bandCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val rowHeightPx = with(density) { EVENT_ROW_HEIGHT.toPx() }
|
|
||||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
|
||||||
val dragging = dragController?.isDragging == true
|
|
||||||
DisposableEffect(rowToken, dragController) {
|
|
||||||
onDispose { dragController?.removeRow(rowToken) }
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(modifier) {
|
Row(modifier) {
|
||||||
// Optional calendar-week gutter, sized so the seven day columns below
|
// Optional calendar-week gutter, sized so the seven day columns below
|
||||||
// divide the remaining width — the absolute bar offsets stay correct
|
// divide the remaining width — the absolute bar offsets stay correct
|
||||||
@@ -1832,28 +1735,7 @@ private fun MonthWeekRow(
|
|||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight(),
|
||||||
.onGloballyPositioned { coords ->
|
|
||||||
dragController?.putRow(
|
|
||||||
rowToken,
|
|
||||||
MonthRowGeometry(
|
|
||||||
days = week.days,
|
|
||||||
cell = coords,
|
|
||||||
columnWidthPx = coords.size.width / 7f,
|
|
||||||
isRtl = isRtl,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.then(
|
|
||||||
monthChipDragModifier(
|
|
||||||
week = week,
|
|
||||||
moveScope = moveScope,
|
|
||||||
controller = dragController,
|
|
||||||
band = bandCoordinates,
|
|
||||||
rowHeightPx = rowHeightPx,
|
|
||||||
isRtl = isRtl,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
val colW = maxWidth / 7
|
val colW = maxWidth / 7
|
||||||
|
|
||||||
@@ -1918,10 +1800,7 @@ private fun MonthWeekRow(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.onGloballyPositioned { bandCoordinates[0] = it }
|
.then(if (morphing) Modifier else Modifier.clipToBounds()),
|
||||||
// A dragged chip travels to another row, so the clip has
|
|
||||||
// to yield for it exactly as it does for a morph.
|
|
||||||
.then(if (morphing || dragging) Modifier else Modifier.clipToBounds()),
|
|
||||||
) {
|
) {
|
||||||
// Spanning bars on their shared lanes.
|
// Spanning bars on their shared lanes.
|
||||||
week.spans.filter { it.lane < shownLanes }.forEach { span ->
|
week.spans.filter { it.lane < shownLanes }.forEach { span ->
|
||||||
@@ -2080,74 +1959,6 @@ private fun MonthWeekRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The row-level pickup for month chips: it resolves which chip the press landed
|
|
||||||
* on from the geometry the row just laid out, and abandons the gesture when the
|
|
||||||
* press was on empty space (or on an event whose calendar can't be moved), so
|
|
||||||
* tapping a day still opens it.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun monthChipDragModifier(
|
|
||||||
week: MonthWeek,
|
|
||||||
moveScope: EventMoveScope?,
|
|
||||||
controller: MonthDragController?,
|
|
||||||
band: Array<LayoutCoordinates?>,
|
|
||||||
rowHeightPx: Float,
|
|
||||||
isRtl: Boolean,
|
|
||||||
): Modifier = rememberDragSurface(
|
|
||||||
enabled = moveScope != null && controller != null,
|
|
||||||
key = week.days.first(),
|
|
||||||
onPickUp = { local, pointerInRoot, nodeInRoot, size ->
|
|
||||||
val bandTop = band[0]?.takeIf { it.isAttached }?.positionInRoot()?.y
|
|
||||||
val columnPx = size.width / 7f
|
|
||||||
val bandY = if (bandTop == null) -1f else local.y - (bandTop - nodeInRoot.y)
|
|
||||||
val lane = (bandY / rowHeightPx).toInt()
|
|
||||||
// The column under the finger on screen, and the day that column shows.
|
|
||||||
val column = (local.x / columnPx).toInt()
|
|
||||||
val dayIndex = if (isRtl) week.days.lastIndex - column else column
|
|
||||||
val event = if (bandY < 0f || columnPx <= 0f) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
week.chipAt(dayIndex, lane, MAX_EVENT_ROWS)
|
|
||||||
}
|
|
||||||
if (event == null || moveScope?.allows(event) != true) {
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
controller?.begin(
|
|
||||||
event = event,
|
|
||||||
grabDate = week.days[dayIndex],
|
|
||||||
pointerInRoot = pointerInRoot,
|
|
||||||
chipInRoot = Offset(
|
|
||||||
x = nodeInRoot.x + column * columnPx,
|
|
||||||
y = requireNotNull(bandTop) + lane * rowHeightPx,
|
|
||||||
),
|
|
||||||
size = IntSize(columnPx.toInt(), rowHeightPx.toInt()),
|
|
||||||
)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMove = { controller?.move(it) },
|
|
||||||
onDrop = {
|
|
||||||
controller?.finish()?.let { drop ->
|
|
||||||
// How many columns the finger crossed — grabbing the middle of a
|
|
||||||
// multi-day bar shifts the event by what the finger travelled, not
|
|
||||||
// to where it landed.
|
|
||||||
val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt()
|
|
||||||
if (delta != 0) {
|
|
||||||
moveScope?.move(
|
|
||||||
MoveRequest(
|
|
||||||
eventId = drop.event.eventId,
|
|
||||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
|
||||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
|
||||||
target = MoveTarget.ByDays(delta),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCancel = { controller?.cancel() },
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
|
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
|
||||||
* day cells' geometry, set apart by the secondaryContainer tint (matching the
|
* day cells' geometry, set apart by the secondaryContainer tint (matching the
|
||||||
@@ -2246,9 +2057,6 @@ private fun MonthBar(
|
|||||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
val fill = eventFill(event.color, dark, soften)
|
val fill = eventFill(event.color, dark, soften)
|
||||||
val moveAction = eventMoveAction(event)
|
|
||||||
// The source stays put as a ghost while its floating copy travels.
|
|
||||||
val lifted = LocalMonthDrag.current?.liftedInstanceId == event.instanceId
|
|
||||||
val shape = RoundedCornerShape(
|
val shape = RoundedCornerShape(
|
||||||
topStart = if (continuesLeft) 0.dp else 4.dp,
|
topStart = if (continuesLeft) 0.dp else 4.dp,
|
||||||
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
||||||
@@ -2257,13 +2065,9 @@ private fun MonthBar(
|
|||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
.then(if (lifted) Modifier.alpha(GHOST_ALPHA) else Modifier)
|
|
||||||
.background(fill, shape)
|
.background(fill, shape)
|
||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = 4.dp)
|
||||||
.semantics {
|
.semantics { contentDescription = title },
|
||||||
contentDescription = title
|
|
||||||
if (moveAction != null) customActions = listOf(moveAction)
|
|
||||||
},
|
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -67,26 +67,6 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
|
|||||||
return byLane.filterNotNull()
|
return byLane.filterNotNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The event drawn in lane [lane] of column [col], or null for an empty slot.
|
|
||||||
*
|
|
||||||
* Mirrors how the paged grid seats a week: spanning bars keep the lane the row
|
|
||||||
* layout gave them, and the day's timed pills fill the slots those bars leave.
|
|
||||||
* Unlike [laneEvents] the lanes are *not* compacted — this answers "what is under
|
|
||||||
* this point", so an empty lane above a seated bar has to stay empty (#68).
|
|
||||||
*/
|
|
||||||
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
|
||||||
if (col !in days.indices || lane !in 0 until laneCap) return null
|
|
||||||
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.let { return it.event }
|
|
||||||
val occupied = spans
|
|
||||||
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
|
|
||||||
.map { it.lane }
|
|
||||||
.toSet()
|
|
||||||
val free = (0 until laneCap).filter { it !in occupied }
|
|
||||||
val index = free.indexOf(lane).takeIf { it >= 0 } ?: return null
|
|
||||||
return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The events on [day] that [laneEvents] had no lane left for — its exact
|
* The events on [day] that [laneEvents] had no lane left for — its exact
|
||||||
* complement, in the same bars-then-pills order. Returned as events rather than
|
* complement, in the same bars-then-pills order. Returned as events rather than
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -62,17 +63,13 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.customActions
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -88,18 +85,6 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GHOST_ALPHA
|
|
||||||
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.TimelineDragOverlay
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.beginsOn
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
@@ -120,11 +105,10 @@ import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||||
@@ -139,10 +123,13 @@ import kotlin.time.Clock
|
|||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
private val GUTTER_WIDTH = 48.dp
|
||||||
|
/** Start inset for the gutter's content (week badge + hour labels) so it centres
|
||||||
|
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp
|
||||||
|
* (the app bar's 4dp inset + 24dp half icon button). */
|
||||||
|
private val GUTTER_CONTENT_START_INSET = 8.dp
|
||||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
/** Gap between day columns; part of the column pitch a drag maps positions through. */
|
|
||||||
private val COLUMN_GAP = 2.dp
|
|
||||||
|
|
||||||
/** Total all-day strip height for a week (0 when there are no all-day events). */
|
/** Total all-day strip height for a week (0 when there are no all-day events). */
|
||||||
private fun WeekUiState.Success.allDayStripHeight(): Dp {
|
private fun WeekUiState.Success.allDayStripHeight(): Dp {
|
||||||
@@ -346,52 +333,32 @@ private fun WeekContent(
|
|||||||
// gestures coexist without fighting.
|
// gestures coexist without fighting.
|
||||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||||
|
|
||||||
// Above the AnimatedContent on purpose: a page change mid-drag would strand
|
AnimatedContent(
|
||||||
// the floating block inside the outgoing page.
|
targetState = state,
|
||||||
val dragController = rememberTimelineDragController()
|
modifier = modifier.then(swipeModifier),
|
||||||
val move = LocalEventMove.current
|
contentKey = { s ->
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
|
||||||
|
|
||||||
Box(modifier = modifier) {
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = state,
|
|
||||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
|
||||||
contentKey = { s ->
|
|
||||||
when (s) {
|
|
||||||
is WeekUiState.Success -> "success-${s.weekStart}"
|
|
||||||
is WeekUiState.Failure -> "failure-${s.reason}"
|
|
||||||
WeekUiState.Loading -> "loading"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
|
||||||
label = "week-transition",
|
|
||||||
) { s ->
|
|
||||||
when (s) {
|
when (s) {
|
||||||
WeekUiState.Loading -> WeekLoading()
|
is WeekUiState.Success -> "success-${s.weekStart}"
|
||||||
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
is WeekUiState.Failure -> "failure-${s.reason}"
|
||||||
is WeekUiState.Success -> WeekSuccess(
|
WeekUiState.Loading -> "loading"
|
||||||
state = s,
|
|
||||||
topSectionColor = topSectionColor,
|
|
||||||
scrollState = scrollState,
|
|
||||||
allDayHeight = allDayHeight,
|
|
||||||
dragController = dragController,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
onOpenDay = onOpenDay,
|
|
||||||
onCreateAt = onCreateAt,
|
|
||||||
onDrop = { drop ->
|
|
||||||
move?.move(
|
|
||||||
MoveRequest(
|
|
||||||
eventId = drop.event.eventId,
|
|
||||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
|
||||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
|
||||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||||
|
label = "week-transition",
|
||||||
|
) { s ->
|
||||||
|
when (s) {
|
||||||
|
WeekUiState.Loading -> WeekLoading()
|
||||||
|
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
|
is WeekUiState.Success -> WeekSuccess(
|
||||||
|
state = s,
|
||||||
|
topSectionColor = topSectionColor,
|
||||||
|
scrollState = scrollState,
|
||||||
|
allDayHeight = allDayHeight,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
TimelineDragOverlay(dragController)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,11 +368,9 @@ private fun WeekSuccess(
|
|||||||
topSectionColor: Color,
|
topSectionColor: Color,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
dragController: TimelineDragController,
|
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
Column(
|
||||||
@@ -422,10 +387,8 @@ private fun WeekSuccess(
|
|||||||
Timeline(
|
Timeline(
|
||||||
state = state,
|
state = state,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
dragController = dragController,
|
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -661,15 +624,13 @@ private fun AllDayBar(
|
|||||||
private fun Timeline(
|
private fun Timeline(
|
||||||
state: WeekUiState.Success,
|
state: WeekUiState.Success,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
dragController: TimelineDragController,
|
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
val zoom = LocalTimelineZoom.current
|
val zoom = LocalTimelineZoom.current
|
||||||
val density = LocalDensity.current
|
|
||||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
|
||||||
|
|
||||||
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
|
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
|
||||||
// timeline's own viewport height, which is only known here — below the top
|
// timeline's own viewport height, which is only known here — below the top
|
||||||
@@ -689,40 +650,45 @@ private fun Timeline(
|
|||||||
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
||||||
// Hour gutter (scrolls in sync with the day columns). Same start inset
|
// Hour gutter (scrolls in sync with the day columns). Same start inset
|
||||||
// as the header badge so the labels sit under it and on the hamburger.
|
// as the header badge so the labels sit under it and on the hamburger.
|
||||||
HourGutter(
|
Column(
|
||||||
scrollState = scrollState,
|
modifier = Modifier
|
||||||
hourHeight = hourHeight,
|
.width(GUTTER_WIDTH)
|
||||||
dragController = dragController,
|
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||||
)
|
.fillMaxHeight()
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
) {
|
||||||
|
(0 until 24).forEach { h ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(hourHeight),
|
||||||
|
) {
|
||||||
|
if (h > 0) {
|
||||||
|
Text(
|
||||||
|
text = formatHourLabel(h, use24Hour, locale),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.offset(y = (-6).dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Day columns: rounded, clipped scroll viewport (permanent corners).
|
// Day columns: rounded, clipped scroll viewport (permanent corners).
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState),
|
||||||
.onGloballyPositioned { dragController.geometry.viewport = it },
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(totalHeight)
|
.height(totalHeight),
|
||||||
// The scrolling content itself, so its root position
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
// already folds in the scroll offset — a drag maps
|
|
||||||
// through it without reading the scroll state.
|
|
||||||
.onGloballyPositioned { coords ->
|
|
||||||
val gap = with(density) { COLUMN_GAP.toPx() }
|
|
||||||
dragController.geometry.let {
|
|
||||||
it.grid = coords
|
|
||||||
it.scroll = scrollState
|
|
||||||
it.hourPx = with(density) { hourHeight.toPx() }
|
|
||||||
it.columnGapPx = gap
|
|
||||||
it.columnWidthPx = (coords.size.width + gap) / state.days.size
|
|
||||||
it.days = state.days
|
|
||||||
it.isRtl = isRtl
|
|
||||||
}
|
|
||||||
},
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP),
|
|
||||||
) {
|
) {
|
||||||
state.days.forEach { day ->
|
state.days.forEach { day ->
|
||||||
DayColumnCard(
|
DayColumnCard(
|
||||||
@@ -731,10 +697,8 @@ private fun Timeline(
|
|||||||
date = day,
|
date = day,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
hourHeight = hourHeight,
|
hourHeight = hourHeight,
|
||||||
dragController = dragController,
|
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
@@ -753,15 +717,19 @@ private fun DayColumnCard(
|
|||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
hourHeight: Dp,
|
hourHeight: Dp,
|
||||||
dragController: TimelineDragController,
|
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||||
val showHourLines = LocalShowHourLines.current
|
val showHourLines = LocalShowHourLines.current
|
||||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||||
|
// The tap detector outlives the composition that installed it — a pinch or a
|
||||||
|
// Settings change moves the hour height without restarting it — so it reads
|
||||||
|
// the height and the callback through state handles instead of capturing
|
||||||
|
// them, or taps land on the scale the column had before the zoom (#148).
|
||||||
|
val currentHourPx = rememberUpdatedState(hourPx)
|
||||||
|
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
|
||||||
Card(
|
Card(
|
||||||
// Plain rectangular columns — the soft corners come from the outer
|
// Plain rectangular columns — the soft corners come from the outer
|
||||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
@@ -781,8 +749,10 @@ private fun DayColumnCard(
|
|||||||
// blocks are consumed by their own handler first. Snaps to hour.
|
// blocks are consumed by their own handler first. Snaps to hour.
|
||||||
.pointerInput(date) {
|
.pointerInput(date) {
|
||||||
detectTapGestures { offset ->
|
detectTapGestures { offset ->
|
||||||
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
|
currentOnCreateAt.value(
|
||||||
onCreateAt(date, hour * 60)
|
date,
|
||||||
|
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -798,10 +768,7 @@ private fun DayColumnCard(
|
|||||||
dark = dark,
|
dark = dark,
|
||||||
height = height,
|
height = height,
|
||||||
width = laneWidth,
|
width = laneWidth,
|
||||||
date = date,
|
|
||||||
dragController = dragController,
|
|
||||||
onClick = { onEventClick(block.event) },
|
onClick = { onEventClick(block.event) },
|
||||||
onDrop = onDrop,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = laneWidth * block.lane, y = top)
|
.offset(x = laneWidth * block.lane, y = top)
|
||||||
.width(laneWidth)
|
.width(laneWidth)
|
||||||
@@ -823,10 +790,7 @@ private fun EventBlock(
|
|||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
height: Dp,
|
height: Dp,
|
||||||
width: Dp,
|
width: Dp,
|
||||||
date: LocalDate,
|
|
||||||
dragController: TimelineDragController,
|
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
@@ -869,34 +833,12 @@ private fun EventBlock(
|
|||||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
val fill = eventFill(block.event.color, dark, soften)
|
val fill = eventFill(block.event.color, dark, soften)
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
|
||||||
val moveAction = eventMoveAction(block.event)
|
|
||||||
// A block clipped at the top continues from the previous day: its top edge is
|
|
||||||
// midnight, not the event's start, so dragging it would invent a time.
|
|
||||||
val draggable = moveAction != null && block.beginsOn(date, zone)
|
|
||||||
val dragModifier = rememberEventDragSource(
|
|
||||||
enabled = draggable,
|
|
||||||
key = block.event.instanceId,
|
|
||||||
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
|
|
||||||
onMove = dragController::move,
|
|
||||||
onDrop = { dragController.finish()?.let(onDrop) },
|
|
||||||
onCancel = dragController::cancel,
|
|
||||||
)
|
|
||||||
val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId
|
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
// The source stays put as a ghost while its floating copy travels.
|
|
||||||
.then(if (lifted) Modifier.alpha(GHOST_ALPHA) else Modifier)
|
|
||||||
.background(fill, RoundedCornerShape(4.dp))
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
// After clickable, so it is the inner node and wins the main pass;
|
|
||||||
// the tap still works, since a drag consumes the up.
|
|
||||||
.then(dragModifier)
|
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
.semantics {
|
.semantics { contentDescription = "$title, $timeLabel" },
|
||||||
contentDescription = "$title, $timeLabel"
|
|
||||||
if (moveAction != null) customActions = listOf(moveAction)
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
if (showTitle) {
|
if (showTitle) {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
<string name="month_prev">الشهر السابق</string>
|
<string name="month_prev">الشهر السابق</string>
|
||||||
<string name="month_next">الشهر القادم</string>
|
<string name="month_next">الشهر القادم</string>
|
||||||
<string name="month_today_action">اليوم</string>
|
<string name="month_today_action">اليوم</string>
|
||||||
<string name="month_action_settings">إعدادات</string>
|
<string name="month_action_settings">الإعدادات</string>
|
||||||
<string name="settings_title">الإعدادات</string>
|
<string name="settings_title">الإعدادات</string>
|
||||||
<string name="settings_theme">الثيم</string>
|
<string name="settings_theme">الثيم</string>
|
||||||
<string name="settings_theme_system">النظام</string>
|
<string name="settings_theme_system">النظام</string>
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
<string name="event_detail_calendar">التقويم</string>
|
<string name="event_detail_calendar">التقويم</string>
|
||||||
<string name="event_detail_calendar_unknown">تقويم غير معروف</string>
|
<string name="event_detail_calendar_unknown">تقويم غير معروف</string>
|
||||||
<string name="event_detail_description">الوصف</string>
|
<string name="event_detail_description">الوصف</string>
|
||||||
<string name="event_detail_all_day">كل يوم</string>
|
<string name="event_detail_all_day">طوال اليوم</string>
|
||||||
<string name="event_detail_location">الموقع</string>
|
<string name="event_detail_location">الموقع</string>
|
||||||
<string name="event_detail_attendees">الحضور</string>
|
<string name="event_detail_attendees">الحضور</string>
|
||||||
<string name="event_detail_recurrence">التكرار</string>
|
<string name="event_detail_recurrence">التكرار</string>
|
||||||
@@ -219,8 +219,8 @@
|
|||||||
<string name="settings_dynamic_color">اللون الديناميكي</string>
|
<string name="settings_dynamic_color">اللون الديناميكي</string>
|
||||||
<string name="settings_dynamic_color_unavailable">يتطلب أندرويد ١٢ أو أحدث</string>
|
<string name="settings_dynamic_color_unavailable">يتطلب أندرويد ١٢ أو أحدث</string>
|
||||||
<string name="settings_default_view">طريقة العرض الافتراضية</string>
|
<string name="settings_default_view">طريقة العرض الافتراضية</string>
|
||||||
<string name="settings_soften_colors">ألوان تقويم ناعمة</string>
|
<string name="settings_soften_colors">ألوان تقويم متناسقة</string>
|
||||||
<string name="settings_soften_colors_summary">خفف ألوان التقويم والأحداث لتتناسب مع الثيم. قم بإيقافه لإظهار الألوان الخام من مصدر التقويم.</string>
|
<string name="settings_soften_colors_summary">حافظ على درجة لون كل تقويم لكن قم بتسوية سطوعه، بحيث كل حدث يظل مقروءًا و تتناسق الألوان معًا. قم بإيقافه لإظهار الألوان الخام من مصدر التقويم.</string>
|
||||||
<string name="settings_font_headings">خط العناوين</string>
|
<string name="settings_font_headings">خط العناوين</string>
|
||||||
<string name="settings_font_body">خط النص</string>
|
<string name="settings_font_body">خط النص</string>
|
||||||
<string name="settings_font_system">افتراضي النظام</string>
|
<string name="settings_font_system">افتراضي النظام</string>
|
||||||
@@ -309,4 +309,237 @@
|
|||||||
<string name="agenda_range_showing_label">إظهار جميع الأحداث القادمة لـ</string>
|
<string name="agenda_range_showing_label">إظهار جميع الأحداث القادمة لـ</string>
|
||||||
<string name="settings_reminders">تذكيرات الحدث</string>
|
<string name="settings_reminders">تذكيرات الحدث</string>
|
||||||
<string name="settings_default_reminder">التذكير الافتراضي</string>
|
<string name="settings_default_reminder">التذكير الافتراضي</string>
|
||||||
|
<string name="reminder_custom_set">تعيين</string>
|
||||||
|
<string name="settings_autofocus_title_hint">عند بدء حدث جديد، ضع المؤشر في حقل العنوان وافتح لوحة المفاتيح على الفور.</string>
|
||||||
|
<string name="settings_form_fields_hint">الخانات المعروضة افتراضيًا — كل شيء آخر موجود ضمن \"المزيد من الخانات\"</string>
|
||||||
|
<string name="settings_section_event_form">نموذج حدث جديد</string>
|
||||||
|
<string name="settings_quick_switch_header">زر التبديل السريع</string>
|
||||||
|
<string name="app_name">التقويم</string>
|
||||||
|
<string name="settings_theme_system_summary">الحالي %1$s</string>
|
||||||
|
<string name="settings_theme_hint">سواء التطبيق فاتحًا أو داكنًا. الاختيار يُطبق على الفور.</string>
|
||||||
|
<string name="settings_week_start_auto_summary">الحالي %1$s</string>
|
||||||
|
<string name="settings_time_format_auto_summary">اتباع النظام: %1$s</string>
|
||||||
|
<string name="settings_hour_lines">خطوط الساعات</string>
|
||||||
|
<string name="settings_hour_lines_summary">إظهار خط فاصل في كل ساعة في طريقة عرض الأسبوع واليوم</string>
|
||||||
|
<string name="timeline_scale_fit_day_summary">جميع الـ 24 ساعة على شاشة واحدة، لا تمرير</string>
|
||||||
|
<string name="timeline_scale_custom">مخصص</string>
|
||||||
|
<string name="settings_widget_size_small">صغير</string>
|
||||||
|
<string name="settings_widget_size_medium">متوسط</string>
|
||||||
|
<string name="settings_widget_size_large">كبير</string>
|
||||||
|
<string name="settings_widget_size_extra_large">كبير جدًا</string>
|
||||||
|
<plurals name="agenda_range_days">
|
||||||
|
<item quantity="zero">(%d) لا أيام</item>
|
||||||
|
<item quantity="one">(%d) يوم واحد</item>
|
||||||
|
<item quantity="two">%d يومان</item>
|
||||||
|
<item quantity="few">%d أيام</item>
|
||||||
|
<item quantity="many">%d يوم</item>
|
||||||
|
<item quantity="other">%d يوم</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="settings_agenda_range_bar_hint">اعرض شريطًا في الجزء العلوي من الجدول يحدد التواريخ المعروضة، مع زر لتبديل النطاق للجلسة</string>
|
||||||
|
<string name="settings_agenda_range_bar">شريط النطاق</string>
|
||||||
|
<string name="settings_month_header">عرض الشهر</string>
|
||||||
|
<string name="month_style_continuous">تمرير الشهور</string>
|
||||||
|
<string name="month_style_dense">أسابيع سلسة</string>
|
||||||
|
<string name="month_style_continuous_summary">كل شهر يقع تحت عنوانه الخاص، مع مساحة صغيرة تميزه عن الشهر التالي.</string>
|
||||||
|
<string name="month_style_dense_summary">تمر الأسابيع دون انقطاع، ويتدفق كل شهر مباشرة إلى الشهر التالي دون وجود فجوة بينهما.</string>
|
||||||
|
<string name="month_split_no_events">لا شيء مجدول</string>
|
||||||
|
<string name="month_style_split_summary">شبكة مدمجة تحدد الأيام التي تتضمن أحداثًا، ويتم إدراج اليوم الذي تنقر عليه تحتها.</string>
|
||||||
|
<string name="settings_autofocus_title">تركيز العنوان على حدث جديد</string>
|
||||||
|
<string name="settings_event_duration">المدة الافتراضية</string>
|
||||||
|
<string name="settings_event_duration_hint">مدة الحدث الجديد تبقى كما هي حتى أنت تغيير وقت نهايته. أما أحداث التي هي طوال اليوم فلن تتأثر.</string>
|
||||||
|
<string name="settings_section_notifications">الإشعارات</string>
|
||||||
|
<string name="settings_reminders_hint">ترى التذكيرات مرتين؟ هناك تطبيق تقويم آخر ينشرهم أيضًا — قم بإيقافهم في أحد من الاثنين.</string>
|
||||||
|
<string name="settings_group_look">المظهر و السلوك</string>
|
||||||
|
<string name="settings_group_data">البيانات</string>
|
||||||
|
<string name="settings_group_app">التطبيق</string>
|
||||||
|
<string name="settings_language">لغة التطبيق</string>
|
||||||
|
<string name="settings_dynamic_color_summary">أخذ ألوان التطبيق من خلفيتك.</string>
|
||||||
|
<string name="settings_group_about">حول</string>
|
||||||
|
<string name="settings_about_author">بواسطة Jean-Luc Makiola</string>
|
||||||
|
<string name="settings_about_source">المصدر</string>
|
||||||
|
<string name="settings_about_privacy">سياسة الخصوصية</string>
|
||||||
|
<string name="settings_about_support">دَعم التطوير</string>
|
||||||
|
<string name="settings_about_version">الإصدار %1$s</string>
|
||||||
|
<string name="settings_about_logo_desc">رمز تطبيق Calendula</string>
|
||||||
|
<string name="crash_report_body_template">شكرًا لإبلاغ عن عطل في %1$s. يُرجى إضافة أي شيء تتذكره حول ما كنت تفعله، ثم إرسال.\n\n### ماذا حدث\n\n\n### تقرير العطل\n%2$s\n</string>
|
||||||
|
<string name="settings_section_about">حول</string>
|
||||||
|
<string name="settings_report_problem">الإبلاغ عن مشكلة</string>
|
||||||
|
<string name="settings_report_problem_hint">أرسل تقرير الأعطال أو افتح متعقب المشكلات</string>
|
||||||
|
<string name="settings_language_auto">افتراضي النظام</string>
|
||||||
|
<string name="settings_section_backup">النسخ الاحتياطي والاستعادة</string>
|
||||||
|
<string name="settings_special_dates_enable">إظهار تواريخ جهات الاتصال</string>
|
||||||
|
<string name="settings_special_dates_enable_hint">اعكس أعياد ميلاد جهات اتصالك والتواريخ الأخرى إلى التقويمات المحلية. يقرأ جهات الاتصال على هذا الجهاز فقط — لا يتم رفع أي شئ، وجهات اتصالك لا يتم تغييرها أبدًا.</string>
|
||||||
|
<string name="settings_section_language">اللغة</string>
|
||||||
|
<string name="qs_tile_new_event_label">حدث جديد</string>
|
||||||
|
<string name="shortcut_new_event_long">إنشاء حدث جديد</string>
|
||||||
|
<string name="shortcut_new_event_short">حدث جديد</string>
|
||||||
|
<string name="settings_qs_tile_hint">أضف مربع ”حدث جديد“ إلى لوحة الإعدادات السريعة.</string>
|
||||||
|
<string name="calendars_local_header">تقويماتك</string>
|
||||||
|
<string name="settings_section_special_dates">تواريخ جهات الاتصال الخاصة</string>
|
||||||
|
<string name="calendars_title">التقويمات</string>
|
||||||
|
<string name="calendars_add">إضافة تقويم</string>
|
||||||
|
<string name="settings_section_calendars">التقويمات</string>
|
||||||
|
<string name="settings_manage_calendars">إدارة التقويمات</string>
|
||||||
|
<string name="event_edit_recurrence_next">التالي: %1$s</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="zero">(%d) لا دقائق قبل</item>
|
||||||
|
<item quantity="one">(%d) دقيقة واحده قبل</item>
|
||||||
|
<item quantity="two">%d دقيقتان قبل</item>
|
||||||
|
<item quantity="few">%d دقائق قبل</item>
|
||||||
|
<item quantity="many">%d دقيقة قبل</item>
|
||||||
|
<item quantity="other">%d دقيقة قبل</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="zero">(%d) لا ساعات قبل</item>
|
||||||
|
<item quantity="one">(%d) ساعة واحده قبل</item>
|
||||||
|
<item quantity="two">%d ساعتان قبل</item>
|
||||||
|
<item quantity="few">%d ساعات قبل</item>
|
||||||
|
<item quantity="many">%d ساعة قبل</item>
|
||||||
|
<item quantity="other">%d ساعة قبل</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="zero">(%d) لا أيام قبل</item>
|
||||||
|
<item quantity="one">(%d) يوم واحد قبل</item>
|
||||||
|
<item quantity="two">%d يومان قبل</item>
|
||||||
|
<item quantity="few">%d أيام قبل</item>
|
||||||
|
<item quantity="many">%d يوم قبل</item>
|
||||||
|
<item quantity="other">%d يوم قبل</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="zero">(%d) لا أسابيع قبل</item>
|
||||||
|
<item quantity="one">(%d) أسبوع واحد قبل</item>
|
||||||
|
<item quantity="two">%d أسبوعان قبل</item>
|
||||||
|
<item quantity="few">%d أسابيع قبل</item>
|
||||||
|
<item quantity="many">%d أسبوع قبل</item>
|
||||||
|
<item quantity="other">%d أسبوع قبل</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="reminder_benefit_reversible_body">يوجد المفتاح في الإعدادات، ضمن الإشعارات.</string>
|
||||||
|
<string name="reminder_custom_amount">القيمة</string>
|
||||||
|
<string name="settings_manage_calendars_hint">إنشاء تقويمات محلية؛ إدارة التقويمات المتزامنة</string>
|
||||||
|
<string name="settings_snooze_duration">مدة التأجيل</string>
|
||||||
|
<string name="settings_calendar_reminder_inherits">الافتراضي (%1$s)</string>
|
||||||
|
<string name="settings_translate">المساعدة في الترجمة</string>
|
||||||
|
<string name="settings_translate_hint">إضافة أو تحسين لغة على Weblate</string>
|
||||||
|
<string name="settings_appearance_subtitle">الثيم، الألوان، الخطوط</string>
|
||||||
|
<string name="settings_views_subtitle">العرض، التخطيط، الترتيب</string>
|
||||||
|
<string name="settings_event_form_subtitle">الخانات الافتراضية و السلوك</string>
|
||||||
|
<string name="settings_notifications_subtitle">التذكيرات و التسليم</string>
|
||||||
|
<string name="settings_special_dates_subtitle">أعياد ميلاد جهات الاتصال و احتفالات ذكرى</string>
|
||||||
|
<string name="settings_special_dates_type_birthday">أعياد الميلاد</string>
|
||||||
|
<string name="settings_special_dates_type_custom">تواريخ أخرى</string>
|
||||||
|
<string name="settings_license">الترخيص</string>
|
||||||
|
<string name="settings_special_dates_disable_title">إيقاف تشغيل تواريخ جهات الاتصال؟</string>
|
||||||
|
<string name="settings_special_dates_disable_confirm">إيقاف</string>
|
||||||
|
<string name="settings_special_dates_sync_now">زامن الآن</string>
|
||||||
|
<string name="settings_special_dates_never_synced">لم تتم المزامنة بعد</string>
|
||||||
|
<string name="settings_special_dates_paused_hint">لم يعد بإمكان Calendula قراءة جهات الاتصال الخاصة بك، لذلك لا يتم تحديث هذه التقويمات.</string>
|
||||||
|
<string name="calendars_manage_in_app">الإدارة في التطبيق</string>
|
||||||
|
<string name="calendars_enable_all">تفعيل الكل</string>
|
||||||
|
<string name="calendars_disable_all">تعطيل الكل</string>
|
||||||
|
<string name="calendars_add_account">إضافة حساب</string>
|
||||||
|
<string name="calendars_new_title">تقويم جديد</string>
|
||||||
|
<string name="calendars_edit_title">تعديل التقويم</string>
|
||||||
|
<string name="calendars_name_label">الاسم</string>
|
||||||
|
<string name="calendars_color_label">اللون</string>
|
||||||
|
<string name="calendars_description_hint">أضف وصفًا</string>
|
||||||
|
<string name="calendars_delete_confirm_title">حذف التقويم؟</string>
|
||||||
|
<string name="calendars_delete_confirm_message">\"%1$s\" وجميع أحداثه ستتم إزالتها نهائيًا من هذا الجهاز.</string>
|
||||||
|
<string name="calendars_write_error">تعذّر حفظ التغيير.</string>
|
||||||
|
<string name="calendars_backup_header">النسخ الاحتياطي</string>
|
||||||
|
<string name="calendars_backup_hint">التقويمات المحلية لا تتم مزامنتها في أي مكان، لذا قم بتصديرها إلى ملف .ics للاحتفاظ بنسخة.</string>
|
||||||
|
<string name="dialog_save">حفظ</string>
|
||||||
|
<string name="settings_special_dates_grant">منح الوصول</string>
|
||||||
|
<string name="calendars_backup_action">تصدير كملف .ics</string>
|
||||||
|
<string name="calendars_export_title">تصدير التقويمات</string>
|
||||||
|
<string name="calendars_export_hint">اختر التقويمات التي تريد تضمينها في .ics الملف.</string>
|
||||||
|
<string name="calendars_export_action">تصدير</string>
|
||||||
|
<string name="calendars_restore_header">استعادة</string>
|
||||||
|
<string name="calendars_restore_action">استعادة من ملف .ics</string>
|
||||||
|
<string name="calendars_restore_hint">استيراد الأحداث من نسخة احتياطية أو تطبيق تقويم آخر.</string>
|
||||||
|
<string name="calendars_auto_backup">النسخ الاحتياطي التلقائي</string>
|
||||||
|
<string name="calendars_auto_backup_hint">قم بتصدير تقويماتك المحلية بشكل دوري إلى مجلد كـ .ics الملف.</string>
|
||||||
|
<string name="calendars_auto_backup_folder">مجلد النسخ الاحتياطي</string>
|
||||||
|
<string name="calendars_auto_backup_folder_unset">اضغط لاختيار مجلد</string>
|
||||||
|
<string name="calendars_auto_backup_every">كل %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_interval_min">الحد الأدني ٣٠ دقيقة.</string>
|
||||||
|
<string name="calendars_auto_backup_status_never">لا نسخ احتياطي تلقائي بعد</string>
|
||||||
|
<string name="calendars_auto_backup_status_ok">آخر نسخة احتياطية: %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_status_failed">فشل آخر نسخ احتياطي: %1$s</string>
|
||||||
|
<string name="backup_channel_name">النسخ الاحتياطي</string>
|
||||||
|
<string name="backup_channel_description">يحذّر إذا تفشل النسخ الاحتياطي التلقائي بشكل متكرر.</string>
|
||||||
|
<string name="backup_failed_title">فشل النسخ الاحتياطي التلقائي</string>
|
||||||
|
<string name="backup_failed_text">Calendula تعذَّر في كتابة ملف النسخ الاحتياطي. تحقق من مجلد النسخ الاحتياطي في الإعدادات.</string>
|
||||||
|
<string name="calendars_backup_failed">تعذّر تصدير النسخة الاحتياطية.</string>
|
||||||
|
<plurals name="calendars_backup_done">
|
||||||
|
<item quantity="zero">تم تصدير (%d) لا أحداث.</item>
|
||||||
|
<item quantity="one">تم تصدير (%d) حدث واحد.</item>
|
||||||
|
<item quantity="two">تم تصدير %d حدثان.</item>
|
||||||
|
<item quantity="few">تم تصدير %d أحداث.</item>
|
||||||
|
<item quantity="many">تم تصدير %d حدث.</item>
|
||||||
|
<item quantity="other">تم تصدير %d حدث.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="import_title">استيراد الأحداث</string>
|
||||||
|
<string name="import_target_header">إضافة إلى تقويم</string>
|
||||||
|
<string name="import_empty">لم يتم العثور على أحداث في هذا الملف.</string>
|
||||||
|
<string name="import_failed">تعذَّر قراءة هذا الملف.</string>
|
||||||
|
<string name="import_no_calendar">لا تقويم قابل للكتابة لاستيراد إليه. أنشئ تقويمًا محليًا أولاً.</string>
|
||||||
|
<string name="import_done_title">اكتمل الاستيراد</string>
|
||||||
|
<string name="import_done_dedup_note">تم تخطي الأحداث الموجودة بالفعل في التقويم.</string>
|
||||||
|
<string name="settings_special_dates_reminders">التذكيرات</string>
|
||||||
|
<string name="calendars_account_menu_a11y">المزيد من الخيارات لـ %1$s</string>
|
||||||
|
<string name="calendars_synced_hint">تأتي هذه من الحسابات الموجودة على جهازك. يمكنك إنشاؤها وتعديلها في تطبيقها الخاص.</string>
|
||||||
|
<string name="calendars_synced_header">التقويمات المتزامنة</string>
|
||||||
|
<string name="calendars_local_empty">لا تقويمات محلية حتى الآن. أنشئ واحدًا للاحتفاظ بالأحداث على هذا الجهاز فقط.</string>
|
||||||
|
<string name="calendars_auto_backup_interval">الفاصل الزمني</string>
|
||||||
|
<string name="import_warning_no_start">تم تخطي حدث بدون وقت بدء.</string>
|
||||||
|
<string name="import_warning_recurrence">تم تخطي بعض التكرارات التي تم تغييرها للأحداث المتكررة.</string>
|
||||||
|
<string name="import_close">إغلاق</string>
|
||||||
|
<string name="import_done_skipped_label">التكرارات</string>
|
||||||
|
<string name="import_done_added_label">أُضيف</string>
|
||||||
|
<string name="crash_dialog_title">%1$s تعطل</string>
|
||||||
|
<string name="settings_qs_tile">إضافة مربع الإعدادات السريعة</string>
|
||||||
|
<string name="import_button">استيراد</string>
|
||||||
|
<string name="crash_dialog_dismiss">ليس الآن</string>
|
||||||
|
<string name="crash_report_copied">تم نسخ التقرير إلى الحافظة</string>
|
||||||
|
<string name="crash_report_open_failed">تعذّر فتح متتبع المشكلات. التقرير موجود في الحافظة الخاصة بك.</string>
|
||||||
|
<string name="crash_report_issue_title">تقرير العطل</string>
|
||||||
|
<string name="crash_report_clip_label">%1$s تقرير العطل</string>
|
||||||
|
<string name="crash_report_body_paste">_(كان التقرير طويلًا جدًا لهذا الرابط — الصقه من الحافظة هنا.)_</string>
|
||||||
|
<string name="special_dates_calendar_birthday">أعياد الميلاد</string>
|
||||||
|
<string name="special_dates_calendar_custom">التواريخ الخاصة</string>
|
||||||
|
<string name="event_edit_recurrence_next_none">هذه القاعدة لا تتكرر أبدًا</string>
|
||||||
|
<string name="event_access_public_summary">يري كل شخص لديه حق الوصول التفاصيل الكاملة</string>
|
||||||
|
<string name="event_access_private_summary">الآخرون يرون فقط أنك مشغول</string>
|
||||||
|
<string name="event_access_default_summary">أينما كان ما يفعله هذا التقويم عادةً</string>
|
||||||
|
<string name="month_split_collapse">عرض أحداث اليوم</string>
|
||||||
|
<string name="month_split_expand">إظهار الشهر بأكمله</string>
|
||||||
|
<string name="settings_week_day_header">الأسبوع و اليوم</string>
|
||||||
|
<string name="settings_default_view_hint">طريقة العرض التي يفتحها Calendula عندما تقوم ببدءه.</string>
|
||||||
|
<string name="settings_week_start_hint">اليوم الذي يبدأ به كل أسبوع، في جميع طرق العرض والودجتز.</string>
|
||||||
|
<string name="settings_time_format_hint">كيف الأوقات تُكتب في جميع أنحاء التطبيق. تلقائيًا يتبع إعداد نظامك.</string>
|
||||||
|
<string name="settings_past_events_hint">ما الذي يفعله جدول بالأحداث التي انتهت بالفعل.</string>
|
||||||
|
<string name="calendars_visibility_a11y">إظهار \"%1$s\"</string>
|
||||||
|
<string name="calendars_visibility_notice_title">بعض التقويمات متوقفة</string>
|
||||||
|
<string name="calendars_visibility_notice_message">Calendula يعرض الآن التقويمات التي تم تشغيلها لهذا الجهاز، لذلك ما تراه وما يذكرك لم يعد بإمكانه أن يختلف. بعض تقويماتك متوقفة حاليًا — تم إيقافها هنا أو في تطبيق تقويم آخر. أعد تشغيل أي منها في الإعدادات ← التقويمات.</string>
|
||||||
|
<string name="calendar_picker_missing_title">تفتقد تقويمًا؟</string>
|
||||||
|
<string name="calendar_picker_missing_summary">قد يكون متوقفًا عن التشغيل، للقراءة فقط أو مملوءًا من جهات الاتصال الخاصة بك — يمكنك إدارة تقويماتك هنا.</string>
|
||||||
|
<string name="calendars_state_read_only">للقراءة فقط</string>
|
||||||
|
<string name="calendars_state_not_synced">لم تتم المزامنة مع هذا الجهاز</string>
|
||||||
|
<string name="calendars_state_managed">تم ملؤه من جهات اتصالك</string>
|
||||||
|
<string name="calendars_managed_delete_locked">تم ملء هذا التقويم من جهات الاتصال الخاصة بك، لذلك سيقوم Calendula بإنشائه مرة أخرى في المزامنة التالية. قم بإيقاف تشغيل التواريخ الخاصة ضمن الإعدادات ← التواريخ الخاصة لحذفها.</string>
|
||||||
|
<string name="duration_custom_max">كحد أقصى %1$s</string>
|
||||||
|
<string name="settings_calendar_duration_inherits">الافتراضي (%1$s)</string>
|
||||||
|
<string name="settings_calendar_duration_use_default">استخدام المدة الافتراضية (%1$s)</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">التذكيرات، تُسلَّم</string>
|
||||||
|
<string name="reminder_use_default">استخدام التذكير الافتراضي</string>
|
||||||
|
<string name="settings_timeline_scale">ارتفاع الساعة</string>
|
||||||
|
<string name="settings_timeline_scale_hint">كم مدى المساحة العمودية التي تأخذها الساعة الواحده في عرض الأسبوع واليوم. كلا العرضين يشتركا في هذا الإعداد. يمكنك أيضًا الضغط على المخطط الزمني بإصبعين لتعيين أي ارتفاع بين.</string>
|
||||||
|
<string name="timeline_scale_fit_day">الملائمة لليوم بأكمله</string>
|
||||||
|
<string name="timeline_scale_compact">مضغوط</string>
|
||||||
|
<string name="timeline_scale_regular_summary">المساحة القياسية</string>
|
||||||
|
<string name="timeline_scale_comfortable">مُريَّح</string>
|
||||||
|
<string name="timeline_scale_compact_summary">المزيد من الساعات لكل شاشة، ومجموعات أصغر</string>
|
||||||
|
<string name="timeline_scale_regular">العادي</string>
|
||||||
|
<string name="timeline_scale_comfortable_summary">مجموعات أوسع، تمرير أكثر</string>
|
||||||
|
<string name="timeline_scale_custom_summary">الارتفاع الذي قمت بضغظت المخطط الزمني إليه</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -491,4 +491,5 @@
|
|||||||
<item quantity="many">Importando %d eventos</item>
|
<item quantity="many">Importando %d eventos</item>
|
||||||
<item quantity="other">Importando %d eventos</item>
|
<item quantity="other">Importando %d eventos</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<string name="settings_soften_colors">Armoniza los colores del calendario</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -65,20 +65,6 @@
|
|||||||
<string name="event_edit_recurring_title">Edit recurring event</string>
|
<string name="event_edit_recurring_title">Edit recurring event</string>
|
||||||
<string name="event_delete_failed">Couldn\'t delete the event</string>
|
<string name="event_delete_failed">Couldn\'t delete the event</string>
|
||||||
<string name="event_delete_write_denied">Calendula needs write access to delete events</string>
|
<string name="event_delete_write_denied">Calendula needs write access to delete events</string>
|
||||||
|
|
||||||
<!-- Drag an event to another time or day (#68) -->
|
|
||||||
<string name="event_move_action">Move…</string>
|
|
||||||
<string name="event_move_recurring_title">Move recurring event</string>
|
|
||||||
<string name="event_move_occurrence_only">This series picks its days in a way that can\'t be recalculated from one moved event, so only this event can move.</string>
|
|
||||||
<!-- %1$s is the new date and time, e.g. "Fri, 7 Aug, 09:00". -->
|
|
||||||
<string name="event_move_done">Moved to %1$s</string>
|
|
||||||
<string name="event_move_undo">Undo</string>
|
|
||||||
<string name="event_move_undone">Move undone</string>
|
|
||||||
<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_blocked_series_end">Can\'t move an event past the end of its series</string>
|
|
||||||
|
|
||||||
<string name="dialog_cancel">Cancel</string>
|
<string name="dialog_cancel">Cancel</string>
|
||||||
<string name="dialog_ok">OK</string>
|
<string name="dialog_ok">OK</string>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import com.google.common.truth.Truth.assertThat
|
|||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.realignRecurrence
|
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
@@ -286,89 +285,6 @@ class EventWriteMapperTest {
|
|||||||
.isEqualTo(instantAt("2026-01-09T14:30", "Europe/Berlin"))
|
.isEqualTo(instantAt("2026-01-09T14:30", "Europe/Berlin"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a weekday move must carry the rule with the anchor, or the series stays put`() {
|
|
||||||
// The anchor moves by the same shift as the occurrence, so a Monday
|
|
||||||
// series lands on a Wednesday — while RRULE is written verbatim. Without
|
|
||||||
// realignRecurrence the rule would still say Monday and nothing moves.
|
|
||||||
val series = instantAt("2026-01-05T09:00", "Europe/Berlin")
|
|
||||||
val original = form(
|
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 8), LocalTime(9, 0)),
|
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 8), LocalTime(10, 0)),
|
|
||||||
).copy(rrule = "FREQ=WEEKLY;BYDAY=MO")
|
|
||||||
val moved = original.copy(
|
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(9, 0)),
|
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(10, 0)),
|
|
||||||
rrule = realignRecurrence("FREQ=WEEKLY;BYDAY=MO", LocalDate(2026, 6, 8), LocalDate(2026, 6, 10)),
|
|
||||||
)
|
|
||||||
|
|
||||||
val values = update(original, moved, series)
|
|
||||||
|
|
||||||
val anchor = java.time.Instant.ofEpochMilli(values[CalendarContract.Events.DTSTART] as Long)
|
|
||||||
.atZone(java.time.ZoneId.of("Europe/Berlin"))
|
|
||||||
assertThat(anchor.dayOfWeek).isEqualTo(java.time.DayOfWeek.WEDNESDAY)
|
|
||||||
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY;BYDAY=WE")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a recurring move keeps the series' length across a DST boundary`() {
|
|
||||||
// The occurrence being dragged is in CET; the series anchor is in CEST.
|
|
||||||
val series = instantAt("2026-07-15T09:00", "Europe/Berlin")
|
|
||||||
val original = form(
|
|
||||||
start = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(9, 0)),
|
|
||||||
end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 30)),
|
|
||||||
).copy(rrule = "FREQ=WEEKLY")
|
|
||||||
val moved = original.copy(
|
|
||||||
start = LocalDateTime(LocalDate(2026, 1, 8), LocalTime(9, 0)),
|
|
||||||
end = LocalDateTime(LocalDate(2026, 1, 8), LocalTime(10, 30)),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertThat(update(original, moved, series)[CalendarContract.Events.DURATION])
|
|
||||||
.isEqualTo("P5400S")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `shifting a one-off event and back again restores the original values`() {
|
|
||||||
val original = form()
|
|
||||||
val moved = original.copy(
|
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 0)),
|
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 0)),
|
|
||||||
)
|
|
||||||
|
|
||||||
val forward = update(original, moved)
|
|
||||||
val back = update(moved, original)
|
|
||||||
|
|
||||||
assertThat(back[CalendarContract.Events.DTSTART])
|
|
||||||
.isEqualTo(original.toWriteTimes(berlin).dtStartMillis)
|
|
||||||
assertThat(back[CalendarContract.Events.DTEND])
|
|
||||||
.isEqualTo(original.toWriteTimes(berlin).dtEndMillis)
|
|
||||||
assertThat(forward[CalendarContract.Events.DTSTART])
|
|
||||||
.isNotEqualTo(back[CalendarContract.Events.DTSTART])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `undoing a whole-series move lands the anchor back where it started`() {
|
|
||||||
// What makes undo safe for "all events": the shift is applied to the
|
|
||||||
// anchor the provider currently holds, so re-issuing it with the two
|
|
||||||
// forms swapped applies the exact inverse to the moved anchor.
|
|
||||||
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
|
||||||
val original = form(
|
|
||||||
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
|
|
||||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
|
||||||
).copy(rrule = "FREQ=WEEKLY")
|
|
||||||
val moved = original.copy(
|
|
||||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)),
|
|
||||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)),
|
|
||||||
)
|
|
||||||
|
|
||||||
val movedAnchor = update(original, moved, series)[CalendarContract.Events.DTSTART] as Long
|
|
||||||
assertThat(movedAnchor).isNotEqualTo(series)
|
|
||||||
|
|
||||||
val restored = update(moved, original, movedAnchor)
|
|
||||||
assertThat(restored[CalendarContract.Events.DTSTART]).isEqualTo(series)
|
|
||||||
assertThat(restored[CalendarContract.Events.DURATION]).isEqualTo("P3600S")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `switching a recurring event to all-day anchors the series on a UTC midnight`() {
|
fun `switching a recurring event to all-day anchors the series on a UTC midnight`() {
|
||||||
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||||
|
|||||||
@@ -94,27 +94,6 @@ class CalendarRowStateTest {
|
|||||||
assertThat(calendar.isEventTarget).isFalse()
|
assertThat(calendar.isEventTarget).isFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a managed calendar's events are editable but never movable`() {
|
|
||||||
// Their date is owned by the contacts sync, so a drag would be undone.
|
|
||||||
val managed = cal().copy(isManaged = true)
|
|
||||||
assertThat(managed.allowsEventMove).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a read-only calendar's events are not movable`() {
|
|
||||||
assertThat(cal().copy(canModifyContents = false).allowsEventMove).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a switched-off calendar's events stay movable, unlike a new-event target`() {
|
|
||||||
// Nothing renders them, so the question is moot — but the predicate is
|
|
||||||
// deliberately not isEventTarget, which would also exclude them.
|
|
||||||
val hidden = cal().copy(isVisibleInSystem = false)
|
|
||||||
assertThat(hidden.isEventTarget).isFalse()
|
|
||||||
assertThat(hidden.allowsEventMove).isTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `manager order puts non-syncing calendars last and is otherwise stable`() {
|
fun `manager order puts non-syncing calendars last and is otherwise stable`() {
|
||||||
val ordered = listOf(
|
val ordered = listOf(
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.LocalDateTime
|
|
||||||
import kotlinx.datetime.LocalTime
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
|
|
||||||
class EventShiftTest {
|
|
||||||
|
|
||||||
private val berlin = TimeZone.of("Europe/Berlin")
|
|
||||||
private val newYork = TimeZone.of("America/New_York")
|
|
||||||
|
|
||||||
private fun form(
|
|
||||||
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
|
||||||
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
|
||||||
timezone: String? = null,
|
|
||||||
isAllDay: Boolean = false,
|
|
||||||
) = EventForm(
|
|
||||||
calendarId = 1L,
|
|
||||||
title = "Standup",
|
|
||||||
isAllDay = isAllDay,
|
|
||||||
start = start,
|
|
||||||
end = end,
|
|
||||||
timezone = timezone,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `shifting to a new start keeps the length`() {
|
|
||||||
val original = form()
|
|
||||||
val target = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 30)).toInstant(berlin)
|
|
||||||
|
|
||||||
val moved = original.shiftedTo(target, berlin)
|
|
||||||
|
|
||||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 30)))
|
|
||||||
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 30)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a pinned event is re-derived in its own zone, not the device's`() {
|
|
||||||
// 09:00 in New York, opened on a Berlin device.
|
|
||||||
val original = form(
|
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)),
|
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
|
||||||
timezone = "America/New_York",
|
|
||||||
)
|
|
||||||
// The grid says "15:30 Berlin", which is 09:30 in New York.
|
|
||||||
val target = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(15, 30)).toInstant(berlin)
|
|
||||||
|
|
||||||
val moved = original.shiftedTo(target, berlin)
|
|
||||||
|
|
||||||
assertThat(moved.timezone).isEqualTo("America/New_York")
|
|
||||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
|
|
||||||
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 30)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `the instant duration survives a shift across spring forward`() {
|
|
||||||
// 2026-03-29 02:00 CET is when Berlin skips to 03:00.
|
|
||||||
val original = form(
|
|
||||||
start = LocalDateTime(LocalDate(2026, 3, 28), LocalTime(23, 0)),
|
|
||||||
end = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)),
|
|
||||||
)
|
|
||||||
val target = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)).toInstant(berlin)
|
|
||||||
|
|
||||||
val moved = original.shiftedTo(target, berlin)
|
|
||||||
|
|
||||||
// Two real hours from 01:00 CET lands at 04:00 CEST, not 03:00: the wall
|
|
||||||
// clock stretches because the hour in between does not exist.
|
|
||||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)))
|
|
||||||
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(4, 0)))
|
|
||||||
assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin))
|
|
||||||
.isEqualTo(original.end.toInstant(berlin) - original.start.toInstant(berlin))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a drop into the spring-forward gap resolves forward by the missing hour`() {
|
|
||||||
val original = form()
|
|
||||||
// 02:30 does not exist on 2026-03-29 in Berlin.
|
|
||||||
val target = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(2, 30)).toInstant(berlin)
|
|
||||||
|
|
||||||
val moved = original.shiftedTo(target, berlin)
|
|
||||||
|
|
||||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a zero-distance drop leaves the form untouched`() {
|
|
||||||
val original = form()
|
|
||||||
assertThat(original.shiftedTo(original.start.toInstant(berlin), berlin)).isEqualTo(original)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an unparseable pinned zone falls back to the device, like the write path`() {
|
|
||||||
val original = form(timezone = "Mars/Olympus")
|
|
||||||
val target = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)).toInstant(newYork)
|
|
||||||
|
|
||||||
val moved = original.shiftedTo(target, newYork)
|
|
||||||
|
|
||||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an all-day event ignores shiftedTo and moves by whole days instead`() {
|
|
||||||
val allDay = form(isAllDay = true)
|
|
||||||
val target = LocalDateTime(LocalDate(2026, 7, 1), LocalTime(3, 0)).toInstant(berlin)
|
|
||||||
|
|
||||||
assertThat(allDay.shiftedTo(target, berlin)).isEqualTo(allDay)
|
|
||||||
|
|
||||||
val moved = allDay.shiftedByDays(3, berlin)
|
|
||||||
assertThat(moved.start.date).isEqualTo(LocalDate(2026, 6, 14))
|
|
||||||
assertThat(moved.end.date).isEqualTo(LocalDate(2026, 6, 14))
|
|
||||||
// The placeholder times exist only for a switch back to timed; untouched.
|
|
||||||
assertThat(moved.start.time).isEqualTo(allDay.start.time)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a multi-day event keeps its span when shifted by days`() {
|
|
||||||
val multiDay = form(
|
|
||||||
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(22, 0)),
|
|
||||||
end = LocalDateTime(LocalDate(2026, 6, 13), LocalTime(2, 0)),
|
|
||||||
)
|
|
||||||
|
|
||||||
val moved = multiDay.shiftedByDays(-2, berlin)
|
|
||||||
|
|
||||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 9), LocalTime(22, 0)))
|
|
||||||
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(2, 0)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a timed shift onto a DST changeover keeps the real length, not the wall clock`() {
|
|
||||||
// 22:00 Sat -> 04:00 Sun is six real hours; the target Sunday is the one
|
|
||||||
// Berlin springs forward on. Keeping wall clock would write a five-hour
|
|
||||||
// DURATION for the whole series.
|
|
||||||
val overnight = form(
|
|
||||||
start = LocalDateTime(LocalDate(2026, 3, 21), LocalTime(22, 0)),
|
|
||||||
end = LocalDateTime(LocalDate(2026, 3, 22), LocalTime(4, 0)),
|
|
||||||
)
|
|
||||||
|
|
||||||
val moved = overnight.shiftedByDays(7, berlin)
|
|
||||||
|
|
||||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 28), LocalTime(22, 0)))
|
|
||||||
assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin))
|
|
||||||
.isEqualTo(overnight.end.toInstant(berlin) - overnight.start.toInstant(berlin))
|
|
||||||
// The wall-clock end therefore lands an hour later than a naive +7 days.
|
|
||||||
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(5, 0)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `shifting by no days is a no-op`() {
|
|
||||||
val original = form()
|
|
||||||
assertThat(original.shiftedByDays(0, berlin)).isEqualTo(original)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
|
|
||||||
class RecurrenceRealignTest {
|
|
||||||
|
|
||||||
private val monday = LocalDate(2026, 6, 8)
|
|
||||||
private val wednesday = LocalDate(2026, 6, 10)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a weekly BYDAY rule follows the occurrence to its new weekday`() {
|
|
||||||
assertThat(realignRecurrence("FREQ=WEEKLY;BYDAY=MO", monday, wednesday))
|
|
||||||
.isEqualTo("FREQ=WEEKLY;BYDAY=WE")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `unrelated parts survive the rewrite`() {
|
|
||||||
assertThat(
|
|
||||||
realignRecurrence("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;COUNT=10", monday, wednesday),
|
|
||||||
).isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=WE;COUNT=10")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a day-of-month rule is refused, because the anchor moves by days not dates`() {
|
|
||||||
// BYMONTHDAY=28 with a January anchor, occurrence Feb 28 dragged to Mar 1:
|
|
||||||
// the rebuilt rule would say the 1st while the anchor became Jan 29 — a
|
|
||||||
// DTSTART that is not an instance of its own rule. Weekday arithmetic is
|
|
||||||
// uniform mod 7 and survives the same shift; day-of-month is not.
|
|
||||||
assertThat(realignRecurrence("FREQ=MONTHLY;BYMONTHDAY=8", monday, wednesday)).isNull()
|
|
||||||
assertThat(
|
|
||||||
realignRecurrence("FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=8", monday, LocalDate(2026, 7, 20)),
|
|
||||||
).isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `BYDAY on a non-weekly rule is refused`() {
|
|
||||||
// "every Monday of the month" is a shape this has not been reasoned about,
|
|
||||||
// and parseSimpleRecurrence can't read it either — so the UNTIL guard
|
|
||||||
// downstream would be blind to it.
|
|
||||||
assertThat(realignRecurrence("FREQ=MONTHLY;BYDAY=MO", monday, wednesday)).isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a rule with no FREQ is refused`() {
|
|
||||||
assertThat(realignRecurrence("INTERVAL=2;BYDAY=MO", monday, wednesday)).isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a rule with no day-selecting part needs no rewrite`() {
|
|
||||||
assertThat(realignRecurrence("FREQ=DAILY;INTERVAL=3", monday, wednesday))
|
|
||||||
.isEqualTo("FREQ=DAILY;INTERVAL=3")
|
|
||||||
assertThat(realignRecurrence("FREQ=WEEKLY", monday, wednesday)).isEqualTo("FREQ=WEEKLY")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a rule the move does not disturb comes back verbatim`() {
|
|
||||||
val rule = "FREQ=WEEKLY;BYDAY=MO,WE,FR"
|
|
||||||
assertThat(realignRecurrence(rule, monday, monday)).isEqualTo(rule)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a multi-day BYDAY cannot be resolved from one moved occurrence`() {
|
|
||||||
assertThat(realignRecurrence("FREQ=WEEKLY;BYDAY=MO,WE", monday, wednesday)).isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an ordinal BYDAY cannot be resolved`() {
|
|
||||||
assertThat(realignRecurrence("FREQ=MONTHLY;BYDAY=2MO", monday, wednesday)).isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a BYDAY that does not name the occurrence's own weekday is refused`() {
|
|
||||||
// The rule and DTSTART already disagree; guessing would make it worse.
|
|
||||||
assertThat(realignRecurrence("FREQ=WEEKLY;BYDAY=TU", monday, wednesday)).isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `parts we cannot reason about are refused rather than guessed`() {
|
|
||||||
assertThat(realignRecurrence("FREQ=WEEKLY;BYDAY=MO;BYSETPOS=1", monday, wednesday))
|
|
||||||
.isNull()
|
|
||||||
assertThat(realignRecurrence("FREQ=YEARLY;BYYEARDAY=159", monday, wednesday)).isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `everything realignable is also a rule the UNTIL guard can read`() {
|
|
||||||
// problems() checks UNTIL through parseSimpleRecurrence; a rule this
|
|
||||||
// realigns but that parser rejects would move a series past its own end
|
|
||||||
// unchecked. Accepting only weekly BYDAY keeps the two in step.
|
|
||||||
val realignable = listOf(
|
|
||||||
"FREQ=WEEKLY;BYDAY=MO",
|
|
||||||
"FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;UNTIL=20261231T225959Z",
|
|
||||||
"FREQ=DAILY;COUNT=5",
|
|
||||||
)
|
|
||||||
realignable.forEach { rule ->
|
|
||||||
assertThat(realignRecurrence(rule, monday, wednesday)).isNotNull()
|
|
||||||
assertThat(parseSimpleRecurrence(realignRecurrence(rule, monday, wednesday)!!))
|
|
||||||
.isNotNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a leading RRULE prefix is preserved`() {
|
|
||||||
assertThat(realignRecurrence("RRULE:FREQ=WEEKLY;BYDAY=MO", monday, wednesday))
|
|
||||||
.isEqualTo("RRULE:FREQ=WEEKLY;BYDAY=WE")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a malformed rule is refused`() {
|
|
||||||
assertThat(realignRecurrence("FREQ=WEEKLY;GARBAGE", monday, wednesday)).isNull()
|
|
||||||
assertThat(realignRecurrence("", monday, wednesday)).isNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,442 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
|
||||||
import kotlinx.coroutines.test.resetMain
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import kotlinx.coroutines.test.setMain
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.LocalDateTime
|
|
||||||
import kotlinx.datetime.LocalTime
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import org.junit.jupiter.api.AfterEach
|
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import org.junit.jupiter.api.io.TempDir
|
|
||||||
import java.nio.file.Path
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The drop pipeline: which repository call a scope maps to, when a recurring drop
|
|
||||||
* has to ask first, and every case that must refuse to write.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
class RescheduleViewModelTest {
|
|
||||||
|
|
||||||
private val dispatcher = UnconfinedTestDispatcher()
|
|
||||||
|
|
||||||
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
|
|
||||||
@AfterEach fun tearDown() = Dispatchers.resetMain()
|
|
||||||
|
|
||||||
// Monday 2026-06-08, midday in whatever zone the test JVM runs in — resolved
|
|
||||||
// in the device zone rather than UTC, because that is the zone the drop path
|
|
||||||
// reads dates back in, and at UTC+13 a UTC midday is already Tuesday. The
|
|
||||||
// BYDAY assertions below depend on this really being a Monday.
|
|
||||||
private val monday = LocalDate(2026, 6, 8)
|
|
||||||
private val beginMillis = LocalDateTime(monday, LocalTime(12, 0))
|
|
||||||
.toInstant(TimeZone.currentSystemDefault())
|
|
||||||
.toEpochMilliseconds()
|
|
||||||
private val endMillis = beginMillis + 3_600_000L
|
|
||||||
|
|
||||||
private fun cal(
|
|
||||||
id: Long,
|
|
||||||
canModify: Boolean = true,
|
|
||||||
managed: Boolean = false,
|
|
||||||
): CalendarSource = CalendarSource(
|
|
||||||
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
|
|
||||||
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = canModify,
|
|
||||||
isManaged = managed,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun detail(
|
|
||||||
rrule: String? = null,
|
|
||||||
isAllDay: Boolean = false,
|
|
||||||
isException: Boolean = false,
|
|
||||||
): EventDetail = EventDetail(
|
|
||||||
instance = EventInstance(
|
|
||||||
instanceId = 42L, eventId = 42L, calendarId = 1L, title = "Standup",
|
|
||||||
start = Instant.fromEpochMilliseconds(beginMillis),
|
|
||||||
end = Instant.fromEpochMilliseconds(endMillis),
|
|
||||||
isAllDay = isAllDay, color = 0xFF000000.toInt(), location = null,
|
|
||||||
),
|
|
||||||
description = null, organizer = null, attendees = emptyList(), rrule = rrule,
|
|
||||||
isException = isException,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel {
|
|
||||||
val prefs = CalendarPrefs(
|
|
||||||
PreferenceDataStoreFactory.create(
|
|
||||||
scope = CoroutineScope(dispatcher),
|
|
||||||
produceFile = { tempDir.resolve("move_prefs.preferences_pb").toFile() },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val settings = SettingsPrefs(
|
|
||||||
PreferenceDataStoreFactory.create(
|
|
||||||
scope = CoroutineScope(dispatcher),
|
|
||||||
produceFile = { tempDir.resolve("move_settings.preferences_pb").toFile() },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher)
|
|
||||||
return RescheduleViewModel(repo, dispatcher)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun CoroutineScope.activate(vm: RescheduleViewModel): Job =
|
|
||||||
launch { vm.movableCalendarIds.collect {} }
|
|
||||||
|
|
||||||
/** A drop two days later, expressed the way the month grid expresses one. */
|
|
||||||
private fun toWednesday() = MoveRequest(
|
|
||||||
eventId = 42L,
|
|
||||||
beginMillis = beginMillis,
|
|
||||||
endMillis = endMillis,
|
|
||||||
target = MoveTarget.ByDays(2),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** A drop an hour later, expressed the way a timeline drag expresses one. */
|
|
||||||
private fun oneHourLater() = MoveRequest(
|
|
||||||
eventId = 42L,
|
|
||||||
beginMillis = beginMillis,
|
|
||||||
endMillis = endMillis,
|
|
||||||
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis + 3_600_000L)),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a one-off drop writes straight through with no scope prompt`(
|
|
||||||
@TempDir tempDir: Path,
|
|
||||||
) = runTest(dispatcher) {
|
|
||||||
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
|
|
||||||
val vm = viewModel(tempDir, fake)
|
|
||||||
|
|
||||||
vm.move(oneHourLater())
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertThat(vm.scopePrompt.value).isNull()
|
|
||||||
assertThat(fake.updatedEvents).hasSize(1)
|
|
||||||
val (id, original, updated) = fake.updatedEvents.single()
|
|
||||||
assertThat(id).isEqualTo(42L)
|
|
||||||
assertThat(updated.start).isNotEqualTo(original.start)
|
|
||||||
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a recurring drop parks for the scope instead of writing`(
|
|
||||||
@TempDir tempDir: Path,
|
|
||||||
) = runTest(dispatcher) {
|
|
||||||
val fake = FakeCalendarDataSource().apply {
|
|
||||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
|
|
||||||
}
|
|
||||||
val vm = viewModel(tempDir, fake)
|
|
||||||
|
|
||||||
vm.move(oneHourLater())
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = false))
|
|
||||||
assertThat(fake.updatedEvents).isEmpty()
|
|
||||||
assertThat(fake.updatedOccurrences).isEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `each scope maps to its own repository call`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
|
||||||
val occurrence = FakeCalendarDataSource().apply {
|
|
||||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
|
|
||||||
}
|
|
||||||
viewModel(tempDir.resolve("a").also { it.toFile().mkdirs() }, occurrence).run {
|
|
||||||
move(oneHourLater())
|
|
||||||
moveWithScope(RecurringWriteScope.ThisEvent)
|
|
||||||
}
|
|
||||||
advanceUntilIdle()
|
|
||||||
assertThat(occurrence.updatedOccurrences).hasSize(1)
|
|
||||||
assertThat(occurrence.updatedOccurrences.single().second).isEqualTo(beginMillis)
|
|
||||||
|
|
||||||
val following = FakeCalendarDataSource().apply {
|
|
||||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
|
|
||||||
}
|
|
||||||
viewModel(tempDir.resolve("b").also { it.toFile().mkdirs() }, following).run {
|
|
||||||
move(oneHourLater())
|
|
||||||
moveWithScope(RecurringWriteScope.ThisAndFollowing)
|
|
||||||
}
|
|
||||||
advanceUntilIdle()
|
|
||||||
assertThat(following.updatedFromOccurrences).hasSize(1)
|
|
||||||
|
|
||||||
val series = FakeCalendarDataSource().apply {
|
|
||||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
|
|
||||||
}
|
|
||||||
viewModel(tempDir.resolve("c").also { it.toFile().mkdirs() }, series).run {
|
|
||||||
move(oneHourLater())
|
|
||||||
moveWithScope(RecurringWriteScope.AllEvents)
|
|
||||||
}
|
|
||||||
advanceUntilIdle()
|
|
||||||
assertThat(series.updatedEvents).hasSize(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a weekly BYDAY rule is realigned when the whole series moves weekday`(
|
|
||||||
@TempDir tempDir: Path,
|
|
||||||
) = runTest(dispatcher) {
|
|
||||||
val fake = FakeCalendarDataSource().apply {
|
|
||||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
|
|
||||||
}
|
|
||||||
val vm = viewModel(tempDir, fake)
|
|
||||||
|
|
||||||
vm.move(toWednesday())
|
|
||||||
vm.moveWithScope(RecurringWriteScope.AllEvents)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
// Without this the anchor becomes a Wednesday while the rule still says
|
|
||||||
// Monday, and the series does not move at all.
|
|
||||||
assertThat(fake.updatedEvents.single().third.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=WE")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a rule that cannot be realigned offers only the single occurrence`(
|
|
||||||
@TempDir tempDir: Path,
|
|
||||||
) = runTest(dispatcher) {
|
|
||||||
val fake = FakeCalendarDataSource().apply {
|
|
||||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO,WE") }
|
|
||||||
}
|
|
||||||
val vm = viewModel(tempDir, fake)
|
|
||||||
|
|
||||||
vm.move(toWednesday())
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a same-time drop within one day still needs no realignment`(
|
|
||||||
@TempDir tempDir: Path,
|
|
||||||
) = runTest(dispatcher) {
|
|
||||||
val fake = FakeCalendarDataSource().apply {
|
|
||||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO,WE") }
|
|
||||||
}
|
|
||||||
val vm = viewModel(tempDir, fake)
|
|
||||||
|
|
||||||
vm.move(oneHourLater())
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
// The weekday is unchanged, so the ambiguous BYDAY is not in the way.
|
|
||||||
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = false))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an exception row is written as a plain event, never as a nested exception`(
|
|
||||||
@TempDir tempDir: Path,
|
|
||||||
) = runTest(dispatcher) {
|
|
||||||
val fake = FakeCalendarDataSource().apply {
|
|
||||||
// A sync adapter that left the series' rule on the override row.
|
|
||||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY", isException = true) }
|
|
||||||
}
|
|
||||||
val vm = viewModel(tempDir, fake)
|
|
||||||
|
|
||||||
vm.move(oneHourLater())
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertThat(vm.scopePrompt.value).isNull()
|
|
||||||
assertThat(fake.updatedEvents).hasSize(1)
|
|
||||||
assertThat(fake.updatedOccurrences).isEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a drop past the series' own UNTIL is refused, not written`(
|
|
||||||
@TempDir tempDir: Path,
|
|
||||||
) = runTest(dispatcher) {
|
|
||||||
val fake = FakeCalendarDataSource().apply {
|
|
||||||
// Midday UTC, so the device zone can't push the UNTIL date onto the
|
|
||||||
// day the event is being dragged to.
|
|
||||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;UNTIL=20260608T120000Z") }
|
|
||||||
}
|
|
||||||
val vm = viewModel(tempDir, fake)
|
|
||||||
|
|
||||||
vm.move(toWednesday())
|
|
||||||
vm.moveWithScope(RecurringWriteScope.AllEvents)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedSeriesEnd)
|
|
||||||
assertThat(fake.updatedEvents).isEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `dragging a bounded series' last occurrence still allows moving just it`(
|
|
||||||
@TempDir tempDir: Path,
|
|
||||||
) = runTest(dispatcher) {
|
|
||||||
val fake = FakeCalendarDataSource().apply {
|
|
||||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;UNTIL=20260608T120000Z") }
|
|
||||||
}
|
|
||||||
val vm = viewModel(tempDir, fake)
|
|
||||||
|
|
||||||
vm.move(toWednesday())
|
|
||||||
advanceUntilIdle()
|
|
||||||
// The block belongs to the write, not to the drop: an exception row is
|
|
||||||
// constrained by no UNTIL, so this scope must still be on offer.
|
|
||||||
assertThat(vm.scopePrompt.value).isNotNull()
|
|
||||||
|
|
||||||
vm.moveWithScope(RecurringWriteScope.ThisEvent)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertThat(fake.updatedOccurrences).hasSize(1)
|
|
||||||
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a drag that changes both the day and the time cannot move the series`(
|
|
||||||
@TempDir tempDir: Path,
|
|
||||||
) = runTest(dispatcher) {
|
|
||||||
val fake = FakeCalendarDataSource().apply {
|
|
||||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
|
|
||||||
}
|
|
||||||
val vm = viewModel(tempDir, fake)
|
|
||||||
|
|
||||||
// Onto the next day *and* two hours later: the anchor moves by the same
|
|
||||||
// wall-clock shift, so an anchor late enough in the day would cross two
|
|
||||||
// midnights and land on a weekday the rebuilt rule doesn't name.
|
|
||||||
vm.move(
|
|
||||||
MoveRequest(
|
|
||||||
eventId = 42L,
|
|
||||||
beginMillis = beginMillis,
|
|
||||||
endMillis = endMillis,
|
|
||||||
target = MoveTarget.Start(
|
|
||||||
Instant.fromEpochMilliseconds(beginMillis + 26 * 3_600_000L),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a zero-distance drop writes nothing and says nothing`(
|
|
||||||
@TempDir tempDir: Path,
|
|
||||||
) = runTest(dispatcher) {
|
|
||||||
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
|
|
||||||
val vm = viewModel(tempDir, fake)
|
|
||||||
|
|
||||||
vm.move(
|
|
||||||
MoveRequest(
|
|
||||||
eventId = 42L,
|
|
||||||
beginMillis = beginMillis,
|
|
||||||
endMillis = endMillis,
|
|
||||||
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertThat(fake.updatedEvents).isEmpty()
|
|
||||||
assertThat(vm.outcome.value).isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a vanished event reports itself as gone`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
|
||||||
val fake = FakeCalendarDataSource().apply { eventDetailResult = { null } }
|
|
||||||
val vm = viewModel(tempDir, fake)
|
|
||||||
|
|
||||||
vm.move(oneHourLater())
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Gone)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a revoked write permission is reported apart from a plain failure`(
|
|
||||||
@TempDir tempDir: Path,
|
|
||||||
) = runTest(dispatcher) {
|
|
||||||
val fake = FakeCalendarDataSource().apply {
|
|
||||||
eventDetailResult = { detail() }
|
|
||||||
writeError = SecurityException("revoked")
|
|
||||||
}
|
|
||||||
val vm = viewModel(tempDir, fake)
|
|
||||||
|
|
||||||
vm.move(oneHourLater())
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `undo writes the move back the other way`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
|
||||||
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
|
|
||||||
val vm = viewModel(tempDir, fake)
|
|
||||||
|
|
||||||
vm.move(oneHourLater())
|
|
||||||
advanceUntilIdle()
|
|
||||||
val undo = (vm.outcome.value as MoveOutcome.Moved).undo
|
|
||||||
assertThat(undo).isNotNull()
|
|
||||||
|
|
||||||
vm.undo(undo!!)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertThat(fake.updatedEvents).hasSize(2)
|
|
||||||
val forward = fake.updatedEvents[0]
|
|
||||||
val back = fake.updatedEvents[1]
|
|
||||||
assertThat(back.second).isEqualTo(forward.third)
|
|
||||||
assertThat(back.third).isEqualTo(forward.second)
|
|
||||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Undone)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `an occurrence write offers no undo, since shifting back cannot restore it`(
|
|
||||||
@TempDir tempDir: Path,
|
|
||||||
) = runTest(dispatcher) {
|
|
||||||
val fake = FakeCalendarDataSource().apply {
|
|
||||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
|
|
||||||
}
|
|
||||||
val vm = viewModel(tempDir, fake)
|
|
||||||
|
|
||||||
vm.move(oneHourLater())
|
|
||||||
vm.moveWithScope(RecurringWriteScope.ThisEvent)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertThat((vm.outcome.value as MoveOutcome.Moved).undo).isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `only writable, unmanaged calendars can be dragged from`(
|
|
||||||
@TempDir tempDir: Path,
|
|
||||||
) = runTest(dispatcher) {
|
|
||||||
val fake = FakeCalendarDataSource().apply {
|
|
||||||
calendarsResult = listOf(
|
|
||||||
cal(1L),
|
|
||||||
cal(2L, canModify = false),
|
|
||||||
cal(3L, managed = true),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val vm = viewModel(tempDir, fake)
|
|
||||||
val job = activate(vm)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertThat(vm.movableCalendarIds.value).containsExactly(1L)
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `cancelling the scope dialog writes nothing`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
|
||||||
val fake = FakeCalendarDataSource().apply {
|
|
||||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
|
|
||||||
}
|
|
||||||
val vm = viewModel(tempDir, fake)
|
|
||||||
|
|
||||||
vm.move(oneHourLater())
|
|
||||||
vm.cancelScope()
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertThat(vm.scopePrompt.value).isNull()
|
|
||||||
assertThat(fake.updatedEvents).isEmpty()
|
|
||||||
assertThat(fake.updatedOccurrences).isEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,14 +27,6 @@ class TimeFormatTest {
|
|||||||
assertThat(formatMinuteOfDay(13 * 60 + 15, is24Hour = true, Locale.US)).isEqualTo("13:15")
|
assertThat(formatMinuteOfDay(13 * 60 + 15, is24Hour = true, Locale.US)).isEqualTo("13:15")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `gutter time drops the meridiem and clamps to the day`() {
|
|
||||||
assertThat(formatGutterTime(9 * 60 + 15, is24Hour = true, Locale.US)).isEqualTo("09:15")
|
|
||||||
assertThat(formatGutterTime(13 * 60 + 45, is24Hour = false, Locale.US)).isEqualTo("1:45")
|
|
||||||
assertThat(formatGutterTime(0, is24Hour = false, Locale.US)).isEqualTo("12:00")
|
|
||||||
assertThat(formatGutterTime(1_440, is24Hour = true, Locale.US)).isEqualTo("23:59")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `hour label is zero-padded in 24h and compact am-pm in 12h`() {
|
fun `hour label is zero-padded in 24h and compact am-pm in 12h`() {
|
||||||
assertThat(formatHourLabel(13, is24Hour = true, Locale.US)).isEqualTo("13")
|
assertThat(formatHourLabel(13, is24Hour = true, Locale.US)).isEqualTo("13")
|
||||||
|
|||||||
@@ -126,4 +126,24 @@ class TimelineScaleTest {
|
|||||||
assertThat(parseTimelineScale(stored)).isEqualTo(TimelineScale.Regular)
|
assertThat(parseTimelineScale(stored)).isEqualTo(TimelineScale.Regular)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a tap creates at the hour it landed in, at whatever scale`() {
|
||||||
|
// Same point on the same column reads as a different hour once the
|
||||||
|
// timeline has been pinched — which is the whole of #148: the tap has to
|
||||||
|
// be measured against the height the column is drawing at now.
|
||||||
|
assertThat(tappedMinuteOfDay(offsetY = 500f, hourPx = 100f)).isEqualTo(5 * 60)
|
||||||
|
assertThat(tappedMinuteOfDay(offsetY = 500f, hourPx = 50f)).isEqualTo(10 * 60)
|
||||||
|
// Within an hour it snaps back to that hour's start.
|
||||||
|
assertThat(tappedMinuteOfDay(offsetY = 599f, hourPx = 100f)).isEqualTo(5 * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a tap outside the day stays inside it`() {
|
||||||
|
assertThat(tappedMinuteOfDay(offsetY = -20f, hourPx = 56f)).isEqualTo(0)
|
||||||
|
assertThat(tappedMinuteOfDay(offsetY = 99_999f, hourPx = 56f)).isEqualTo(23 * 60)
|
||||||
|
// A viewport measured at nothing has no grid to read — midnight, not a
|
||||||
|
// division by zero.
|
||||||
|
assertThat(tappedMinuteOfDay(offsetY = 500f, hourPx = 0f)).isEqualTo(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,61 +108,6 @@ on-device):
|
|||||||
UTC, so zones ahead of UTC can't leak an extra occurrence.
|
UTC, so zones ahead of UTC can't leak an extra occurrence.
|
||||||
- All-day events are normalised to UTC midnights with an exclusive end.
|
- All-day events are normalised to UTC midnights with an exclusive end.
|
||||||
|
|
||||||
### Drag to reschedule
|
|
||||||
|
|
||||||
Dropping an event on another slot (#68) is not a second write path: the drop
|
|
||||||
loads the event, prefills the **same** `toEditForm` the edit screen uses, shifts
|
|
||||||
it (`EventShift.kt`), and dispatches through the same three repository calls a
|
|
||||||
save does — so recurring writes, reminder reconciliation and attendee
|
|
||||||
preservation behave identically. The full form is carried through, never a
|
|
||||||
stripped one: `updateOccurrence` reconciles reminders *and* attendees onto the
|
|
||||||
new exception row, and a partial form would wipe them.
|
|
||||||
|
|
||||||
Two things the drag has to get right that the edit screen sidesteps:
|
|
||||||
|
|
||||||
- **`RRULE` day parts go stale.** `buildEventUpdateValues` writes the rule
|
|
||||||
verbatim while `DTSTART` moves, so dragging a `FREQ=WEEKLY;BYDAY=MO` occurrence
|
|
||||||
onto a Wednesday under *All events* leaves a Wednesday anchor under a Monday
|
|
||||||
rule and the series does not move. `realignRecurrence` re-derives `BYDAY`, and
|
|
||||||
returns **null** for everything else — the drop then offers only *this event*,
|
|
||||||
whose exception row carries no rule at all. The same staleness is reachable
|
|
||||||
from the edit screen; wiring it there too is a separate change.
|
|
||||||
|
|
||||||
What the rule has to agree with is the **series anchor**, not the occurrence
|
|
||||||
being dragged, and the anchor moves by the same *wall-clock* shift. Only two
|
|
||||||
things survive that intact, which is exactly the envelope the realigner accepts:
|
|
||||||
weekday (uniform mod 7, so every anchor time of day crosses the same number of
|
|
||||||
midnights) and a shift that is a whole number of days (otherwise a late-enough
|
|
||||||
anchor crosses one midnight more than the occurrence did). Day-of-month is not
|
|
||||||
uniform — `BYMONTHDAY=28` with a January anchor, occurrence Feb 28 dragged to
|
|
||||||
Mar 1, would leave a Jan 29 anchor under a `BYMONTHDAY=1` rule, a DTSTART that
|
|
||||||
is not an instance of its own rule and a phantom occurrence on any client that
|
|
||||||
trusts it. Those drags may only move the one occurrence.
|
|
||||||
|
|
||||||
The accepted parts are deliberately a **subset** of what `parseSimpleRecurrence`
|
|
||||||
understands, so anything realignable is also a rule whose `UNTIL` the guard
|
|
||||||
below can actually read.
|
|
||||||
- **The eligibility gate is load-bearing.** Nothing below the UI refuses a
|
|
||||||
write, so `CalendarSource.allowsEventMove` is what keeps read-only and
|
|
||||||
contact-managed events from being dragged. It is deliberately *not*
|
|
||||||
`isEventTarget`: a managed event is editable (reminders, notes) yet must never
|
|
||||||
move, while a switched-off calendar renders nothing to grab anyway.
|
|
||||||
|
|
||||||
A drop that would push a series past its own `UNTIL` — the provider then
|
|
||||||
generates zero occurrences and the event vanishes from every view — is refused
|
|
||||||
rather than written. The check happens at **write** time, not at drop time,
|
|
||||||
because which date has to clear `UNTIL` depends on how far the write reaches: a
|
|
||||||
whole-series move carries the *anchor*, a split starts a new series at the moved
|
|
||||||
occurrence, and a single occurrence becomes an exception row that no `UNTIL`
|
|
||||||
constrains. Testing the occurrence in every case would refuse the perfectly
|
|
||||||
ordinary drag of a bounded series' last occurrence.
|
|
||||||
|
|
||||||
Two known limitations of a whole-series move, both shared with the edit screen's
|
|
||||||
own *All events* time save rather than introduced here — a drag just makes them
|
|
||||||
one gesture away: the series' `EXDATE` stamps and its exception rows are **not**
|
|
||||||
re-anchored, so previously deleted occurrences can reappear and previously
|
|
||||||
modified ones stay behind while the rest of the series moves.
|
|
||||||
|
|
||||||
### Event time zones
|
### Event time zones
|
||||||
|
|
||||||
`EventForm.timezone` is the zone its wall-clock times mean, and **null means
|
`EventForm.timezone` is the zone its wall-clock times mean, and **null means
|
||||||
@@ -199,22 +144,6 @@ changes to untouched fields survive either way. Fields the form cannot
|
|||||||
write (attendees, status, reminder methods) are excluded so sync noise
|
write (attendees, status, reminder methods) are excluded so sync noise
|
||||||
can't fake a conflict.
|
can't fake a conflict.
|
||||||
|
|
||||||
A **dropped** event gets no conflict dialog, deliberately. Its blast radius is
|
|
||||||
already bounded by the same dirty check — only `ALL_DAY`, `EVENT_TIMEZONE`,
|
|
||||||
`DTSTART` and `DTEND`/`DURATION`/`RRULE` are written — so a concurrent remote
|
|
||||||
edit to the title, notes or guests survives untouched. What a drop *can* clobber
|
|
||||||
is a concurrent remote **time** change, and parking a one-gesture action behind a
|
|
||||||
modal would cost more than that case is worth; the undo in the confirmation
|
|
||||||
snackbar is the answer instead. Undo restores semantics, not the row's byte
|
|
||||||
shape (`DURATION` normalises to `P<n>S`/`P<n>D`, `EVENT_TIMEZONE` is stamped
|
|
||||||
concrete), and it is offered only where the inverse is one symmetric write — a
|
|
||||||
one-off event or a whole-series shift. *This event* leaves an exception row
|
|
||||||
behind and *this and following* splits the series; neither is undone by shifting
|
|
||||||
back, so both get a plain confirmation. Two further gaps, both narrow and
|
|
||||||
accepted: a shift whose *anchor* crosses a DST gap is not invertible in wall
|
|
||||||
clock (the −Δ normalises back to where it started), and an undo after a
|
|
||||||
concurrent remote time change overwrites it, exactly as the forward move would.
|
|
||||||
|
|
||||||
## Reminder delivery
|
## Reminder delivery
|
||||||
|
|
||||||
Calendula plans and fires its own reminders. It reads the offsets in
|
Calendula plans and fires its own reminders. It reads the offsets in
|
||||||
|
|||||||
2
fastlane/metadata/android/ar/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/ar/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
إصلاحات
|
||||||
|
• عرض الأسبوع واليوم: النقر على مساحة فارغة يُنشئ الحدث في الساعة التي نقرت عليها، حتى بعد تكبير شريط الوقت أو تغيير ارتفاع الساعة.
|
||||||
2
fastlane/metadata/android/de-DE/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/de-DE/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Behoben
|
||||||
|
• Wochen- und Tagesansicht: Ein Tippen auf einen freien Bereich legt den Termin jetzt zur angetippten Uhrzeit an – auch nachdem die Zeitleiste gezoomt oder die Stundenhöhe geändert wurde.
|
||||||
2
fastlane/metadata/android/en-GB/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/en-GB/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Fixed
|
||||||
|
• Week and day view: tapping an empty slot now creates the event at the hour you tapped, even after zooming the timeline or changing the hour height.
|
||||||
2
fastlane/metadata/android/en-US/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Fixed
|
||||||
|
• Week and day view: tapping an empty slot now creates the event at the hour you tapped, even after zooming the timeline or changing the hour height.
|
||||||
2
fastlane/metadata/android/es-ES/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/es-ES/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Corregido
|
||||||
|
• Vista de semana y día: al tocar un hueco libre, el evento se crea a la hora que has tocado, incluso después de ampliar la línea de tiempo o cambiar la altura de la hora.
|
||||||
2
fastlane/metadata/android/fr-FR/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/fr-FR/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Corrigé
|
||||||
|
• Vues Semaine et Jour : appuyer sur un créneau vide crée désormais l'événement à l'heure touchée, même après avoir zoomé sur la grille ou modifié la hauteur d'une heure.
|
||||||
2
fastlane/metadata/android/it-IT/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/it-IT/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Corretto
|
||||||
|
• Vista settimana e giorno: toccando uno spazio libero l'evento viene creato all'ora toccata, anche dopo aver ingrandito la linea temporale o cambiato l'altezza dell'ora.
|
||||||
2
fastlane/metadata/android/pl-PL/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/pl-PL/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Poprawki
|
||||||
|
• Widok tygodnia i dnia: dotknięcie pustego miejsca tworzy teraz wydarzenie o dotkniętej godzinie, także po powiększeniu osi czasu lub zmianie wysokości godziny.
|
||||||
2
fastlane/metadata/android/pt-PT/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/pt-PT/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Corrigido
|
||||||
|
• Vista de semana e de dia: tocar num espaço livre cria o evento à hora tocada, mesmo depois de ampliar a linha temporal ou alterar a altura da hora.
|
||||||
2
fastlane/metadata/android/ru-RU/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/ru-RU/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Исправлено
|
||||||
|
• Вид недели и дня: нажатие на свободное место создаёт событие на выбранный час — в том числе после масштабирования шкалы времени или изменения высоты часа.
|
||||||
2
fastlane/metadata/android/zh-CN/changelogs/21801.txt
Normal file
2
fastlane/metadata/android/zh-CN/changelogs/21801.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
修复
|
||||||
|
• 周视图和日视图:点按空白处会在所点的时间创建事件,即使在缩放时间轴或更改小时高度之后也是如此。
|
||||||
Reference in New Issue
Block a user