Compare commits

..
Author SHA1 Message Date
makiolaj 90e812de11 Note long-press selection in the changelog (#141) 2026-10-09 21:40:46 +02:00
makiolaj 99970b3bb9 Keep a selected block lifted as its resize lands (#141) 2026-10-09 19:15:54 +02:00
makiolaj 97dbdcad0c Fix second review round on selection, resize and the FAB (#141) 2026-10-09 19:09:06 +02:00
makiolaj f50095ab1e Fold the selection bar while a chip shows and match it to the FAB (#141) 2026-10-09 18:42:05 +02:00
makiolaj 06959e7a69 Trim comments to the project's style (#141) 2026-10-09 18:32:47 +02:00
makiolaj 3fe2cd4e9a Fix review findings on selection, resize and chips (#141) 2026-10-09 18:29:08 +02:00
makiolaj e195160f8c Keep the selection top bar on the header's surface (#141) 2026-10-09 15:51:46 +02:00
makiolaj 679e3fc849 Colour the selection top bar like the FAB (#141) 2026-10-09 13:46:20 +02:00
makiolaj 5e397d8882 Show the selection in a contextual top bar (#141) 2026-10-09 13:30:04 +02:00
makiolaj 8ac4f0316a Ease a released long press into the selection (#141) 2026-10-09 13:21:48 +02:00
makiolaj 51ac3e4a4d Turn the FAB's glyph between plus and a chevron (#141) 2026-10-09 13:18:45 +02:00
makiolaj c1d93a236e Hold the FAB still while the selection bar unfolds (#141) 2026-10-09 13:04:27 +02:00
makiolaj 7d48e13b1c Make the FAB itself the selection bar's toggle (#141) 2026-10-09 12:57:48 +02:00
makiolaj f5ec696bc3 Use a regular FAB for the selection bar's button (#141) 2026-10-09 12:31:33 +02:00
makiolaj d40bf2c3ee Fold the selection bar into its own button and drop the extra scroll room (#141) 2026-10-09 12:29:08 +02:00
makiolaj a64fbc9e70 Compact the selection bar instead of scrolling the event up (#141) 2026-10-09 12:06:54 +02:00
makiolaj 9377e5d3ba Keep the selection bar out of the way of a resize (#141) 2026-10-09 11:54:54 +02:00
makiolaj eefd9430e0 Steady the resize edge and enlarge its handles (#141) 2026-10-09 11:39:49 +02:00
makiolaj 38a860aafd Keep resize handles alive mid-gesture and follow a moved occurrence (#141) 2026-10-09 11:31:35 +02:00
makiolaj c8d3d21ba3 Resize a selected event from handles on its ends (#141) 2026-10-09 11:13:44 +02:00
makiolaj e7f3afe581 Drag a selected event straight away and keep it selected (#141) 2026-10-09 11:11:23 +02:00
makiolaj 44f91a963a Select timeline events with a long press and act on them from a bar (#141) 2026-10-09 11:00:44 +02:00
makiolaj a48becefc0 Share the detail screen's duplicate, share and delete actions (#141) 2026-10-09 10:54:39 +02:00
makiolaj 4747926e27 Let the reschedule path resize events (#141) 2026-10-09 10:51:17 +02:00
19 changed files with 67 additions and 430 deletions
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
detect: detect:
# Gitea only; see the same guard in release.yaml. # Gitea only; see the same guard in release.yaml.
if: github.repository_owner == 'makiolaj' if: github.repository_owner == 'makiolaj'
runs-on: light runs-on: docker
outputs: outputs:
is_beta: ${{ steps.v.outputs.is_beta }} is_beta: ${{ steps.v.outputs.is_beta }}
version: ${{ steps.v.outputs.version }} version: ${{ steps.v.outputs.version }}
@@ -41,7 +41,7 @@ jobs:
beta: beta:
needs: detect needs: detect
if: needs.detect.outputs.is_beta == 'true' if: needs.detect.outputs.is_beta == 'true'
runs-on: gradle runs-on: docker
env: env:
ANDROID_HOME: /opt/android-sdk ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk
+2 -2
View File
@@ -43,7 +43,7 @@ jobs:
# secrets. repository_owner differs between the two forges regardless of # secrets. repository_owner differs between the two forges regardless of
# URL, proxy or instance rename, so this closes it permanently. # URL, proxy or instance rename, so this closes it permanently.
if: github.repository_owner == 'makiolaj' if: github.repository_owner == 'makiolaj'
runs-on: light runs-on: docker
outputs: outputs:
is_release: ${{ steps.v.outputs.is_release }} is_release: ${{ steps.v.outputs.is_release }}
version: ${{ steps.v.outputs.version }} version: ${{ steps.v.outputs.version }}
@@ -85,7 +85,7 @@ jobs:
release: release:
needs: detect needs: detect
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch' if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
runs-on: gradle runs-on: docker
env: env:
ANDROID_HOME: /opt/android-sdk ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk
-2
View File
@@ -9,7 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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 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]). - **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 ### Fixed
@@ -1860,4 +1859,3 @@ automatically, with zero telemetry and no internet permission.
[#367]: https://codeberg.org/jlmakiola/calendula/issues/367 [#367]: https://codeberg.org/jlmakiola/calendula/issues/367
[#373]: https://codeberg.org/jlmakiola/calendula/issues/373 [#373]: https://codeberg.org/jlmakiola/calendula/issues/373
[#141]: https://codeberg.org/jlmakiola/calendula/issues/141 [#141]: https://codeberg.org/jlmakiola/calendula/issues/141
[#153]: https://codeberg.org/jlmakiola/calendula/issues/153
+2 -2
View File
@@ -32,8 +32,8 @@ android {
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001, // (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. // 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md. // See docs/RELEASING.md.
versionCode = 2230003 versionCode = 2230001
versionName = "2.23.0-beta.3" versionName = "2.23.0-beta.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -239,18 +239,12 @@ fun CalendarHost(
var heldCreateIso by remember { mutableStateOf<String?>(null) } var heldCreateIso by remember { mutableStateOf<String?>(null) }
var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) } var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
var heldCreateMinutes by remember { mutableStateOf<Int?>(null) } var heldCreateMinutes by remember { mutableStateOf<Int?>(null) }
// A drafted block's end (#153), in minutes from the start day's midnight. val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes ->
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() heldCreateIso = date.toString()
createDateIso = date.toString() createDateIso = date.toString()
heldCreateMinutes = startMinutes heldCreateMinutes = startMinutes
createStartMinutes = 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 // Edit form (v1.3) — reuses the detail screen's occurrence key; for
// recurring events the form itself asks for the write scope at save // recurring events the form itself asks for the write scope at save
@@ -386,8 +380,6 @@ fun CalendarHost(
createDateIso = iso createDateIso = iso
heldCreateMinutes = null heldCreateMinutes = null
createStartMinutes = null createStartMinutes = null
heldCreateEndMinutes = null
createEndMinutes = null
onWidgetNavConsumed() onWidgetNavConsumed()
} }
null -> {} null -> {}
@@ -591,7 +583,6 @@ fun CalendarHost(
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
onCreateSpan = onCreateSpan,
) )
ViewScreen.Month -> MonthScreen( ViewScreen.Month -> MonthScreen(
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
@@ -680,7 +671,6 @@ fun CalendarHost(
EventEditScreen( EventEditScreen(
initialDateIso = iso, initialDateIso = iso,
initialStartMinutes = createStartMinutes ?: heldCreateMinutes, initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
initialEndMinutes = createEndMinutes ?: heldCreateEndMinutes,
onClose = { createDateIso = null }, onClose = { createDateIso = null },
onSaved = { createDateIso = null }, onSaved = { createDateIso = null },
onManageCalendars = { showCalendars = true }, onManageCalendars = { showCalendars = true },
@@ -278,23 +278,12 @@ 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 * 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 * 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. * 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) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun animatedBlockPlacement( fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement {
xFraction: Float,
widthFraction: Float,
y: Dp,
height: Dp,
landing: Boolean = false,
): BlockPlacement {
val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
val dpSpec: FiniteAnimationSpec<Dp> = when { val dpSpec: FiniteAnimationSpec<Dp> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
still -> snap()
landing -> MaterialTheme.motionScheme.fastEffectsSpec()
else -> MaterialTheme.motionScheme.fastSpatialSpec()
}
val shareSpec: FiniteAnimationSpec<Float> = if (still) snap() 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 animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x")
val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width") val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width")
@@ -69,15 +69,9 @@ class ScreenActions(
/** /**
* An event a long press left selected (#141), which the host answers with the * 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. * 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 @Stable
class EventSelection( class EventSelection(val event: EventInstance, val dismiss: () -> Unit)
val event: EventInstance,
val dismiss: () -> Unit,
val create: (() -> Unit)? = null,
)
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */ /** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
@Stable @Stable
@@ -120,26 +114,10 @@ fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
/** Hands the screen's selected [event] to the host, for as long as it stays selected. */ /** Hands the screen's selected [event] to the host, for as long as it stays selected. */
@Composable @Composable
fun PublishSelection( fun PublishSelection(event: EventInstance?, busy: Boolean, compact: Boolean, onDismiss: () -> Unit) {
event: EventInstance?,
busy: Boolean,
compact: Boolean,
onDismiss: () -> Unit,
onCreate: (() -> Unit)? = null,
) {
val slot = LocalChromeSlot.current ?: return val slot = LocalChromeSlot.current ?: return
val currentDismiss by rememberUpdatedState(onDismiss) val currentDismiss by rememberUpdatedState(onDismiss)
val currentCreate by rememberUpdatedState(onCreate) val selection = remember(event) { event?.let { EventSelection(it) { currentDismiss() } } }
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 host = slot.host
val current = slot.current val current = slot.current
DisposableEffect(host, selection, current) { DisposableEffect(host, selection, current) {
@@ -73,8 +73,7 @@ fun rememberEventDragSource(
* The same pickup, for a surface that carries many draggable pieces rather than * 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 * 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 * layer. [onPickUp] receives the press position local to this node and answers
* whether anything is there; false abandons the gesture. [skipClaimedDown] leaves * whether anything is there; false abandons the gesture.
* a press a descendant already consumed alone.
*/ */
@Composable @Composable
fun rememberDragSurface( fun rememberDragSurface(
@@ -84,8 +83,6 @@ fun rememberDragSurface(
onMove: (pointerInRoot: Offset) -> Unit, onMove: (pointerInRoot: Offset) -> Unit,
onDrop: () -> Unit, onDrop: () -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
onRelease: (() -> Unit)? = null,
skipClaimedDown: Boolean = false,
): Modifier = rememberPickUpSurface( ): Modifier = rememberPickUpSurface(
enabled = enabled, enabled = enabled,
key = key, key = key,
@@ -96,8 +93,7 @@ fun rememberDragSurface(
onMove = onMove, onMove = onMove,
onDrop = onDrop, onDrop = onDrop,
onCancel = onCancel, onCancel = onCancel,
onRelease = onRelease, onRelease = null,
skipClaimedDown = skipClaimedDown,
) )
@Composable @Composable
@@ -110,7 +106,6 @@ private fun rememberPickUpSurface(
onDrop: () -> Unit, onDrop: () -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
onRelease: (() -> Unit)?, onRelease: (() -> Unit)?,
skipClaimedDown: Boolean = false,
): Modifier { ): Modifier {
if (!enabled) return Modifier if (!enabled) return Modifier
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
@@ -130,8 +125,6 @@ private fun rememberPickUpSurface(
val slop = viewConfiguration.touchSlop val slop = viewConfiguration.touchSlop
awaitEachGesture { awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false) 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 origin = down.position
val immediate = currentImmediate val immediate = currentImmediate
// Null: held still. True: an immediate pickup past slop. False: abandoned. // Null: held still. True: an immediate pickup past slop. False: abandoned.
@@ -5,8 +5,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons 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.ContentCopy
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
@@ -54,26 +52,6 @@ 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 @Composable
private fun BarAction(icon: ImageVector, label: Int, onClick: () -> Unit) { private fun BarAction(icon: ImageVector, label: Int, onClick: () -> Unit) {
IconButton(onClick = onClick) { IconButton(onClick = onClick) {
@@ -47,23 +47,6 @@ fun rememberFabSelection(
val currentWrite by rememberUpdatedState(requireWrite) val currentWrite by rememberUpdatedState(requireWrite)
val selection = host.selection ?: return null 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 event = selection.event
val calendar = calendars[event.calendarId] val calendar = calendars[event.calendarId]
val canEdit = calendar?.canModifyContents == true val canEdit = calendar?.canModifyContents == true
@@ -195,20 +195,15 @@ 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. */ /** Reveals the hidden event if a tap at [y] (content px) lands on a bar. */
fun tap(y: Float): Boolean { fun tap(y: Float): Boolean {
val edge = edgeAt(y) ?: return false val edge = tappedEdge(
events = events,
y = y,
viewportTop = scrollState.value.toFloat(),
viewportPx = scrollState.viewportSize,
reachPx = reachPx,
) ?: return false
reveal(edge) reveal(edge)
return true return true
} }
@@ -40,9 +40,7 @@ fun SelectionTopBar(selection: EventSelection) {
IconButton(onClick = selection.dismiss) { IconButton(onClick = selection.dismiss) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = stringResource( contentDescription = stringResource(R.string.event_selection_close),
if (selection.create != null) R.string.event_draft_discard else R.string.event_selection_close,
),
) )
} }
}, },
@@ -44,7 +44,6 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
import de.jeanlucmakiola.calendula.ui.week.TimedBlock import de.jeanlucmakiola.calendula.ui.week.TimedBlock
@@ -62,7 +61,6 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant import kotlin.time.Instant
/** Start granularity a dragged block snaps to, matching the big calendar apps. */ /** Start granularity a dragged block snaps to, matching the big calendar apps. */
@@ -271,41 +269,6 @@ data class TimelineDrop(
val clipOffsetMin: Int = 0, 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). */ /** Which end of an event a resize handle moves (#141). */
enum class ResizeEdge { Top, Bottom } enum class ResizeEdge { Top, Bottom }
@@ -428,9 +391,6 @@ class TimelineDragController {
*/ */
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null) 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]. */ /** The selected event, if any — see [TimelineSelection]. */
var selection: TimelineSelection? by mutableStateOf(null) var selection: TimelineSelection? by mutableStateOf(null)
private set private set
@@ -441,7 +401,6 @@ class TimelineDragController {
} }
fun deselect() { fun deselect() {
if (selection?.event?.isDraft == true && settling?.event?.isDraft == true) release()
selection = null selection = null
selectionCovered = false selectionCovered = false
} }
@@ -601,16 +560,6 @@ class TimelineDragController {
originSlot = drag?.slot 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) { fun move(pointerInRoot: Offset) {
pointer = pointerInRoot pointer = pointerInRoot
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
@@ -691,8 +640,6 @@ class TimelineDragController {
val clipOffset = clipOffsetMin val clipOffset = clipOffsetMin
cancel() cancel()
if (landed == null || landed.slot == origin) return null 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 settlingSelected = selection?.event?.instanceId == landed.event.instanceId
// An edge piece is a hint for the finger, not a preview of the landing // 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 // slot: the event belongs to a day this timeline has no column for, so
@@ -716,12 +663,18 @@ class TimelineDragController {
titleLines: Int, titleLines: Int,
pointerInRoot: Offset, pointerInRoot: Offset,
) { ) {
val span = wallClockSpan(block.event, zone) val start = block.event.start.toLocalDateTime(zone)
val end = block.event.end.toLocalDateTime(zone)
source = block source = block
resizeEdge = edge resizeEdge = edge
resizeDay = span.day resizeDay = start.date
resizeStartMin = span.startMin resizeStartMin = start.time.toSecondOfDay() / 60
resizeEndMin = span.endMin // 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,
)
clipOffsetMin = 0 clipOffsetMin = 0
this.titleLines = titleLines this.titleLines = titleLines
settling = null settling = null
@@ -731,7 +684,7 @@ class TimelineDragController {
pointer = pointerInRoot pointer = pointerInRoot
// Measured from the edge's own column: the handle overhangs into the next. // 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 edgeDay = if (edge == ResizeEdge.Top) 0 else (resizeEndMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY)) val edgeIndex = geometry.days.indexOf(start.date.plus(edgeDay, DateTimeUnit.DAY))
resizeColumn = edgeIndex.takeIf { it >= 0 } resizeColumn = edgeIndex.takeIf { it >= 0 }
?.let { if (geometry.isRtl) geometry.days.lastIndex - it else it } ?.let { if (geometry.isRtl) geometry.days.lastIndex - it else it }
resizeGrabMin = (pointerResizeMinutes() ?: 0f) - resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
@@ -750,16 +703,16 @@ class TimelineDragController {
val origin = originSlot val origin = originSlot
cancel() cancel()
if (landed == null || landed.slot == origin) return null if (landed == null || landed.slot == origin) return null
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 settlingSelected = selection?.event?.instanceId == landed.event.instanceId
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
liftedInstanceId = landed.event.instanceId liftedInstanceId = landed.event.instanceId
settledOrigin = landed.event.eventId to landed.event.start settledOrigin = landed.event.eventId to landed.event.start
settledAbandonedMark = abandonedTicks?.value ?: 0 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),
)
settledEnd = drop.end settledEnd = drop.end
return drop return drop
} }
@@ -1004,27 +957,10 @@ fun TimelineDrop.startInstant(zone: TimeZone): Instant =
wallClockInstant(date, startMin - clipOffsetMin, zone) wallClockInstant(date, startMin - clipOffsetMin, zone)
/** [minutes] past [date]'s midnight, either way past it, as wall clock in [zone] (DST-safe). */ /** [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 day = date.plus(minutes.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
val minute = minutes.mod(MINUTES_PER_DAY) val minute = minutes.mod(MINUTES_PER_DAY)
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)) return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone)
}
/** 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))
} }
/** /**
@@ -1276,7 +1212,7 @@ private fun DragCopy(
Text( Text(
text = label, text = label,
// As the block it lifted off sets its own time (#219). // As the block it lifted off sets its own time (#219).
style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime()), style = MaterialTheme.typography.labelSmall.asEventTime(),
maxLines = timeMaxLines, maxLines = timeMaxLines,
overflow = overflow.overflow, overflow = overflow.overflow,
softWrap = overflow.softWrap, softWrap = overflow.softWrap,
@@ -1297,8 +1233,7 @@ fun selectionLift(selected: Boolean, snap: Boolean = false): Float {
val target = if (selected) 1f else 0f val target = if (selected) 1f else 0f
if (reduceMotion || (selected && snap)) raise.snapTo(target) else raise.animateTo(target, spec) if (reduceMotion || (selected && snap)) raise.snapTo(target) else raise.animateTo(target, spec)
} }
// The effect runs a frame late, and that frame would show the block unlifted. return raise.value
return if (selected && snap) 1f else raise.value
} }
/** /**
@@ -201,7 +201,6 @@ fun EventEditScreen(
onSaved: () -> Unit, onSaved: () -> Unit,
editKey: LongArray? = null, editKey: LongArray? = null,
initialStartMinutes: Int? = null, initialStartMinutes: Int? = null,
initialEndMinutes: Int? = null,
initialForm: EventForm? = null, initialForm: EventForm? = null,
initialFormSource: ImportSource = ImportSource.File, initialFormSource: ImportSource = ImportSource.File,
onManageCalendars: (() -> Unit)? = null, onManageCalendars: (() -> Unit)? = null,
@@ -221,7 +220,7 @@ fun EventEditScreen(
else -> { else -> {
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() } val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
viewModel.openNew(date, initialStartMinutes, initialEndMinutes) viewModel.openNew(date, initialStartMinutes)
} }
} }
} }
@@ -26,7 +26,6 @@ import de.jeanlucmakiola.calendula.domain.populatedFields
import de.jeanlucmakiola.calendula.domain.problems import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.toEditSnapshot import de.jeanlucmakiola.calendula.domain.toEditSnapshot
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME 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.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -325,12 +324,10 @@ class EventEditViewModel @Inject constructor(
* in the day/week grid; without it the default is the next full hour (today) * 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 * or 09:00 (any other day). The event opens an hour long and is stretched to
* the configured default by [applyDefaultDuration], like the reminders. * 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 * No-op when a form is already open, so user input survives configuration
* changes; [reset] clears it when the screen closes. * changes; [reset] clears it when the screen closes.
*/ */
fun openNew(date: LocalDate, startMinutes: Int? = null, endMinutes: Int? = null) { fun openNew(date: LocalDate, startMinutes: Int? = null) {
if (_form.value != null) return if (_form.value != null) return
val zone = TimeZone.currentSystemDefault() val zone = TimeZone.currentSystemDefault()
val now = Clock.System.now() val now = Clock.System.now()
@@ -345,12 +342,9 @@ class EventEditViewModel @Inject constructor(
} }
else -> LocalDateTime(date, LocalTime(9, 0)) else -> LocalDateTime(date, LocalTime(9, 0))
} }
val chosenEnd = endMinutes?.takeIf { startMinutes != null && it > startMinutes } val end = (start.toInstant(zone) + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes)
?.let { wallClockTime(date, it) }
val end = chosenEnd ?: (start.toInstant(zone) + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes)
.toLocalDateTime(zone) .toLocalDateTime(zone)
_form.value = EventForm(calendarId = null, start = start, end = end) _form.value = EventForm(calendarId = null, start = start, end = end)
if (chosenEnd != null) _durationTouched.value = true
applyDefaultReminder() applyDefaultReminder()
applyDefaultDuration() applyDefaultDuration()
} }
@@ -64,16 +64,6 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.compose.ui.geometry.Offset 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.PickUp
import de.jeanlucmakiola.calendula.ui.common.SelectionBarClearance import de.jeanlucmakiola.calendula.ui.common.SelectionBarClearance
import de.jeanlucmakiola.calendula.ui.common.ResizeEdge import de.jeanlucmakiola.calendula.ui.common.ResizeEdge
@@ -249,7 +239,6 @@ fun TimelineScreen(
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
onCreateSpan: (LocalDate, Int, Int?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
weekViewModel: WeekViewModel = hiltViewModel(), weekViewModel: WeekViewModel = hiltViewModel(),
multiDayViewModel: MultiDayViewModel = hiltViewModel(), multiDayViewModel: MultiDayViewModel = hiltViewModel(),
@@ -264,7 +253,6 @@ fun TimelineScreen(
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
onCreateSpan = onCreateSpan,
modifier = modifier, modifier = modifier,
) )
} }
@@ -322,7 +310,6 @@ private fun PagedTimelineScreen(
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
onCreateSpan: (LocalDate, Int, Int?) -> Unit,
modifier: Modifier, modifier: Modifier,
) { ) {
// The view on screen, which trails [view] while the columns resize into it. // The view on screen, which trails [view] while the columns resize into it.
@@ -355,7 +342,6 @@ private fun PagedTimelineScreen(
val grid = position.grid val grid = position.grid
val anchorPage = position.page val anchorPage = position.page
val dimCompleted by key(viewModel) { viewModel.dimCompletedEvents.collectAsStateWithLifecycle() } 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 // The instant before which an event counts as completed, or null when dimming
// is off. derivedStateOf keeps the per-minute "now" from recomposing the // is off. derivedStateOf keeps the per-minute "now" from recomposing the
// screen while the setting is off (it stays null regardless of the tick). // screen while the setting is off (it stays null regardless of the tick).
@@ -504,8 +490,6 @@ private fun PagedTimelineScreen(
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) }, onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
onCreateSpan = onCreateSpan,
draftMinutes = draftMinutes,
modifier = Modifier modifier = Modifier
.padding(innerPadding) .padding(innerPadding)
.fillMaxSize(), .fillMaxSize(),
@@ -528,8 +512,6 @@ private fun WeekContent(
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit, onCreateAt: (LocalDate, Int) -> Unit,
onCreateSpan: (LocalDate, Int, Int?) -> Unit,
draftMinutes: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val fadeSpec = rememberCalendarFadeSpec() val fadeSpec = rememberCalendarFadeSpec()
@@ -544,39 +526,33 @@ private fun WeekContent(
// Above the pager: a page change mid-drag would strand the floating block // Above the pager: a page change mid-drag would strand the floating block
// inside the outgoing page. // inside the outgoing page.
val dragController = rememberTimelineDragController() val dragController = rememberTimelineDragController()
SideEffect { dragController.draftMinutes = draftMinutes }
val move = LocalEventMove.current val move = LocalEventMove.current
val zone = remember { TimeZone.currentSystemDefault() } val zone = remember { TimeZone.currentSystemDefault() }
val onDrop: (TimelineDrop) -> Unit = drop@{ drop -> val onDrop: (TimelineDrop) -> Unit = { 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). // A selected event stays selected where it landed (#141).
if (dragController.selection?.event?.instanceId == drop.event.instanceId) { if (dragController.selection?.event?.instanceId == drop.event.instanceId) {
dragController.select(moved, moved = true) val start = drop.startInstant(zone)
dragController.select(
drop.event.copy(start = start, end = start + (drop.event.end - drop.event.start)),
moved = true,
)
} }
move?.move( move?.move(
MoveRequest( MoveRequest(
eventId = drop.event.eventId, eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(), beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(), endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.Start(start), target = MoveTarget.Start(drop.startInstant(zone)),
), ),
) )
} }
// A resize keeps its event selected, at its new length (#141). // A resize keeps its event selected, at its new length (#141).
val onResize: (TimelineResizeDrop) -> Unit = resize@{ drop -> val onResize: (TimelineResizeDrop) -> Unit = { drop ->
val resized = drop.event.copy(start = drop.start, end = drop.end) dragController.select(
if (drop.event.isDraft) { drop.event.copy(start = drop.start, end = drop.end),
dragController.select(resized) moved = true,
return@resize )
}
dragController.select(resized, moved = true)
move?.move( move?.move(
MoveRequest( MoveRequest(
eventId = drop.event.eventId, eventId = drop.event.eventId,
@@ -587,13 +563,6 @@ 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. // Selection (#141) ends on back, a page or view change, or any tap.
val selection = dragController.selection val selection = dragController.selection
PublishSelection( PublishSelection(
@@ -601,15 +570,10 @@ private fun WeekContent(
busy = dragController.isDragging, busy = dragController.isDragging,
compact = dragController.selectionCovered, compact = dragController.selectionCovered,
onDismiss = dragController::deselect, onDismiss = dragController::deselect,
onCreate = selection?.event?.takeIf { it.isDraft }?.let { draft -> { createDraft(draft) } },
) )
BackHandler(enabled = selection != null) { dragController.deselect() } BackHandler(enabled = selection != null) { dragController.deselect() }
LaunchedEffect(pagerState, dragController) { LaunchedEffect(pagerState, dragController) {
snapshotFlow { pagerState.currentPage }.drop(1).collect { snapshotFlow { pagerState.currentPage }.drop(1).collect { dragController.deselect() }
// 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(morph != null) { if (morph != null) dragController.deselect() }
LaunchedEffect(selection, dragController.settling, dragController.isDragging, dragController.selectedCandidates.size) { LaunchedEffect(selection, dragController.settling, dragController.isDragging, dragController.selectedCandidates.size) {
@@ -623,14 +587,9 @@ private fun WeekContent(
delay(SELECTION_ORPHAN_GRACE_MILLIS) delay(SELECTION_ORPHAN_GRACE_MILLIS)
dragController.deselect() dragController.deselect()
} }
val requireWrite = rememberWriteGate()
val openEvent: (EventInstance) -> Unit = { event -> val openEvent: (EventInstance) -> Unit = { event ->
if (event.isDraft) { dragController.deselect()
requireWrite { createDraft(event) } onEventClick(event)
} else {
dragController.deselect()
onEventClick(event)
}
} }
Box(modifier = modifier) { Box(modifier = modifier) {
@@ -1411,15 +1370,9 @@ private fun DayColumnCard(
val morphing = LocalViewMorph.current != null val morphing = LocalViewMorph.current != null
val showHourGrid = LocalShowHourGrid.current val showHourGrid = LocalShowHourGrid.current
val zone = remember { TimeZone.currentSystemDefault() } 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. // Tells a settled drop when this column has caught up with it.
LaunchedEffect(shown, dragController.settling) { LaunchedEffect(blocks, dragController.settling) {
dragController.noteGrid(date, shown) dragController.noteGrid(date, blocks)
} }
// The tap detector outlives the composition that installed it — a pinch or a // 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 // Settings change moves the hour height without restarting it — so it reads
@@ -1429,7 +1382,6 @@ private fun DayColumnCard(
val currentOnCreateAt = rememberUpdatedState(onCreateAt) val currentOnCreateAt = rememberUpdatedState(onCreateAt)
val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx) val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx)
val currentEdges = rememberUpdatedState(edges) val currentEdges = rememberUpdatedState(edges)
val draftSurface = rememberDraftSurface(date, edges, hourPx, dragController, onDrop)
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.
@@ -1468,17 +1420,14 @@ private fun DayColumnCard(
tappedMinuteOfDay(offset.y, currentHourPx.value), 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 val minEventHeight = hourHeight * MIN_EVENT_FRACTION
// Keyed by event, so a block that changes time or lane is the *same* // Keyed by event, so a block that changes time or lane is the *same*
// composable afterwards and tweens there. The ordinal disambiguates // composable afterwards and tweens there. The ordinal disambiguates
// a column holding two occurrences of one series. // a column holding two occurrences of one series.
val ordinals = mutableMapOf<Long, Int>() val ordinals = mutableMapOf<Long, Int>()
shown.forEach { block -> blocks.forEach { block ->
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1 val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
key(block.event.eventId, ordinal) { key(block.event.eventId, ordinal) {
val top = hourHeight * (block.startMin / 60f) val top = hourHeight * (block.startMin / 60f)
@@ -1489,7 +1438,6 @@ private fun DayColumnCard(
widthFraction = 1f / block.laneCount, widthFraction = 1f / block.laneCount,
y = top, y = top,
height = height, height = height,
landing = dragController.ghosts(block),
) )
// Where this block is cut at midnight, which decides both // Where this block is cut at midnight, which decides both
// its shape and the half-gap it gives up to the hour cells. // its shape and the half-gap it gives up to the hour cells.
@@ -1624,8 +1572,7 @@ private fun EventBlock(
val dimmed = dimCutoff != null && block.event.isPastOn(date, dimCutoff) val dimmed = dimCutoff != null && block.event.isPastOn(date, dimCutoff)
val zone = remember { TimeZone.currentSystemDefault() } val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event) val moveAction = eventMoveAction(block.event)
val draft = block.event.isDraft val draggable = eventDragAllowed(block.event)
val draggable = draft || eventDragAllowed(block.event)
val selectable = eventSelectAllowed() val selectable = eventSelectAllowed()
val selected = dragController.isSelected(block) val selected = dragController.isSelected(block)
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
@@ -1667,12 +1614,7 @@ private fun EventBlock(
// Released in place, the block takes over the copy's lift without a dip. // Released in place, the block takes over the copy's lift without a dip.
val takesOverLift = selected && dragController.selection?.fromLift == true val takesOverLift = selected && dragController.selection?.fromLift == true
val ghostFade = ghostAlpha(lifted) val ghostFade = ghostAlpha(lifted)
val ghost = when { val ghost = if (takesOverLift && !lifted) 1f else ghostFade
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) val raise = selectionLift(selected, snap = takesOverLift)
// Tells the action bar to fold when it would cover the bottom handle. // Tells the action bar to fold when it would cover the bottom handle.
val revealExtraPx = with(density) { (ResizeHandleTarget / 2).toPx() } val revealExtraPx = with(density) { (ResizeHandleTarget / 2).toPx() }
@@ -1747,10 +1689,9 @@ private fun EventBlock(
} }
} }
val end = { dragController.finishResize(zone)?.let(onResize) } val end = { dragController.finishResize(zone)?.let(onResize) }
// No bounce: an overshoot reads as the block's ends jumping.
val handleIn by animateFloatAsState( val handleIn by animateFloatAsState(
targetValue = if (handlesShown) 1f else 0f, targetValue = if (handlesShown) 1f else 0f,
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(), animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
label = "handles", label = "handles",
) )
val handleAlpha = Modifier.graphicsLayer { val handleAlpha = Modifier.graphicsLayer {
@@ -1790,62 +1731,6 @@ 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 private const val SELECTION_ORPHAN_GRACE_MILLIS = 500L
/** How far in from the block's side a resize handle's centre sits. */ /** How far in from the block's side a resize handle's centre sits. */
@@ -91,14 +91,6 @@ abstract class PagedTimelineViewModel(
initialValue = false, 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 private val todayDate: LocalDate
get() = currentDate() get() = currentDate()
-2
View File
@@ -102,8 +102,6 @@
<string name="event_selection_actions">Event actions</string> <string name="event_selection_actions">Event actions</string>
<string name="event_selection_hide">Hide event actions</string> <string name="event_selection_hide">Hide event actions</string>
<string name="event_selection_close">Deselect event</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_action_load_failed">Couldn\'t open the event</string>
<string name="event_move_undo">Undo</string> <string name="event_move_undo">Undo</string>
<string name="event_move_undone">Move undone</string> <string name="event_move_undone">Move undone</string>
@@ -1,68 +0,0 @@
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)
}