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