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:
# Gitea only; see the same guard in release.yaml.
if: github.repository_owner == 'makiolaj'
runs-on: light
runs-on: docker
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: gradle
runs-on: docker
env:
ANDROID_HOME: /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
# URL, proxy or instance rename, so this closes it permanently.
if: github.repository_owner == 'makiolaj'
runs-on: light
runs-on: docker
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: gradle
runs-on: docker
env:
ANDROID_HOME: /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
- **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
@@ -1860,4 +1859,3 @@ 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
+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,
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md.
versionCode = 2230003
versionName = "2.23.0-beta.3"
versionCode = 2230001
versionName = "2.23.0-beta.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -239,18 +239,12 @@ fun CalendarHost(
var heldCreateIso by remember { mutableStateOf<String?>(null) }
var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
var heldCreateMinutes by remember { mutableStateOf<Int?>(null) }
// A drafted block's end (#153), in minutes from the start day's midnight.
var createEndMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
var heldCreateEndMinutes by remember { mutableStateOf<Int?>(null) }
val onCreateSpan: (LocalDate, Int?, Int?) -> Unit = { date, startMinutes, endMinutes ->
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes ->
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
@@ -386,8 +380,6 @@ fun CalendarHost(
createDateIso = iso
heldCreateMinutes = null
createStartMinutes = null
heldCreateEndMinutes = null
createEndMinutes = null
onWidgetNavConsumed()
}
null -> {}
@@ -591,7 +583,6 @@ fun CalendarHost(
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onCreateSpan = onCreateSpan,
)
ViewScreen.Month -> MonthScreen(
onOpenDay = onOpenDay,
@@ -680,7 +671,6 @@ fun CalendarHost(
EventEditScreen(
initialDateIso = iso,
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
initialEndMinutes = createEndMinutes ?: heldCreateEndMinutes,
onClose = { createDateIso = null },
onSaved = { createDateIso = null },
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
* 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,
landing: Boolean = false,
): BlockPlacement {
fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement {
val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
val dpSpec: FiniteAnimationSpec<Dp> = when {
still -> snap()
landing -> MaterialTheme.motionScheme.fastEffectsSpec()
else -> MaterialTheme.motionScheme.fastSpatialSpec()
}
val dpSpec: FiniteAnimationSpec<Dp> = 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 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
* 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,
val create: (() -> Unit)? = null,
)
class EventSelection(val event: EventInstance, val dismiss: () -> Unit)
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
@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. */
@Composable
fun PublishSelection(
event: EventInstance?,
busy: Boolean,
compact: Boolean,
onDismiss: () -> Unit,
onCreate: (() -> Unit)? = null,
) {
fun PublishSelection(event: EventInstance?, busy: Boolean, compact: Boolean, onDismiss: () -> Unit) {
val slot = LocalChromeSlot.current ?: return
val currentDismiss by rememberUpdatedState(onDismiss)
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 selection = remember(event) { event?.let { EventSelection(it) { currentDismiss() } } }
val host = slot.host
val current = slot.current
DisposableEffect(host, selection, current) {
@@ -73,8 +73,7 @@ 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. [skipClaimedDown] leaves
* a press a descendant already consumed alone.
* whether anything is there; false abandons the gesture.
*/
@Composable
fun rememberDragSurface(
@@ -84,8 +83,6 @@ fun rememberDragSurface(
onMove: (pointerInRoot: Offset) -> Unit,
onDrop: () -> Unit,
onCancel: () -> Unit,
onRelease: (() -> Unit)? = null,
skipClaimedDown: Boolean = false,
): Modifier = rememberPickUpSurface(
enabled = enabled,
key = key,
@@ -96,8 +93,7 @@ fun rememberDragSurface(
onMove = onMove,
onDrop = onDrop,
onCancel = onCancel,
onRelease = onRelease,
skipClaimedDown = skipClaimedDown,
onRelease = null,
)
@Composable
@@ -110,7 +106,6 @@ private fun rememberPickUpSurface(
onDrop: () -> Unit,
onCancel: () -> Unit,
onRelease: (() -> Unit)?,
skipClaimedDown: Boolean = false,
): Modifier {
if (!enabled) return Modifier
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
@@ -130,8 +125,6 @@ private fun rememberPickUpSurface(
val slop = viewConfiguration.touchSlop
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
// A child that took the press, such as a resize handle, keeps it.
if (skipClaimedDown && down.isConsumed) return@awaitEachGesture
val origin = down.position
val immediate = currentImmediate
// Null: held still. True: an immediate pickup past slop. False: abandoned.
@@ -5,8 +5,6 @@ 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
@@ -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
private fun BarAction(icon: ImageVector, label: Int, onClick: () -> Unit) {
IconButton(onClick = onClick) {
@@ -47,23 +47,6 @@ fun rememberFabSelection(
val currentWrite by rememberUpdatedState(requireWrite)
val selection = host.selection ?: return null
selection.create?.let { create ->
return remember(selection, expanded, chipShown) {
FabSelection(
expanded = expanded,
onToggle = {
if (chipShown) currentDismissChip()
chosen = !expanded
},
bar = {
DraftSelectionBar(
onCreate = { currentWrite { create() } },
onDiscard = selection.dismiss,
)
},
)
}
}
val event = selection.event
val calendar = calendars[event.calendarId]
val canEdit = calendar?.canModifyContents == true
@@ -195,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. */
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)
return true
}
@@ -40,9 +40,7 @@ fun SelectionTopBar(selection: EventSelection) {
IconButton(onClick = selection.dismiss) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(
if (selection.create != null) R.string.event_draft_discard else R.string.event_selection_close,
),
contentDescription = stringResource(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.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
@@ -62,7 +61,6 @@ 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. */
@@ -271,41 +269,6 @@ 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 }
@@ -428,9 +391,6 @@ class TimelineDragController {
*/
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
/** How long a block drafted on an empty slot starts out, in minutes (#153). */
var draftMinutes: Int = SettingsPrefs.DEFAULT_EVENT_DURATION
/** The selected event, if any — see [TimelineSelection]. */
var selection: TimelineSelection? by mutableStateOf(null)
private set
@@ -441,7 +401,6 @@ class TimelineDragController {
}
fun deselect() {
if (selection?.event?.isDraft == true && settling?.event?.isDraft == true) release()
selection = null
selectionCovered = false
}
@@ -601,16 +560,6 @@ 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
@@ -691,8 +640,6 @@ 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
@@ -716,12 +663,18 @@ class TimelineDragController {
titleLines: Int,
pointerInRoot: Offset,
) {
val span = wallClockSpan(block.event, zone)
val start = block.event.start.toLocalDateTime(zone)
val end = block.event.end.toLocalDateTime(zone)
source = block
resizeEdge = edge
resizeDay = span.day
resizeStartMin = span.startMin
resizeEndMin = span.endMin
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,
)
clipOffsetMin = 0
this.titleLines = titleLines
settling = null
@@ -731,7 +684,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(span.day.plus(edgeDay, DateTimeUnit.DAY))
val edgeIndex = geometry.days.indexOf(start.date.plus(edgeDay, DateTimeUnit.DAY))
resizeColumn = edgeIndex.takeIf { it >= 0 }
?.let { if (geometry.isRtl) geometry.days.lastIndex - it else it }
resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
@@ -750,16 +703,16 @@ class TimelineDragController {
val origin = originSlot
cancel()
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
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),
)
settledEnd = drop.end
return drop
}
@@ -1004,27 +957,10 @@ 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 =
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 {
fun wallClockInstant(date: LocalDate, minutes: Int, zone: TimeZone): Instant {
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))
}
/** 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))
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone)
}
/**
@@ -1276,7 +1212,7 @@ private fun DragCopy(
Text(
text = label,
// As the block it lifted off sets its own time (#219).
style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime()),
style = MaterialTheme.typography.labelSmall.asEventTime(),
maxLines = timeMaxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
@@ -1297,8 +1233,7 @@ 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)
}
// The effect runs a frame late, and that frame would show the block unlifted.
return if (selected && snap) 1f else raise.value
return raise.value
}
/**
@@ -201,7 +201,6 @@ fun EventEditScreen(
onSaved: () -> Unit,
editKey: LongArray? = null,
initialStartMinutes: Int? = null,
initialEndMinutes: Int? = null,
initialForm: EventForm? = null,
initialFormSource: ImportSource = ImportSource.File,
onManageCalendars: (() -> Unit)? = null,
@@ -221,7 +220,7 @@ fun EventEditScreen(
else -> {
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?: 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.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
@@ -325,12 +324,10 @@ 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, endMinutes: Int? = null) {
fun openNew(date: LocalDate, startMinutes: Int? = null) {
if (_form.value != null) return
val zone = TimeZone.currentSystemDefault()
val now = Clock.System.now()
@@ -345,12 +342,9 @@ class EventEditViewModel @Inject constructor(
}
else -> LocalDateTime(date, LocalTime(9, 0))
}
val chosenEnd = endMinutes?.takeIf { startMinutes != null && it > startMinutes }
?.let { wallClockTime(date, it) }
val end = chosenEnd ?: (start.toInstant(zone) + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes)
val end = (start.toInstant(zone) + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes)
.toLocalDateTime(zone)
_form.value = EventForm(calendarId = null, start = start, end = end)
if (chosenEnd != null) _durationTouched.value = true
applyDefaultReminder()
applyDefaultDuration()
}
@@ -64,16 +64,6 @@ 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
@@ -249,7 +239,6 @@ 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(),
@@ -264,7 +253,6 @@ fun TimelineScreen(
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onCreateSpan = onCreateSpan,
modifier = modifier,
)
}
@@ -322,7 +310,6 @@ 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.
@@ -355,7 +342,6 @@ 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).
@@ -504,8 +490,6 @@ private fun PagedTimelineScreen(
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
onCreateSpan = onCreateSpan,
draftMinutes = draftMinutes,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
@@ -528,8 +512,6 @@ 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()
@@ -544,39 +526,33 @@ 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@{ 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
}
val onDrop: (TimelineDrop) -> Unit = { drop ->
// A selected event stays selected where it landed (#141).
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(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.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).
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)
val onResize: (TimelineResizeDrop) -> Unit = { drop ->
dragController.select(
drop.event.copy(start = drop.start, end = drop.end),
moved = true,
)
move?.move(
MoveRequest(
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.
val selection = dragController.selection
PublishSelection(
@@ -601,15 +570,10 @@ 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 {
// 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()
}
snapshotFlow { pagerState.currentPage }.drop(1).collect { dragController.deselect() }
}
LaunchedEffect(morph != null) { if (morph != null) dragController.deselect() }
LaunchedEffect(selection, dragController.settling, dragController.isDragging, dragController.selectedCandidates.size) {
@@ -623,14 +587,9 @@ private fun WeekContent(
delay(SELECTION_ORPHAN_GRACE_MILLIS)
dragController.deselect()
}
val requireWrite = rememberWriteGate()
val openEvent: (EventInstance) -> Unit = { event ->
if (event.isDraft) {
requireWrite { createDraft(event) }
} else {
dragController.deselect()
onEventClick(event)
}
dragController.deselect()
onEventClick(event)
}
Box(modifier = modifier) {
@@ -1411,15 +1370,9 @@ 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(shown, dragController.settling) {
dragController.noteGrid(date, shown)
LaunchedEffect(blocks, dragController.settling) {
dragController.noteGrid(date, blocks)
}
// 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
@@ -1429,7 +1382,6 @@ 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.
@@ -1468,17 +1420,14 @@ private fun DayColumnCard(
tappedMinuteOfDay(offset.y, currentHourPx.value),
)
}
}
// Inside the tap detector, which consumes every down, so only a
// block or handle on top can claim the press first.
.then(draftSurface),
},
) {
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
// Keyed by event, so a block that changes time or lane is the *same*
// composable afterwards and tweens there. The ordinal disambiguates
// a column holding two occurrences of one series.
val ordinals = mutableMapOf<Long, Int>()
shown.forEach { block ->
blocks.forEach { block ->
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
key(block.event.eventId, ordinal) {
val top = hourHeight * (block.startMin / 60f)
@@ -1489,7 +1438,6 @@ 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.
@@ -1624,8 +1572,7 @@ private fun EventBlock(
val dimmed = dimCutoff != null && block.event.isPastOn(date, dimCutoff)
val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event)
val draft = block.event.isDraft
val draggable = draft || eventDragAllowed(block.event)
val draggable = eventDragAllowed(block.event)
val selectable = eventSelectAllowed()
val selected = dragController.isSelected(block)
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.
val takesOverLift = selected && dragController.selection?.fromLift == true
val ghostFade = ghostAlpha(lifted)
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 ghost = if (takesOverLift && !lifted) 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() }
@@ -1747,10 +1689,9 @@ 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.fastEffectsSpec(),
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
label = "handles",
)
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
/** How far in from the block's side a resize handle's centre sits. */
@@ -91,14 +91,6 @@ abstract class PagedTimelineViewModel(
initialValue = false,
)
/** How long a block drafted on an empty slot starts out (#153). */
val defaultEventDuration: StateFlow<Int> = settingsPrefs.defaultEventDurationMinutes
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = SettingsPrefs.DEFAULT_EVENT_DURATION,
)
private val todayDate: LocalDate
get() = currentDate()
-2
View File
@@ -102,8 +102,6 @@
<string name="event_selection_actions">Event actions</string>
<string name="event_selection_hide">Hide event actions</string>
<string name="event_selection_close">Deselect event</string>
<string name="event_draft_create">Create event</string>
<string name="event_draft_discard">Discard new event</string>
<string name="event_action_load_failed">Couldn\'t open the event</string>
<string name="event_move_undo">Undo</string>
<string name="event_move_undone">Move undone</string>
@@ -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)
}