Compare commits
4 Commits
v2.18.0
...
feat/searc
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ab8fcb5f5 | |||
|
|
4e38866dfd | ||
|
|
44233cff35 | ||
|
|
5764bd889b |
@@ -616,9 +616,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
// A recurring master's DTSTART is the series start; show its
|
// A recurring master's DTSTART is the series start; show its
|
||||||
// nearest occurrence instead so the date is the one the user
|
// nearest occurrence instead so the date is the one the user
|
||||||
// actually cares about (and sorting reflects it).
|
// actually cares about (and sorting reflects it).
|
||||||
val recurring = !reader.getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
out += if (base.isRecurring) {
|
||||||
!reader.getString(SearchProjection.IDX_RDATE).isNullOrEmpty()
|
|
||||||
out += if (recurring) {
|
|
||||||
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
|
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
|
||||||
base.copy(
|
base.copy(
|
||||||
start = begin.toKotlinInstantFromEpochMillis(),
|
start = begin.toKotlinInstantFromEpochMillis(),
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ 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,6 +89,9 @@ 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; "no RRULE" alone
|
||||||
|
// can't tell an exception from a master (#68).
|
||||||
|
CalendarContract.Events.ORIGINAL_ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_EVENT_ID = 0
|
const val IDX_EVENT_ID = 0
|
||||||
@@ -110,6 +113,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -40,5 +40,7 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
|||||||
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
|
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
|
||||||
color = color,
|
color = color,
|
||||||
location = getString(SearchProjection.IDX_LOCATION),
|
location = getString(SearchProjection.IDX_LOCATION),
|
||||||
|
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||||
|
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ 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]: a managed event stays editable (reminders,
|
||||||
|
* notes) yet must never move, since 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)
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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].
|
||||||
|
*/
|
||||||
|
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
|
||||||
|
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form moved so it starts at [newStart], keeping its **instant** duration —
|
||||||
|
* a recurring event's length travels to the provider as `DURATION`, so keeping
|
||||||
|
* wall clock instead would rewrite the series' length across a DST boundary.
|
||||||
|
*
|
||||||
|
* All-day events are date-anchored — use [shiftedByDays]; 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 span.
|
||||||
|
* All-day events are pure date arithmetic; a timed one preserves its **instant**
|
||||||
|
* duration, for the same reason [shiftedTo] does.
|
||||||
|
*/
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -62,6 +62,12 @@ data class EventInstance(
|
|||||||
val isAllDay: Boolean,
|
val isAllDay: Boolean,
|
||||||
val color: Int,
|
val color: Int,
|
||||||
val location: String?,
|
val location: String?,
|
||||||
|
/**
|
||||||
|
* Whether this row's event carries a recurrence rule. Only search results
|
||||||
|
* (which read the series master) fill this in — the Instances query already
|
||||||
|
* yields one row per occurrence, so it stays false there.
|
||||||
|
*/
|
||||||
|
val isRecurring: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,6 +130,12 @@ 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) rather than a master, so a reschedule 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()):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
internal 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",
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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 `FREQ=WEEKLY;BYDAY=MO`
|
||||||
|
* would keep naming Monday after the anchor became a Wednesday and the series
|
||||||
|
* would not move at all — `BYDAY` is 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*, and weekday arithmetic is the only kind
|
||||||
|
* that survives the same wall-clock shift unchanged. Everything else returns
|
||||||
|
* null, as do rules one moved occurrence can't resolve (`BYDAY=MO,WE`, `2TH`,
|
||||||
|
* `BYSETPOS`). The accepted parts are a subset of [parseSimpleRecurrence], 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.
|
||||||
|
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,6 +10,7 @@ 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
|
||||||
@@ -27,6 +28,10 @@ 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
|
||||||
@@ -294,6 +299,29 @@ 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,
|
||||||
|
inFlight = reschedule.inFlight,
|
||||||
|
undoStarted = reschedule.undoStarted,
|
||||||
|
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
|
||||||
@@ -311,6 +339,7 @@ fun CalendarHost(
|
|||||||
// navigation, so it fades through rather than sliding — paging *within* a
|
// navigation, so it fades through rather than sliding — paging *within* a
|
||||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||||
val viewSwitch = fadeThrough()
|
val viewSwitch = fadeThrough()
|
||||||
|
CompositionLocalProvider(LocalEventMove provides moveScope) {
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = view,
|
targetState = view,
|
||||||
transitionSpec = { viewSwitch },
|
transitionSpec = { viewSwitch },
|
||||||
@@ -367,6 +396,11 @@ fun CalendarHost(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope prompt + confirmation/undo snackbar for a dropped event, declared
|
||||||
|
// right after the calendar views so later overlays cover it.
|
||||||
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.snap
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A timed block's own time label, crossfaded rather than replaced — the block
|
||||||
|
* slides to its new slot, so the label shouldn't change in a single frame.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
|
||||||
|
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
|
||||||
|
snap()
|
||||||
|
} else {
|
||||||
|
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||||
|
}
|
||||||
|
Crossfade(
|
||||||
|
targetState = label,
|
||||||
|
animationSpec = spec,
|
||||||
|
label = "block-time",
|
||||||
|
modifier = modifier,
|
||||||
|
) { text ->
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = color,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a timed block sits in its column, after tweening. */
|
||||||
|
@Immutable
|
||||||
|
data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A timed block's placement, tweened rather than jumped. Continuity comes from
|
||||||
|
* the caller keying each block by identity; a block composed for the first time
|
||||||
|
* starts at its target, so nothing flies in on the first frame.
|
||||||
|
*
|
||||||
|
* A pinch-zoom rewrites the hour height every pointer frame, and the gutter and
|
||||||
|
* grid lines follow it instantly — so the tween stands down for the gesture
|
||||||
|
* rather than leaving the blocks trailing the ruler they are measured against.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement {
|
||||||
|
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion() ||
|
||||||
|
LocalTimelineZoom.current.isPinching
|
||||||
|
) {
|
||||||
|
snap()
|
||||||
|
} else {
|
||||||
|
MaterialTheme.motionScheme.fastSpatialSpec()
|
||||||
|
}
|
||||||
|
val animatedX by animateDpAsState(x, spec, label = "block-x")
|
||||||
|
val animatedY by animateDpAsState(y, spec, label = "block-y")
|
||||||
|
val animatedWidth by animateDpAsState(width, spec, label = "block-width")
|
||||||
|
val animatedHeight by animateDpAsState(height, spec, label = "block-height")
|
||||||
|
return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight)
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
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 — well under touch slop. */
|
||||||
|
private val PICKUP_TOLERANCE = 6.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick an event block up with a long press and drag it, without
|
||||||
|
* `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an
|
||||||
|
* ancestor consumes or the finger leaves the block, which a
|
||||||
|
* `MIN_EVENT_FRACTION`-tall block loses immediately. Movement before the timeout
|
||||||
|
* is deliberately not consumed, so a scroll starting on top of a block survives.
|
||||||
|
*/
|
||||||
|
@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. [onPickUp] receives the press position local to this node and answers
|
||||||
|
* whether anything is there; false abandons the gesture.
|
||||||
|
*/
|
||||||
|
@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 {
|
||||||
|
// Driven on the initial pass, which runs parent → child: an
|
||||||
|
// ancestor that outranks us (the pinch) has already consumed
|
||||||
|
// by the time we look, and descendants — the month grid's
|
||||||
|
// full-bleed tap layer — see our consumption.
|
||||||
|
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 —
|
||||||
|
// a cancel, not a drop, and must never write.
|
||||||
|
if (dropped) currentDrop() else currentCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A lift on pickup, then a tick every time the drop target snaps to a new slot. */
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
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.components.SnackChip
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** How long the confirmation chip stays up, matching a short snackbar. */
|
||||||
|
private const val CHIP_MILLIS = 4_000L
|
||||||
|
|
||||||
|
/** How long an *undone* move stays up — shorter, since it offers nothing to act on. */
|
||||||
|
private const val UNDONE_CHIP_MILLIS = 1_600L
|
||||||
|
|
||||||
|
/** What the chip currently reads, kept past the outcome it was built from. */
|
||||||
|
private data class ChipContent(val message: String, val undo: MoveUndo?)
|
||||||
|
|
||||||
|
/** The FAB's own band at the bottom end, which the chip must not run into. */
|
||||||
|
private val FAB_BAND = 88.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two surfaces a drag-and-drop reschedule needs on top of the calendar: the
|
||||||
|
* recurring-scope prompt, and the confirmation chip carrying Undo. None of the
|
||||||
|
* calendar screens sets a `snackbarHost`, so this hosts its own confirmation as
|
||||||
|
* a pill on the FAB's band, leaving the calendar visible behind it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) {
|
||||||
|
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
|
||||||
|
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
|
||||||
|
val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle()
|
||||||
|
val writeInFlight by viewModel.inFlight.collectAsStateWithLifecycle()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Held past the outcome being consumed so the chip has something to draw
|
||||||
|
// while it springs back out. Updated in composition rather than from an
|
||||||
|
// effect, which would land a frame late and open the chip on stale text.
|
||||||
|
val shown = remember { mutableStateOf(ChipContent("", null)) }
|
||||||
|
if (message != null && (shown.value.message != message || shown.value.undo != moved?.undo)) {
|
||||||
|
shown.value = ChipContent(message, moved?.undo)
|
||||||
|
}
|
||||||
|
val content = shown.value
|
||||||
|
// Restarted by the undo tick and held while a write is in flight: an undo
|
||||||
|
// tapped in the last moments of the window leaves the outcome at Moved on
|
||||||
|
// purpose, and this timer would otherwise fire mid-undo and close the chip
|
||||||
|
// just before the same chip has to say "undone".
|
||||||
|
LaunchedEffect(outcome, undoTick, writeInFlight) {
|
||||||
|
if (outcome == null || writeInFlight) return@LaunchedEffect
|
||||||
|
delay(if (outcome == MoveOutcome.Undone) UNDONE_CHIP_MILLIS else CHIP_MILLIS)
|
||||||
|
viewModel.consumeOutcome()
|
||||||
|
}
|
||||||
|
|
||||||
|
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||||
|
val chipMaxWidth = maxWidth - FAB_BAND
|
||||||
|
// A FAB-height band at the bottom start with the FAB's own margin, so
|
||||||
|
// the chip lines up beside the bottom-end FAB.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(start = SnackChipMargin, bottom = SnackChipMargin)
|
||||||
|
.height(SnackChipHeight),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
// The action goes away while a write runs: undo() refuses a second
|
||||||
|
// write anyway, so offering it would be a button that does nothing.
|
||||||
|
val undo = content.undo?.takeIf { !writeInFlight }
|
||||||
|
SnackChip(
|
||||||
|
visible = outcome != null,
|
||||||
|
message = content.message,
|
||||||
|
maxWidth = chipMaxWidth,
|
||||||
|
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
|
||||||
|
onAction = undo?.let { { viewModel.undo(it) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by
|
||||||
|
* whichever event block is being composed. Null means moving is off entirely and
|
||||||
|
* blocks register no drag gesture at all.
|
||||||
|
*/
|
||||||
|
@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>,
|
||||||
|
/** False when the drop was refused outright, so nothing will be written. */
|
||||||
|
val move: (MoveRequest) -> Boolean,
|
||||||
|
/**
|
||||||
|
* True while a dropped event is being written, including the time its scope
|
||||||
|
* dialog is up. A flow rather than a value so this scope stays the same
|
||||||
|
* object across a move — every visible block reads it as a composition local.
|
||||||
|
*/
|
||||||
|
val inFlight: StateFlow<Boolean>,
|
||||||
|
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
|
||||||
|
val undoStarted: StateFlow<Int>,
|
||||||
|
/** 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 }
|
||||||
|
|
||||||
|
private val NEVER_IN_FLIGHT = MutableStateFlow(false)
|
||||||
|
|
||||||
|
private val NEVER_UNDONE = MutableStateFlow(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a dropped event is still being written — false wherever moving is off.
|
||||||
|
* The drag overlays hold a landed block on its target for this window.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun moveInFlight(): Boolean {
|
||||||
|
val flow = LocalEventMove.current?.inFlight ?: NEVER_IN_FLIGHT
|
||||||
|
return flow.collectAsStateWithLifecycle().value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs [onUndo] when an undo write begins, and never for one that began before
|
||||||
|
* this composable came on screen.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun OnUndoStarted(onUndo: () -> Unit) {
|
||||||
|
val flow = LocalEventMove.current?.undoStarted ?: NEVER_UNDONE
|
||||||
|
val tick by flow.collectAsStateWithLifecycle()
|
||||||
|
var seen by remember { mutableIntStateOf(tick) }
|
||||||
|
LaunchedEffect(tick) {
|
||||||
|
if (tick == seen) return@LaunchedEffect
|
||||||
|
seen = tick
|
||||||
|
onUndo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opacity the source block keeps while its floating copy travels. */
|
||||||
|
const val GHOST_ALPHA: Float = 0.3f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opacity for a block whose copy is in flight: ghosted from the lift until the
|
||||||
|
* copy is handed back, then animated up rather than switched, so the block
|
||||||
|
* appears to travel to its new slot instead of vanishing and reappearing.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ghostAlpha(lifted: Boolean): Float = animateFloatAsState(
|
||||||
|
targetValue = if (lifted) GHOST_ALPHA else 1f,
|
||||||
|
label = "ghost-alpha",
|
||||||
|
).value
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't
|
||||||
|
* pointer-only. Null when this event can't be moved.
|
||||||
|
*/
|
||||||
|
@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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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: the drag is rewritten every frame, its snapped start once a slot.
|
||||||
|
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 already announces this time.
|
||||||
|
.clearAndSetSemantics { },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = formatGutterTime(startMin, use24Hour, locale),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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. Shared by the edit
|
||||||
|
* screen's save and a drag-and-drop reschedule; one of the two carve-outs from
|
||||||
|
* the full-screen picker rule.
|
||||||
|
*
|
||||||
|
* [allowOccurrence] drops "only this event" (an exception row can't carry its own
|
||||||
|
* rule); [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)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
private const val MILLIS_PER_MINUTE = 60_000
|
||||||
|
private const val MINUTES_PER_DAY = 24 * 60
|
||||||
|
|
||||||
|
/** 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, so the screen and this view model can't
|
||||||
|
* disagree by a day over which zone the event's first day is resolved in.
|
||||||
|
*/
|
||||||
|
data class ByDays(val days: Int) : MoveTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One dropped event. [beginMillis]/[endMillis] are the dragged *occurrence's*
|
||||||
|
* own times (`Instances.BEGIN`/`END`), as the detail and edit screens pass them.
|
||||||
|
*/
|
||||||
|
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 a wider write would leave the rule and the series
|
||||||
|
* anchor disagreeing — either the rule names days one moved occurrence can't
|
||||||
|
* re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor
|
||||||
|
* across the same midnights the occurrence crossed.
|
||||||
|
*/
|
||||||
|
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 — a stripped one would wipe the
|
||||||
|
* reminders and attendees the occurrence-exception path reconciles.
|
||||||
|
*/
|
||||||
|
@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
|
||||||
|
|
||||||
|
private val _inFlight = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True from the moment a drop is accepted until its write settles — the
|
||||||
|
* window the dropped block holds its landing position for.
|
||||||
|
*/
|
||||||
|
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
|
||||||
|
|
||||||
|
private val _undoStarted = MutableStateFlow(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ticks the moment an undo write begins, before the provider has anything to
|
||||||
|
* re-read, so the view that drew the drop can carry its chip back rather than
|
||||||
|
* let it reappear on the old day.
|
||||||
|
*/
|
||||||
|
val undoStarted: StateFlow<Int> = _undoStarted.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set from the moment a drop is accepted until its write settles. Two drops
|
||||||
|
* of the same recurring event inside that window would compute their shift
|
||||||
|
* from the same pre-move occurrence, and the shifts would compound.
|
||||||
|
*/
|
||||||
|
private var busy = false
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
_inFlight.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendars whose events may be dragged. Nothing below the UI guards
|
||||||
|
* this, so a block outside this set registers no drag gesture at all.
|
||||||
|
*/
|
||||||
|
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. */
|
||||||
|
val canRealign: Boolean,
|
||||||
|
/** The series row's DTSTART date after the move — what its `UNTIL` must clear. */
|
||||||
|
val newAnchorDate: LocalDate,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a drop, unless one is already being written. False means nothing will
|
||||||
|
* be written and the caller must let its held copy go now — waiting on
|
||||||
|
* [inFlight] would strand it until the settle timeout instead.
|
||||||
|
*/
|
||||||
|
fun move(request: MoveRequest): Boolean {
|
||||||
|
if (busy || _scopePrompt.value != null) return false
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answer the scope dialog. */
|
||||||
|
fun moveWithScope(scope: RecurringWriteScope) {
|
||||||
|
val prepared = pending ?: return
|
||||||
|
// Guard against the dialog ever offering a scope the rule can't carry.
|
||||||
|
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. The outcome stands until the
|
||||||
|
* inverse write reports back, so the one chip changes what it says rather
|
||||||
|
* than closing and reopening. False when another write is already running —
|
||||||
|
* the chip hides its action for that window, so this is the backstop.
|
||||||
|
*/
|
||||||
|
fun undo(undo: MoveUndo): Boolean {
|
||||||
|
if (busy) return false
|
||||||
|
busy = true
|
||||||
|
_undoStarted.value += 1
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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.
|
||||||
|
if (shifted == original) return null
|
||||||
|
|
||||||
|
// The UNTIL check is deferred to the write, which knows how far the move
|
||||||
|
// reaches and so which date has to clear it.
|
||||||
|
if ((shifted.problems() - 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 carries it across the same number
|
||||||
|
// of midnights whatever time of day it sits at. A same-date drag has its
|
||||||
|
// own, narrower check — see anchorKeepsItsDay.
|
||||||
|
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 = when {
|
||||||
|
!isRecurring -> true
|
||||||
|
movedDay -> realigned != null
|
||||||
|
else -> anchorKeepsItsDay(detail, original, shifted, zone)
|
||||||
|
},
|
||||||
|
// Where the series row's own DTSTART lands, given the anchor moves by
|
||||||
|
// the same shift. Only read under canRealign, which is what
|
||||||
|
// guarantees the anchor really does travel this many days.
|
||||||
|
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 at all. Which date
|
||||||
|
* has to clear `UNTIL` depends on the scope: a whole-series move carries the
|
||||||
|
* series *anchor*, a split starts at the moved occurrence, and a single
|
||||||
|
* occurrence becomes an exception row that no `UNTIL` constrains.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a same-date drag leaves the series anchor on its own day too. The
|
||||||
|
* anchor moves by the same wall-clock delta as the occurrence, and normally
|
||||||
|
* shares its time of day — but a row with no `EVENT_TIMEZONE` resolves the
|
||||||
|
* two in zones that can sit a DST hour apart, so a near-midnight drag could
|
||||||
|
* carry the anchor across a midnight the occurrence never crossed and leave
|
||||||
|
* `BYDAY` naming the wrong weekday.
|
||||||
|
*/
|
||||||
|
private fun anchorKeepsItsDay(
|
||||||
|
detail: EventDetail,
|
||||||
|
original: EventForm,
|
||||||
|
shifted: EventForm,
|
||||||
|
zone: TimeZone,
|
||||||
|
): Boolean {
|
||||||
|
val anchorZone = if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone)
|
||||||
|
val anchorMinute = detail.instance.start.toLocalDateTime(anchorZone)
|
||||||
|
.time.toMillisecondOfDay() / MILLIS_PER_MINUTE
|
||||||
|
val delta = (
|
||||||
|
shifted.start.time.toMillisecondOfDay() - original.start.time.toMillisecondOfDay()
|
||||||
|
) / MILLIS_PER_MINUTE
|
||||||
|
return anchorMinute + delta in 0 until MINUTES_PER_DAY
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, and a whole-series move. "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: UTC midnight for an all-day event.
|
||||||
|
*/
|
||||||
|
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,6 +17,7 @@ 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 =
|
||||||
@@ -42,6 +43,18 @@ 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 — the hour labels around it
|
||||||
|
* already carry it, and the gutter is too narrow.
|
||||||
|
*/
|
||||||
|
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".
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
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.coroutines.delay
|
||||||
|
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. Plain fields rather
|
||||||
|
* than snapshot state: it changes on every scroll frame, and the drag loop reads
|
||||||
|
* it per frame anyway.
|
||||||
|
*/
|
||||||
|
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 mapping has to flip with it.
|
||||||
|
*/
|
||||||
|
var isRtl: Boolean = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hoisted drag state for one timeline (#68). It lives above the per-page
|
||||||
|
* `AnimatedContent`, so a page change mid-drag can't strand a ghost, and the
|
||||||
|
* block it renders is drawn in an overlay, clear of the day column's clip.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class TimelineDragController {
|
||||||
|
val geometry = TimelineGeometry()
|
||||||
|
|
||||||
|
var drag: TimelineDrag? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dropped block, held at the slot it landed on while the write runs — the
|
||||||
|
* grid behind it still shows the old time until the query re-reads.
|
||||||
|
*/
|
||||||
|
var settling: TimelineDrag? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which block is lifted, and whether anything is. Separate snapshot state
|
||||||
|
* from [drag], which changes every frame, so blocks that only need "am I the
|
||||||
|
* ghost" don't recompose that often. Stays set through [settling].
|
||||||
|
*/
|
||||||
|
var liftedInstanceId: Long? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the settled drop came from, and which event row it belongs to. The
|
||||||
|
* instance id alone can't identify the source block for the length of the
|
||||||
|
* write: the provider regenerates `Instances` rows, so a re-read carrying the
|
||||||
|
* *old* time can arrive under a new instance id.
|
||||||
|
*/
|
||||||
|
private var settledOrigin: Triple<Long, LocalDate, Int>? by mutableStateOf(null)
|
||||||
|
|
||||||
|
/** Whether a finger is on a block right now — [settling] is not dragging. */
|
||||||
|
var isDragging: Boolean by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the grid itself now draws the settled drop at its landing slot.
|
||||||
|
* The copy may only be handed back once this is true, or the source ghost
|
||||||
|
* flashes back to full opacity in its old slot.
|
||||||
|
*/
|
||||||
|
var settledOnGrid: Boolean by mutableStateOf(false)
|
||||||
|
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 — not the same
|
||||||
|
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
|
||||||
|
*/
|
||||||
|
private var originSlot: Pair<LocalDate, Int>? = null
|
||||||
|
|
||||||
|
fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) {
|
||||||
|
source = block
|
||||||
|
settling = null
|
||||||
|
isDragging = true
|
||||||
|
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
|
||||||
|
isDragging = false
|
||||||
|
liftedInstanceId = null
|
||||||
|
originSlot = null
|
||||||
|
drag = null
|
||||||
|
settling = null
|
||||||
|
settledOnGrid = false
|
||||||
|
settledOrigin = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [block] is the one whose copy is in flight, and so must stay a
|
||||||
|
* ghost: the instance the finger picked up, or once dropped whatever now
|
||||||
|
* sits in the slot it left.
|
||||||
|
*/
|
||||||
|
fun ghosts(block: TimedBlock, date: LocalDate): Boolean {
|
||||||
|
if (liftedInstanceId == null) return false
|
||||||
|
if (block.event.instanceId == liftedInstanceId) return true
|
||||||
|
val (eventId, originDate, originMin) = settledOrigin ?: return false
|
||||||
|
return block.event.eventId == eventId &&
|
||||||
|
date == originDate &&
|
||||||
|
block.startMin == originMin
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a day column now holds, so a settled drop can tell when the grid has
|
||||||
|
* caught up with it. Matched on the landing slot plus either the event row
|
||||||
|
* or its title, since a single-occurrence move writes a new `eventId`.
|
||||||
|
*/
|
||||||
|
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
|
||||||
|
val landed = settling ?: return
|
||||||
|
if (settledOnGrid || landed.date != date) return
|
||||||
|
settledOnGrid = blocks.any { block ->
|
||||||
|
block.startMin == landed.startMin &&
|
||||||
|
(
|
||||||
|
block.event.eventId == landed.event.eventId ||
|
||||||
|
block.event.title == landed.event.title
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End the drag, handing back where it landed — null when it never resolved,
|
||||||
|
* or when it landed back on the slot it started from. A real drop keeps its
|
||||||
|
* copy on the target as [settling] until [release].
|
||||||
|
*/
|
||||||
|
fun finish(): TimelineDrop? {
|
||||||
|
val landed = drag
|
||||||
|
val origin = originSlot
|
||||||
|
cancel()
|
||||||
|
if (landed == null || landed.slot == origin) return null
|
||||||
|
settling = landed
|
||||||
|
liftedInstanceId = landed.event.instanceId
|
||||||
|
settledOrigin = origin?.let { (date, min) -> Triple(landed.event.eventId, date, min) }
|
||||||
|
return TimelineDrop(landed.event, landed.date, landed.startMin)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop ghosting the source — the grid draws the drop itself by now — while
|
||||||
|
* the copy is still dissolving. Un-ghosting at [release] instead would leave
|
||||||
|
* the block dim under the fading copy and then brighten it: a visible dip.
|
||||||
|
*/
|
||||||
|
fun handOver() {
|
||||||
|
liftedInstanceId = null
|
||||||
|
settledOrigin = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the copy, once it has faded into the grid's own block. */
|
||||||
|
fun release() {
|
||||||
|
settling = null
|
||||||
|
settledOnGrid = false
|
||||||
|
handOver()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 past midnight is fine.
|
||||||
|
val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
|
||||||
|
// The event's own length, not the block's: TimedBlock.endMin is clipped
|
||||||
|
// at midnight, which would draw a 22:00–02:00 event as a two-hour copy.
|
||||||
|
val span = (block.event.end - block.event.start).inWholeMinutes.toInt().coerceAtLeast(0)
|
||||||
|
// 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, since a loop of
|
||||||
|
* `animateScrollBy` would re-acquire the mutex and cancel 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)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A beat after the grid draws the drop, so its own block is under way before the
|
||||||
|
* copy starts dissolving.
|
||||||
|
*/
|
||||||
|
const val SETTLE_GRACE_MILLIS: Long = 60L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long to wait for a grid that never confirms the drop — it landed on a day
|
||||||
|
* this timeline doesn't show, or the write failed. The copy has to go either way.
|
||||||
|
*/
|
||||||
|
private const val SETTLE_TIMEOUT_MILLIS = 900L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hand-over: the copy dissolves over this while the grid's own block slides
|
||||||
|
* in under it. Kept short, because a full-width copy sits over any neighbour it
|
||||||
|
* now shares a lane with until it is gone.
|
||||||
|
*/
|
||||||
|
const val SETTLE_FADE_MILLIS: Int = 250
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The floating block, drawn over the whole calendar so it is free of the day
|
||||||
|
* column's clip and of the scroll viewport's rounded corners.
|
||||||
|
*/
|
||||||
|
@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
|
||||||
|
val moveInFlight = moveInFlight()
|
||||||
|
|
||||||
|
// Both live here rather than beside the controller: they read [drag], which
|
||||||
|
// changes every frame, and this composable is the one that is meant to.
|
||||||
|
DragSnapHaptics(controller.drag?.slot)
|
||||||
|
LaunchedEffect(controller.isDragging) {
|
||||||
|
if (controller.isDragging) controller.autoScroll()
|
||||||
|
}
|
||||||
|
// Hold the landed copy until the write is done — including the time a
|
||||||
|
// recurring drop's scope dialog is up — and then until the grid draws the
|
||||||
|
// drop itself.
|
||||||
|
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid) {
|
||||||
|
if (controller.settling == null || moveInFlight) return@LaunchedEffect
|
||||||
|
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
|
||||||
|
handingOver = true
|
||||||
|
controller.handOver()
|
||||||
|
delay(SETTLE_FADE_MILLIS.toLong())
|
||||||
|
controller.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
// A copy of a block still in the tree behind it; announcing it again
|
||||||
|
// would duplicate the event for the drag's duration.
|
||||||
|
.clearAndSetSemantics { }
|
||||||
|
.onGloballyPositioned { origin = it.positionInRoot() },
|
||||||
|
) {
|
||||||
|
val drag = controller.drag ?: controller.settling ?: return@Box
|
||||||
|
// Landed: the copy sinks back to the grid's own plane while the write runs.
|
||||||
|
val landed = controller.drag == null
|
||||||
|
val lift by animateFloatAsState(
|
||||||
|
targetValue = if (landed || reduceMotion) 0f else 1f,
|
||||||
|
label = "drag-lift",
|
||||||
|
)
|
||||||
|
val copyAlpha by animateFloatAsState(
|
||||||
|
targetValue = if (handingOver) 0f else 1f,
|
||||||
|
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||||
|
label = "drag-handover",
|
||||||
|
)
|
||||||
|
val fill = eventFill(drag.event.color, dark, soften)
|
||||||
|
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
// An end past midnight wraps rather than saturating, so a four-hour
|
||||||
|
// event dragged to 22:00 reads "22:00–02:00" and not "22:00–24:00".
|
||||||
|
val endMin = if (drag.endMin > MINUTES_PER_DAY) {
|
||||||
|
drag.endMin % MINUTES_PER_DAY
|
||||||
|
} else {
|
||||||
|
drag.endMin
|
||||||
|
}
|
||||||
|
val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}–" +
|
||||||
|
formatMinuteOfDay(endMin, 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 {
|
||||||
|
scaleX = 1f + 0.02f * lift
|
||||||
|
scaleY = 1f + 0.02f * lift
|
||||||
|
shadowElevation = 8.dp.toPx() * lift
|
||||||
|
alpha = copyAlpha
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,14 @@ class TimelineZoom(
|
|||||||
var scale: TimelineScale by mutableStateOf(initial)
|
var scale: TimelineScale by mutableStateOf(initial)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
private var pinching = false
|
/**
|
||||||
|
* Whether fingers are rescaling the timeline right now. Snapshot state, so
|
||||||
|
* anything that tweens off [scale] can stand down for the gesture: the
|
||||||
|
* height changes every pointer frame, and a spring would spend the whole
|
||||||
|
* pinch chasing a target that has already moved.
|
||||||
|
*/
|
||||||
|
var isPinching: Boolean by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Take a value that came from the preference. Ignored mid-pinch: the stored
|
* Take a value that came from the preference. Ignored mid-pinch: the stored
|
||||||
@@ -49,11 +56,11 @@ class TimelineZoom(
|
|||||||
* snap the timeline back while the user is still pinching.
|
* snap the timeline back while the user is still pinching.
|
||||||
*/
|
*/
|
||||||
fun adopt(stored: TimelineScale) {
|
fun adopt(stored: TimelineScale) {
|
||||||
if (!pinching) scale = stored
|
if (!isPinching) scale = stored
|
||||||
}
|
}
|
||||||
|
|
||||||
fun beginPinch() {
|
fun beginPinch() {
|
||||||
pinching = true
|
isPinching = true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pinchTo(hourHeight: Dp) {
|
fun pinchTo(hourHeight: Dp) {
|
||||||
@@ -61,7 +68,7 @@ class TimelineZoom(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun endPinch() {
|
fun endPinch() {
|
||||||
pinching = false
|
isPinching = false
|
||||||
persist(scale)
|
persist(scale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import androidx.compose.material3.rememberDrawerState
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
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
|
||||||
@@ -53,14 +54,19 @@ 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
|
||||||
@@ -74,7 +80,21 @@ 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.BlockTimeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||||
|
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
|
||||||
@@ -93,8 +113,9 @@ 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.week.TimedBlock
|
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -106,11 +127,6 @@ 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
|
||||||
|
|
||||||
@@ -298,31 +314,54 @@ 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)
|
||||||
|
|
||||||
AnimatedContent(
|
// Above the AnimatedContent: a page change mid-drag would strand the
|
||||||
targetState = state,
|
// floating block inside the outgoing page.
|
||||||
modifier = modifier.then(swipeModifier),
|
val dragController = rememberTimelineDragController()
|
||||||
contentKey = { s ->
|
val move = LocalEventMove.current
|
||||||
|
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) {
|
||||||
is DayUiState.Success -> "success-${s.date}"
|
DayUiState.Loading -> DayLoading()
|
||||||
is DayUiState.Failure -> "failure-${s.reason}"
|
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
DayUiState.Loading -> "loading"
|
is DayUiState.Success -> DaySuccess(
|
||||||
|
state = s,
|
||||||
|
topSectionColor = topSectionColor,
|
||||||
|
scrollState = scrollState,
|
||||||
|
allDayHeight = allDayHeight,
|
||||||
|
dragController = dragController,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
|
onDrop = { drop ->
|
||||||
|
val took = move?.move(
|
||||||
|
MoveRequest(
|
||||||
|
eventId = drop.event.eventId,
|
||||||
|
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||||
|
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||||
|
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Refused, so nothing will land: let the copy go now
|
||||||
|
// rather than hold it out for a settle that never comes.
|
||||||
|
if (took != true) dragController.release()
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,8 +371,10 @@ 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,
|
||||||
@@ -352,8 +393,10 @@ 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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -487,13 +530,15 @@ 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
|
||||||
@@ -512,39 +557,19 @@ 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.
|
||||||
Column(
|
HourGutter(
|
||||||
modifier = Modifier
|
scrollState = scrollState,
|
||||||
.width(GUTTER_WIDTH)
|
hourHeight = hourHeight,
|
||||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
dragController = dragController,
|
||||||
.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,
|
||||||
@@ -552,11 +577,26 @@ 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
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -570,13 +610,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
|
||||||
|
// Tells a settled drop when this column has caught up with it.
|
||||||
|
LaunchedEffect(blocks, dragController.settling) {
|
||||||
|
dragController.noteGrid(date, blocks)
|
||||||
|
}
|
||||||
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.
|
||||||
@@ -604,22 +650,38 @@ private fun DayColumnCard(
|
|||||||
) {
|
) {
|
||||||
val colWidth = maxWidth
|
val colWidth = maxWidth
|
||||||
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
||||||
|
// Keyed by event, so a block that changes time or lane is the *same*
|
||||||
|
// composable afterwards and tweens there. The ordinal disambiguates
|
||||||
|
// a column holding two occurrences of one series.
|
||||||
|
val ordinals = mutableMapOf<Long, Int>()
|
||||||
blocks.forEach { block ->
|
blocks.forEach { block ->
|
||||||
val laneWidth = colWidth / block.laneCount
|
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
||||||
val top = hourHeight * (block.startMin / 60f)
|
key(block.event.eventId, ordinal) {
|
||||||
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
val laneWidth = colWidth / block.laneCount
|
||||||
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
val top = hourHeight * (block.startMin / 60f)
|
||||||
EventBlock(
|
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||||
block = block,
|
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||||
dark = dark,
|
val place = animatedBlockPlacement(
|
||||||
height = height,
|
x = laneWidth * block.lane,
|
||||||
onClick = { onEventClick(block.event) },
|
y = top,
|
||||||
modifier = Modifier
|
width = laneWidth,
|
||||||
.offset(x = laneWidth * block.lane, y = top)
|
height = height,
|
||||||
.width(laneWidth)
|
)
|
||||||
.height(height)
|
EventBlock(
|
||||||
.padding(horizontal = 1.dp),
|
block = block,
|
||||||
)
|
dark = dark,
|
||||||
|
height = place.height,
|
||||||
|
date = date,
|
||||||
|
dragController = dragController,
|
||||||
|
onClick = { onEventClick(block.event) },
|
||||||
|
onDrop = onDrop,
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(x = place.x, y = place.y)
|
||||||
|
.width(place.width)
|
||||||
|
.height(place.height)
|
||||||
|
.padding(horizontal = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Current-time line, on top of the events, only on today's column.
|
// Current-time line, on top of the events, only on today's column.
|
||||||
if (date == today) {
|
if (date == today) {
|
||||||
@@ -634,7 +696,10 @@ 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) }
|
||||||
@@ -658,12 +723,35 @@ 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.ghosts(block, date)
|
||||||
|
val ghost = ghostAlpha(lifted)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
// The source stays put as a ghost while its floating copy travels.
|
||||||
|
.then(if (ghost < 1f) Modifier.alpha(ghost) 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 { contentDescription = "$title, $timeLabel" },
|
.semantics {
|
||||||
|
contentDescription = "$title, $timeLabel"
|
||||||
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
if (showTitle) {
|
if (showTitle) {
|
||||||
@@ -676,11 +764,8 @@ private fun EventBlock(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
Text(
|
BlockTimeLabel(
|
||||||
text = timeLabel,
|
label = timeLabel,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ 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
|
||||||
@@ -452,30 +453,11 @@ private fun SaveScopeDialog(
|
|||||||
onSelect: (RecurringWriteScope) -> Unit,
|
onSelect: (RecurringWriteScope) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
AlertDialog(
|
RecurringScopeDialog(
|
||||||
onDismissRequest = onDismiss,
|
title = stringResource(R.string.event_edit_recurring_title),
|
||||||
title = { Text(stringResource(R.string.event_edit_recurring_title)) },
|
onSelect = onSelect,
|
||||||
text = {
|
onDismiss = onDismiss,
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
allowOccurrence = !recurrenceChanged,
|
||||||
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)) }
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
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.layout.positionInRoot
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
/** The lane band inside that box, where the chips themselves are seated. */
|
||||||
|
val band: LayoutCoordinates?,
|
||||||
|
val columnWidthPx: Float,
|
||||||
|
val laneHeightPx: Float,
|
||||||
|
val laneCount: Int,
|
||||||
|
/**
|
||||||
|
* Whether the columns are laid out right-to-left. Pointer coordinates are
|
||||||
|
* never mirrored but the grid is, so the mapping has to flip with it.
|
||||||
|
*/
|
||||||
|
val isRtl: Boolean,
|
||||||
|
/** What this row currently draws in [lane] of column `col`, or null. */
|
||||||
|
val chipAt: (col: Int, lane: Int) -> EventInstance?,
|
||||||
|
) {
|
||||||
|
/** Root top-left of the chip seated in [lane] of column [col]. */
|
||||||
|
fun seat(col: Int, lane: Int): Offset? {
|
||||||
|
val band = band?.takeIf { it.isAttached } ?: return null
|
||||||
|
val origin = band.positionInRoot()
|
||||||
|
val column = if (isRtl) days.lastIndex - col else col
|
||||||
|
return Offset(origin.x + column * columnWidthPx, origin.y + lane * laneHeightPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this row seats [event] on [date], or null when it doesn't hold it —
|
||||||
|
* the day isn't in this week, or the chip went into the day's "+N" overflow.
|
||||||
|
*/
|
||||||
|
fun seatOf(event: EventInstance, date: LocalDate): Offset? {
|
||||||
|
val col = days.indexOf(date).takeIf { it >= 0 } ?: return null
|
||||||
|
val lane = (0 until laneCount).firstOrNull { lane ->
|
||||||
|
chipAt(col, lane)?.let { isSameEvent(it, event) } == true
|
||||||
|
} ?: return null
|
||||||
|
return seat(col, lane)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [chip] is the moved event [moved] as the grid now holds it. Neither id
|
||||||
|
* alone will do: re-read instances get new instance ids, and a single-occurrence
|
||||||
|
* move writes a new event id — the title survives both.
|
||||||
|
*/
|
||||||
|
private fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean =
|
||||||
|
chip.eventId == moved.eventId || chip.title == moved.title
|
||||||
|
|
||||||
|
/** 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 target resolved against
|
||||||
|
* whichever registered row the finger is over. Rows are keyed by an identity
|
||||||
|
* token rather than a date: the continuous style can show the same week twice.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class MonthDragController {
|
||||||
|
private val rows = LinkedHashMap<Any, MonthRowGeometry>()
|
||||||
|
|
||||||
|
var drag: MonthChipDrag? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dropped chip, held on the day it landed on while the write runs — the
|
||||||
|
* grid behind it still shows the old day until the query re-reads.
|
||||||
|
*/
|
||||||
|
var settling: MonthChipDrag? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which chip is lifted, and whether anything is. Separate snapshot state from
|
||||||
|
* [drag], which changes every frame, so the chips and rows that only need
|
||||||
|
* "am I the ghost" don't recompose that often. Stays set through [settling].
|
||||||
|
*/
|
||||||
|
var liftedInstanceId: Long? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the grid itself now draws the drop, in root coordinates — published
|
||||||
|
* by whichever week row ends up holding it. The copy is dropped wherever the
|
||||||
|
* finger was while the grid seats it in a lane, so without this it would
|
||||||
|
* cross-fade across the gap instead of gliding onto its seat.
|
||||||
|
*/
|
||||||
|
var settledInRoot: Offset? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the drop's own chip is still standing in for the copy. Cleared at
|
||||||
|
* [handOver] rather than [release], or the chip stays dim under a copy that
|
||||||
|
* has already faded and brightens afterwards: a visible dip.
|
||||||
|
*/
|
||||||
|
private var settledGhost: Boolean by mutableStateOf(false)
|
||||||
|
|
||||||
|
/** Whether a finger is on a chip right now — [settling] is not dragging. */
|
||||||
|
var isDragging: Boolean by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last drop this controller made, kept past [release] for [beginUndo] —
|
||||||
|
* the confirmation chip carries Undo long after the copy is gone.
|
||||||
|
*/
|
||||||
|
private var undoable: MonthChipDrag? = null
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
settling = null
|
||||||
|
isDragging = true
|
||||||
|
liftedInstanceId = event.instanceId
|
||||||
|
grab = pointerInRoot - chipInRoot
|
||||||
|
pointer = pointerInRoot
|
||||||
|
sizePx = size
|
||||||
|
recompute()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun move(pointerInRoot: Offset) {
|
||||||
|
pointer = pointerInRoot
|
||||||
|
recompute()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
event = null
|
||||||
|
grabDate = null
|
||||||
|
isDragging = false
|
||||||
|
liftedInstanceId = null
|
||||||
|
drag = null
|
||||||
|
settling = null
|
||||||
|
settledInRoot = null
|
||||||
|
settledGhost = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [days] holds the chip the grid has drawn for the settled drop —
|
||||||
|
* the seat the copy is on its way to, which must ghost until it gets there.
|
||||||
|
*/
|
||||||
|
fun isSettledChip(event: EventInstance, days: List<LocalDate>?): Boolean {
|
||||||
|
val landed = settling?.takeIf { settledGhost } ?: return false
|
||||||
|
if (days == null || landed.targetDate !in days) return false
|
||||||
|
return isSameEvent(event, landed.event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the row keyed [token] whether it now seats the settled chip. Both
|
||||||
|
* copies of a week the continuous style shows twice will answer; the seat
|
||||||
|
* nearest where the chip was let go is the one the finger was over.
|
||||||
|
*/
|
||||||
|
fun noteSettled(token: Any) {
|
||||||
|
val landed = settling ?: return
|
||||||
|
val target = landed.targetDate ?: return
|
||||||
|
val at = rows[token]?.seatOf(landed.event, target) ?: return
|
||||||
|
val current = settledInRoot
|
||||||
|
val closer = current == null ||
|
||||||
|
abs(at.y - landed.topLeftInRoot.y) < abs(current.y - landed.topLeftInRoot.y)
|
||||||
|
if (closer) settledInRoot = at
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put a chip on the journey an undo's inverse write is about to make, so it
|
||||||
|
* travels back rather than teleporting. Writes nothing itself. False when
|
||||||
|
* there is no drop of this controller's left to undo, or the grid doesn't
|
||||||
|
* seat the moved event where the chip would have to start from.
|
||||||
|
*/
|
||||||
|
fun beginUndo(): Boolean {
|
||||||
|
val last = undoable ?: return false
|
||||||
|
undoable = null
|
||||||
|
val from = last.targetDate ?: return false
|
||||||
|
val at = rows.values.firstNotNullOfOrNull { it.seatOf(last.event, from) } ?: return false
|
||||||
|
settling = last.copy(grabDate = from, targetDate = last.grabDate, topLeftInRoot = at)
|
||||||
|
liftedInstanceId = last.event.instanceId
|
||||||
|
settledGhost = true
|
||||||
|
settledInRoot = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End the drag, handing back where it landed — null when it never resolved,
|
||||||
|
* or when it landed back on the day it came from. A real drop keeps its chip
|
||||||
|
* on the target day as [settling] until [release], slid over to that day's
|
||||||
|
* column so it lands where the grid is about to draw it.
|
||||||
|
*/
|
||||||
|
fun finish(): MonthChipDrop? {
|
||||||
|
val landed = drag
|
||||||
|
val left = landed?.targetDate?.let(::columnLeft)
|
||||||
|
cancel()
|
||||||
|
val target = landed?.targetDate?.takeIf { it != landed.grabDate } ?: return null
|
||||||
|
settling = landed.copy(
|
||||||
|
topLeftInRoot = Offset(left ?: landed.topLeftInRoot.x, landed.topLeftInRoot.y),
|
||||||
|
)
|
||||||
|
liftedInstanceId = landed.event.instanceId
|
||||||
|
settledGhost = true
|
||||||
|
undoable = settling
|
||||||
|
return MonthChipDrop(landed.event, landed.grabDate, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop ghosting the source while the copy is still dissolving — un-ghosting
|
||||||
|
* at [release] instead leaves the chip dim and then brightens it.
|
||||||
|
*/
|
||||||
|
fun handOver() {
|
||||||
|
liftedInstanceId = null
|
||||||
|
settledGhost = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the copy, once it has faded into the grid's own chip. */
|
||||||
|
fun release() {
|
||||||
|
settling = null
|
||||||
|
settledInRoot = null
|
||||||
|
handOver()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Root x of [date]'s column, in whichever visible row shows that day. */
|
||||||
|
private fun columnLeft(date: LocalDate): Float? = rows.values.firstNotNullOfOrNull { row ->
|
||||||
|
val index = row.days.indexOf(date).takeIf { it >= 0 } ?: return@firstNotNullOfOrNull null
|
||||||
|
val bounds = row.cell.takeIf { it.isAttached }?.boundsInRoot()
|
||||||
|
?: return@firstNotNullOfOrNull null
|
||||||
|
val column = if (row.isRtl) row.days.lastIndex - index else index
|
||||||
|
bounds.left + column * row.columnWidthPx
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 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 }
|
||||||
@@ -4,7 +4,9 @@ import androidx.activity.compose.BackHandler
|
|||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
import androidx.compose.animation.SharedTransitionLayout
|
import androidx.compose.animation.SharedTransitionLayout
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.VectorConverter
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.snap
|
import androidx.compose.animation.core.snap
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
@@ -59,9 +61,11 @@ import androidx.compose.material3.TopAppBarDefaults
|
|||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
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
|
||||||
@@ -77,7 +81,32 @@ 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.moveInFlight
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS
|
||||||
|
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
|
||||||
@@ -126,6 +155,7 @@ import de.jeanlucmakiola.floret.locale.currentLocale
|
|||||||
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
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -384,13 +414,23 @@ fun MonthScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(
|
// Hoisted above every style's grid: a dragged chip is drawn in an
|
||||||
|
// overlay, clear of the week row's and the list viewport's clips.
|
||||||
|
val chipDrag = rememberMonthDragController()
|
||||||
|
// Undo moves the event back, so the chip travels back too. The
|
||||||
|
// signal comes from the write, not from the chip that offers it.
|
||||||
|
OnUndoStarted { chipDrag.beginUndo() }
|
||||||
|
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(LocalDimCutoff provides dimCutoff) {
|
CompositionLocalProvider(
|
||||||
|
LocalDimCutoff provides dimCutoff,
|
||||||
|
LocalMonthDrag provides chipDrag,
|
||||||
|
) {
|
||||||
if (scrolling) {
|
if (scrolling) {
|
||||||
ContinuousMonthContent(
|
ContinuousMonthContent(
|
||||||
state = continuousState,
|
state = continuousState,
|
||||||
@@ -427,10 +467,103 @@ fun MonthScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
MonthDragOverlay(chipDrag)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long to wait for a grid that never seats the drop — it landed in a day's
|
||||||
|
* "+N" overflow or on a day this month doesn't show, or the write failed.
|
||||||
|
*/
|
||||||
|
private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L
|
||||||
|
|
||||||
|
/** 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()
|
||||||
|
val moveInFlight = moveInFlight()
|
||||||
|
// Here rather than beside the controller: this reads [drag], which changes
|
||||||
|
// every frame, and this composable is the one that is meant to.
|
||||||
|
DragSnapHaptics(controller.drag?.targetDate)
|
||||||
|
// Hold the landed chip until the write is done — including the time a
|
||||||
|
// recurring drop's scope dialog is up — and then, unlike the timeline, carry
|
||||||
|
// it to its seat: it was dropped at whatever height the finger was at, while
|
||||||
|
// the grid seats it in a lane.
|
||||||
|
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
||||||
|
var gliding by remember(controller.settling) { mutableStateOf(false) }
|
||||||
|
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||||
|
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
|
||||||
|
val seat = controller.settledInRoot
|
||||||
|
LaunchedEffect(controller.settling, moveInFlight, seat) {
|
||||||
|
val landed = controller.settling
|
||||||
|
if (landed == null || moveInFlight) return@LaunchedEffect
|
||||||
|
if (seat == null) {
|
||||||
|
delay(MONTH_SETTLE_TIMEOUT_MILLIS)
|
||||||
|
} else {
|
||||||
|
if (!gliding) glide.snapTo(landed.topLeftInRoot)
|
||||||
|
gliding = true
|
||||||
|
if (reduceMotion) glide.snapTo(seat) else glide.animateTo(seat, glideSpec)
|
||||||
|
delay(SETTLE_GRACE_MILLIS)
|
||||||
|
}
|
||||||
|
handingOver = true
|
||||||
|
controller.handOver()
|
||||||
|
delay(SETTLE_FADE_MILLIS.toLong())
|
||||||
|
controller.release()
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
// A copy of a chip still in the tree behind it; announcing it again
|
||||||
|
// would duplicate the event for the drag's duration.
|
||||||
|
.clearAndSetSemantics { }
|
||||||
|
.onGloballyPositioned { origin = it.positionInRoot() },
|
||||||
|
) {
|
||||||
|
val drag = controller.drag ?: controller.settling ?: return@Box
|
||||||
|
// Landed: the copy sinks back to the grid's own plane while the write runs.
|
||||||
|
val lift by animateFloatAsState(
|
||||||
|
targetValue = if (controller.drag == null || reduceMotion) 0f else 1f,
|
||||||
|
label = "chip-lift",
|
||||||
|
)
|
||||||
|
val copyAlpha by animateFloatAsState(
|
||||||
|
targetValue = if (handingOver) 0f else 1f,
|
||||||
|
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||||
|
label = "chip-handover",
|
||||||
|
)
|
||||||
|
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 {
|
||||||
|
val at = if (gliding) glide.value else drag.topLeftInRoot
|
||||||
|
IntOffset(
|
||||||
|
(at.x - origin.x).roundToInt(),
|
||||||
|
(at.y - origin.y).roundToInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.width(with(density) { drag.sizePx.width.toDp() })
|
||||||
|
.height(with(density) { drag.sizePx.height.toDp() })
|
||||||
|
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = 1f + 0.04f * lift
|
||||||
|
scaleY = 1f + 0.04f * lift
|
||||||
|
shadowElevation = 8.dp.toPx() * lift
|
||||||
|
alpha = copyAlpha
|
||||||
|
shape = RoundedCornerShape(4.dp)
|
||||||
|
clip = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthContent(
|
private fun MonthContent(
|
||||||
state: MonthUiState,
|
state: MonthUiState,
|
||||||
@@ -1720,6 +1853,50 @@ 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 sits on top of them — 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 cellCoordinates = 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) }
|
||||||
|
}
|
||||||
|
// Republished on layout *and* on every recomposition: the coordinates change
|
||||||
|
// only on the former, but what the row draws — which the controller reads to
|
||||||
|
// find a moved chip's seat — changes on the latter.
|
||||||
|
val publish = {
|
||||||
|
val cell = cellCoordinates[0]?.takeIf { it.isAttached }
|
||||||
|
if (dragController != null && cell != null) {
|
||||||
|
dragController.putRow(
|
||||||
|
rowToken,
|
||||||
|
MonthRowGeometry(
|
||||||
|
days = week.days,
|
||||||
|
cell = cell,
|
||||||
|
band = bandCoordinates[0],
|
||||||
|
columnWidthPx = cell.size.width / 7f,
|
||||||
|
laneHeightPx = rowHeightPx,
|
||||||
|
laneCount = MAX_EVENT_ROWS,
|
||||||
|
isRtl = isRtl,
|
||||||
|
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SideEffect { publish() }
|
||||||
|
// Once the grid holds the settled chip, tell the controller where this row
|
||||||
|
// has seated it, so the copy in flight can glide onto it. Keyed on the week,
|
||||||
|
// which is what changes when the re-read lands.
|
||||||
|
LaunchedEffect(week, dragController?.settling) {
|
||||||
|
dragController?.noteSettled(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
|
||||||
@@ -1735,7 +1912,21 @@ private fun MonthWeekRow(
|
|||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight()
|
||||||
|
.onGloballyPositioned { coords ->
|
||||||
|
cellCoordinates[0] = coords
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
.then(
|
||||||
|
monthChipDragModifier(
|
||||||
|
week = week,
|
||||||
|
moveScope = moveScope,
|
||||||
|
controller = dragController,
|
||||||
|
band = bandCoordinates,
|
||||||
|
rowHeightPx = rowHeightPx,
|
||||||
|
isRtl = isRtl,
|
||||||
|
),
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
val colW = maxWidth / 7
|
val colW = maxWidth / 7
|
||||||
|
|
||||||
@@ -1800,7 +1991,13 @@ private fun MonthWeekRow(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.then(if (morphing) Modifier else Modifier.clipToBounds()),
|
.onGloballyPositioned {
|
||||||
|
bandCoordinates[0] = it
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
// A dragged chip travels to another row, so the clip
|
||||||
|
// yields for it 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 ->
|
||||||
@@ -1810,6 +2007,7 @@ private fun MonthWeekRow(
|
|||||||
dark = dark,
|
dark = dark,
|
||||||
continuesLeft = span.continuesLeft,
|
continuesLeft = span.continuesLeft,
|
||||||
continuesRight = span.continuesRight,
|
continuesRight = span.continuesRight,
|
||||||
|
days = week.days.subList(span.startCol, span.endCol + 1),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(
|
.offset(
|
||||||
x = colW * span.startCol,
|
x = colW * span.startCol,
|
||||||
@@ -1879,6 +2077,7 @@ private fun MonthWeekRow(
|
|||||||
dark = dark,
|
dark = dark,
|
||||||
continuesLeft = false,
|
continuesLeft = false,
|
||||||
continuesRight = false,
|
continuesRight = false,
|
||||||
|
days = listOf(d),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(
|
.offset(
|
||||||
x = colW * col,
|
x = colW * col,
|
||||||
@@ -1959,6 +2158,73 @@ private fun MonthWeekRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The row-level pickup for month chips: resolves which chip the press landed on
|
||||||
|
* from the geometry the row just laid out, and abandons the gesture on empty
|
||||||
|
* space (or an unmovable calendar) 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 by what it travelled, not to where it landed.
|
||||||
|
val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt()
|
||||||
|
val took = moveScope?.move(
|
||||||
|
MoveRequest(
|
||||||
|
eventId = drop.event.eventId,
|
||||||
|
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||||
|
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||||
|
target = MoveTarget.ByDays(delta),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Refused, so nothing will land: let the copy go now rather than
|
||||||
|
// hold the chip out for a settle that can never arrive.
|
||||||
|
if (took != true) controller?.release()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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
|
||||||
@@ -2051,12 +2317,24 @@ private fun MonthBar(
|
|||||||
continuesLeft: Boolean,
|
continuesLeft: Boolean,
|
||||||
continuesRight: Boolean,
|
continuesRight: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
/**
|
||||||
|
* The days this chip covers in its row, for the drag (#68). A drop's own chip
|
||||||
|
* has to ghost until the copy lands on it, and the instance id can't find it:
|
||||||
|
* the provider hands the re-read instance a new one.
|
||||||
|
*/
|
||||||
|
days: List<LocalDate>? = null,
|
||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
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 monthDrag = LocalMonthDrag.current
|
||||||
|
val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
|
||||||
|
monthDrag?.isSettledChip(event, days) == true
|
||||||
|
val ghost = ghostAlpha(lifted)
|
||||||
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,
|
||||||
@@ -2065,9 +2343,13 @@ private fun MonthBar(
|
|||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
|
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||||
.background(fill, shape)
|
.background(fill, shape)
|
||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = 4.dp)
|
||||||
.semantics { contentDescription = title },
|
.semantics {
|
||||||
|
contentDescription = title
|
||||||
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
|
},
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -67,6 +67,23 @@ 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.
|
||||||
|
* 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
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.search
|
package de.jeanlucmakiola.calendula.ui.search
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -11,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -19,7 +25,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.SearchOff
|
import androidx.compose.material.icons.filled.SearchOff
|
||||||
|
import androidx.compose.material.icons.filled.SelectAll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -27,41 +37,53 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
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.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChip
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import java.time.Instant as JavaInstant
|
import java.time.Instant as JavaInstant
|
||||||
@@ -69,10 +91,14 @@ import java.time.ZoneId
|
|||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
|
/** How long the delete confirmation chip stays up, matching a short snackbar. */
|
||||||
|
private const val CHIP_MILLIS = 4_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-text event search (top-bar entry). Type a query → matching events
|
* Full-text event search (top-bar entry). Type a query → matching events
|
||||||
* (title / location / description) across the whole calendar, newest-relevant
|
* (title / location / description) across the whole calendar, newest-relevant
|
||||||
* first; tap a result to open its detail. A full-screen overlay hosted by
|
* first; tap a result to open its detail. Long-press a result to enter selection
|
||||||
|
* mode and delete several at once (#80). A full-screen overlay hosted by
|
||||||
* [de.jeanlucmakiola.calendula.ui.CalendarHost].
|
* [de.jeanlucmakiola.calendula.ui.CalendarHost].
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -85,8 +111,13 @@ fun SearchScreen(
|
|||||||
) {
|
) {
|
||||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val selection by viewModel.selection.collectAsStateWithLifecycle()
|
||||||
|
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val keyboard = LocalSoftwareKeyboardController.current
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val inSelection = selection.isNotEmpty()
|
||||||
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Each fresh open starts blank and straight into typing. The ViewModel is
|
// Each fresh open starts blank and straight into typing. The ViewModel is
|
||||||
// activity-scoped so it outlives the overlay; clearing on (re)enter is what
|
// activity-scoped so it outlives the overlay; clearing on (re)enter is what
|
||||||
@@ -97,46 +128,83 @@ fun SearchScreen(
|
|||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
keyboard?.show()
|
keyboard?.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same in-place WRITE_CALENDAR upgrade the detail screen does: a v1.0 install
|
||||||
|
// holds only READ_CALENDAR, and granting continues into the held delete.
|
||||||
|
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
|
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
if (granted) pendingWrite?.invoke()
|
||||||
|
pendingWrite = null
|
||||||
|
}
|
||||||
|
val requireWrite: (() -> Unit) -> Unit = { action ->
|
||||||
|
val granted = ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (granted) {
|
||||||
|
action()
|
||||||
|
} else {
|
||||||
|
pendingWrite = action
|
||||||
|
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back leaves selection mode before it leaves the screen — and without the
|
||||||
|
// predictive-back scale-out, which would read as closing search only to have
|
||||||
|
// it snap back. The two handlers are mutually exclusive on [inSelection].
|
||||||
|
BackHandler(enabled = inSelection) { viewModel.clearSelection() }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.predictiveBack(onBack = onBack),
|
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection),
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
if (inSelection) {
|
||||||
title = {
|
SelectionTopBar(
|
||||||
InlineTextField(
|
count = selection.size,
|
||||||
value = query,
|
onClose = viewModel::clearSelection,
|
||||||
onValueChange = viewModel::setQuery,
|
onSelectAll = viewModel::selectAll,
|
||||||
placeholder = stringResource(R.string.search_hint),
|
onDelete = { requireWrite { showDeleteDialog = true } },
|
||||||
capitalization = KeyboardCapitalization.None,
|
)
|
||||||
imeAction = ImeAction.Search,
|
} else {
|
||||||
onImeAction = { keyboard?.hide() },
|
TopAppBar(
|
||||||
modifier = Modifier
|
title = {
|
||||||
.fillMaxWidth()
|
InlineTextField(
|
||||||
.focusRequester(focusRequester),
|
value = query,
|
||||||
)
|
onValueChange = viewModel::setQuery,
|
||||||
},
|
placeholder = stringResource(R.string.search_hint),
|
||||||
navigationIcon = {
|
capitalization = KeyboardCapitalization.None,
|
||||||
IconButton(onClick = onBack) {
|
imeAction = ImeAction.Search,
|
||||||
Icon(
|
onImeAction = { keyboard?.hide() },
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
modifier = Modifier
|
||||||
contentDescription = stringResource(R.string.search_back),
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
},
|
navigationIcon = {
|
||||||
actions = {
|
IconButton(onClick = onBack) {
|
||||||
if (query.isNotEmpty()) {
|
|
||||||
IconButton(onClick = { viewModel.setQuery("") }) {
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
contentDescription = stringResource(R.string.search_clear),
|
contentDescription = stringResource(R.string.search_back),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
actions = {
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
if (query.isNotEmpty()) {
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
IconButton(onClick = { viewModel.setQuery("") }) {
|
||||||
),
|
Icon(
|
||||||
)
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.search_clear),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
// imePadding shrinks the content by the keyboard, so the centered
|
// imePadding shrinks the content by the keyboard, so the centered
|
||||||
@@ -158,19 +226,113 @@ fun SearchScreen(
|
|||||||
text = stringResource(R.string.search_empty, s.query),
|
text = stringResource(R.string.search_empty, s.query),
|
||||||
)
|
)
|
||||||
is SearchUiState.Results -> SearchResults(
|
is SearchUiState.Results -> SearchResults(
|
||||||
events = s.events,
|
results = s,
|
||||||
|
selection = selection,
|
||||||
|
inSelection = inSelection,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onToggle = viewModel::toggleSelection,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
DeleteOutcomeChip(
|
||||||
|
deleteState = deleteState,
|
||||||
|
onConsume = viewModel::consumeDeleteResult,
|
||||||
|
modifier = Modifier.align(Alignment.BottomStart),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDeleteDialog) {
|
||||||
|
val count = selection.size
|
||||||
|
// One decision for the whole batch: the scope question is only asked when
|
||||||
|
// a recurring event is in it, and one-offs ignore whatever comes back.
|
||||||
|
if (viewModel.selectionHasRecurring()) {
|
||||||
|
RecurringScopeDialog(
|
||||||
|
title = stringResource(R.string.event_delete_recurring_title),
|
||||||
|
onSelect = { scope ->
|
||||||
|
showDeleteDialog = false
|
||||||
|
viewModel.deleteSelected(scope)
|
||||||
|
},
|
||||||
|
onDismiss = { showDeleteDialog = false },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDeleteDialog = false },
|
||||||
|
title = {
|
||||||
|
Text(pluralStringResource(R.plurals.search_delete_title, count, count))
|
||||||
|
},
|
||||||
|
text = { Text(stringResource(R.string.event_delete_body)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showDeleteDialog = false
|
||||||
|
viewModel.deleteSelected(RecurringWriteScope.AllEvents)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_detail_delete),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDeleteDialog = false }) {
|
||||||
|
Text(stringResource(R.string.dialog_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Contextual bar replacing the search field while results are picked. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun SelectionTopBar(
|
||||||
|
count: Int,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onSelectAll: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(pluralStringResource(R.plurals.search_selected_count, count, count)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.search_selection_close),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onSelectAll) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.SelectAll,
|
||||||
|
contentDescription = stringResource(R.string.search_select_all),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = stringResource(R.string.search_delete_selected),
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchResults(
|
private fun SearchResults(
|
||||||
events: List<EventInstance>,
|
results: SearchUiState.Results,
|
||||||
|
selection: Set<Long>,
|
||||||
|
inSelection: Boolean,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onToggle: (Long) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val events = results.events
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp),
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp),
|
||||||
@@ -179,11 +341,22 @@ private fun SearchResults(
|
|||||||
items = events,
|
items = events,
|
||||||
key = { _, event -> event.eventId },
|
key = { _, event -> event.eventId },
|
||||||
) { index, event ->
|
) { index, event ->
|
||||||
|
val deletable = results.isDeletable(event)
|
||||||
SearchResultRow(
|
SearchResultRow(
|
||||||
event = event,
|
event = event,
|
||||||
position = positionOf(index, events.size),
|
position = positionOf(index, events.size),
|
||||||
modifier = animateItemMotion(),
|
modifier = animateItemMotion(),
|
||||||
onClick = { onEventClick(event) },
|
selected = event.eventId in selection,
|
||||||
|
// A read-only calendar's row can't join a batch, so in selection
|
||||||
|
// mode it goes quiet rather than offering a checkbox that fails.
|
||||||
|
inSelection = inSelection,
|
||||||
|
selectable = deletable,
|
||||||
|
onClick = when {
|
||||||
|
inSelection && deletable -> ({ onToggle(event.eventId) })
|
||||||
|
inSelection -> null
|
||||||
|
else -> ({ onEventClick(event) })
|
||||||
|
},
|
||||||
|
onLongClick = { onToggle(event.eventId) }.takeIf { deletable && !inSelection },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,7 +367,11 @@ private fun SearchResultRow(
|
|||||||
event: EventInstance,
|
event: EventInstance,
|
||||||
position: Position,
|
position: Position,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit,
|
selected: Boolean = false,
|
||||||
|
inSelection: Boolean = false,
|
||||||
|
selectable: Boolean = true,
|
||||||
|
onClick: (() -> Unit)?,
|
||||||
|
onLongClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
@@ -204,6 +381,8 @@ private fun SearchResultRow(
|
|||||||
summary = searchSummary(event),
|
summary = searchSummary(event),
|
||||||
position = position,
|
position = position,
|
||||||
minHeight = 64.dp,
|
minHeight = 64.dp,
|
||||||
|
selected = selected,
|
||||||
|
dimmed = inSelection && !selectable,
|
||||||
leading = {
|
leading = {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -212,10 +391,69 @@ private fun SearchResultRow(
|
|||||||
.background(eventAccent(event.color, dark, soften)),
|
.background(eventAccent(event.color, dark, soften)),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
trailing = if (inSelection && selectable) {
|
||||||
|
{
|
||||||
|
Checkbox(
|
||||||
|
checked = selected,
|
||||||
|
onCheckedChange = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The batch's receipt: how many events went, and whether any refused. Sits on
|
||||||
|
* the FAB band so the shortened list stays visible behind it. Deletes aren't
|
||||||
|
* reversible through the provider, so this carries no Undo.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DeleteOutcomeChip(
|
||||||
|
deleteState: BulkDeleteUiState,
|
||||||
|
onConsume: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val message = when (deleteState) {
|
||||||
|
is BulkDeleteUiState.Done -> when {
|
||||||
|
deleteState.failed > 0 -> stringResource(
|
||||||
|
R.string.search_delete_partial,
|
||||||
|
deleteState.deleted,
|
||||||
|
deleteState.failed,
|
||||||
|
)
|
||||||
|
else -> pluralStringResource(
|
||||||
|
R.plurals.search_delete_done,
|
||||||
|
deleteState.deleted,
|
||||||
|
deleteState.deleted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
BulkDeleteUiState.NeedsPermission -> stringResource(R.string.event_delete_write_denied)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
// Held past the state being consumed so the chip has something to draw while
|
||||||
|
// it springs back out.
|
||||||
|
val shown = remember { mutableStateOf("") }
|
||||||
|
if (message != null && shown.value != message) shown.value = message
|
||||||
|
|
||||||
|
LaunchedEffect(deleteState) {
|
||||||
|
if (message == null) return@LaunchedEffect
|
||||||
|
delay(CHIP_MILLIS)
|
||||||
|
onConsume()
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(start = SnackChipMargin, bottom = SnackChipMargin)
|
||||||
|
.height(SnackChipHeight),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
SnackChip(visible = message != null, message = shown.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */
|
/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun searchSummary(event: EventInstance): String {
|
private fun searchSummary(event: EventInstance): String {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
@@ -14,12 +16,16 @@ import kotlinx.coroutines.flow.SharingStarted
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.mapLatest
|
import kotlinx.coroutines.flow.mapLatest
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -34,7 +40,31 @@ sealed interface SearchUiState {
|
|||||||
data class Empty(val query: String) : SearchUiState
|
data class Empty(val query: String) : SearchUiState
|
||||||
|
|
||||||
/** Matches, ordered nearest-to-today first (upcoming ascending, then past descending). */
|
/** Matches, ordered nearest-to-today first (upcoming ascending, then past descending). */
|
||||||
data class Results(val events: List<EventInstance>) : SearchUiState
|
data class Results(
|
||||||
|
val events: List<EventInstance>,
|
||||||
|
/**
|
||||||
|
* Calendars among the results that can't be written to (WebCal, birthday
|
||||||
|
* mirrors, …). Their rows are excluded from selection rather than failing
|
||||||
|
* at delete time.
|
||||||
|
*/
|
||||||
|
val readOnlyCalendarIds: Set<Long> = emptySet(),
|
||||||
|
) : SearchUiState {
|
||||||
|
fun isDeletable(event: EventInstance): Boolean =
|
||||||
|
event.calendarId !in readOnlyCalendarIds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome of deleting the selected results (#80). */
|
||||||
|
sealed interface BulkDeleteUiState {
|
||||||
|
data object Idle : BulkDeleteUiState
|
||||||
|
|
||||||
|
data object Deleting : BulkDeleteUiState
|
||||||
|
|
||||||
|
/** Terminal: [deleted] events went, [failed] wouldn't. */
|
||||||
|
data class Done(val deleted: Int, val failed: Int) : BulkDeleteUiState
|
||||||
|
|
||||||
|
/** WRITE_CALENDAR was revoked mid-flight; nothing further was attempted. */
|
||||||
|
data object NeedsPermission : BulkDeleteUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||||
@@ -47,17 +77,34 @@ class SearchViewModel @Inject constructor(
|
|||||||
private val _query = MutableStateFlow("")
|
private val _query = MutableStateFlow("")
|
||||||
val query: StateFlow<String> = _query.asStateFlow()
|
val query: StateFlow<String> = _query.asStateFlow()
|
||||||
|
|
||||||
val state: StateFlow<SearchUiState> = _query
|
/** Bumped after a delete so the same query re-runs against the changed provider. */
|
||||||
.debounce(250L)
|
private val _reload = MutableStateFlow(0)
|
||||||
.map { it.trim() }
|
|
||||||
.distinctUntilChanged()
|
private val _selection = MutableStateFlow<Set<Long>>(emptySet())
|
||||||
|
|
||||||
|
/** Event ids picked in selection mode; empty means the mode is off. */
|
||||||
|
val selection: StateFlow<Set<Long>> = _selection.asStateFlow()
|
||||||
|
|
||||||
|
private val _deleteState = MutableStateFlow<BulkDeleteUiState>(BulkDeleteUiState.Idle)
|
||||||
|
val deleteState: StateFlow<BulkDeleteUiState> = _deleteState.asStateFlow()
|
||||||
|
|
||||||
|
val state: StateFlow<SearchUiState> = combine(
|
||||||
|
_query.debounce(250L).map { it.trim() }.distinctUntilChanged(),
|
||||||
|
_reload,
|
||||||
|
) { q, _ -> q }
|
||||||
.mapLatest { q ->
|
.mapLatest { q ->
|
||||||
if (q.length < MIN_QUERY_LENGTH) {
|
if (q.length < MIN_QUERY_LENGTH) {
|
||||||
SearchUiState.Idle
|
SearchUiState.Idle
|
||||||
} else {
|
} else {
|
||||||
val results = repository.searchEvents(q)
|
val results = repository.searchEvents(q)
|
||||||
if (results.isEmpty()) SearchUiState.Empty(q)
|
if (results.isEmpty()) {
|
||||||
else SearchUiState.Results(sortNearestFirst(results))
|
SearchUiState.Empty(q)
|
||||||
|
} else {
|
||||||
|
SearchUiState.Results(
|
||||||
|
events = sortNearestFirst(results),
|
||||||
|
readOnlyCalendarIds = readOnlyCalendarIds(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch { emit(SearchUiState.Idle) }
|
.catch { emit(SearchUiState.Idle) }
|
||||||
@@ -65,9 +112,101 @@ class SearchViewModel @Inject constructor(
|
|||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
|
||||||
|
|
||||||
fun setQuery(value: String) {
|
fun setQuery(value: String) {
|
||||||
|
if (value != _query.value) _selection.value = emptySet()
|
||||||
_query.value = value
|
_query.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggle one result; the first pick is what turns selection mode on. */
|
||||||
|
fun toggleSelection(eventId: Long) {
|
||||||
|
val current = _selection.value
|
||||||
|
_selection.value = if (eventId in current) current - eventId else current + eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick every result that lives in a writable calendar. */
|
||||||
|
fun selectAll() {
|
||||||
|
val results = state.value as? SearchUiState.Results ?: return
|
||||||
|
_selection.value = results.events.filter(results::isDeletable).map { it.eventId }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leave selection mode. */
|
||||||
|
fun clearSelection() {
|
||||||
|
_selection.value = emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the current selection contains a recurring event, i.e. whether the
|
||||||
|
* batch needs a [RecurringWriteScope] decision before it can run.
|
||||||
|
*/
|
||||||
|
fun selectionHasRecurring(): Boolean {
|
||||||
|
val results = state.value as? SearchUiState.Results ?: return false
|
||||||
|
val picked = _selection.value
|
||||||
|
return results.events.any { it.eventId in picked && it.isRecurring }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete every selected event. [scope] applies to the recurring ones only —
|
||||||
|
* a one-off is always removed whole, whatever the batch decision was. Runs
|
||||||
|
* one event at a time so a single failure doesn't take the rest with it;
|
||||||
|
* the tally lands in [deleteState].
|
||||||
|
*/
|
||||||
|
fun deleteSelected(scope: RecurringWriteScope) {
|
||||||
|
if (_deleteState.value == BulkDeleteUiState.Deleting) return
|
||||||
|
val results = state.value as? SearchUiState.Results ?: return
|
||||||
|
val picked = _selection.value
|
||||||
|
val targets = results.events.filter { it.eventId in picked && results.isDeletable(it) }
|
||||||
|
if (targets.isEmpty()) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_deleteState.value = BulkDeleteUiState.Deleting
|
||||||
|
var deleted = 0
|
||||||
|
var failed = 0
|
||||||
|
var denied = false
|
||||||
|
for (event in targets) {
|
||||||
|
try {
|
||||||
|
withContext(io) { deleteOne(event, scope) }
|
||||||
|
deleted++
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
denied = true
|
||||||
|
break
|
||||||
|
} catch (e: Exception) {
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_selection.value = emptySet()
|
||||||
|
_reload.value += 1
|
||||||
|
_deleteState.value = if (denied) {
|
||||||
|
BulkDeleteUiState.NeedsPermission
|
||||||
|
} else {
|
||||||
|
BulkDeleteUiState.Done(deleted = deleted, failed = failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset [deleteState] after the screen showed the outcome. */
|
||||||
|
fun consumeDeleteResult() {
|
||||||
|
_deleteState.value = BulkDeleteUiState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun deleteOne(event: EventInstance, scope: RecurringWriteScope) {
|
||||||
|
val begin = event.start.toEpochMilliseconds()
|
||||||
|
when {
|
||||||
|
!event.isRecurring -> repository.deleteEvent(event.eventId)
|
||||||
|
scope == RecurringWriteScope.ThisEvent ->
|
||||||
|
repository.deleteOccurrence(event.eventId, begin)
|
||||||
|
scope == RecurringWriteScope.ThisAndFollowing ->
|
||||||
|
repository.deleteEventFromOccurrence(event.eventId, begin)
|
||||||
|
else -> repository.deleteEvent(event.eventId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun readOnlyCalendarIds(): Set<Long> =
|
||||||
|
repository.calendars().first()
|
||||||
|
.filterNot { it.canModifyContents }
|
||||||
|
.map { it.id }
|
||||||
|
.toSet()
|
||||||
|
|
||||||
/** Soonest upcoming (and ongoing) first, then the most recent past. */
|
/** Soonest upcoming (and ongoing) first, then the most recent past. */
|
||||||
private fun sortNearestFirst(events: List<EventInstance>): List<EventInstance> {
|
private fun sortNearestFirst(events: List<EventInstance>): List<EventInstance> {
|
||||||
val now = Clock.System.now()
|
val now = Clock.System.now()
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.getValue
|
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
|
||||||
@@ -62,13 +63,17 @@ 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
|
||||||
@@ -84,6 +89,20 @@ 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.BlockTimeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||||
|
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
|
||||||
@@ -104,8 +123,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.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
@@ -121,13 +142,10 @@ 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 {
|
||||||
@@ -331,32 +349,55 @@ private fun WeekContent(
|
|||||||
// gestures coexist without fighting.
|
// gestures coexist without fighting.
|
||||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||||
|
|
||||||
AnimatedContent(
|
// Above the AnimatedContent: a page change mid-drag would strand the
|
||||||
targetState = state,
|
// floating block inside the outgoing page.
|
||||||
modifier = modifier.then(swipeModifier),
|
val dragController = rememberTimelineDragController()
|
||||||
contentKey = { s ->
|
val move = LocalEventMove.current
|
||||||
|
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) {
|
||||||
is WeekUiState.Success -> "success-${s.weekStart}"
|
WeekUiState.Loading -> WeekLoading()
|
||||||
is WeekUiState.Failure -> "failure-${s.reason}"
|
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
WeekUiState.Loading -> "loading"
|
is WeekUiState.Success -> WeekSuccess(
|
||||||
|
state = s,
|
||||||
|
topSectionColor = topSectionColor,
|
||||||
|
scrollState = scrollState,
|
||||||
|
allDayHeight = allDayHeight,
|
||||||
|
dragController = dragController,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
|
onDrop = { drop ->
|
||||||
|
val took = move?.move(
|
||||||
|
MoveRequest(
|
||||||
|
eventId = drop.event.eventId,
|
||||||
|
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||||
|
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||||
|
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Refused, so nothing will land: let the copy go now
|
||||||
|
// rather than hold it out for a settle that never comes.
|
||||||
|
if (took != true) dragController.release()
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,9 +407,11 @@ 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(
|
||||||
@@ -385,8 +428,10 @@ 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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -622,13 +667,15 @@ 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
|
||||||
@@ -648,45 +695,39 @@ 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.
|
||||||
Column(
|
HourGutter(
|
||||||
modifier = Modifier
|
scrollState = scrollState,
|
||||||
.width(GUTTER_WIDTH)
|
hourHeight = hourHeight,
|
||||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
dragController = dragController,
|
||||||
.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)
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
// The scrolling content itself, so its root position
|
||||||
|
// already folds in the scroll offset.
|
||||||
|
.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(
|
||||||
@@ -695,8 +736,10 @@ 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(),
|
||||||
@@ -715,13 +758,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
|
||||||
|
// Tells a settled drop when this column has caught up with it.
|
||||||
|
LaunchedEffect(blocks, dragController.settling) {
|
||||||
|
dragController.noteGrid(date, blocks)
|
||||||
|
}
|
||||||
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.
|
||||||
@@ -748,23 +797,39 @@ private fun DayColumnCard(
|
|||||||
) {
|
) {
|
||||||
val colWidth = maxWidth
|
val colWidth = maxWidth
|
||||||
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
||||||
|
// Keyed by event, so a block that changes time or lane is the *same*
|
||||||
|
// composable afterwards and tweens there. The ordinal disambiguates
|
||||||
|
// a column holding two occurrences of one series.
|
||||||
|
val ordinals = mutableMapOf<Long, Int>()
|
||||||
blocks.forEach { block ->
|
blocks.forEach { block ->
|
||||||
val laneWidth = colWidth / block.laneCount
|
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
||||||
val top = hourHeight * (block.startMin / 60f)
|
key(block.event.eventId, ordinal) {
|
||||||
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
val laneWidth = colWidth / block.laneCount
|
||||||
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
val top = hourHeight * (block.startMin / 60f)
|
||||||
EventBlock(
|
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||||
block = block,
|
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||||
dark = dark,
|
val place = animatedBlockPlacement(
|
||||||
height = height,
|
x = laneWidth * block.lane,
|
||||||
width = laneWidth,
|
y = top,
|
||||||
onClick = { onEventClick(block.event) },
|
width = laneWidth,
|
||||||
modifier = Modifier
|
height = height,
|
||||||
.offset(x = laneWidth * block.lane, y = top)
|
)
|
||||||
.width(laneWidth)
|
EventBlock(
|
||||||
.height(height)
|
block = block,
|
||||||
.padding(horizontal = 1.dp),
|
dark = dark,
|
||||||
)
|
height = place.height,
|
||||||
|
width = place.width,
|
||||||
|
date = date,
|
||||||
|
dragController = dragController,
|
||||||
|
onClick = { onEventClick(block.event) },
|
||||||
|
onDrop = onDrop,
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(x = place.x, y = place.y)
|
||||||
|
.width(place.width)
|
||||||
|
.height(place.height)
|
||||||
|
.padding(horizontal = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Current-time line, on top of the events, only on today's column.
|
// Current-time line, on top of the events, only on today's column.
|
||||||
if (date == today) {
|
if (date == today) {
|
||||||
@@ -780,7 +845,10 @@ 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) }
|
||||||
@@ -823,12 +891,35 @@ 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.ghosts(block, date)
|
||||||
|
val ghost = ghostAlpha(lifted)
|
||||||
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 (ghost < 1f) Modifier.alpha(ghost) 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 { contentDescription = "$title, $timeLabel" },
|
.semantics {
|
||||||
|
contentDescription = "$title, $timeLabel"
|
||||||
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
if (showTitle) {
|
if (showTitle) {
|
||||||
@@ -841,11 +932,8 @@ private fun EventBlock(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
Text(
|
BlockTimeLabel(
|
||||||
text = timeLabel,
|
label = timeLabel,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
<string name="event_detail_share">Partager</string>
|
<string name="event_detail_share">Partager</string>
|
||||||
<string name="event_share_chooser_title">Evénement partagé</string>
|
<string name="event_share_chooser_title">Evénement partagé</string>
|
||||||
<string name="event_share_failed">Impossible de partager cet événement.</string>
|
<string name="event_share_failed">Impossible de partager cet événement.</string>
|
||||||
<string name="event_delete_title">Evénement supprimé ?</string>
|
<string name="event_delete_title">Evénement supprimé ?</string>
|
||||||
<string name="event_delete_body">Cet événement est retiré de votre calendrier et de chaque appareil auquel il est synchronisé.</string>
|
<string name="event_delete_body">Cet événement est retiré de votre calendrier et de chaque appareil auquel il est synchronisé.</string>
|
||||||
<string name="event_delete_recurring_title">Supprimer l\'événement récurrent</string>
|
<string name="event_delete_recurring_title">Supprimer l\'événement récurrent</string>
|
||||||
<string name="event_delete_option_occurrence">Seulement cet événement</string>
|
<string name="event_delete_option_occurrence">Seulement cet événement</string>
|
||||||
@@ -95,14 +95,14 @@
|
|||||||
<string name="event_edit_color_unsupported_hint">Ce calendrier ne propose aucun ensemble de couleurs. Vous pouvez autoriser des couleurs personnalisées pour ces calendriers dans les paramètres.</string>
|
<string name="event_edit_color_unsupported_hint">Ce calendrier ne propose aucun ensemble de couleurs. Vous pouvez autoriser des couleurs personnalisées pour ces calendriers dans les paramètres.</string>
|
||||||
<string name="event_edit_color_sync_warning">Ce calendrier pourrait retirer ou remplacer la couleur lors de sa prochaine synchronisation.</string>
|
<string name="event_edit_color_sync_warning">Ce calendrier pourrait retirer ou remplacer la couleur lors de sa prochaine synchronisation.</string>
|
||||||
<string name="event_edit_conflict_title">L\'événement a changé ailleurs</string>
|
<string name="event_edit_conflict_title">L\'événement a changé ailleurs</string>
|
||||||
<string name="event_edit_conflict_body">Durant l\'édition, cet événement a été altéré - par la synchronisation ou une autre application. Voulez-vous enregistrer ou annuler vos modifications ?</string>
|
<string name="event_edit_conflict_body">Durant l\'édition, cet événement a été altéré - par la synchronisation ou une autre application. Voulez-vous enregistrer ou annuler vos modifications ?</string>
|
||||||
<string name="event_edit_conflict_overwrite">Enregistrer mes modifications</string>
|
<string name="event_edit_conflict_overwrite">Enregistrer mes modifications</string>
|
||||||
<string name="event_edit_conflict_overwrite_hint">Seuls les champs que vous modifiez remplacent l\'altération externe</string>
|
<string name="event_edit_conflict_overwrite_hint">Seuls les champs que vous modifiez remplacent l\'altération externe</string>
|
||||||
<string name="event_edit_conflict_discard">Annuler mes modifications</string>
|
<string name="event_edit_conflict_discard">Annuler mes modifications</string>
|
||||||
<string name="event_edit_conflict_discard_hint">L’événement reste tel qu’il est maintenant</string>
|
<string name="event_edit_conflict_discard_hint">L’événement reste tel qu’il est maintenant</string>
|
||||||
<string name="event_edit_gone_title">Evénement supprimé</string>
|
<string name="event_edit_gone_title">Evénement supprimé</string>
|
||||||
<string name="event_edit_gone_body">Cet événement a été supprimé entre-temps, par exemple sur un autre appareil. Vos modifications ne peuvent plus être enregistrées.</string>
|
<string name="event_edit_gone_body">Cet événement a été supprimé entre-temps, par exemple sur un autre appareil. Vos modifications ne peuvent plus être enregistrées.</string>
|
||||||
<string name="import_reminder_prompt_title">Appliquer votre rappel par défaut ?</string>
|
<string name="import_reminder_prompt_title">Appliquer votre rappel par défaut ?</string>
|
||||||
<string name="import_reminder_prompt_body_none">Cet événement a été importé sans aucun rappel.</string>
|
<string name="import_reminder_prompt_body_none">Cet événement a été importé sans aucun rappel.</string>
|
||||||
<plurals name="import_reminder_prompt_body_existing">
|
<plurals name="import_reminder_prompt_body_existing">
|
||||||
<item quantity="one">Cet événement a été importé avec %1$d rappel.</item>
|
<item quantity="one">Cet événement a été importé avec %1$d rappel.</item>
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
<string name="event_access_confidential">Confidentiel</string>
|
<string name="event_access_confidential">Confidentiel</string>
|
||||||
<string name="event_attendee_organizer">Organisateur</string>
|
<string name="event_attendee_organizer">Organisateur</string>
|
||||||
<string name="event_attendee_resource">Ressource</string>
|
<string name="event_attendee_resource">Ressource</string>
|
||||||
<string name="event_detail_self_response">Votre réponse : %1$s</string>
|
<string name="event_detail_self_response">Votre réponse : %1$s</string>
|
||||||
<string name="reminder_default">Rappel par défaut</string>
|
<string name="reminder_default">Rappel par défaut</string>
|
||||||
<plurals name="reminder_minutes">
|
<plurals name="reminder_minutes">
|
||||||
<item quantity="one">%d minute avant</item>
|
<item quantity="one">%d minute avant</item>
|
||||||
@@ -283,7 +283,7 @@
|
|||||||
<string name="event_status_tentative">Provisoire</string>
|
<string name="event_status_tentative">Provisoire</string>
|
||||||
<string name="reminder_at_time">Au moment de l’événement</string>
|
<string name="reminder_at_time">Au moment de l’événement</string>
|
||||||
<string name="reminder_onboarding_body">Android n’affiche pas de rappels d’événements par lui-même — une application de calendrier doit le faire. Laissez Calendula faire ce travail.</string>
|
<string name="reminder_onboarding_body">Android n’affiche pas de rappels d’événements par lui-même — une application de calendrier doit le faire. Laissez Calendula faire ce travail.</string>
|
||||||
<string name="reminder_benefit_duplicates_title">Utiliser une deuxième application de calendrier ?</string>
|
<string name="reminder_benefit_duplicates_title">Utiliser une deuxième application de calendrier ?</string>
|
||||||
<string name="reminder_benefit_duplicates_body">Si une autre application publie également des rappels, vous les verrez deux fois, désactivez-les là ou ici.</string>
|
<string name="reminder_benefit_duplicates_body">Si une autre application publie également des rappels, vous les verrez deux fois, désactivez-les là ou ici.</string>
|
||||||
<string name="reminder_benefit_reversible_title">modifier à tout moment</string>
|
<string name="reminder_benefit_reversible_title">modifier à tout moment</string>
|
||||||
<string name="reminder_benefit_reversible_body">Le commutateur se trouve dans les paramètres, sous Notifications.</string>
|
<string name="reminder_benefit_reversible_body">Le commutateur se trouve dans les paramètres, sous Notifications.</string>
|
||||||
@@ -331,14 +331,14 @@
|
|||||||
<string name="settings_drawer_order_hint">Faites glisser pour réorganiser les vues répertoriées dans le menu de navigation.</string>
|
<string name="settings_drawer_order_hint">Faites glisser pour réorganiser les vues répertoriées dans le menu de navigation.</string>
|
||||||
<string name="reorder_drag_handle">Faites glisser pour réorganiser</string>
|
<string name="reorder_drag_handle">Faites glisser pour réorganiser</string>
|
||||||
<string name="settings_section_event_form">Nouveau formulaire d’événement</string>
|
<string name="settings_section_event_form">Nouveau formulaire d’événement</string>
|
||||||
<string name="settings_form_fields_hint">Champs affichés par défaut — tout le reste se trouve derrière « Plus de champs »</string>
|
<string name="settings_form_fields_hint">Champs affichés par défaut — tout le reste se trouve derrière « Plus de champs »</string>
|
||||||
<string name="settings_autofocus_title">Titre principal du nouvel événement</string>
|
<string name="settings_autofocus_title">Titre principal du nouvel événement</string>
|
||||||
<string name="settings_autofocus_title_hint">Lorsque vous démarrez un nouvel événement, placez le curseur dans le champ du titre et ouvrez immédiatement le clavier.</string>
|
<string name="settings_autofocus_title_hint">Lorsque vous démarrez un nouvel événement, placez le curseur dans le champ du titre et ouvrez immédiatement le clavier.</string>
|
||||||
<string name="settings_color_unsupported">Autoriser les couleurs dans les calendriers non pris en charge</string>
|
<string name="settings_color_unsupported">Autoriser les couleurs dans les calendriers non pris en charge</string>
|
||||||
<string name="settings_color_unsupported_hint">Certains calendriers (par exemple, certains CalDAV) ne publient aucun ensemble de couleurs, une couleur d’événement personnalisée peut être supprimée ou remplacée lors de leur prochaine synchronisation. C’est une limitation de ces calendriers, pas quelque chose que Calendula peut réparer.</string>
|
<string name="settings_color_unsupported_hint">Certains calendriers (par exemple, certains CalDAV) ne publient aucun ensemble de couleurs, une couleur d’événement personnalisée peut être supprimée ou remplacée lors de leur prochaine synchronisation. C’est une limitation de ces calendriers, pas quelque chose que Calendula peut réparer.</string>
|
||||||
<string name="settings_section_notifications">Notifications</string>
|
<string name="settings_section_notifications">Notifications</string>
|
||||||
<string name="settings_reminders">rappels d\'événements</string>
|
<string name="settings_reminders">rappels d\'événements</string>
|
||||||
<string name="settings_reminders_hint">Vous voyez des rappels deux fois ? Une autre application de calendrier les publie aussi — désactivez-les dans l’un des deux.</string>
|
<string name="settings_reminders_hint">Vous voyez des rappels deux fois ? Une autre application de calendrier les publie aussi — désactivez-les dans l’un des deux.</string>
|
||||||
<string name="settings_default_reminder">rappel par défaut</string>
|
<string name="settings_default_reminder">rappel par défaut</string>
|
||||||
<string name="settings_default_reminder_allday">événements d\'une journée entière</string>
|
<string name="settings_default_reminder_allday">événements d\'une journée entière</string>
|
||||||
<string name="settings_allday_reminder_time">Heure de rappel toute la journée</string>
|
<string name="settings_allday_reminder_time">Heure de rappel toute la journée</string>
|
||||||
@@ -383,9 +383,9 @@
|
|||||||
<string name="settings_calendar_reminders_managed_hint">Définir dans les dates spéciales de contact</string>
|
<string name="settings_calendar_reminders_managed_hint">Définir dans les dates spéciales de contact</string>
|
||||||
<string name="settings_special_dates_paused_title">suspendu</string>
|
<string name="settings_special_dates_paused_title">suspendu</string>
|
||||||
<string name="settings_special_dates_paused_hint">Calendula ne peut plus lire vos contacts, donc ces calendriers ne se mettent pas à jour.</string>
|
<string name="settings_special_dates_paused_hint">Calendula ne peut plus lire vos contacts, donc ces calendriers ne se mettent pas à jour.</string>
|
||||||
<string name="settings_special_dates_disable_title">Désactiver les dates de contact ?</string>
|
<string name="settings_special_dates_disable_title">Désactiver les dates de contact ?</string>
|
||||||
<string name="settings_special_dates_disable_all_message">Cela supprime les calendriers de contact et leurs événements. Tous les rappels ou notes que vous leur avez ajoutés seront perdus.</string>
|
<string name="settings_special_dates_disable_all_message">Cela supprime les calendriers de contact et leurs événements. Tous les rappels ou notes que vous leur avez ajoutés seront perdus.</string>
|
||||||
<string name="settings_special_dates_disable_type_message">Cela supprime le calendrier « %1$s » et ses événements. Tous les rappels ou notes que vous y avez ajoutés seront perdus.</string>
|
<string name="settings_special_dates_disable_type_message">Cela supprime le calendrier « %1$s » et ses événements. Tous les rappels ou notes que vous y avez ajoutés seront perdus.</string>
|
||||||
<string name="settings_special_dates_disable_confirm">désactiver</string>
|
<string name="settings_special_dates_disable_confirm">désactiver</string>
|
||||||
<string name="settings_section_about">A propos</string>
|
<string name="settings_section_about">A propos</string>
|
||||||
<string name="settings_license">Licence</string>
|
<string name="settings_license">Licence</string>
|
||||||
@@ -411,7 +411,7 @@
|
|||||||
<string name="calendars_edit_title">Editer un agenda</string>
|
<string name="calendars_edit_title">Editer un agenda</string>
|
||||||
<string name="calendars_name_label">Nom</string>
|
<string name="calendars_name_label">Nom</string>
|
||||||
<string name="calendars_description_hint">Ajouter une description</string>
|
<string name="calendars_description_hint">Ajouter une description</string>
|
||||||
<string name="calendars_delete_confirm_title">Supprimer un calendrier ?</string>
|
<string name="calendars_delete_confirm_title">Supprimer un calendrier ?</string>
|
||||||
<string name="calendars_delete_confirm_message">\"%1$s\" et tous ses événements seront définitivement retirés de cet appareil.</string>
|
<string name="calendars_delete_confirm_message">\"%1$s\" et tous ses événements seront définitivement retirés de cet appareil.</string>
|
||||||
<string name="calendars_write_error">Impossible de sauvegarder les changements.</string>
|
<string name="calendars_write_error">Impossible de sauvegarder les changements.</string>
|
||||||
<string name="calendars_backup_hint">Les calendriers locaux ne sont synchronisés nulle part, alors exportez-les vers un fichier .ics pour en garder une copie.</string>
|
<string name="calendars_backup_hint">Les calendriers locaux ne sont synchronisés nulle part, alors exportez-les vers un fichier .ics pour en garder une copie.</string>
|
||||||
@@ -429,8 +429,8 @@
|
|||||||
<string name="calendars_auto_backup_interval">Intervalle</string>
|
<string name="calendars_auto_backup_interval">Intervalle</string>
|
||||||
<string name="calendars_auto_backup_interval_min">Minimum 30 minutes.</string>
|
<string name="calendars_auto_backup_interval_min">Minimum 30 minutes.</string>
|
||||||
<string name="calendars_auto_backup_status_never">Pas encore de sauvegarde automatique</string>
|
<string name="calendars_auto_backup_status_never">Pas encore de sauvegarde automatique</string>
|
||||||
<string name="calendars_auto_backup_status_ok">Dernière sauvegarde : %1$s</string>
|
<string name="calendars_auto_backup_status_ok">Dernière sauvegarde : %1$s</string>
|
||||||
<string name="calendars_auto_backup_status_failed">La dernière sauvegarde a échoué : %1$s</string>
|
<string name="calendars_auto_backup_status_failed">La dernière sauvegarde a échoué : %1$s</string>
|
||||||
<string name="backup_channel_description">Avertit si les sauvegardes automatiques échouent de manière répétée.</string>
|
<string name="backup_channel_description">Avertit si les sauvegardes automatiques échouent de manière répétée.</string>
|
||||||
<string name="backup_failed_title">La sauvegarde automatique a échoué</string>
|
<string name="backup_failed_title">La sauvegarde automatique a échoué</string>
|
||||||
<string name="backup_failed_text">Calendula n’a pas pu écrire le fichier de sauvegarde. Vérifiez le dossier de sauvegarde dans les paramètres.</string>
|
<string name="backup_failed_text">Calendula n’a pas pu écrire le fichier de sauvegarde. Vérifiez le dossier de sauvegarde dans les paramètres.</string>
|
||||||
@@ -481,7 +481,7 @@
|
|||||||
</plurals>
|
</plurals>
|
||||||
<string name="shortcut_new_event_long">créer un événement</string>
|
<string name="shortcut_new_event_long">créer un événement</string>
|
||||||
<string name="settings_qs_tile">Ajouter des paramètres rapides</string>
|
<string name="settings_qs_tile">Ajouter des paramètres rapides</string>
|
||||||
<string name="settings_qs_tile_hint">Ajoutez un bouton « Nouvel événement » au panneau Paramètres rapides.</string>
|
<string name="settings_qs_tile_hint">Ajoutez un bouton « Nouvel événement » au panneau Paramètres rapides.</string>
|
||||||
<string name="crash_dialog_title">%1$s a planté</string>
|
<string name="crash_dialog_title">%1$s a planté</string>
|
||||||
<string name="crash_dialog_message">%1$s a été fermé de manière inattendue la dernière fois. Vous pouvez aider à le corriger en envoyant ce rapport en tant que problème. Il reste sur votre appareil jusqu’à ce que vous choisissiez de le partager, et n’inclut aucune donnée personnelle ni contenu de calendrier — seuls les détails techniques ci-dessous.</string>
|
<string name="crash_dialog_message">%1$s a été fermé de manière inattendue la dernière fois. Vous pouvez aider à le corriger en envoyant ce rapport en tant que problème. Il reste sur votre appareil jusqu’à ce que vous choisissiez de le partager, et n’inclut aucune donnée personnelle ni contenu de calendrier — seuls les détails techniques ci-dessous.</string>
|
||||||
<string name="crash_dialog_report">rapport</string>
|
<string name="crash_dialog_report">rapport</string>
|
||||||
|
|||||||
@@ -65,6 +65,20 @@
|
|||||||
<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">Moving the whole series would change which days it falls on, 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>
|
||||||
|
|
||||||
@@ -304,6 +318,24 @@
|
|||||||
<string name="search_idle_hint">Search your events by title, location or notes.</string>
|
<string name="search_idle_hint">Search your events by title, location or notes.</string>
|
||||||
<string name="search_empty">No events match “%1$s”.</string>
|
<string name="search_empty">No events match “%1$s”.</string>
|
||||||
|
|
||||||
|
<!-- Selecting search results to delete several at once (#80) -->
|
||||||
|
<plurals name="search_selected_count">
|
||||||
|
<item quantity="one">%d selected</item>
|
||||||
|
<item quantity="other">%d selected</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="search_selection_close">Cancel selection</string>
|
||||||
|
<string name="search_select_all">Select all</string>
|
||||||
|
<string name="search_delete_selected">Delete selected</string>
|
||||||
|
<plurals name="search_delete_title">
|
||||||
|
<item quantity="one">Delete %d event?</item>
|
||||||
|
<item quantity="other">Delete %d events?</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="search_delete_done">
|
||||||
|
<item quantity="one">%d event deleted</item>
|
||||||
|
<item quantity="other">%d events deleted</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="search_delete_partial">%1$d deleted, %2$d couldn\'t be</string>
|
||||||
|
|
||||||
<!-- Home-screen widgets -->
|
<!-- Home-screen widgets -->
|
||||||
<string name="widget_agenda_title">Upcoming</string>
|
<string name="widget_agenda_title">Upcoming</string>
|
||||||
<string name="widget_agenda_label">Calendula agenda</string>
|
<string name="widget_agenda_label">Calendula agenda</string>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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
|
||||||
@@ -285,6 +286,88 @@ 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, while RRULE is
|
||||||
|
// written verbatim: without realignRecurrence 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")
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ class SearchMapperTest {
|
|||||||
eventColor: Any? = null,
|
eventColor: Any? = null,
|
||||||
calendarColor: Int = 0xFFAABBCC.toInt(),
|
calendarColor: Int = 0xFFAABBCC.toInt(),
|
||||||
location: String? = null,
|
location: String? = null,
|
||||||
|
rrule: String? = null,
|
||||||
|
rdate: String? = null,
|
||||||
): MapColumnReader = MapColumnReader(
|
): MapColumnReader = MapColumnReader(
|
||||||
|
SearchProjection.IDX_RRULE to rrule,
|
||||||
|
SearchProjection.IDX_RDATE to rdate,
|
||||||
SearchProjection.IDX_ID to id,
|
SearchProjection.IDX_ID to id,
|
||||||
SearchProjection.IDX_CALENDAR_ID to calendarId,
|
SearchProjection.IDX_CALENDAR_ID to calendarId,
|
||||||
SearchProjection.IDX_TITLE to title,
|
SearchProjection.IDX_TITLE to title,
|
||||||
@@ -42,4 +46,16 @@ class SearchMapperTest {
|
|||||||
fun `absent dtstart drops the search hit`() {
|
fun `absent dtstart drops the search hit`() {
|
||||||
assertThat(searchReader(dtstart = null).toSearchResult()).isNull()
|
assertThat(searchReader(dtstart = null).toSearchResult()).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a rule or an rdate marks the hit recurring (issue #80)`() {
|
||||||
|
assertThat(searchReader().toSearchResult()!!.isRecurring).isFalse()
|
||||||
|
assertThat(searchReader(rrule = "").toSearchResult()!!.isRecurring).isFalse()
|
||||||
|
assertThat(
|
||||||
|
searchReader(rrule = "FREQ=WEEKLY").toSearchResult()!!.isRecurring,
|
||||||
|
).isTrue()
|
||||||
|
assertThat(
|
||||||
|
searchReader(rdate = "20260101T000000Z").toSearchResult()!!.isRecurring,
|
||||||
|
).isTrue()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,26 @@ 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`() {
|
||||||
|
// The predicate is deliberately not isEventTarget.
|
||||||
|
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(
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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: 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, onto the Sunday Berlin
|
||||||
|
// springs forward on. Keeping wall clock would write five.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
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.
|
||||||
|
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`() {
|
||||||
|
// 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 go unchecked.
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
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 the device zone — the zone the drop path
|
||||||
|
// reads dates back in. The BYDAY assertions depend on it 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,
|
||||||
|
anchorMillis: Long = beginMillis,
|
||||||
|
): EventDetail = EventDetail(
|
||||||
|
instance = EventInstance(
|
||||||
|
instanceId = 42L, eventId = 42L, calendarId = 1L, title = "Standup",
|
||||||
|
start = Instant.fromEpochMilliseconds(anchorMillis),
|
||||||
|
end = Instant.fromEpochMilliseconds(anchorMillis + (endMillis - beginMillis)),
|
||||||
|
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 `a time-only drop that would carry the anchor past midnight offers only the occurrence`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
// The anchor sits at 23:30 while the dragged occurrence is at midday —
|
||||||
|
// only possible when the row pins no zone, so the two resolve an hour
|
||||||
|
// apart. +1h leaves the occurrence on its day but rolls the anchor onto
|
||||||
|
// the next one, which would leave BYDAY naming the wrong weekday.
|
||||||
|
val anchor = LocalDateTime(LocalDate(2026, 6, 1), LocalTime(23, 30))
|
||||||
|
.toInstant(TimeZone.currentSystemDefault())
|
||||||
|
.toEpochMilliseconds()
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO", anchorMillis = anchor) }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
|
||||||
|
vm.move(oneHourLater())
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a second drop is refused while the first is still in flight`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
|
||||||
|
assertThat(vm.move(toWednesday())).isTrue()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Parked on the scope dialog, so the first drop still owns the pipeline.
|
||||||
|
assertThat(vm.move(oneHourLater())).isFalse()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(fake.updatedEvents).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `undo is refused while a write is running`(@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!!
|
||||||
|
|
||||||
|
// A second drop parks on its scope dialog, which holds the pipeline.
|
||||||
|
fake.eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
|
||||||
|
vm.move(toWednesday())
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(vm.undo(undo)).isFalse()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(fake.updatedEvents).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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()
|
||||||
|
// An exception row is constrained by no UNTIL, so the scope stays 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: a late enough anchor would
|
||||||
|
// cross two midnights under the same wall-clock shift.
|
||||||
|
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,6 +27,14 @@ 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")
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.search
|
||||||
|
|
||||||
|
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.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 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selecting search results and deleting the batch (#80): which repository call
|
||||||
|
* each kind of hit routes to, and what a read-only calendar is allowed to join.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class SearchViewModelTest {
|
||||||
|
|
||||||
|
private val dispatcher = UnconfinedTestDispatcher()
|
||||||
|
|
||||||
|
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
|
||||||
|
@AfterEach fun tearDown() = Dispatchers.resetMain()
|
||||||
|
|
||||||
|
private val begin = 1_800_000_000_000L
|
||||||
|
|
||||||
|
private fun cal(id: Long, canModify: Boolean = true) = CalendarSource(
|
||||||
|
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
|
||||||
|
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = canModify,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun hit(
|
||||||
|
id: Long,
|
||||||
|
calendarId: Long = 1L,
|
||||||
|
recurring: Boolean = false,
|
||||||
|
startMillis: Long = begin,
|
||||||
|
) = EventInstance(
|
||||||
|
instanceId = id, eventId = id, calendarId = calendarId, title = "Standup $id",
|
||||||
|
start = Instant.fromEpochMilliseconds(startMillis),
|
||||||
|
end = Instant.fromEpochMilliseconds(startMillis + 3_600_000L),
|
||||||
|
isAllDay = false, color = 0xFF000000.toInt(), location = null, isRecurring = recurring,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): SearchViewModel {
|
||||||
|
val prefs = CalendarPrefs(
|
||||||
|
PreferenceDataStoreFactory.create(
|
||||||
|
scope = CoroutineScope(dispatcher),
|
||||||
|
produceFile = { tempDir.resolve("search_prefs.preferences_pb").toFile() },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val settings = SettingsPrefs(
|
||||||
|
PreferenceDataStoreFactory.create(
|
||||||
|
scope = CoroutineScope(dispatcher),
|
||||||
|
produceFile = { tempDir.resolve("search_settings.preferences_pb").toFile() },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher)
|
||||||
|
return SearchViewModel(repo, dispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.activate(vm: SearchViewModel): Job = launch { vm.state.collect {} }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a batch of one-offs deletes each event whole`(@TempDir tempDir: Path) =
|
||||||
|
runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
calendarsResult = listOf(cal(1L))
|
||||||
|
searchResult = { listOf(hit(1L), hit(2L), hit(3L)) }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
val job = activate(vm)
|
||||||
|
|
||||||
|
vm.setQuery("standup")
|
||||||
|
advanceUntilIdle()
|
||||||
|
vm.toggleSelection(1L)
|
||||||
|
vm.toggleSelection(3L)
|
||||||
|
assertThat(vm.selectionHasRecurring()).isFalse()
|
||||||
|
|
||||||
|
vm.deleteSelected(RecurringWriteScope.AllEvents)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(fake.deletedEventIds).containsExactly(1L, 3L)
|
||||||
|
assertThat(fake.deletedOccurrences).isEmpty()
|
||||||
|
assertThat(vm.selection.value).isEmpty()
|
||||||
|
assertThat(vm.deleteState.value).isEqualTo(BulkDeleteUiState.Done(deleted = 2, failed = 0))
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the batch scope reaches the recurring hits only`(@TempDir tempDir: Path) =
|
||||||
|
runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
calendarsResult = listOf(cal(1L))
|
||||||
|
searchResult = { listOf(hit(1L), hit(2L, recurring = true)) }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
val job = activate(vm)
|
||||||
|
|
||||||
|
vm.setQuery("standup")
|
||||||
|
advanceUntilIdle()
|
||||||
|
vm.selectAll()
|
||||||
|
assertThat(vm.selectionHasRecurring()).isTrue()
|
||||||
|
|
||||||
|
vm.deleteSelected(RecurringWriteScope.ThisEvent)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// The one-off goes whole whatever the batch decided; only the series
|
||||||
|
// sees the scope, cancelling the occurrence the result row stands for.
|
||||||
|
assertThat(fake.deletedEventIds).containsExactly(1L)
|
||||||
|
assertThat(fake.deletedOccurrences).containsExactly(2L to begin)
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `this-and-following truncates the recurring hit from its shown occurrence`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
calendarsResult = listOf(cal(1L))
|
||||||
|
searchResult = { listOf(hit(9L, recurring = true)) }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
val job = activate(vm)
|
||||||
|
|
||||||
|
vm.setQuery("standup")
|
||||||
|
advanceUntilIdle()
|
||||||
|
vm.toggleSelection(9L)
|
||||||
|
vm.deleteSelected(RecurringWriteScope.ThisAndFollowing)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(fake.deletedFromOccurrences).containsExactly(9L to begin)
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a read-only calendar's hit cannot be selected or deleted`(@TempDir tempDir: Path) =
|
||||||
|
runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
calendarsResult = listOf(cal(1L), cal(2L, canModify = false))
|
||||||
|
searchResult = { listOf(hit(1L, calendarId = 1L), hit(2L, calendarId = 2L)) }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
val job = activate(vm)
|
||||||
|
|
||||||
|
vm.setQuery("standup")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val results = vm.state.value as SearchUiState.Results
|
||||||
|
assertThat(results.isDeletable(results.events.single { it.eventId == 2L })).isFalse()
|
||||||
|
|
||||||
|
vm.selectAll()
|
||||||
|
assertThat(vm.selection.value).containsExactly(1L)
|
||||||
|
|
||||||
|
// Even a hand-toggled read-only row is dropped before the write.
|
||||||
|
vm.toggleSelection(2L)
|
||||||
|
vm.deleteSelected(RecurringWriteScope.AllEvents)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(fake.deletedEventIds).containsExactly(1L)
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a failing delete leaves the rest of the batch alone`(@TempDir tempDir: Path) =
|
||||||
|
runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
calendarsResult = listOf(cal(1L))
|
||||||
|
searchResult = { listOf(hit(1L), hit(2L)) }
|
||||||
|
writeError = IllegalStateException("provider said no")
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
val job = activate(vm)
|
||||||
|
|
||||||
|
vm.setQuery("standup")
|
||||||
|
advanceUntilIdle()
|
||||||
|
vm.selectAll()
|
||||||
|
vm.deleteSelected(RecurringWriteScope.AllEvents)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(vm.deleteState.value)
|
||||||
|
.isEqualTo(BulkDeleteUiState.Done(deleted = 0, failed = 2))
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `changing the query drops the selection`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
calendarsResult = listOf(cal(1L))
|
||||||
|
searchResult = { listOf(hit(1L)) }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
val job = activate(vm)
|
||||||
|
|
||||||
|
vm.setQuery("standup")
|
||||||
|
advanceUntilIdle()
|
||||||
|
vm.toggleSelection(1L)
|
||||||
|
assertThat(vm.selection.value).isNotEmpty()
|
||||||
|
|
||||||
|
vm.setQuery("retro")
|
||||||
|
assertThat(vm.selection.value).isEmpty()
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,6 +108,83 @@ 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 `buildEventUpdateValues` moves that anchor by the same
|
||||||
|
*wall-clock* shift. So the realigner only touches weekday, which is uniform mod
|
||||||
|
7 and therefore survives that shift whatever time of day the anchor sits at.
|
||||||
|
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.
|
||||||
|
|
||||||
|
Uniform mod 7 is not enough on its own: the anchor also has to cross the *same
|
||||||
|
number of midnights* as the occurrence did, or the rebuilt weekday is off by a
|
||||||
|
day. `RescheduleViewModel` requires that of every write wider than one
|
||||||
|
occurrence, in the two shapes a drag comes in:
|
||||||
|
|
||||||
|
- a drag that **changes the day** must be a whole number of days, so the two
|
||||||
|
move in step whatever the anchor's time of day;
|
||||||
|
- a **time-only** drag must leave the anchor on its own day. That is normally
|
||||||
|
free, since the anchor shares the occurrence's time of day — but a row with
|
||||||
|
no `EVENT_TIMEZONE` resolves anchor and occurrence in zones that can sit a
|
||||||
|
DST hour apart, and a near-midnight drag would then carry the anchor across a
|
||||||
|
midnight the occurrence never crossed.
|
||||||
|
|
||||||
|
Whatever falls outside — a rule the realigner won't rebuild, or a shift that
|
||||||
|
would move rule and anchor out of step — 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.
|
||||||
|
|
||||||
|
**One drop is written at a time.** Two drops of the same recurring event landing
|
||||||
|
inside one write window would each compute their shift from the same pre-move
|
||||||
|
occurrence, while the data layer applies both to the re-read anchor — so the
|
||||||
|
shifts would compound. `RescheduleViewModel.move` therefore refuses while a write
|
||||||
|
(or its scope dialog) is outstanding, and says so in its return value: the view
|
||||||
|
that took the drop releases the block it was holding on the target instead of
|
||||||
|
waiting out a settle that will never arrive.
|
||||||
|
|
||||||
|
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
|
||||||
@@ -144,6 +221,22 @@ 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 on the confirmation chip
|
||||||
|
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
|
||||||
|
|||||||
Submodule floret-kit updated: ed1d3ca5e8...b9475688a8
Reference in New Issue
Block a user