diff --git a/.gitea/workflows/beta.yaml b/.gitea/workflows/beta.yaml index 46e33baa..fe452a95 100644 --- a/.gitea/workflows/beta.yaml +++ b/.gitea/workflows/beta.yaml @@ -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 diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 5d2d4b24..1bdb69e8 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index a56bcfe4..f3803d8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 1a769156..704481f2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -239,12 +239,18 @@ fun CalendarHost( var heldCreateIso by remember { mutableStateOf(null) } var createStartMinutes by rememberSaveable { mutableStateOf(null) } var heldCreateMinutes by remember { mutableStateOf(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(null) } + var heldCreateEndMinutes by remember { mutableStateOf(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 }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt index 923726ad..9ec316c1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt @@ -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 = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec() + val dpSpec: FiniteAnimationSpec = when { + still -> snap() + landing -> MaterialTheme.motionScheme.fastEffectsSpec() + else -> MaterialTheme.motionScheme.fastSpatialSpec() + } val shareSpec: FiniteAnimationSpec = 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") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt index 508dd5f8..d252ad27 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt @@ -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) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt index ee282d4f..c1746b40 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt @@ -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. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionBar.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionBar.kt index f161c52c..66ae7beb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionBar.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionBar.kt @@ -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) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionHost.kt index 34f4603c..c26c48d6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionHost.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt index 7954e3c4..67cd7227 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt @@ -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 } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SelectionTopBar.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SelectionTopBar.kt index 7e460912..37ef7592 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SelectionTopBar.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SelectionTopBar.kt @@ -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, + ), ) } }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index b93f87ef..5bd3aaa6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -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? 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 } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 90dff7a1..2fde7aa3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -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) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 99894e80..d758baf0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -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() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 55fc236b..acd9d4d7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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() - 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(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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index 5561e973..643216fa 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -91,6 +91,14 @@ abstract class PagedTimelineViewModel( initialValue = false, ) + /** How long a block drafted on an empty slot starts out (#153). */ + val defaultEventDuration: StateFlow = settingsPrefs.defaultEventDurationMinutes + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = SettingsPrefs.DEFAULT_EVENT_DURATION, + ) + private val todayDate: LocalDate get() = currentDate() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dfe552fc..259e1e14 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -102,6 +102,8 @@ Event actions Hide event actions Deselect event + Create event + Discard new event Couldn\'t open the event Undo Move undone diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/DraftBlockTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/DraftBlockTest.kt new file mode 100644 index 00000000..f238ee2d --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/DraftBlockTest.kt @@ -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) +}