Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cb2f62627 | ||
|
|
427e87c6e6 |
@@ -15,7 +15,7 @@ jobs:
|
||||
detect:
|
||||
# Gitea only; see the same guard in release.yaml.
|
||||
if: github.repository_owner == 'makiolaj'
|
||||
runs-on: docker
|
||||
runs-on: light
|
||||
outputs:
|
||||
is_beta: ${{ steps.v.outputs.is_beta }}
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
beta:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.is_beta == 'true'
|
||||
runs-on: docker
|
||||
runs-on: gradle
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
# secrets. repository_owner differs between the two forges regardless of
|
||||
# URL, proxy or instance rename, so this closes it permanently.
|
||||
if: github.repository_owner == 'makiolaj'
|
||||
runs-on: docker
|
||||
runs-on: light
|
||||
outputs:
|
||||
is_release: ${{ steps.v.outputs.is_release }}
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
release:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: docker
|
||||
runs-on: gradle
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
|
||||
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
- **Long press an event to select it.** In the day, 3-day and week views, a long press lifts the event and selects it. Keep your finger down and move to drag it straight away. A selected event has handles at its top and bottom: drag them to change when it starts or ends, in 15-minute steps and across midnight if you want. The bar at the top edits, duplicates, shares or deletes it, and tapping elsewhere or going back deselects it. Read-only events can be selected but not moved or resized ([#141]).
|
||||
- **Long press an empty slot to draft an event.** In the day, 3-day and week views, a long press on free time drops a block of your default event length there. Move it while still holding, or let go and drag it or its handles into place, then tap the tick to open a new event at that time. The ✕, a tap elsewhere or going back throws the draft away ([#153]).
|
||||
- **Scroll back to earlier days in the agenda.** Scroll up past today to see earlier days' events, loaded a month at a time. Past days follow the past-events setting: Dim fades them, and with Hide the agenda still starts at today ([#367]).
|
||||
|
||||
### Fixed
|
||||
@@ -1859,3 +1860,4 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#367]: https://codeberg.org/jlmakiola/calendula/issues/367
|
||||
[#373]: https://codeberg.org/jlmakiola/calendula/issues/373
|
||||
[#141]: https://codeberg.org/jlmakiola/calendula/issues/141
|
||||
[#153]: https://codeberg.org/jlmakiola/calendula/issues/153
|
||||
|
||||
@@ -32,8 +32,8 @@ android {
|
||||
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
|
||||
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
|
||||
// See docs/RELEASING.md.
|
||||
versionCode = 2230002
|
||||
versionName = "2.23.0-beta.2"
|
||||
versionCode = 2230003
|
||||
versionName = "2.23.0-beta.3"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -239,12 +239,18 @@ fun CalendarHost(
|
||||
var heldCreateIso by remember { mutableStateOf<String?>(null) }
|
||||
var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
|
||||
var heldCreateMinutes by remember { mutableStateOf<Int?>(null) }
|
||||
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes ->
|
||||
// A drafted block's end (#153), in minutes from the start day's midnight.
|
||||
var createEndMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
|
||||
var heldCreateEndMinutes by remember { mutableStateOf<Int?>(null) }
|
||||
val onCreateSpan: (LocalDate, Int?, Int?) -> Unit = { date, startMinutes, endMinutes ->
|
||||
heldCreateIso = date.toString()
|
||||
createDateIso = date.toString()
|
||||
heldCreateMinutes = startMinutes
|
||||
createStartMinutes = startMinutes
|
||||
heldCreateEndMinutes = endMinutes
|
||||
createEndMinutes = endMinutes
|
||||
}
|
||||
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> onCreateSpan(date, startMinutes, null) }
|
||||
|
||||
// Edit form (v1.3) — reuses the detail screen's occurrence key; for
|
||||
// recurring events the form itself asks for the write scope at save
|
||||
@@ -380,6 +386,8 @@ fun CalendarHost(
|
||||
createDateIso = iso
|
||||
heldCreateMinutes = null
|
||||
createStartMinutes = null
|
||||
heldCreateEndMinutes = null
|
||||
createEndMinutes = null
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
null -> {}
|
||||
@@ -583,6 +591,7 @@ fun CalendarHost(
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
onCreateSpan = onCreateSpan,
|
||||
)
|
||||
ViewScreen.Month -> MonthScreen(
|
||||
onOpenDay = onOpenDay,
|
||||
@@ -671,6 +680,7 @@ fun CalendarHost(
|
||||
EventEditScreen(
|
||||
initialDateIso = iso,
|
||||
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
||||
initialEndMinutes = createEndMinutes ?: heldCreateEndMinutes,
|
||||
onClose = { createDateIso = null },
|
||||
onSaved = { createDateIso = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
|
||||
@@ -278,12 +278,23 @@ data class BlockPlacement(val xFraction: Float, val widthFraction: Float, val y:
|
||||
* 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.
|
||||
* A [landing] block, under a dropped copy, moves without the spring's bounce.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement {
|
||||
fun animatedBlockPlacement(
|
||||
xFraction: Float,
|
||||
widthFraction: Float,
|
||||
y: Dp,
|
||||
height: Dp,
|
||||
landing: Boolean = false,
|
||||
): BlockPlacement {
|
||||
val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
|
||||
val dpSpec: FiniteAnimationSpec<Dp> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
val dpSpec: FiniteAnimationSpec<Dp> = when {
|
||||
still -> snap()
|
||||
landing -> MaterialTheme.motionScheme.fastEffectsSpec()
|
||||
else -> MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
}
|
||||
val shareSpec: FiniteAnimationSpec<Float> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x")
|
||||
val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width")
|
||||
|
||||
@@ -69,9 +69,15 @@ class ScreenActions(
|
||||
/**
|
||||
* An event a long press left selected (#141), which the host answers with the
|
||||
* action bar in place of the FAB. [dismiss] clears it on the screen that owns it.
|
||||
* [create] is set for a block drafted on an empty slot (#153), which has no
|
||||
* event yet, so the bar offers to create one.
|
||||
*/
|
||||
@Stable
|
||||
class EventSelection(val event: EventInstance, val dismiss: () -> Unit)
|
||||
class EventSelection(
|
||||
val event: EventInstance,
|
||||
val dismiss: () -> Unit,
|
||||
val create: (() -> Unit)? = null,
|
||||
)
|
||||
|
||||
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
|
||||
@Stable
|
||||
@@ -114,10 +120,26 @@ fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
|
||||
|
||||
/** Hands the screen's selected [event] to the host, for as long as it stays selected. */
|
||||
@Composable
|
||||
fun PublishSelection(event: EventInstance?, busy: Boolean, compact: Boolean, onDismiss: () -> Unit) {
|
||||
fun PublishSelection(
|
||||
event: EventInstance?,
|
||||
busy: Boolean,
|
||||
compact: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onCreate: (() -> Unit)? = null,
|
||||
) {
|
||||
val slot = LocalChromeSlot.current ?: return
|
||||
val currentDismiss by rememberUpdatedState(onDismiss)
|
||||
val selection = remember(event) { event?.let { EventSelection(it) { currentDismiss() } } }
|
||||
val currentCreate by rememberUpdatedState(onCreate)
|
||||
val drafted = onCreate != null
|
||||
val selection = remember(event, drafted) {
|
||||
event?.let {
|
||||
EventSelection(
|
||||
event = it,
|
||||
dismiss = { currentDismiss() },
|
||||
create = if (drafted) ({ currentCreate?.invoke() }) else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
val host = slot.host
|
||||
val current = slot.current
|
||||
DisposableEffect(host, selection, current) {
|
||||
|
||||
@@ -73,7 +73,8 @@ fun rememberEventDragSource(
|
||||
* 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.
|
||||
* whether anything is there; false abandons the gesture. [skipClaimedDown] leaves
|
||||
* a press a descendant already consumed alone.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberDragSurface(
|
||||
@@ -83,6 +84,8 @@ fun rememberDragSurface(
|
||||
onMove: (pointerInRoot: Offset) -> Unit,
|
||||
onDrop: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onRelease: (() -> Unit)? = null,
|
||||
skipClaimedDown: Boolean = false,
|
||||
): Modifier = rememberPickUpSurface(
|
||||
enabled = enabled,
|
||||
key = key,
|
||||
@@ -93,7 +96,8 @@ fun rememberDragSurface(
|
||||
onMove = onMove,
|
||||
onDrop = onDrop,
|
||||
onCancel = onCancel,
|
||||
onRelease = null,
|
||||
onRelease = onRelease,
|
||||
skipClaimedDown = skipClaimedDown,
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -106,6 +110,7 @@ private fun rememberPickUpSurface(
|
||||
onDrop: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onRelease: (() -> Unit)?,
|
||||
skipClaimedDown: Boolean = false,
|
||||
): Modifier {
|
||||
if (!enabled) return Modifier
|
||||
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
|
||||
@@ -125,6 +130,8 @@ private fun rememberPickUpSurface(
|
||||
val slop = viewConfiguration.touchSlop
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
// A child that took the press, such as a resize handle, keeps it.
|
||||
if (skipClaimedDown && down.isConsumed) return@awaitEachGesture
|
||||
val origin = down.position
|
||||
val immediate = currentImmediate
|
||||
// Null: held still. True: an immediate pickup past slop. False: abandoned.
|
||||
|
||||
@@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
@@ -52,6 +54,26 @@ fun EventSelectionBar(
|
||||
}
|
||||
}
|
||||
|
||||
/** A drafted block's bar (#153): create the event, or let the draft go. */
|
||||
@Composable
|
||||
fun DraftSelectionBar(onCreate: () -> Unit, onDiscard: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
shadowElevation = 6.dp,
|
||||
modifier = modifier.height(FabSize),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
) {
|
||||
BarAction(Icons.Default.Close, R.string.event_draft_discard, onDiscard)
|
||||
BarAction(Icons.Default.Check, R.string.event_draft_create, onCreate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BarAction(icon: ImageVector, label: Int, onClick: () -> Unit) {
|
||||
IconButton(onClick = onClick) {
|
||||
|
||||
@@ -47,6 +47,23 @@ fun rememberFabSelection(
|
||||
val currentWrite by rememberUpdatedState(requireWrite)
|
||||
|
||||
val selection = host.selection ?: return null
|
||||
selection.create?.let { create ->
|
||||
return remember(selection, expanded, chipShown) {
|
||||
FabSelection(
|
||||
expanded = expanded,
|
||||
onToggle = {
|
||||
if (chipShown) currentDismissChip()
|
||||
chosen = !expanded
|
||||
},
|
||||
bar = {
|
||||
DraftSelectionBar(
|
||||
onCreate = { currentWrite { create() } },
|
||||
onDiscard = selection.dismiss,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
val event = selection.event
|
||||
val calendar = calendars[event.calendarId]
|
||||
val canEdit = calendar?.canModifyContents == true
|
||||
|
||||
@@ -195,15 +195,20 @@ class OutOfViewEdges internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a press at [y] (content px) lands on an edge bar (#153). */
|
||||
fun covers(y: Float): Boolean = edgeAt(y) != null
|
||||
|
||||
private fun edgeAt(y: Float) = tappedEdge(
|
||||
events = events,
|
||||
y = y,
|
||||
viewportTop = scrollState.value.toFloat(),
|
||||
viewportPx = scrollState.viewportSize,
|
||||
reachPx = reachPx,
|
||||
)
|
||||
|
||||
/** Reveals the hidden event if a tap at [y] (content px) lands on a bar. */
|
||||
fun tap(y: Float): Boolean {
|
||||
val edge = tappedEdge(
|
||||
events = events,
|
||||
y = y,
|
||||
viewportTop = scrollState.value.toFloat(),
|
||||
viewportPx = scrollState.viewportSize,
|
||||
reachPx = reachPx,
|
||||
) ?: return false
|
||||
val edge = edgeAt(y) ?: return false
|
||||
reveal(edge)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -40,7 +40,9 @@ fun SelectionTopBar(selection: EventSelection) {
|
||||
IconButton(onClick = selection.dismiss) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.event_selection_close),
|
||||
contentDescription = stringResource(
|
||||
if (selection.create != null) R.string.event_draft_discard else R.string.event_selection_close,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -44,6 +44,7 @@ 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.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
@@ -61,6 +62,7 @@ import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Start granularity a dragged block snaps to, matching the big calendar apps. */
|
||||
@@ -269,6 +271,41 @@ data class TimelineDrop(
|
||||
val clipOffsetMin: Int = 0,
|
||||
)
|
||||
|
||||
/** The id a drafted block's stand-in event carries, which no provider row has (#153). */
|
||||
private const val DRAFT_ID = Long.MIN_VALUE
|
||||
|
||||
/** Whether this is the stand-in for a block drafted on an empty slot, not a real event. */
|
||||
val EventInstance.isDraft: Boolean get() = instanceId == DRAFT_ID
|
||||
|
||||
/** A drafted block's stand-in event, [minutes] long from [start]. */
|
||||
fun draftEvent(title: String, color: Int, start: Instant, minutes: Int): EventInstance = EventInstance(
|
||||
instanceId = DRAFT_ID,
|
||||
eventId = DRAFT_ID,
|
||||
calendarId = DRAFT_ID,
|
||||
title = title,
|
||||
start = start,
|
||||
end = start + minutes.minutes,
|
||||
isAllDay = false,
|
||||
color = color,
|
||||
location = null,
|
||||
)
|
||||
|
||||
/** Where a long press [minutes] down the column starts its draft, snapped down to the slot. */
|
||||
internal fun draftStartMin(minutes: Float): Int =
|
||||
(minutes / DRAG_SNAP_MINUTES).toInt().times(DRAG_SNAP_MINUTES)
|
||||
.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
|
||||
|
||||
/** The part of [draft] on [date] as a full-width block, or null if none of it falls there. */
|
||||
internal fun draftBlock(draft: EventInstance, date: LocalDate, zone: TimeZone): TimedBlock? {
|
||||
val start = draft.start.toLocalDateTime(zone)
|
||||
val end = draft.end.toLocalDateTime(zone)
|
||||
// The end is exclusive, so a draft ending at midnight belongs to the day before.
|
||||
if (start.date > date || end.date < date || (end.date == date && end.time == LocalTime(0, 0))) return null
|
||||
val startMin = if (start.date < date) 0 else start.time.toSecondOfDay() / 60
|
||||
val endMin = if (end.date > date) MINUTES_PER_DAY else end.time.toSecondOfDay() / 60
|
||||
return TimedBlock(draft, startMin, endMin, lane = 0, laneCount = 1)
|
||||
}
|
||||
|
||||
/** Which end of an event a resize handle moves (#141). */
|
||||
enum class ResizeEdge { Top, Bottom }
|
||||
|
||||
@@ -391,6 +428,9 @@ class TimelineDragController {
|
||||
*/
|
||||
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
|
||||
|
||||
/** How long a block drafted on an empty slot starts out, in minutes (#153). */
|
||||
var draftMinutes: Int = SettingsPrefs.DEFAULT_EVENT_DURATION
|
||||
|
||||
/** The selected event, if any — see [TimelineSelection]. */
|
||||
var selection: TimelineSelection? by mutableStateOf(null)
|
||||
private set
|
||||
@@ -401,6 +441,7 @@ class TimelineDragController {
|
||||
}
|
||||
|
||||
fun deselect() {
|
||||
if (selection?.event?.isDraft == true && settling?.event?.isDraft == true) release()
|
||||
selection = null
|
||||
selectionCovered = false
|
||||
}
|
||||
@@ -560,6 +601,16 @@ class TimelineDragController {
|
||||
originSlot = drag?.slot
|
||||
}
|
||||
|
||||
/**
|
||||
* Draft a block on an empty slot and lift it at once (#153). It is selected
|
||||
* from the start, so it keeps its handles and bar once the finger lets go.
|
||||
*/
|
||||
fun beginDraft(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) {
|
||||
deselect()
|
||||
select(block.event)
|
||||
begin(block, clipOffsetMin = 0, titleLines = 1, pointerInRoot = pointerInRoot, blockInRoot = blockInRoot)
|
||||
}
|
||||
|
||||
fun move(pointerInRoot: Offset) {
|
||||
pointer = pointerInRoot
|
||||
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
|
||||
@@ -640,6 +691,8 @@ class TimelineDragController {
|
||||
val clipOffset = clipOffsetMin
|
||||
cancel()
|
||||
if (landed == null || landed.slot == origin) return null
|
||||
// A draft dropped on a day off screen would vanish, so it stays where it was (#153).
|
||||
if (landed.edgeOnly && landed.event.isDraft) return null
|
||||
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
|
||||
// An edge piece is a hint for the finger, not a preview of the landing
|
||||
// slot: the event belongs to a day this timeline has no column for, so
|
||||
@@ -663,18 +716,12 @@ class TimelineDragController {
|
||||
titleLines: Int,
|
||||
pointerInRoot: Offset,
|
||||
) {
|
||||
val start = block.event.start.toLocalDateTime(zone)
|
||||
val end = block.event.end.toLocalDateTime(zone)
|
||||
val span = wallClockSpan(block.event, zone)
|
||||
source = block
|
||||
resizeEdge = edge
|
||||
resizeDay = start.date
|
||||
resizeStartMin = start.time.toSecondOfDay() / 60
|
||||
// Wall clock at both ends, as the drop resolves them (DST-safe).
|
||||
resizeEndMin = maxOf(
|
||||
resizeStartMin,
|
||||
(end.date.toEpochDays() - start.date.toEpochDays()).toInt() * MINUTES_PER_DAY +
|
||||
end.time.toSecondOfDay() / 60,
|
||||
)
|
||||
resizeDay = span.day
|
||||
resizeStartMin = span.startMin
|
||||
resizeEndMin = span.endMin
|
||||
clipOffsetMin = 0
|
||||
this.titleLines = titleLines
|
||||
settling = null
|
||||
@@ -684,7 +731,7 @@ class TimelineDragController {
|
||||
pointer = pointerInRoot
|
||||
// Measured from the edge's own column: the handle overhangs into the next.
|
||||
val edgeDay = if (edge == ResizeEdge.Top) 0 else (resizeEndMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
|
||||
val edgeIndex = geometry.days.indexOf(start.date.plus(edgeDay, DateTimeUnit.DAY))
|
||||
val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY))
|
||||
resizeColumn = edgeIndex.takeIf { it >= 0 }
|
||||
?.let { if (geometry.isRtl) geometry.days.lastIndex - it else it }
|
||||
resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
|
||||
@@ -703,16 +750,16 @@ class TimelineDragController {
|
||||
val origin = originSlot
|
||||
cancel()
|
||||
if (landed == null || landed.slot == origin) return null
|
||||
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
|
||||
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledOrigin = landed.event.eventId to landed.event.start
|
||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||
val drop = TimelineResizeDrop(
|
||||
event = landed.event,
|
||||
start = wallClockInstant(landed.date, landed.eventStartMin, zone),
|
||||
end = wallClockInstant(landed.date, landed.eventStartMin + landed.eventSpanMin, zone),
|
||||
)
|
||||
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
|
||||
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledOrigin = landed.event.eventId to landed.event.start
|
||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||
settledEnd = drop.end
|
||||
return drop
|
||||
}
|
||||
@@ -957,10 +1004,27 @@ fun TimelineDrop.startInstant(zone: TimeZone): Instant =
|
||||
wallClockInstant(date, startMin - clipOffsetMin, zone)
|
||||
|
||||
/** [minutes] past [date]'s midnight, either way past it, as wall clock in [zone] (DST-safe). */
|
||||
fun wallClockInstant(date: LocalDate, minutes: Int, zone: TimeZone): Instant {
|
||||
fun wallClockInstant(date: LocalDate, minutes: Int, zone: TimeZone): Instant =
|
||||
wallClockTime(date, minutes).toInstant(zone)
|
||||
|
||||
/** The wall-clock time [minutes] past [date]'s midnight, on a later day past 1440. */
|
||||
fun wallClockTime(date: LocalDate, minutes: Int): LocalDateTime {
|
||||
val day = date.plus(minutes.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
|
||||
val minute = minutes.mod(MINUTES_PER_DAY)
|
||||
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone)
|
||||
return LocalDateTime(day, LocalTime(minute / 60, minute % 60))
|
||||
}
|
||||
|
||||
/** An event's span as wall-clock minutes from its start day's midnight. */
|
||||
data class WallClockSpan(val day: LocalDate, val startMin: Int, val endMin: Int)
|
||||
|
||||
/** [event]'s [WallClockSpan], its end past 1440 when it ends on a later day (DST-safe). */
|
||||
fun wallClockSpan(event: EventInstance, zone: TimeZone): WallClockSpan {
|
||||
val start = event.start.toLocalDateTime(zone)
|
||||
val end = event.end.toLocalDateTime(zone)
|
||||
val startMin = start.time.toSecondOfDay() / 60
|
||||
val endMin = (end.date.toEpochDays() - start.date.toEpochDays()).toInt() * MINUTES_PER_DAY +
|
||||
end.time.toSecondOfDay() / 60
|
||||
return WallClockSpan(start.date, startMin, maxOf(startMin, endMin))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1212,7 +1276,7 @@ private fun DragCopy(
|
||||
Text(
|
||||
text = label,
|
||||
// As the block it lifted off sets its own time (#219).
|
||||
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
||||
style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime()),
|
||||
maxLines = timeMaxLines,
|
||||
overflow = overflow.overflow,
|
||||
softWrap = overflow.softWrap,
|
||||
@@ -1233,7 +1297,8 @@ fun selectionLift(selected: Boolean, snap: Boolean = false): Float {
|
||||
val target = if (selected) 1f else 0f
|
||||
if (reduceMotion || (selected && snap)) raise.snapTo(target) else raise.animateTo(target, spec)
|
||||
}
|
||||
return raise.value
|
||||
// The effect runs a frame late, and that frame would show the block unlifted.
|
||||
return if (selected && snap) 1f else raise.value
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -201,6 +201,7 @@ fun EventEditScreen(
|
||||
onSaved: () -> Unit,
|
||||
editKey: LongArray? = null,
|
||||
initialStartMinutes: Int? = null,
|
||||
initialEndMinutes: Int? = null,
|
||||
initialForm: EventForm? = null,
|
||||
initialFormSource: ImportSource = ImportSource.File,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
@@ -220,7 +221,7 @@ fun EventEditScreen(
|
||||
else -> {
|
||||
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
viewModel.openNew(date, initialStartMinutes)
|
||||
viewModel.openNew(date, initialStartMinutes, initialEndMinutes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||
import de.jeanlucmakiola.calendula.domain.problems
|
||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
||||
import de.jeanlucmakiola.calendula.ui.common.wallClockTime
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -324,10 +325,12 @@ class EventEditViewModel @Inject constructor(
|
||||
* in the day/week grid; without it the default is the next full hour (today)
|
||||
* or 09:00 (any other day). The event opens an hour long and is stretched to
|
||||
* the configured default by [applyDefaultDuration], like the reminders.
|
||||
* A block drafted on the timeline (#153) passes [endMinutes] instead,
|
||||
* counted from [date]'s midnight and past 1440 for a later day.
|
||||
* No-op when a form is already open, so user input survives configuration
|
||||
* changes; [reset] clears it when the screen closes.
|
||||
*/
|
||||
fun openNew(date: LocalDate, startMinutes: Int? = null) {
|
||||
fun openNew(date: LocalDate, startMinutes: Int? = null, endMinutes: Int? = null) {
|
||||
if (_form.value != null) return
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
val now = Clock.System.now()
|
||||
@@ -342,9 +345,12 @@ class EventEditViewModel @Inject constructor(
|
||||
}
|
||||
else -> LocalDateTime(date, LocalTime(9, 0))
|
||||
}
|
||||
val end = (start.toInstant(zone) + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes)
|
||||
val chosenEnd = endMinutes?.takeIf { startMinutes != null && it > startMinutes }
|
||||
?.let { wallClockTime(date, it) }
|
||||
val end = chosenEnd ?: (start.toInstant(zone) + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes)
|
||||
.toLocalDateTime(zone)
|
||||
_form.value = EventForm(calendarId = null, start = start, end = end)
|
||||
if (chosenEnd != null) _durationTouched.value = true
|
||||
applyDefaultReminder()
|
||||
applyDefaultDuration()
|
||||
}
|
||||
|
||||
@@ -64,6 +64,16 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import de.jeanlucmakiola.calendula.ui.common.draftBlock
|
||||
import de.jeanlucmakiola.calendula.ui.common.draftEvent
|
||||
import de.jeanlucmakiola.calendula.ui.common.draftStartMin
|
||||
import de.jeanlucmakiola.calendula.ui.common.isDraft
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
|
||||
import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdges
|
||||
import de.jeanlucmakiola.calendula.ui.common.wallClockInstant
|
||||
import de.jeanlucmakiola.calendula.ui.common.wallClockSpan
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickUp
|
||||
import de.jeanlucmakiola.calendula.ui.common.SelectionBarClearance
|
||||
import de.jeanlucmakiola.calendula.ui.common.ResizeEdge
|
||||
@@ -239,6 +249,7 @@ fun TimelineScreen(
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
onCreateSpan: (LocalDate, Int, Int?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
weekViewModel: WeekViewModel = hiltViewModel(),
|
||||
multiDayViewModel: MultiDayViewModel = hiltViewModel(),
|
||||
@@ -253,6 +264,7 @@ fun TimelineScreen(
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
onCreateSpan = onCreateSpan,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -310,6 +322,7 @@ private fun PagedTimelineScreen(
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
onCreateSpan: (LocalDate, Int, Int?) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
// The view on screen, which trails [view] while the columns resize into it.
|
||||
@@ -342,6 +355,7 @@ private fun PagedTimelineScreen(
|
||||
val grid = position.grid
|
||||
val anchorPage = position.page
|
||||
val dimCompleted by key(viewModel) { viewModel.dimCompletedEvents.collectAsStateWithLifecycle() }
|
||||
val draftMinutes by key(viewModel) { viewModel.defaultEventDuration.collectAsStateWithLifecycle() }
|
||||
// The instant before which an event counts as completed, or null when dimming
|
||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||
// screen while the setting is off (it stays null regardless of the tick).
|
||||
@@ -490,6 +504,8 @@ private fun PagedTimelineScreen(
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||
onCreateSpan = onCreateSpan,
|
||||
draftMinutes = draftMinutes,
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
@@ -512,6 +528,8 @@ private fun WeekContent(
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onCreateSpan: (LocalDate, Int, Int?) -> Unit,
|
||||
draftMinutes: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
@@ -526,33 +544,39 @@ private fun WeekContent(
|
||||
// Above the pager: a page change mid-drag would strand the floating block
|
||||
// inside the outgoing page.
|
||||
val dragController = rememberTimelineDragController()
|
||||
SideEffect { dragController.draftMinutes = draftMinutes }
|
||||
val move = LocalEventMove.current
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val onDrop: (TimelineDrop) -> Unit = { drop ->
|
||||
val onDrop: (TimelineDrop) -> Unit = drop@{ drop ->
|
||||
val start = drop.startInstant(zone)
|
||||
val moved = drop.event.copy(start = start, end = start + (drop.event.end - drop.event.start))
|
||||
// A draft has nothing to write, so it stays selected where it landed (#153).
|
||||
if (drop.event.isDraft) {
|
||||
dragController.select(moved)
|
||||
return@drop
|
||||
}
|
||||
// A selected event stays selected where it landed (#141).
|
||||
if (dragController.selection?.event?.instanceId == drop.event.instanceId) {
|
||||
val start = drop.startInstant(zone)
|
||||
dragController.select(
|
||||
drop.event.copy(start = start, end = start + (drop.event.end - drop.event.start)),
|
||||
moved = true,
|
||||
)
|
||||
dragController.select(moved, moved = true)
|
||||
}
|
||||
move?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||
target = MoveTarget.Start(start),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// A resize keeps its event selected, at its new length (#141).
|
||||
val onResize: (TimelineResizeDrop) -> Unit = { drop ->
|
||||
dragController.select(
|
||||
drop.event.copy(start = drop.start, end = drop.end),
|
||||
moved = true,
|
||||
)
|
||||
val onResize: (TimelineResizeDrop) -> Unit = resize@{ drop ->
|
||||
val resized = drop.event.copy(start = drop.start, end = drop.end)
|
||||
if (drop.event.isDraft) {
|
||||
dragController.select(resized)
|
||||
return@resize
|
||||
}
|
||||
dragController.select(resized, moved = true)
|
||||
move?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
@@ -563,6 +587,13 @@ private fun WeekContent(
|
||||
)
|
||||
}
|
||||
|
||||
// A drafted block (#153) opens the new-event form at the span it shows.
|
||||
val createDraft: (EventInstance) -> Unit = { draft ->
|
||||
dragController.deselect()
|
||||
val span = wallClockSpan(draft, zone)
|
||||
onCreateSpan(span.day, span.startMin, span.endMin)
|
||||
}
|
||||
|
||||
// Selection (#141) ends on back, a page or view change, or any tap.
|
||||
val selection = dragController.selection
|
||||
PublishSelection(
|
||||
@@ -570,10 +601,15 @@ private fun WeekContent(
|
||||
busy = dragController.isDragging,
|
||||
compact = dragController.selectionCovered,
|
||||
onDismiss = dragController::deselect,
|
||||
onCreate = selection?.event?.takeIf { it.isDraft }?.let { draft -> { createDraft(draft) } },
|
||||
)
|
||||
BackHandler(enabled = selection != null) { dragController.deselect() }
|
||||
LaunchedEffect(pagerState, dragController) {
|
||||
snapshotFlow { pagerState.currentPage }.drop(1).collect { dragController.deselect() }
|
||||
snapshotFlow { pagerState.currentPage }.drop(1).collect {
|
||||
// Deselecting would delete a draft mid-drag, so it stays until it lands.
|
||||
val draftInFlight = dragController.isDragging && dragController.selection?.event?.isDraft == true
|
||||
if (!draftInFlight) dragController.deselect()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(morph != null) { if (morph != null) dragController.deselect() }
|
||||
LaunchedEffect(selection, dragController.settling, dragController.isDragging, dragController.selectedCandidates.size) {
|
||||
@@ -587,9 +623,14 @@ private fun WeekContent(
|
||||
delay(SELECTION_ORPHAN_GRACE_MILLIS)
|
||||
dragController.deselect()
|
||||
}
|
||||
val requireWrite = rememberWriteGate()
|
||||
val openEvent: (EventInstance) -> Unit = { event ->
|
||||
dragController.deselect()
|
||||
onEventClick(event)
|
||||
if (event.isDraft) {
|
||||
requireWrite { createDraft(event) }
|
||||
} else {
|
||||
dragController.deselect()
|
||||
onEventClick(event)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
@@ -1370,9 +1411,15 @@ private fun DayColumnCard(
|
||||
val morphing = LocalViewMorph.current != null
|
||||
val showHourGrid = LocalShowHourGrid.current
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
// A block drafted on an empty slot (#153), drawn over the part of it on this day.
|
||||
val draft by remember(dragController) {
|
||||
derivedStateOf { dragController.selection?.event?.takeIf { it.isDraft } }
|
||||
}
|
||||
val draftPiece = remember(draft, date, zone) { draft?.let { draftBlock(it, date, zone) } }
|
||||
val shown = remember(blocks, draftPiece) { if (draftPiece == null) blocks else blocks + draftPiece }
|
||||
// Tells a settled drop when this column has caught up with it.
|
||||
LaunchedEffect(blocks, dragController.settling) {
|
||||
dragController.noteGrid(date, blocks)
|
||||
LaunchedEffect(shown, dragController.settling) {
|
||||
dragController.noteGrid(date, shown)
|
||||
}
|
||||
// The tap detector outlives the composition that installed it — a pinch or a
|
||||
// Settings change moves the hour height without restarting it — so it reads
|
||||
@@ -1382,6 +1429,7 @@ private fun DayColumnCard(
|
||||
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
|
||||
val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx)
|
||||
val currentEdges = rememberUpdatedState(edges)
|
||||
val draftSurface = rememberDraftSurface(date, edges, hourPx, dragController, onDrop)
|
||||
Card(
|
||||
// Plain rectangular columns — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
@@ -1420,14 +1468,17 @@ private fun DayColumnCard(
|
||||
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
// Inside the tap detector, which consumes every down, so only a
|
||||
// block or handle on top can claim the press first.
|
||||
.then(draftSurface),
|
||||
) {
|
||||
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 ->
|
||||
shown.forEach { block ->
|
||||
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
||||
key(block.event.eventId, ordinal) {
|
||||
val top = hourHeight * (block.startMin / 60f)
|
||||
@@ -1438,6 +1489,7 @@ private fun DayColumnCard(
|
||||
widthFraction = 1f / block.laneCount,
|
||||
y = top,
|
||||
height = height,
|
||||
landing = dragController.ghosts(block),
|
||||
)
|
||||
// Where this block is cut at midnight, which decides both
|
||||
// its shape and the half-gap it gives up to the hour cells.
|
||||
@@ -1572,7 +1624,8 @@ private fun EventBlock(
|
||||
val dimmed = dimCutoff != null && block.event.isPastOn(date, dimCutoff)
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val moveAction = eventMoveAction(block.event)
|
||||
val draggable = eventDragAllowed(block.event)
|
||||
val draft = block.event.isDraft
|
||||
val draggable = draft || eventDragAllowed(block.event)
|
||||
val selectable = eventSelectAllowed()
|
||||
val selected = dragController.isSelected(block)
|
||||
val haptics = LocalHapticFeedback.current
|
||||
@@ -1614,7 +1667,12 @@ private fun EventBlock(
|
||||
// Released in place, the block takes over the copy's lift without a dip.
|
||||
val takesOverLift = selected && dragController.selection?.fromLift == true
|
||||
val ghostFade = ghostAlpha(lifted)
|
||||
val ghost = if (takesOverLift && !lifted) 1f else ghostFade
|
||||
val ghost = when {
|
||||
takesOverLift && !lifted -> 1f
|
||||
// A draft leaves no ghost behind, and the copy it lands under is identical.
|
||||
draft -> if (lifted) 0f else 1f
|
||||
else -> ghostFade
|
||||
}
|
||||
val raise = selectionLift(selected, snap = takesOverLift)
|
||||
// Tells the action bar to fold when it would cover the bottom handle.
|
||||
val revealExtraPx = with(density) { (ResizeHandleTarget / 2).toPx() }
|
||||
@@ -1689,9 +1747,10 @@ private fun EventBlock(
|
||||
}
|
||||
}
|
||||
val end = { dragController.finishResize(zone)?.let(onResize) }
|
||||
// No bounce: an overshoot reads as the block's ends jumping.
|
||||
val handleIn by animateFloatAsState(
|
||||
targetValue = if (handlesShown) 1f else 0f,
|
||||
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
|
||||
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||
label = "handles",
|
||||
)
|
||||
val handleAlpha = Modifier.graphicsLayer {
|
||||
@@ -1731,6 +1790,62 @@ private fun EventBlock(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A long press on an empty stretch of [date]'s column (#153): drafts a block of
|
||||
* the default length from the slot under the finger and lifts it, as a long
|
||||
* press on an event does. Presses on a block or handle are left to them.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberDraftSurface(
|
||||
date: LocalDate,
|
||||
edges: OutOfViewEdges,
|
||||
hourPx: Float,
|
||||
dragController: TimelineDragController,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
): Modifier {
|
||||
val currentDrop by rememberUpdatedState(onDrop)
|
||||
val title = stringResource(R.string.event_edit_new_title)
|
||||
val color = MaterialTheme.colorScheme.primary.toArgb()
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val currentEdges by rememberUpdatedState(edges)
|
||||
// The draft this gesture lifted, selected again on release after the cancel.
|
||||
val lifted = remember { arrayOfNulls<EventInstance>(1) }
|
||||
val currentHourPx by rememberUpdatedState(hourPx)
|
||||
return rememberDragSurface(
|
||||
enabled = eventSelectAllowed(),
|
||||
key = date,
|
||||
skipClaimedDown = true,
|
||||
// A press on a block or handle was consumed by it and never gets here.
|
||||
onPickUp = { local, pointer, node, _ ->
|
||||
val hour = currentHourPx
|
||||
if (hour <= 0f || currentEdges.covers(local.y)) return@rememberDragSurface false
|
||||
val startMin = draftStartMin(local.y / hour * 60f)
|
||||
val event = draftEvent(
|
||||
title = title,
|
||||
color = color,
|
||||
start = wallClockInstant(date, startMin, zone),
|
||||
minutes = dragController.draftMinutes,
|
||||
)
|
||||
val block = draftBlock(event, date, zone) ?: return@rememberDragSurface false
|
||||
lifted[0] = event
|
||||
dragController.beginDraft(
|
||||
block = block,
|
||||
pointerInRoot = pointer,
|
||||
blockInRoot = node + Offset(0f, startMin / 60f * hour),
|
||||
)
|
||||
true
|
||||
},
|
||||
onMove = dragController::move,
|
||||
onDrop = { dragController.finish()?.let(currentDrop) },
|
||||
// An abandoned pickup leaves no draft behind; a release selects it again below.
|
||||
onCancel = {
|
||||
dragController.cancel()
|
||||
if (dragController.selection?.event?.isDraft == true) dragController.deselect()
|
||||
},
|
||||
onRelease = { lifted[0]?.let { dragController.select(it, fromLift = true) } },
|
||||
)
|
||||
}
|
||||
|
||||
private const val SELECTION_ORPHAN_GRACE_MILLIS = 500L
|
||||
|
||||
/** How far in from the block's side a resize handle's centre sits. */
|
||||
|
||||
@@ -91,6 +91,14 @@ abstract class PagedTimelineViewModel(
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
/** How long a block drafted on an empty slot starts out (#153). */
|
||||
val defaultEventDuration: StateFlow<Int> = settingsPrefs.defaultEventDurationMinutes
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = SettingsPrefs.DEFAULT_EVENT_DURATION,
|
||||
)
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = currentDate()
|
||||
|
||||
|
||||
@@ -102,6 +102,8 @@
|
||||
<string name="event_selection_actions">Event actions</string>
|
||||
<string name="event_selection_hide">Hide event actions</string>
|
||||
<string name="event_selection_close">Deselect event</string>
|
||||
<string name="event_draft_create">Create event</string>
|
||||
<string name="event_draft_discard">Discard new event</string>
|
||||
<string name="event_action_load_failed">Couldn\'t open the event</string>
|
||||
<string name="event_move_undo">Undo</string>
|
||||
<string name="event_move_undone">Move undone</string>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** A block drafted on an empty timeline slot (#153). */
|
||||
class DraftBlockTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
private val day = LocalDate(2026, 10, 9)
|
||||
|
||||
private fun draft(startMin: Int, minutes: Int) =
|
||||
draftEvent("New event", 0, wallClockInstant(day, startMin, zone), minutes)
|
||||
|
||||
@Test
|
||||
fun `a press snaps down to the slot it is in`() {
|
||||
assertThat(draftStartMin(9 * 60 + 14f)).isEqualTo(9 * 60)
|
||||
assertThat(draftStartMin(9 * 60 + 15f)).isEqualTo(9 * 60 + 15)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a press at the very bottom still leaves a slot before midnight`() {
|
||||
assertThat(draftStartMin(24 * 60f)).isEqualTo(24 * 60 - DRAG_SNAP_MINUTES)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the draft is told apart from real events`() {
|
||||
assertThat(draft(9 * 60, 60).isDraft).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a draft within its day is one block the column wide`() {
|
||||
val block = draftBlock(draft(9 * 60, 60), day, zone)!!
|
||||
assertThat(block.startMin).isEqualTo(9 * 60)
|
||||
assertThat(block.endMin).isEqualTo(10 * 60)
|
||||
assertThat(block.lane to block.laneCount).isEqualTo(0 to 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a draft past midnight is cut there and carries on the next day`() {
|
||||
val event = draft(23 * 60, 120)
|
||||
val first = draftBlock(event, day, zone)!!
|
||||
val second = draftBlock(event, day.plusDays(1), zone)!!
|
||||
assertThat(first.startMin to first.endMin).isEqualTo(23 * 60 to 24 * 60)
|
||||
assertThat(second.startMin to second.endMin).isEqualTo(0 to 60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a draft ending at midnight leaves the next day alone`() {
|
||||
assertThat(draftBlock(draft(23 * 60, 60), day.plusDays(1), zone)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a draft misses the day before it`() {
|
||||
assertThat(draftBlock(draft(9 * 60, 60), day.plusDays(-1), zone)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a draft past midnight opens the form with its end on the next day`() {
|
||||
val span = wallClockSpan(draft(23 * 60, 120), zone)
|
||||
assertThat(span).isEqualTo(WallClockSpan(day, 23 * 60, 25 * 60))
|
||||
assertThat(wallClockTime(span.day, span.endMin)).isEqualTo(wallClockTime(day.plusDays(1), 60))
|
||||
}
|
||||
|
||||
private fun LocalDate.plusDays(days: Int): LocalDate = LocalDate.fromEpochDays(toEpochDays() + days)
|
||||
}
|
||||
Reference in New Issue
Block a user