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
27 changed files with 97 additions and 864 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"
}
@@ -346,19 +346,6 @@ class SettingsPrefs @Inject constructor(
store.edit { it[DRAG_TO_RESCHEDULE_KEY] = enabled }
}
/**
* Whether tapping an event in the calendar views opens the edit form instead
* of the detail screen (#178). Defaults to OFF; read-only events and
* reminder notifications always open the detail screen.
*/
val tapOpensEditor: Flow<Boolean> = store.data.map { prefs ->
prefs[TAP_OPENS_EDITOR_KEY] ?: false
}
suspend fun setTapOpensEditor(enabled: Boolean) {
store.edit { it[TAP_OPENS_EDITOR_KEY] = enabled }
}
/**
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
* [AgendaRange.Month] — a month of upcoming events. Independent of the
@@ -1032,7 +1019,6 @@ class SettingsPrefs @Inject constructor(
internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale")
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
internal val DRAG_TO_RESCHEDULE_KEY = booleanPreferencesKey("drag_to_reschedule")
internal val TAP_OPENS_EDITOR_KEY = booleanPreferencesKey("tap_opens_editor")
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
@@ -192,7 +192,7 @@ fun CalendarHost(
// placeholder — so the destination never loads a bogus id=0 on first frame.
var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
var heldKey by remember { mutableStateOf<LongArray?>(null) }
val onOpenDetail: (EventInstance) -> Unit = { event ->
val onEventClick: (EventInstance) -> Unit = { event ->
val key = longArrayOf(
event.eventId,
event.start.toEpochMilliseconds(),
@@ -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 -> {}
@@ -410,17 +402,6 @@ fun CalendarHost(
heldEditKey = key
editKey = key
}
// A tap opens the edit form instead of the detail screen when the user chose
// so (#178) — only for events whose calendar takes writes.
val tapOpensEditor = viewModel.tapOpensEditor.collectAsStateWithLifecycle().value
val actionCalendars by eventActions.calendars.collectAsStateWithLifecycle()
val onEventClick: (EventInstance) -> Unit = { event ->
if (tapOpensEditor && actionCalendars[event.calendarId]?.canModifyContents == true) {
onEditEvent(event)
} else {
onOpenDetail(event)
}
}
// Off by preference (#173) switches off the gesture, not the scope: blocks
// register no drag, while the edit form and its TalkBack action stay put.
val dragToReschedule = viewModel.dragToReschedule.collectAsStateWithLifecycle().value
@@ -602,7 +583,6 @@ fun CalendarHost(
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onCreateSpan = onCreateSpan,
)
ViewScreen.Month -> MonthScreen(
onOpenDay = onOpenDay,
@@ -691,7 +671,6 @@ fun CalendarHost(
EventEditScreen(
initialDateIso = iso,
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
initialEndMinutes = createEndMinutes ?: heldCreateEndMinutes,
onClose = { createDateIso = null },
onSaved = { createDateIso = null },
onManageCalendars = { showCalendars = true },
@@ -716,10 +695,6 @@ fun CalendarHost(
detailKey = null
},
onManageCalendars = { showCalendars = true },
onDuplicate = { form ->
importFormSource = ImportSource.Insert
importForm = form
},
visible = editKey != null,
)
}
@@ -78,12 +78,4 @@ class CalendarHostViewModel @Inject constructor(
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
/** Whether tapping an event opens the edit form rather than the detail screen (#178). */
val tapOpensEditor: StateFlow<Boolean> = prefs.tapOpensEditor
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
}
@@ -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
}
/**
@@ -36,15 +36,6 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.Add
import kotlinx.coroutines.launch
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
@@ -201,10 +192,6 @@ import kotlin.time.Clock
* first asks how far the change reaches (this occurrence / this and
* following / whole series) — then closes. The calendar picker offers only
* writable calendars; when editing, the calendar is fixed.
*
* Leaving with unsaved changes asks to save, discard or keep editing (#178).
* While editing, the overflow menu offers Duplicate (the form as it stands,
* handed to [onDuplicate] as a new event), Share and Delete.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -214,12 +201,9 @@ fun EventEditScreen(
onSaved: () -> Unit,
editKey: LongArray? = null,
initialStartMinutes: Int? = null,
initialEndMinutes: Int? = null,
initialForm: EventForm? = null,
initialFormSource: ImportSource = ImportSource.File,
onManageCalendars: (() -> Unit)? = null,
onDuplicate: ((EventForm) -> Unit)? = null,
onDeleted: () -> Unit = onSaved,
visible: Boolean = true,
viewModel: EventEditViewModel = hiltViewModel(),
) {
@@ -236,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)
}
}
}
@@ -251,12 +235,6 @@ fun EventEditScreen(
viewModel.reset()
onClose()
}
val hasUnsavedChanges by viewModel.hasUnsavedChanges.collectAsStateWithLifecycle()
var showLeavePrompt by rememberSaveable { mutableStateOf(false) }
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
val requestClose = {
if (hasUnsavedChanges) showLeavePrompt = true else close()
}
// The event vanished between the detail screen and the edit tap — fall
// back to the detail screen, which shows its own failure state.
LaunchedEffect(loadFailed) {
@@ -284,42 +262,14 @@ fun EventEditScreen(
}
}
val scope = rememberCoroutineScope()
val requireWrite = rememberWriteGate()
val shareFailedMessage = stringResource(R.string.event_share_failed)
val shareChooserTitle = stringResource(R.string.event_share_chooser_title)
val onShareClick: () -> Unit = {
scope.launch {
val uri = viewModel.shareUri()
val sent = uri != null && context.sendEventShare(uri, shareChooserTitle)
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
}
}
// The copy opens over this form; a form with nothing to lose steps aside.
val onDuplicateClick: () -> Unit = {
viewModel.duplicateForm()?.let { form ->
onDuplicate?.invoke(form)
if (!hasUnsavedChanges) close()
}
}
val saveFailedMessage = stringResource(R.string.event_edit_save_failed)
val writeDeniedMessage = stringResource(R.string.event_edit_write_denied)
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
LaunchedEffect(state?.saveState) {
when (state?.saveState) {
SaveUiState.Saved -> {
viewModel.reset()
onSaved()
}
SaveUiState.Deleted -> {
viewModel.reset()
onDeleted()
}
SaveUiState.DeleteFailed -> {
viewModel.consumeSaveResult()
snackbarHostState.showSnackbar(deleteFailedMessage, duration = SnackbarDuration.Long)
}
// A failed save leaves the form looking unchanged, so the snackbar is
// the only sign anything happened — long rather than the default
// flash (Codeberg #234: it read as "nothing happens at all").
@@ -337,7 +287,7 @@ fun EventEditScreen(
}
Scaffold(
modifier = Modifier.predictiveBack(onBack = requestClose, visible = visible),
modifier = Modifier.predictiveBack(onBack = close, visible = visible),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
@@ -345,7 +295,7 @@ fun EventEditScreen(
// matching the detail screen's bare top bar.
title = {},
navigationIcon = {
IconButton(onClick = requestClose) {
IconButton(onClick = close) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.event_edit_close),
@@ -353,15 +303,6 @@ fun EventEditScreen(
}
},
actions = {
if (state?.isEditing == true) {
EditActionsMenu(
canDelete = state?.isManaged == false,
canDuplicate = onDuplicate != null,
onDuplicate = onDuplicateClick,
onShare = onShareClick,
onDelete = { requireWrite { showDeleteDialog = true } },
)
}
Button(
onClick = onSaveClick,
enabled = state != null && state?.saveState != SaveUiState.Saving,
@@ -388,32 +329,6 @@ fun EventEditScreen(
}
}
if (showLeavePrompt) {
UnsavedChangesDialog(
onSave = {
showLeavePrompt = false
onSaveClick()
},
onDiscard = {
showLeavePrompt = false
close()
},
onDismiss = { showLeavePrompt = false },
)
}
val recurs = viewModel.editedEventRecurs()
if (showDeleteDialog && recurs != null) {
DeleteEventDialog(
isRecurring = recurs,
onConfirm = { deleteScope ->
showDeleteDialog = false
viewModel.delete(deleteScope)
},
onDismiss = { showDeleteDialog = false },
)
}
if (state?.saveState == SaveUiState.AwaitingScope) {
SaveScopeDialog(
recurrenceChanged = state?.recurrenceChanged == true,
@@ -458,90 +373,6 @@ fun EventEditScreen(
}
}
/** Duplicate, Share and Delete for the event being edited, behind an overflow button. */
@Composable
private fun EditActionsMenu(
canDelete: Boolean,
canDuplicate: Boolean,
onDuplicate: () -> Unit,
onShare: () -> Unit,
onDelete: () -> Unit,
) {
var open by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { open = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.event_edit_more_actions),
)
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
if (canDuplicate) {
DropdownMenuItem(
text = { Text(stringResource(R.string.event_detail_duplicate)) },
leadingIcon = { Icon(Icons.Default.ContentCopy, contentDescription = null) },
onClick = {
open = false
onDuplicate()
},
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.event_detail_share)) },
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) },
onClick = {
open = false
onShare()
},
)
// Managed special-dates events have no delete, as on the detail screen.
if (canDelete) {
DropdownMenuItem(
text = { Text(stringResource(R.string.event_detail_delete)) },
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
onClick = {
open = false
onDelete()
},
)
}
}
}
}
/**
* Asked when leaving the form with unsaved changes (#178). Dismissing keeps
* editing; a form with nothing changed closes without asking.
*/
@Composable
private fun UnsavedChangesDialog(
onSave: () -> Unit,
onDiscard: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_edit_unsaved_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OptionCard(
label = stringResource(R.string.event_edit_unsaved_save),
onClick = onSave,
)
OptionCard(
label = stringResource(R.string.event_edit_unsaved_discard),
onClick = onDiscard,
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.event_edit_unsaved_keep))
}
},
)
}
/**
* Offer to apply the settings default reminder to an event opened from a `.ics`
* file. The file's own reminders are kept unless the user accepts. A plain
@@ -77,9 +77,6 @@ sealed interface SaveUiState {
data object Gone : SaveUiState
data object Saving : SaveUiState
data object Saved : SaveUiState
/** The event was deleted from the form (#178). */
data object Deleted : SaveUiState
data object DeleteFailed : SaveUiState
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
data object NeedsPermission : SaveUiState
data object Failed : SaveUiState
@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui.edit
import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -8,7 +7,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
@@ -20,7 +18,6 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EditSnapshot
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
@@ -29,9 +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.deleteEvent
import de.jeanlucmakiola.calendula.ui.common.stageEventShare
import de.jeanlucmakiola.calendula.ui.common.wallClockTime
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -138,7 +132,6 @@ class EventEditViewModel @Inject constructor(
private val repository: CalendarRepository,
private val prefs: CalendarPrefs,
private val settingsPrefs: SettingsPrefs,
private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
@@ -163,9 +156,6 @@ class EventEditViewModel @Inject constructor(
// A one-time offer, raised when a .ics import opens, to replace the file's
// reminders with the settings default (#49). Null while there's nothing to ask.
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
// Set by the first change the user makes, so a form only holding its own
// defaults or an import's prefill can be left without asking.
private val _edited = MutableStateFlow(false)
/** True when the event to edit couldn't be loaded; the screen closes itself. */
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
@@ -186,8 +176,6 @@ class EventEditViewModel @Inject constructor(
val beginMillis: Long,
val endMillis: Long,
val zone: TimeZone,
/** The event as loaded, on the tapped occurrence's times — what Share exports. */
val detail: EventDetail,
) {
val original: EventForm get() = snapshot.form
}
@@ -323,18 +311,6 @@ class EventEditViewModel @Inject constructor(
initialValue = null,
)
/**
* Whether leaving now would lose something the user did (#178): an edit that
* differs from the loaded event, or any change to a new or imported form.
*/
val hasUnsavedChanges: StateFlow<Boolean> = combine(_form, _editTarget, _edited) { form, target, edited ->
when {
form == null -> false
target != null -> form != target.original
else -> edited
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
/**
* First day of the week for ordering the recurrence weekday toggles — the
* app's "week starts on" preference, matching the calendar views (Auto
@@ -348,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()
@@ -368,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()
}
@@ -554,13 +525,7 @@ class EventEditViewModel @Inject constructor(
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
?: detail.instance.end.toEpochMilliseconds()
val snapshot = detail.toEditSnapshot(begin, end, zone)
val occurrence = detail.copy(
instance = detail.instance.copy(
start = Instant.fromEpochMilliseconds(begin),
end = Instant.fromEpochMilliseconds(end),
),
)
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone, occurrence)
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone)
// Sections holding data must show even when not in the defaults.
_revealed.value = snapshot.form.populatedFields()
_form.value = snapshot.form
@@ -578,7 +543,6 @@ class EventEditViewModel @Inject constructor(
_remindersTouched.value = false
_durationTouched.value = false
_importReminderPrompt.value = null
_edited.value = false
}
/** Unfold one optional field, picked in the "more fields" dialog. */
@@ -589,24 +553,24 @@ class EventEditViewModel @Inject constructor(
// The title field wraps (multi-line) so long titles stay visible (#33), but
// a title is one logical line: drop any newline the IME's Enter key or a
// paste would introduce, so it never reaches the provider's TITLE column.
fun setTitle(value: String) = edit {
fun setTitle(value: String) = update {
val cleaned = value.replace("\n", "").replace("\r", "")
it.copy(title = capLength(cleaned, it.title))
}
// The location wraps too (#273) but is likewise one logical line. A pasted
// multi-line address joins the way the contact picker's does (#146), so it
// never runs together into "12 Main St10115 Berlin".
fun setLocation(value: String) = edit {
fun setLocation(value: String) = update {
val cleaned = value.replace(LINE_BREAK, ", ")
it.copy(location = capLength(cleaned, it.location))
}
fun setDescription(value: String) = edit { it.copy(description = value) }
fun setDescription(value: String) = update { it.copy(description = value) }
fun setAllDay(value: Boolean) {
// Going all-day drops any pinned zone: the times become bare dates that
// the provider stores UTC-anchored, so a zone could only misrepresent
// them. Coming back out of all-day leaves the event on the device zone,
// which is the same thing a fresh event gets.
edit { it.copy(isAllDay = value, timezone = if (value) null else it.timezone) }
update { it.copy(isAllDay = value, timezone = if (value) null else it.timezone) }
// The default reminder differs for all-day vs timed; re-apply the
// type-appropriate default unless the user has hand-edited it (guarded).
applyDefaultReminder()
@@ -621,7 +585,7 @@ class EventEditViewModel @Inject constructor(
* time — only real picks are remembered, not the device fallback.
*/
fun setTimezone(zoneId: String?) {
edit { it.copy(timezone = zoneId) }
update { it.copy(timezone = zoneId) }
if (zoneId != null) {
viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } }
}
@@ -633,35 +597,35 @@ class EventEditViewModel @Inject constructor(
* The event falls back to the new calendar's colour until re-picked.
*/
fun setCalendar(id: Long) {
edit { it.copy(calendarId = id, colorKey = null, color = null) }
update { it.copy(calendarId = id, colorKey = null, color = null) }
// A fresh event re-inherits the new calendar's default reminder and
// length unless the user has already hand-edited them (guarded inside).
applyDefaultReminder(id)
applyDefaultDuration(id)
}
fun setAvailability(value: Availability) = edit { it.copy(availability = value) }
fun setAccessLevel(value: AccessLevel) = edit { it.copy(accessLevel = value) }
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
/** Pick a palette colour (round-trips via its key); [argb] is its swatch. */
fun setColorKey(key: String, argb: Int) = edit { it.copy(colorKey = key, color = argb) }
fun setColorKey(key: String, argb: Int) = update { it.copy(colorKey = key, color = argb) }
/** Pick a raw colour (local / opted-in calendars), clearing any palette key. */
fun setColorRaw(argb: Int) = edit { it.copy(colorKey = null, color = argb) }
fun setColorRaw(argb: Int) = update { it.copy(colorKey = null, color = argb) }
/** Clear the colour so the event inherits its calendar's. */
fun clearColor() = edit { it.copy(colorKey = null, color = null) }
fun clearColor() = update { it.copy(colorKey = null, color = null) }
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
fun setRecurrence(rrule: String?) = edit { it.copy(rrule = rrule) }
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
fun addReminder(minutes: Int) {
_remindersTouched.value = true
edit { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
update { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
}
fun removeReminder(minutes: Int) {
_remindersTouched.value = true
edit { it.copy(reminders = it.reminders - minutes) }
update { it.copy(reminders = it.reminders - minutes) }
}
/**
@@ -672,7 +636,7 @@ class EventEditViewModel @Inject constructor(
fun addAttendee(email: String, name: String = "", optional: Boolean = false) {
val trimmed = email.trim()
if (trimmed.isEmpty()) return
edit { form ->
update { form ->
val attendee = EventAttendee(email = trimmed, name = name.trim(), optional = optional)
val rest = form.attendees.filterNot { it.email.equals(trimmed, ignoreCase = true) }
form.copy(attendees = rest + attendee)
@@ -680,10 +644,10 @@ class EventEditViewModel @Inject constructor(
}
fun removeAttendee(attendee: EventAttendee) =
edit { it.copy(attendees = it.attendees.filterNot { a -> a.email.equals(attendee.email, ignoreCase = true) }) }
update { it.copy(attendees = it.attendees.filterNot { a -> a.email.equals(attendee.email, ignoreCase = true) }) }
/** Flip a guest between required and optional (the per-row role toggle). */
fun setAttendeeOptional(attendee: EventAttendee, optional: Boolean) = edit { form ->
fun setAttendeeOptional(attendee: EventAttendee, optional: Boolean) = update { form ->
form.copy(
attendees = form.attendees.map { a ->
if (a.email.equals(attendee.email, ignoreCase = true)) a.copy(optional = optional) else a
@@ -699,12 +663,12 @@ class EventEditViewModel @Inject constructor(
// default duration, so a later calendar switch keeps their span (#54).
fun setEndDate(date: LocalDate) {
_durationTouched.value = true
edit { it.copy(end = LocalDateTime(date, it.end.time)) }
update { it.copy(end = LocalDateTime(date, it.end.time)) }
}
fun setEndTime(time: LocalTime) {
_durationTouched.value = true
edit { it.copy(end = LocalDateTime(it.end.date, time)) }
update { it.copy(end = LocalDateTime(it.end.date, time)) }
}
/**
@@ -840,46 +804,12 @@ class EventEditViewModel @Inject constructor(
}
}
/** Whether the event being edited recurs; null while not editing. */
fun editedEventRecurs(): Boolean? = _editTarget.value?.let { it.original.rrule != null }
/** The form as it stands, as a fresh one-off event — Duplicate in the edit form (#178). */
fun duplicateForm(): EventForm? = state.value?.form?.copy(rrule = null)
/** A staged `.ics` of the event as saved, or null when it can't be built. */
suspend fun shareUri(): Uri? {
val detail = _editTarget.value?.detail ?: return null
return withContext(io) { icsExporter.stageEventShare(detail) }
}
/** Delete the event being edited, as far as [scope] reaches; result lands in [saveState]. */
fun delete(scope: RecurringWriteScope) {
val target = _editTarget.value ?: return
if (_saveState.value == SaveUiState.Saving) return
viewModelScope.launch {
_saveState.value = SaveUiState.Saving
_saveState.value = try {
repository.deleteEvent(target.eventId, target.beginMillis, scope)
SaveUiState.Deleted
} catch (e: CancellationException) {
throw e
} catch (e: SecurityException) {
SaveUiState.NeedsPermission
} catch (e: NoSuchEventException) {
SaveUiState.Gone
} catch (e: Exception) {
Log.w(TAG, "Delete failed (scope=$scope, eventId=${target.eventId})", e)
SaveUiState.DeleteFailed
}
}
}
/** Reset [saveState] after the screen handled a terminal result. */
fun consumeSaveResult() {
_saveState.value = SaveUiState.Idle
}
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = edit { form ->
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = update { form ->
val zone = TimeZone.currentSystemDefault()
val newStart = transform(form.start)
val duration = form.end.toInstant(zone) - form.start.toInstant(zone)
@@ -890,12 +820,4 @@ class EventEditViewModel @Inject constructor(
private inline fun update(block: (EventForm) -> EventForm) {
_form.value = _form.value?.let(block)
}
/** A change the user made, as opposed to a default the form applied itself. */
private inline fun edit(block: (EventForm) -> EventForm) {
val current = _form.value ?: return
val next = block(current)
if (next != current) _edited.value = true
_form.value = next
}
}
@@ -47,8 +47,6 @@ data class SettingsUiState(
val todayButtonInToolbar: Boolean = false,
/** Whether events can be dragged to another slot to reschedule them (#68, #173). */
val dragToReschedule: Boolean = true,
/** Whether tapping an event opens the edit form instead of the detail screen (#178). */
val tapOpensEditor: Boolean = false,
/** How far ahead the in-app Agenda screen shows events (v2.11). */
val agendaScreenRange: AgendaRange = AgendaRange.Month,
/** How far ahead the agenda widget shows events (v2.11). */
@@ -148,16 +148,13 @@ class SettingsViewModel @Inject constructor(
prefs.softenCalendarColors,
combine(
prefs.todayButtonInToolbar,
combine(prefs.dragToReschedule, prefs.tapOpensEditor, ::Pair),
prefs.dragToReschedule,
prefs.multiDayCount,
::Triple,
),
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, gestures, multiDay) ->
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove, multiDay) ->
DisplayToggles(
hourLines, weekNumbers, showToday, soften, todayInToolbar,
dragToReschedule = gestures.first,
tapOpensEditor = gestures.second,
multiDayCount = multiDay,
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove, multiDay,
)
},
) { view, screenRange, widgetRange, timeFormat, toggles ->
@@ -169,7 +166,6 @@ class SettingsViewModel @Inject constructor(
softenColors = toggles.softenColors,
todayButtonInToolbar = toggles.todayButtonInToolbar,
dragToReschedule = toggles.dragToReschedule,
tapOpensEditor = toggles.tapOpensEditor,
multiDayCount = toggles.multiDayCount,
)
},
@@ -206,7 +202,6 @@ class SettingsViewModel @Inject constructor(
softenColors = views.softenColors,
todayButtonInToolbar = views.todayButtonInToolbar,
dragToReschedule = views.dragToReschedule,
tapOpensEditor = views.tapOpensEditor,
multiDayCount = views.multiDayCount,
agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle,
@@ -310,7 +305,6 @@ class SettingsViewModel @Inject constructor(
val softenColors: Boolean,
val todayButtonInToolbar: Boolean,
val dragToReschedule: Boolean,
val tapOpensEditor: Boolean,
val multiDayCount: Int,
)
@@ -321,7 +315,6 @@ class SettingsViewModel @Inject constructor(
val softenColors: Boolean,
val todayButtonInToolbar: Boolean,
val dragToReschedule: Boolean,
val tapOpensEditor: Boolean,
val multiDayCount: Int,
)
@@ -569,10 +562,6 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setDragToReschedule(enabled) }
}
fun setTapOpensEditor(enabled: Boolean) {
viewModelScope.launch { prefs.setTapOpensEditor(enabled) }
}
/**
* Switch the launcher label (#44). The card highlights at once, then settles
* on what the component state reports; the writes are binder round-trips and
@@ -74,7 +74,6 @@ internal fun ViewsScreen(
var showDefaultView by remember { mutableStateOf(false) }
var showWeekStart by remember { mutableStateOf(false) }
var showTimeFormat by remember { mutableStateOf(false) }
var showTapOpens by remember { mutableStateOf(false) }
var showPastEvents by remember { mutableStateOf(false) }
var showAgendaScreenRange by remember { mutableStateOf(false) }
var showTimelineScale by remember { mutableStateOf(false) }
@@ -126,7 +125,7 @@ internal fun ViewsScreen(
GroupedRow(
title = stringResource(R.string.settings_dim_completed),
summary = stringResource(R.string.settings_dim_completed_summary),
position = Position.Bottom,
position = Position.Middle,
trailing = {
Switch(
checked = state.dimCompletedEvents,
@@ -135,17 +134,6 @@ internal fun ViewsScreen(
},
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
)
Spacer(Modifier.height(8.dp))
// How events respond to a tap and a long press (#178, #141).
SectionHeader(stringResource(R.string.settings_events_header))
GroupedRow(
title = stringResource(R.string.settings_tap_opens),
summary = tapOpensLabel(state.tapOpensEditor),
position = Position.Top,
onClick = { showTapOpens = true },
)
GroupedRow(
title = stringResource(R.string.settings_drag_to_reschedule),
summary = stringResource(R.string.settings_drag_to_reschedule_summary),
@@ -399,18 +387,6 @@ internal fun ViewsScreen(
onDismiss = { showTimeFormat = false },
)
}
if (showTapOpens) {
OptionPicker(
title = stringResource(R.string.settings_tap_opens),
header = { PickerDescription(stringResource(R.string.settings_tap_opens_hint)) },
predictiveBack = true,
options = listOf(false, true),
selected = state.tapOpensEditor,
label = { tapOpensLabel(it) },
onSelect = viewModel::setTapOpensEditor,
onDismiss = { showTapOpens = false },
)
}
if (showPastEvents) {
PastEventsPicker(
selected = state.pastEventDisplay,
@@ -546,11 +522,6 @@ internal fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
.getDisplayName(JavaTextStyle.FULL, currentLocale())
}
@Composable
private fun tapOpensLabel(editor: Boolean): String = stringResource(
if (editor) R.string.settings_tap_opens_editor else R.string.settings_tap_opens_details,
)
@Composable
private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
when (pref) {
@@ -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()
-12
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>
@@ -118,11 +116,6 @@
<!-- Event form (v1.2 create) -->
<string name="event_edit_new_title">New event</string>
<string name="event_edit_close">Close</string>
<string name="event_edit_more_actions">More actions</string>
<string name="event_edit_unsaved_title">Unsaved changes</string>
<string name="event_edit_unsaved_save">Save changes</string>
<string name="event_edit_unsaved_discard">Discard changes</string>
<string name="event_edit_unsaved_keep">Keep editing</string>
<string name="event_edit_save">Save</string>
<string name="event_edit_title_hint">Add title</string>
<!-- %1$s is the managing calendar's name, e.g. "Birthdays". -->
@@ -523,11 +516,6 @@
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
<string name="settings_drag_to_reschedule">Drag to reschedule</string>
<string name="settings_drag_to_reschedule_summary">Move an event by dragging it to another day or time</string>
<string name="settings_events_header">Events</string>
<string name="settings_tap_opens">Tapping an event opens</string>
<string name="settings_tap_opens_details">Details</string>
<string name="settings_tap_opens_editor">Edit form</string>
<string name="settings_tap_opens_hint">Events you can\'t edit and reminder notifications always open the details.</string>
<string name="settings_past_events">Past events</string>
<string name="settings_past_events_show">Show</string>
<string name="settings_past_events_dim">Dim</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)
}
@@ -1,13 +1,11 @@
package de.jeanlucmakiola.calendula.ui.edit
import android.content.ContextWrapper
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
@@ -25,7 +23,6 @@ import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.Dispatchers
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
@@ -98,8 +95,7 @@ class EventEditViewModelTest {
): EventEditViewModel {
val p = prefs(tempDir)
val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher)
// Only `shareUri()` touches the exporter, and nothing here shares.
return EventEditViewModel(repo, p, s, IcsExporter(ContextWrapper(null)), dispatcher)
return EventEditViewModel(repo, p, s, dispatcher)
}
/** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */
@@ -411,68 +407,4 @@ class EventEditViewModelTest {
assertThat(capLength(new, "")).isEqualTo("a".repeat(254))
}
@Test
fun `an edit counts as unsaved only while it differs from the loaded event`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
eventDetailResult = { detail(calendarId = 1L) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
assertThat(vm.hasUnsavedChanges.value).isFalse()
vm.setTitle("Retro")
advanceUntilIdle()
assertThat(vm.hasUnsavedChanges.value).isTrue()
vm.setTitle("Standup")
advanceUntilIdle()
assertThat(vm.hasUnsavedChanges.value).isFalse()
job.cancel()
}
@Test
fun `a new form holding only its defaults has nothing to lose`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openNew(LocalDate(2026, 10, 12), startMinutes = 9 * 60)
advanceUntilIdle()
assertThat(vm.hasUnsavedChanges.value).isFalse()
vm.setTitle("Lunch")
advanceUntilIdle()
assertThat(vm.hasUnsavedChanges.value).isTrue()
job.cancel()
}
@Test
fun `deleting from the form removes the event and reports it`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
eventDetailResult = { detail(calendarId = 1L) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
vm.delete(RecurringWriteScope.AllEvents)
advanceUntilIdle()
assertThat(fake.deletedEventIds).containsExactly(42L)
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Deleted)
job.cancel()
}
}
+1 -5
View File
@@ -119,11 +119,7 @@ gesture finishes from its scaled preview.
switch only changes what they say.
A tapped reminder notification routes through `MainActivity` (`singleTop` +
`onNewIntent`) as an external detail key that `CalendarHost` consumes
like an event tap. With the "tapping an event opens" setting on the edit form
(#178), a tap in the views or search opens the edit overlay directly for events
in writable calendars; notifications and widgets always open the detail. The
edit form guards its own back: unsaved changes ask to save, discard or keep
editing.
exactly like an event tap.
### First-launch wizard