Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dce83005ea | ||
|
|
1ac486cc4b |
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### 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]).
|
||||||
- **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
|
||||||
@@ -1857,3 +1858,4 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#364]: https://codeberg.org/jlmakiola/calendula/issues/364
|
[#364]: https://codeberg.org/jlmakiola/calendula/issues/364
|
||||||
[#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
|
||||||
|
|||||||
@@ -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 = 2230001
|
versionCode = 2230002
|
||||||
versionName = "2.23.0-beta.1"
|
versionName = "2.23.0-beta.2"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,3 +265,20 @@ fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
|||||||
add(EventFormProblem.RecurrenceEndsBeforeStart)
|
add(EventFormProblem.RecurrenceEndsBeforeStart)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A create form seeded from this occurrence, to save a copy as a new,
|
||||||
|
* independent event (#52). The recurrence is dropped — a duplicate is a single
|
||||||
|
* event. The source calendar is kept only when [canModify], otherwise the id is
|
||||||
|
* cleared so the create resolves to a writable calendar, which lets a read-only
|
||||||
|
* event be copied into an editable one. [instance]'s own times carry over.
|
||||||
|
*/
|
||||||
|
fun EventDetail.toDuplicateForm(canModify: Boolean, zone: TimeZone): EventForm =
|
||||||
|
toEditForm(
|
||||||
|
beginMillis = instance.start.toEpochMilliseconds(),
|
||||||
|
endMillis = instance.end.toEpochMilliseconds(),
|
||||||
|
zone = zone,
|
||||||
|
).copy(
|
||||||
|
rrule = null,
|
||||||
|
calendarId = instance.calendarId.takeIf { canModify },
|
||||||
|
)
|
||||||
|
|||||||
@@ -51,3 +51,13 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
|
|||||||
val span = end.toInstant(zone) - start.toInstant(zone)
|
val span = end.toInstant(zone) - start.toInstant(zone)
|
||||||
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone))
|
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The form with new start and end, each in the form's own zone — a resize. All-day is untouched. */
|
||||||
|
fun EventForm.resizedTo(newStart: Instant, newEnd: Instant, deviceZone: TimeZone): EventForm {
|
||||||
|
if (isAllDay) return this
|
||||||
|
val zone = resolvedZone(deviceZone)
|
||||||
|
return copy(
|
||||||
|
start = newStart.toLocalDateTime(zone),
|
||||||
|
end = newEnd.toLocalDateTime(zone),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,7 +55,11 @@ import de.jeanlucmakiola.floret.identity.fadeThrough
|
|||||||
import de.jeanlucmakiola.floret.identity.predictiveBackExit
|
import de.jeanlucmakiola.floret.identity.predictiveBackExit
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventActionsViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventSelectionHost
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventActionFailureMessage
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberFabSelection
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||||
@@ -386,6 +390,9 @@ fun CalendarHost(
|
|||||||
// survives view switches; the calendar surfaces read it out of LocalEventMove.
|
// survives view switches; the calendar surfaces read it out of LocalEventMove.
|
||||||
val reschedule: RescheduleViewModel = hiltViewModel()
|
val reschedule: RescheduleViewModel = hiltViewModel()
|
||||||
val movableCalendarIds by reschedule.movableCalendarIds.collectAsStateWithLifecycle()
|
val movableCalendarIds by reschedule.movableCalendarIds.collectAsStateWithLifecycle()
|
||||||
|
val eventActions: EventActionsViewModel = hiltViewModel()
|
||||||
|
val moveOutcome by reschedule.outcome.collectAsStateWithLifecycle()
|
||||||
|
val selectionFailure by eventActions.failure.collectAsStateWithLifecycle()
|
||||||
val onEditEvent: (EventInstance) -> Unit = { event ->
|
val onEditEvent: (EventInstance) -> Unit = { event ->
|
||||||
val key = longArrayOf(
|
val key = longArrayOf(
|
||||||
event.eventId,
|
event.eventId,
|
||||||
@@ -527,7 +534,25 @@ fun CalendarHost(
|
|||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = { CalendarFab(chromeHost, todayInToolbar) },
|
floatingActionButton = {
|
||||||
|
// While an event is selected the FAB is its action bar's toggle (#141).
|
||||||
|
val fabSelection = rememberFabSelection(
|
||||||
|
host = chromeHost,
|
||||||
|
viewModel = eventActions,
|
||||||
|
onEdit = onEditEvent,
|
||||||
|
onDuplicate = { form ->
|
||||||
|
// A fresh event, like a duplicate from the detail screen (#52).
|
||||||
|
importFormSource = ImportSource.Insert
|
||||||
|
importForm = form
|
||||||
|
},
|
||||||
|
chipShown = moveOutcome != null || selectionFailure != null,
|
||||||
|
onDismissChip = {
|
||||||
|
reschedule.consumeOutcome()
|
||||||
|
eventActions.consumeFailure()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
CalendarFab(chromeHost, todayInToolbar, fabSelection)
|
||||||
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
SharedTransitionLayout(
|
SharedTransitionLayout(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -582,7 +607,15 @@ fun CalendarHost(
|
|||||||
|
|
||||||
// Scope prompt + confirmation/undo snackbar for a dropped event, declared
|
// Scope prompt + confirmation/undo snackbar for a dropped event, declared
|
||||||
// right after the calendar views so later overlays cover it.
|
// right after the calendar views so later overlays cover it.
|
||||||
EventMoveHost(reschedule, modifier = Modifier.fillMaxSize())
|
EventMoveHost(
|
||||||
|
reschedule,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
actionFailure = eventActionFailureMessage(selectionFailure),
|
||||||
|
onActionFailureShown = eventActions::consumeFailure,
|
||||||
|
)
|
||||||
|
|
||||||
|
// The delete prompt and failure chip of a selected event's actions (#141).
|
||||||
|
EventSelectionHost(host = chromeHost, viewModel = eventActions)
|
||||||
|
|
||||||
// Search overlay — below detail/edit in the Box so a tapped result's
|
// Search overlay — below detail/edit in the Box so a tapped result's
|
||||||
// detail screen draws on top, and closing it returns to the results.
|
// detail screen draws on top, and closing it returns to the results.
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
@@ -16,6 +25,9 @@ import androidx.compose.material3.TopAppBarDefaults
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
@@ -24,6 +36,7 @@ import androidx.compose.runtime.staticCompositionLocalOf
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,12 +66,31 @@ class ScreenActions(
|
|||||||
val onCreate: () -> Unit,
|
val onCreate: () -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class EventSelection(val event: EventInstance, val dismiss: () -> Unit)
|
||||||
|
|
||||||
/** 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
|
||||||
class ChromeHost {
|
class ChromeHost {
|
||||||
var chrome by mutableStateOf<ScreenChrome?>(null)
|
var chrome by mutableStateOf<ScreenChrome?>(null)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
var selection by mutableStateOf<EventSelection?>(null)
|
||||||
|
internal set
|
||||||
|
|
||||||
|
/** Whether the selected event is being dragged or resized right now. */
|
||||||
|
var selectionBusy by mutableStateOf(false)
|
||||||
|
internal set
|
||||||
|
|
||||||
|
/** Whether the bar would cover the selected event, and so draws compact. */
|
||||||
|
var selectionCompact by mutableStateOf(false)
|
||||||
|
internal set
|
||||||
|
|
||||||
|
|
||||||
var actions: ScreenActions? = null
|
var actions: ScreenActions? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@@ -80,8 +112,35 @@ fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
|
|||||||
if (slot.current) SideEffect { slot.host.publish(chrome, actions) }
|
if (slot.current) SideEffect { slot.host.publish(chrome, actions) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The top bar every calendar screen shares. */
|
/** Hands the screen's selected [event] to the host, for as long as it stays selected. */
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@Composable
|
||||||
|
fun PublishSelection(event: EventInstance?, busy: Boolean, compact: Boolean, onDismiss: () -> Unit) {
|
||||||
|
val slot = LocalChromeSlot.current ?: return
|
||||||
|
val currentDismiss by rememberUpdatedState(onDismiss)
|
||||||
|
val selection = remember(event) { event?.let { EventSelection(it) { currentDismiss() } } }
|
||||||
|
val host = slot.host
|
||||||
|
val current = slot.current
|
||||||
|
DisposableEffect(host, selection, current) {
|
||||||
|
if (current) host.selection = selection
|
||||||
|
onDispose {
|
||||||
|
if (host.selection === selection) host.selection = null
|
||||||
|
// Left mid-drag, nothing else would clear these for the other views.
|
||||||
|
host.selectionBusy = false
|
||||||
|
host.selectionCompact = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) {
|
||||||
|
SideEffect {
|
||||||
|
host.selectionBusy = busy && selection != null
|
||||||
|
host.selectionCompact = compact && selection != null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The top bar every calendar screen shares — or, while an event is selected,
|
||||||
|
* the selection's own bar in its place (#141).
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarTopBar(
|
fun CalendarTopBar(
|
||||||
host: ChromeHost,
|
host: ChromeHost,
|
||||||
@@ -91,6 +150,43 @@ fun CalendarTopBar(
|
|||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
|
) {
|
||||||
|
val effects = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = host.selection,
|
||||||
|
contentKey = { it != null },
|
||||||
|
transitionSpec = {
|
||||||
|
(fadeIn(effects) + slideInVertically { -it / 4 })
|
||||||
|
.togetherWith(fadeOut(effects) + slideOutVertically { -it / 4 })
|
||||||
|
},
|
||||||
|
label = "top-bar",
|
||||||
|
) { selection ->
|
||||||
|
if (selection != null) {
|
||||||
|
SelectionTopBar(selection)
|
||||||
|
} else {
|
||||||
|
ViewTopBar(
|
||||||
|
host = host,
|
||||||
|
selectedView = selectedView,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
onCycleView = onCycleView,
|
||||||
|
onOpenDrawer = onOpenDrawer,
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
|
showTodayButton = showTodayButton,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun ViewTopBar(
|
||||||
|
host: ChromeHost,
|
||||||
|
selectedView: CalendarView,
|
||||||
|
quickSwitchViews: List<CalendarView>,
|
||||||
|
onCycleView: () -> Unit,
|
||||||
|
onOpenDrawer: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
|
showTodayButton: Boolean,
|
||||||
) {
|
) {
|
||||||
val chrome = host.chrome
|
val chrome = host.chrome
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -136,16 +232,26 @@ fun CalendarTopBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The FAB column every calendar screen shares. */
|
/**
|
||||||
|
* The FAB column every calendar screen shares. While an event is selected the
|
||||||
|
* FAB is [selection]'s toggle instead, and it steps aside mid-gesture.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) {
|
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean, selection: FabSelection? = null) {
|
||||||
val chrome = host.chrome ?: return
|
val chrome = host.chrome ?: return
|
||||||
CalendarFabColumn(
|
AnimatedVisibility(
|
||||||
todayVisible = chrome.awayFromToday && !todayInToolbar,
|
visible = !host.selectionBusy,
|
||||||
todayText = chrome.todayText,
|
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(),
|
||||||
onToday = { host.actions?.onToday?.invoke() },
|
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(),
|
||||||
onCreate = { host.actions?.onCreate?.invoke() },
|
) {
|
||||||
)
|
CalendarFabColumn(
|
||||||
|
todayVisible = chrome.awayFromToday && !todayInToolbar,
|
||||||
|
todayText = chrome.todayText,
|
||||||
|
onToday = { host.actions?.onToday?.invoke() },
|
||||||
|
onCreate = { host.actions?.onCreate?.invoke() },
|
||||||
|
selection = selection,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.expandHorizontally
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.animation.shrinkHorizontally
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
@@ -15,15 +24,38 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/** The standard FAB's size, shared by the selection bar that unrolls from it (#141). */
|
||||||
|
val FabSize = 56.dp
|
||||||
|
|
||||||
|
/** What the FAB is while an event is selected (#141): the toggle for its action bar. */
|
||||||
|
@Stable
|
||||||
|
class FabSelection(
|
||||||
|
val expanded: Boolean,
|
||||||
|
val onToggle: () -> Unit,
|
||||||
|
val bar: @Composable () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The FAB stack shared by the three calendar views: a persistent "+" to
|
* The FAB stack shared by the three calendar views: a persistent "+" to
|
||||||
* create an event, with the jump-to-today pill appearing above it whenever
|
* create an event, with the jump-to-today pill appearing above it whenever
|
||||||
* the view isn't anchored on today.
|
* the view isn't anchored on today. While an event is selected the same FAB,
|
||||||
|
* in the same spot, opens and folds the selection's action bar, which unrolls
|
||||||
|
* from it towards the start.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -32,13 +64,14 @@ fun CalendarFabColumn(
|
|||||||
todayText: String,
|
todayText: String,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
onCreate: () -> Unit,
|
onCreate: () -> Unit,
|
||||||
|
selection: FabSelection? = null,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.End,
|
horizontalAlignment = Alignment.End,
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = todayVisible,
|
visible = todayVisible && selection == null,
|
||||||
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()),
|
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()),
|
||||||
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()),
|
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()),
|
||||||
) {
|
) {
|
||||||
@@ -48,11 +81,71 @@ fun CalendarFabColumn(
|
|||||||
text = { Text(todayText) },
|
text = { Text(todayText) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
FloatingActionButton(onClick = onCreate) {
|
// Held past the selection clearing, so the bar has something to fold away.
|
||||||
Icon(
|
val heldBar = remember { mutableStateOf<(@Composable () -> Unit)?>(null) }
|
||||||
imageVector = Icons.Default.Add,
|
if (selection != null && heldBar.value !== selection.bar) heldBar.value = selection.bar
|
||||||
contentDescription = stringResource(R.string.event_edit_new_title),
|
// Held at the FAB's height so the taller bar can't shift the FAB.
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.height(FabSize),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = selection?.expanded == true,
|
||||||
|
enter = expandHorizontally(
|
||||||
|
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
|
||||||
|
expandFrom = Alignment.End,
|
||||||
|
) + fadeIn(),
|
||||||
|
exit = shrinkHorizontally(
|
||||||
|
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
|
||||||
|
shrinkTowards = Alignment.End,
|
||||||
|
) + fadeOut(),
|
||||||
|
) {
|
||||||
|
heldBar.value?.invoke()
|
||||||
|
}
|
||||||
|
val label = stringResource(
|
||||||
|
when {
|
||||||
|
selection == null -> R.string.event_edit_new_title
|
||||||
|
selection.expanded -> R.string.event_selection_hide
|
||||||
|
else -> R.string.event_selection_actions
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = selection?.onToggle ?: onCreate,
|
||||||
|
modifier = Modifier.semantics { contentDescription = label },
|
||||||
|
) {
|
||||||
|
FabGlyph(selecting = selection != null, expanded = selection?.expanded == true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "+", or with an event selected a chevron that turns to open and fold the bar. */
|
||||||
|
@Composable
|
||||||
|
private fun FabGlyph(selecting: Boolean, expanded: Boolean) {
|
||||||
|
val spatial = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
|
||||||
|
val swap by animateFloatAsState(if (selecting) 1f else 0f, spatial, label = "fab-swap")
|
||||||
|
val turn by animateFloatAsState(if (expanded) 0f else 180f, spatial, label = "fab-turn")
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.graphicsLayer {
|
||||||
|
rotationZ = 90f * swap
|
||||||
|
scaleX = 1f - 0.5f * swap
|
||||||
|
scaleY = 1f - 0.5f * swap
|
||||||
|
alpha = (1f - swap).coerceIn(0f, 1f)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.graphicsLayer {
|
||||||
|
rotationZ = turn - 90f * (1f - swap)
|
||||||
|
scaleX = 0.5f + 0.5f * swap
|
||||||
|
scaleY = 0.5f + 0.5f * swap
|
||||||
|
alpha = swap.coerceIn(0f, 1f)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
||||||
|
import de.jeanlucmakiola.floret.components.OptionCard
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialise [detail] to a cache `.ics` and return a shareable content Uri for an
|
||||||
|
* `ACTION_SEND`, or null when the write fails. Exported as a one-off (see
|
||||||
|
* [toShareIcsEvent]). Blocking — call it off the main thread.
|
||||||
|
*/
|
||||||
|
fun IcsExporter.stageEventShare(detail: EventDetail): Uri? = runCatching {
|
||||||
|
val ics = IcsWriter().writeCalendar(
|
||||||
|
events = listOf(detail.toShareIcsEvent()),
|
||||||
|
dtStamp = Clock.System.now(),
|
||||||
|
)
|
||||||
|
stageShareFile(shareFileName(detail.instance.title), ics)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
/** Open the system chooser for a staged `.ics`; false when nothing could take it. */
|
||||||
|
fun Context.sendEventShare(uri: Uri, chooserTitle: String): Boolean = runCatching {
|
||||||
|
val send = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "text/calendar"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
startActivity(Intent.createChooser(send, chooserTitle))
|
||||||
|
}.isSuccess
|
||||||
|
|
||||||
|
/** Delete an event, or as much of its series as [scope] reaches from the occurrence at [beginMillis]. */
|
||||||
|
suspend fun CalendarRepository.deleteEvent(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
scope: RecurringWriteScope,
|
||||||
|
) = when (scope) {
|
||||||
|
RecurringWriteScope.AllEvents -> deleteEvent(eventId)
|
||||||
|
RecurringWriteScope.ThisEvent -> deleteOccurrence(eventId, beginMillis)
|
||||||
|
RecurringWriteScope.ThisAndFollowing -> deleteEventFromOccurrence(eventId, beginMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs an action that writes to the calendar, asking for `WRITE_CALENDAR` first
|
||||||
|
* where the install only holds read access (v1.0 installs). A grant continues
|
||||||
|
* straight into the action that asked for it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberWriteGate(): (() -> Unit) -> Unit {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var pending by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
if (granted) pending?.invoke()
|
||||||
|
pending = null
|
||||||
|
}
|
||||||
|
return { action ->
|
||||||
|
val granted = ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (granted) {
|
||||||
|
action()
|
||||||
|
} else {
|
||||||
|
pending = action
|
||||||
|
launcher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete confirmation. Recurring events choose between cancelling just the
|
||||||
|
* tapped occurrence (default), truncating the series from it onwards, and
|
||||||
|
* removing the whole series.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DeleteEventDialog(
|
||||||
|
isRecurring: Boolean,
|
||||||
|
onConfirm: (RecurringWriteScope) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (isRecurring) R.string.event_delete_recurring_title
|
||||||
|
else R.string.event_delete_title,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
if (isRecurring) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_delete_option_occurrence),
|
||||||
|
onClick = { scope = RecurringWriteScope.ThisEvent },
|
||||||
|
selected = scope == RecurringWriteScope.ThisEvent,
|
||||||
|
)
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_delete_option_following),
|
||||||
|
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
|
||||||
|
selected = scope == RecurringWriteScope.ThisAndFollowing,
|
||||||
|
)
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_delete_option_series),
|
||||||
|
onClick = { scope = RecurringWriteScope.AllEvents },
|
||||||
|
selected = scope == RecurringWriteScope.AllEvents,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.event_delete_body))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_detail_delete),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.dialog_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
|
||||||
|
private fun shareFileName(title: String): String {
|
||||||
|
val base = title.trim()
|
||||||
|
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
|
||||||
|
.replace(' ', '_')
|
||||||
|
.take(40)
|
||||||
|
.ifBlank { "event" }
|
||||||
|
return "$base.ics"
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
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.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** A delete waiting on its confirmation dialog. */
|
||||||
|
data class DeletePrompt(val event: EventInstance, val isRecurring: Boolean)
|
||||||
|
|
||||||
|
/** How an action on a selected event ended, when it has something to say. */
|
||||||
|
enum class EventActionFailure { LoadFailed, ShareFailed, DeleteFailed, WriteDenied, Gone }
|
||||||
|
|
||||||
|
/** Duplicate, share and delete for a selected event (#141), loading it on demand. */
|
||||||
|
@HiltViewModel
|
||||||
|
class EventActionsViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
private val icsExporter: IcsExporter,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** Every calendar by id, for what a selected event's bar may offer. */
|
||||||
|
val calendars: StateFlow<Map<Long, CalendarSource>> = repository.calendars()
|
||||||
|
.map { list -> list.associateBy { it.id } }
|
||||||
|
.catch { emit(emptyMap()) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _deletePrompt = MutableStateFlow<DeletePrompt?>(null)
|
||||||
|
val deletePrompt: StateFlow<DeletePrompt?> = _deletePrompt.asStateFlow()
|
||||||
|
|
||||||
|
private val _failure = MutableStateFlow<EventActionFailure?>(null)
|
||||||
|
val failure: StateFlow<EventActionFailure?> = _failure.asStateFlow()
|
||||||
|
|
||||||
|
/** A create form copying [event], or null when it can't be read. */
|
||||||
|
suspend fun duplicateForm(event: EventInstance): EventForm? {
|
||||||
|
val detail = load(event) ?: return null
|
||||||
|
val canModify = calendars.value[event.calendarId]?.canModifyContents == true
|
||||||
|
return detail.toDuplicateForm(canModify, TimeZone.currentSystemDefault())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A staged `.ics` for [event], or null — reported — when it can't be built. */
|
||||||
|
suspend fun shareUri(event: EventInstance): Uri? {
|
||||||
|
val uri = load(event)?.let { withContext(io) { icsExporter.stageEventShare(it) } }
|
||||||
|
if (uri == null && _failure.value == null) _failure.value = EventActionFailure.ShareFailed
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ask to delete [event]; the dialog it needs depends on whether it recurs. */
|
||||||
|
fun requestDelete(event: EventInstance) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val detail = load(event) ?: return@launch
|
||||||
|
_deletePrompt.value = DeletePrompt(event, isRecurring = !detail.rrule.isNullOrBlank())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun confirmDelete(scope: RecurringWriteScope) {
|
||||||
|
val prompt = _deletePrompt.value ?: return
|
||||||
|
_deletePrompt.value = null
|
||||||
|
viewModelScope.launch {
|
||||||
|
val event = prompt.event
|
||||||
|
_failure.value = try {
|
||||||
|
repository.deleteEvent(event.eventId, event.start.toEpochMilliseconds(), scope)
|
||||||
|
null
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
EventActionFailure.WriteDenied
|
||||||
|
} catch (e: NoSuchEventException) {
|
||||||
|
EventActionFailure.Gone
|
||||||
|
} catch (e: Exception) {
|
||||||
|
EventActionFailure.DeleteFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelDelete() {
|
||||||
|
_deletePrompt.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The chooser itself couldn't open — nothing on the device takes an `.ics`. */
|
||||||
|
fun reportShareFailed() {
|
||||||
|
_failure.value = EventActionFailure.ShareFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeFailure() {
|
||||||
|
_failure.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun load(event: EventInstance): EventDetail? = try {
|
||||||
|
val detail = repository.eventDetail(event.eventId)
|
||||||
|
// The row holds the series start; the actions work on this occurrence.
|
||||||
|
detail.copy(instance = detail.instance.copy(start = event.start, end = event.end))
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: NoSuchEventException) {
|
||||||
|
_failure.value = EventActionFailure.Gone
|
||||||
|
null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_failure.value = EventActionFailure.LoadFailed
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,31 +24,49 @@ import androidx.compose.ui.unit.dp
|
|||||||
/** How far the finger may wander during the hold — well under touch slop. */
|
/** How far the finger may wander during the hold — well under touch slop. */
|
||||||
private val PICKUP_TOLERANCE = 6.dp
|
private val PICKUP_TOLERANCE = 6.dp
|
||||||
|
|
||||||
|
/** What a long press on a block turned into. */
|
||||||
|
enum class PickUp {
|
||||||
|
/** Nothing to pick up; the gesture is left alone. */
|
||||||
|
None,
|
||||||
|
|
||||||
|
/** Lifted and following the finger. */
|
||||||
|
Drag,
|
||||||
|
|
||||||
|
/** Held but not movable — the gesture is swallowed until the finger lifts. */
|
||||||
|
Hold,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick an event block up with a long press and drag it, without
|
* Pick an event block up with a long press and drag it, without
|
||||||
* `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an
|
* `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an
|
||||||
* ancestor consumes or the finger leaves the block, which a
|
* ancestor consumes or the finger leaves the block, which a
|
||||||
* `MIN_EVENT_FRACTION`-tall block loses immediately. Movement before the timeout
|
* `MIN_EVENT_FRACTION`-tall block loses immediately. Movement before the timeout
|
||||||
* is deliberately not consumed, so a scroll starting on top of a block survives.
|
* is deliberately not consumed, so a scroll starting on top of a block survives.
|
||||||
|
*
|
||||||
|
* [onRelease] is the long press that never moved: the finger lifted within touch
|
||||||
|
* slop of where it was picked up, or a [PickUp.Hold] ended. Without it, such a
|
||||||
|
* release is a drop like any other. [immediate] also lifts the block as soon as
|
||||||
|
* the finger moves past touch slop, with no hold — for a block already selected.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberEventDragSource(
|
fun rememberEventDragSource(
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
key: Any?,
|
key: Any?,
|
||||||
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> Unit,
|
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> PickUp,
|
||||||
onMove: (pointerInRoot: Offset) -> Unit,
|
onMove: (pointerInRoot: Offset) -> Unit,
|
||||||
onDrop: () -> Unit,
|
onDrop: () -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
): Modifier = rememberDragSurface(
|
onRelease: (() -> Unit)? = null,
|
||||||
|
immediate: Boolean = false,
|
||||||
|
): Modifier = rememberPickUpSurface(
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
key = key,
|
key = key,
|
||||||
onPickUp = { _, pointerInRoot, nodeInRoot, size ->
|
immediate = immediate,
|
||||||
onPickUp(pointerInRoot, nodeInRoot, size)
|
onPickUp = { _, pointerInRoot, nodeInRoot, size -> onPickUp(pointerInRoot, nodeInRoot, size) },
|
||||||
true
|
|
||||||
},
|
|
||||||
onMove = onMove,
|
onMove = onMove,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
onCancel = onCancel,
|
onCancel = onCancel,
|
||||||
|
onRelease = onRelease,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,6 +83,29 @@ fun rememberDragSurface(
|
|||||||
onMove: (pointerInRoot: Offset) -> Unit,
|
onMove: (pointerInRoot: Offset) -> Unit,
|
||||||
onDrop: () -> Unit,
|
onDrop: () -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
|
): Modifier = rememberPickUpSurface(
|
||||||
|
enabled = enabled,
|
||||||
|
key = key,
|
||||||
|
immediate = false,
|
||||||
|
onPickUp = { local, pointer, node, size ->
|
||||||
|
if (onPickUp(local, pointer, node, size)) PickUp.Drag else PickUp.None
|
||||||
|
},
|
||||||
|
onMove = onMove,
|
||||||
|
onDrop = onDrop,
|
||||||
|
onCancel = onCancel,
|
||||||
|
onRelease = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberPickUpSurface(
|
||||||
|
enabled: Boolean,
|
||||||
|
key: Any?,
|
||||||
|
immediate: Boolean,
|
||||||
|
onPickUp: (local: Offset, pointerInRoot: Offset, nodeInRoot: Offset, size: IntSize) -> PickUp,
|
||||||
|
onMove: (pointerInRoot: Offset) -> Unit,
|
||||||
|
onDrop: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onRelease: (() -> Unit)?,
|
||||||
): Modifier {
|
): Modifier {
|
||||||
if (!enabled) return Modifier
|
if (!enabled) return Modifier
|
||||||
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
|
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
|
||||||
@@ -74,27 +115,42 @@ fun rememberDragSurface(
|
|||||||
val currentMove by rememberUpdatedState(onMove)
|
val currentMove by rememberUpdatedState(onMove)
|
||||||
val currentDrop by rememberUpdatedState(onDrop)
|
val currentDrop by rememberUpdatedState(onDrop)
|
||||||
val currentCancel by rememberUpdatedState(onCancel)
|
val currentCancel by rememberUpdatedState(onCancel)
|
||||||
|
val currentRelease by rememberUpdatedState(onRelease)
|
||||||
|
// Not a key: the pickup changes it, and a restart would cancel the drag.
|
||||||
|
val currentImmediate by rememberUpdatedState(immediate)
|
||||||
|
|
||||||
return Modifier
|
return Modifier
|
||||||
.onGloballyPositioned { coordinates[0] = it }
|
.onGloballyPositioned { coordinates[0] = it }
|
||||||
.pointerInput(key) {
|
.pointerInput(key) {
|
||||||
|
val slop = viewConfiguration.touchSlop
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
val down = awaitFirstDown(requireUnconsumed = false)
|
val down = awaitFirstDown(requireUnconsumed = false)
|
||||||
val origin = down.position
|
val origin = down.position
|
||||||
val heldStill = withTimeoutOrNull(holdMillis) {
|
val immediate = currentImmediate
|
||||||
while (true) {
|
// Null: held still. True: an immediate pickup past slop. False: abandoned.
|
||||||
|
val outcome = withTimeoutOrNull(holdMillis) {
|
||||||
|
var result: Boolean? = null
|
||||||
|
while (result == null) {
|
||||||
val main = awaitPointerEvent()
|
val main = awaitPointerEvent()
|
||||||
val change = main.changes.firstOrNull { it.id == down.id } ?: break
|
val change = main.changes.firstOrNull { it.id == down.id }
|
||||||
if (!change.pressed) break
|
val distance = change?.let { (it.position - origin).getDistance() } ?: 0f
|
||||||
if (main.changes.count { it.pressed } > 1) break
|
result = when {
|
||||||
if ((change.position - origin).getDistance() > tolerance) break
|
change == null || !change.pressed -> false
|
||||||
// An ancestor's claim only becomes visible once the whole
|
main.changes.count { it.pressed } > 1 -> false
|
||||||
// main pass has run, so look again on the final pass.
|
immediate && distance > slop && !change.isConsumed -> {
|
||||||
val final = awaitPointerEvent(PointerEventPass.Final)
|
change.consume()
|
||||||
if (final.changes.any { it.isConsumed }) break
|
true
|
||||||
|
}
|
||||||
|
!immediate && distance > tolerance -> false
|
||||||
|
// An ancestor's claim only becomes visible once the
|
||||||
|
// whole main pass has run, so look again on the final pass.
|
||||||
|
awaitPointerEvent(PointerEventPass.Final).changes.any { it.isConsumed } -> false
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} == null
|
result
|
||||||
if (!heldStill) return@awaitEachGesture
|
}
|
||||||
|
if (outcome == false) return@awaitEachGesture
|
||||||
|
|
||||||
val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture
|
val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture
|
||||||
val took = currentPickUp(
|
val took = currentPickUp(
|
||||||
@@ -103,8 +159,11 @@ fun rememberDragSurface(
|
|||||||
layout.positionInRoot(),
|
layout.positionInRoot(),
|
||||||
layout.size,
|
layout.size,
|
||||||
)
|
)
|
||||||
if (!took) return@awaitEachGesture
|
if (took == PickUp.None) return@awaitEachGesture
|
||||||
|
// Moved already, for an immediate pickup: it can't be a release.
|
||||||
|
var moved = outcome == true
|
||||||
var dropped = false
|
var dropped = false
|
||||||
|
var released = false
|
||||||
try {
|
try {
|
||||||
// Driven on the initial pass, which runs parent → child: an
|
// Driven on the initial pass, which runs parent → child: an
|
||||||
// ancestor that outranks us (the pinch) has already consumed
|
// ancestor that outranks us (the pinch) has already consumed
|
||||||
@@ -116,18 +175,31 @@ fun rememberDragSurface(
|
|||||||
if (change.isConsumed) break
|
if (change.isConsumed) break
|
||||||
if (!change.pressed) {
|
if (!change.pressed) {
|
||||||
change.consume()
|
change.consume()
|
||||||
dropped = true
|
if (took == PickUp.Hold || (!moved && currentRelease != null)) {
|
||||||
|
released = true
|
||||||
|
} else {
|
||||||
|
dropped = true
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (event.changes.count { it.pressed } > 1) break
|
if (event.changes.count { it.pressed } > 1) break
|
||||||
change.consume()
|
change.consume()
|
||||||
|
if (took == PickUp.Hold) continue
|
||||||
|
if ((change.position - origin).getDistance() > slop) moved = true
|
||||||
coordinates[0]?.takeIf { it.isAttached }
|
coordinates[0]?.takeIf { it.isAttached }
|
||||||
?.let { currentMove(it.localToRoot(change.position)) }
|
?.let { currentMove(it.localToRoot(change.position)) }
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Also reached when the pointer node is disposed mid-drag —
|
// Also reached when the pointer node is disposed mid-drag —
|
||||||
// a cancel, not a drop, and must never write.
|
// a cancel, not a drop, and must never write.
|
||||||
if (dropped) currentDrop() else currentCancel()
|
when {
|
||||||
|
released -> {
|
||||||
|
if (took == PickUp.Drag) currentCancel()
|
||||||
|
currentRelease?.invoke()
|
||||||
|
}
|
||||||
|
dropped -> currentDrop()
|
||||||
|
took == PickUp.Drag -> currentCancel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,12 @@ private val FAB_BAND = 88.dp
|
|||||||
* a pill on the FAB's band, leaving the calendar visible behind it.
|
* a pill on the FAB's band, leaving the calendar visible behind it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) {
|
fun EventMoveHost(
|
||||||
|
viewModel: RescheduleViewModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
actionFailure: String? = null,
|
||||||
|
onActionFailureShown: () -> Unit = {},
|
||||||
|
) {
|
||||||
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
|
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
|
||||||
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
|
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
|
||||||
val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle()
|
val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle()
|
||||||
@@ -58,7 +63,9 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
|
|||||||
|
|
||||||
prompt?.let { pending ->
|
prompt?.let { pending ->
|
||||||
RecurringScopeDialog(
|
RecurringScopeDialog(
|
||||||
title = stringResource(R.string.event_move_recurring_title),
|
title = stringResource(
|
||||||
|
if (pending.resize) R.string.event_resize_recurring_title else R.string.event_move_recurring_title,
|
||||||
|
),
|
||||||
onSelect = viewModel::moveWithScope,
|
onSelect = viewModel::moveWithScope,
|
||||||
onDismiss = viewModel::cancelScope,
|
onDismiss = viewModel::cancelScope,
|
||||||
allowSeries = !pending.occurrenceOnly,
|
allowSeries = !pending.occurrenceOnly,
|
||||||
@@ -69,9 +76,17 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
|
|||||||
|
|
||||||
val moved = outcome as? MoveOutcome.Moved
|
val moved = outcome as? MoveOutcome.Moved
|
||||||
val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) }
|
val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) }
|
||||||
|
val resizedLabel = moved?.endMillis?.let { end ->
|
||||||
|
"$movedLabel–${formatTime(end, use24Hour, locale)}"
|
||||||
|
}
|
||||||
|
// A failed selection action (#141) shares the chip rather than stacking on it.
|
||||||
val message = when (outcome) {
|
val message = when (outcome) {
|
||||||
null -> null
|
null -> actionFailure
|
||||||
is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty())
|
is MoveOutcome.Moved -> if (resizedLabel != null) {
|
||||||
|
stringResource(R.string.event_resize_done, resizedLabel)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.event_move_done, movedLabel.orEmpty())
|
||||||
|
}
|
||||||
MoveOutcome.Undone -> stringResource(R.string.event_move_undone)
|
MoveOutcome.Undone -> stringResource(R.string.event_move_undone)
|
||||||
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
|
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
|
||||||
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
|
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
|
||||||
@@ -96,6 +111,12 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
|
|||||||
delay(if (outcome == MoveOutcome.Undone) UNDONE_CHIP_MILLIS else CHIP_MILLIS)
|
delay(if (outcome == MoveOutcome.Undone) UNDONE_CHIP_MILLIS else CHIP_MILLIS)
|
||||||
viewModel.consumeOutcome()
|
viewModel.consumeOutcome()
|
||||||
}
|
}
|
||||||
|
// A failure waits for the chip to be free, then has its own turn.
|
||||||
|
LaunchedEffect(actionFailure, outcome == null) {
|
||||||
|
if (actionFailure == null || outcome != null) return@LaunchedEffect
|
||||||
|
delay(CHIP_MILLIS)
|
||||||
|
onActionFailureShown()
|
||||||
|
}
|
||||||
|
|
||||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||||
val chipMaxWidth = maxWidth - FAB_BAND
|
val chipMaxWidth = maxWidth - FAB_BAND
|
||||||
@@ -113,7 +134,7 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
|
|||||||
// write anyway, so offering it would be a button that does nothing.
|
// write anyway, so offering it would be a button that does nothing.
|
||||||
val undo = content.undo?.takeIf { !writeInFlight }
|
val undo = content.undo?.takeIf { !writeInFlight }
|
||||||
SnackChip(
|
SnackChip(
|
||||||
visible = outcome != null,
|
visible = outcome != null || actionFailure != null,
|
||||||
message = content.message,
|
message = content.message,
|
||||||
maxWidth = chipMaxWidth,
|
maxWidth = chipMaxWidth,
|
||||||
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
|
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
|
||||||
@@ -142,3 +163,7 @@ private fun formatMovedTo(
|
|||||||
return localizedDateFormatter(locale, skeleton)
|
return localizedDateFormatter(locale, skeleton)
|
||||||
.format(Instant.ofEpochMilli(startMillis).atZone(zone))
|
.format(Instant.ofEpochMilli(startMillis).atZone(zone))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "10:30" — the end of a resized event, read in the device zone like its start. */
|
||||||
|
private fun formatTime(millis: Long, use24Hour: Boolean, locale: Locale): String =
|
||||||
|
timeOfDayFormatter(use24Hour, locale).format(Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()))
|
||||||
|
|||||||
@@ -131,6 +131,13 @@ fun eventDragAllowed(event: EventInstance): Boolean {
|
|||||||
return move.dragEnabled && move.allows(event)
|
return move.dragEnabled && move.allows(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a long press may select events at all (#141) — the gesture setting,
|
||||||
|
* whatever the event's calendar allows. A read-only event is selected but stays put.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun eventSelectAllowed(): Boolean = LocalEventMove.current?.dragEnabled == true
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't
|
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't
|
||||||
* pointer-only. Null when this event can't be moved.
|
* pointer-only. Null when this event can't be moved.
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
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.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/** The selected event's action bar (#141); Edit and Delete only where its calendar allows. */
|
||||||
|
@Composable
|
||||||
|
fun EventSelectionBar(
|
||||||
|
canEdit: Boolean,
|
||||||
|
canDelete: Boolean,
|
||||||
|
onEdit: () -> Unit,
|
||||||
|
onDuplicate: () -> Unit,
|
||||||
|
onShare: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
// The FAB's height and colours, so the two read as one control.
|
||||||
|
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),
|
||||||
|
) {
|
||||||
|
if (canEdit) BarAction(Icons.Default.Edit, R.string.event_detail_edit, onEdit)
|
||||||
|
BarAction(Icons.Default.ContentCopy, R.string.event_detail_duplicate, onDuplicate)
|
||||||
|
BarAction(Icons.Default.Share, R.string.event_detail_share, onShare)
|
||||||
|
if (canDelete) BarAction(Icons.Default.Delete, R.string.event_detail_delete, onDelete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BarAction(icon: ImageVector, label: Int, onClick: () -> Unit) {
|
||||||
|
IconButton(onClick = onClick) {
|
||||||
|
Icon(imageVector = icon, contentDescription = stringResource(label))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** The bottom band the selection bar and FAB cover, margin included. */
|
||||||
|
val SelectionBarClearance = FabSize + SnackChipMargin
|
||||||
|
|
||||||
|
/** The FAB's role while an event is selected (#141): its action bar's toggle. Null otherwise. */
|
||||||
|
@Composable
|
||||||
|
fun rememberFabSelection(
|
||||||
|
host: ChromeHost,
|
||||||
|
viewModel: EventActionsViewModel,
|
||||||
|
onEdit: (EventInstance) -> Unit,
|
||||||
|
onDuplicate: (EventForm) -> Unit,
|
||||||
|
chipShown: Boolean,
|
||||||
|
onDismissChip: () -> Unit,
|
||||||
|
): FabSelection? {
|
||||||
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val requireWrite = rememberWriteGate()
|
||||||
|
val chooserTitle = stringResource(R.string.event_share_chooser_title)
|
||||||
|
|
||||||
|
// What the FAB has been told for this selection, over what the coverage says.
|
||||||
|
var chosen by remember(host.selection?.event) { mutableStateOf<Boolean?>(null) }
|
||||||
|
// Folded while a chip is up, which then sits beside the FAB as usual.
|
||||||
|
val expanded = !chipShown && (chosen ?: !host.selectionCompact)
|
||||||
|
|
||||||
|
val currentEdit by rememberUpdatedState(onEdit)
|
||||||
|
val currentDuplicate by rememberUpdatedState(onDuplicate)
|
||||||
|
val currentDismissChip by rememberUpdatedState(onDismissChip)
|
||||||
|
val currentWrite by rememberUpdatedState(requireWrite)
|
||||||
|
|
||||||
|
val selection = host.selection ?: return null
|
||||||
|
val event = selection.event
|
||||||
|
val calendar = calendars[event.calendarId]
|
||||||
|
val canEdit = calendar?.canModifyContents == true
|
||||||
|
val canDelete = canEdit && calendar?.isManaged == false
|
||||||
|
return remember(selection, expanded, chipShown, canEdit, canDelete) {
|
||||||
|
FabSelection(
|
||||||
|
expanded = expanded,
|
||||||
|
onToggle = {
|
||||||
|
if (chipShown) currentDismissChip()
|
||||||
|
chosen = !expanded
|
||||||
|
},
|
||||||
|
bar = {
|
||||||
|
EventSelectionBar(
|
||||||
|
canEdit = canEdit,
|
||||||
|
canDelete = canDelete,
|
||||||
|
onEdit = {
|
||||||
|
currentWrite {
|
||||||
|
selection.dismiss()
|
||||||
|
currentEdit(event)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDuplicate = {
|
||||||
|
currentWrite {
|
||||||
|
scope.launch {
|
||||||
|
val form = viewModel.duplicateForm(event) ?: return@launch
|
||||||
|
selection.dismiss()
|
||||||
|
currentDuplicate(form)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onShare = {
|
||||||
|
scope.launch {
|
||||||
|
val uri = viewModel.shareUri(event) ?: return@launch
|
||||||
|
if (!context.sendEventShare(uri, chooserTitle)) viewModel.reportShareFailed()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDelete = { currentWrite { viewModel.requestDelete(event) } },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a failed action on a selected event says, for the move host's chip to show (#141). */
|
||||||
|
@Composable
|
||||||
|
fun eventActionFailureMessage(failure: EventActionFailure?): String? = when (failure) {
|
||||||
|
null -> null
|
||||||
|
EventActionFailure.LoadFailed -> stringResource(R.string.event_action_load_failed)
|
||||||
|
EventActionFailure.ShareFailed -> stringResource(R.string.event_share_failed)
|
||||||
|
EventActionFailure.DeleteFailed -> stringResource(R.string.event_delete_failed)
|
||||||
|
EventActionFailure.WriteDenied -> stringResource(R.string.event_delete_write_denied)
|
||||||
|
EventActionFailure.Gone -> stringResource(R.string.event_move_gone)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The delete confirmation a selected event's Delete brings up (#141). */
|
||||||
|
@Composable
|
||||||
|
fun EventSelectionHost(host: ChromeHost, viewModel: EventActionsViewModel) {
|
||||||
|
val deletePrompt by viewModel.deletePrompt.collectAsStateWithLifecycle()
|
||||||
|
deletePrompt?.let { prompt ->
|
||||||
|
DeleteEventDialog(
|
||||||
|
isRecurring = prompt.isRecurring,
|
||||||
|
onConfirm = { writeScope ->
|
||||||
|
host.selection?.takeIf { it.event == prompt.event }?.dismiss()
|
||||||
|
viewModel.confirmDelete(writeScope)
|
||||||
|
},
|
||||||
|
onDismiss = viewModel::cancelDelete,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,12 +19,15 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
|
||||||
/** Width of the hour gutter down the start edge of the day and week timelines. */
|
/** Width of the hour gutter down the start edge of the day and week timelines. */
|
||||||
@@ -67,7 +70,7 @@ fun HourGutter(
|
|||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
// Derived: the drag is rewritten every frame, its snapped start once a slot.
|
// Derived: the drag is rewritten every frame, its snapped start once a slot.
|
||||||
val dragStartMin by remember(dragController) {
|
val dragStartMin by remember(dragController) {
|
||||||
derivedStateOf { dragController.drag?.startMin }
|
derivedStateOf { dragController.drag?.markerMin }
|
||||||
}
|
}
|
||||||
val hourAlpha by animateFloatAsState(
|
val hourAlpha by animateFloatAsState(
|
||||||
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
|
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
|
||||||
@@ -107,12 +110,25 @@ fun HourGutter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dragStartMin?.let { startMin ->
|
// Held while it fades, so it goes out where it was rather than blinking off.
|
||||||
|
val badgeMin = remember { mutableStateOf<Int?>(null) }
|
||||||
|
dragStartMin?.let { badgeMin.value = it }
|
||||||
|
val badgeIn by animateFloatAsState(
|
||||||
|
targetValue = if (dragStartMin != null) 1f else 0f,
|
||||||
|
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||||
|
label = "gutterBadge",
|
||||||
|
)
|
||||||
|
badgeMin.value?.takeIf { badgeIn > 0f }?.let { startMin ->
|
||||||
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
|
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopCenter)
|
.align(Alignment.TopCenter)
|
||||||
.offset(y = top)
|
.offset(y = top)
|
||||||
|
.graphicsLayer {
|
||||||
|
alpha = badgeIn
|
||||||
|
scaleX = 0.8f + 0.2f * badgeIn
|
||||||
|
scaleY = 0.8f + 0.2f * badgeIn
|
||||||
|
}
|
||||||
.height(BADGE_HEIGHT)
|
.height(BADGE_HEIGHT)
|
||||||
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = 4.dp)
|
||||||
@@ -121,7 +137,8 @@ fun HourGutter(
|
|||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = formatGutterTime(startMin, use24Hour, locale),
|
// A resized end can sit on midnight: 00:00, not a clamped 23:59.
|
||||||
|
text = formatGutterTime(startMin % MINUTES_PER_DAY, use24Hour, locale),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.calendula.domain.allowsEventMove
|
|||||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||||
import de.jeanlucmakiola.calendula.domain.problems
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
import de.jeanlucmakiola.calendula.domain.realignRecurrence
|
import de.jeanlucmakiola.calendula.domain.realignRecurrence
|
||||||
|
import de.jeanlucmakiola.calendula.domain.resizedTo
|
||||||
import de.jeanlucmakiola.calendula.domain.resolvedZone
|
import de.jeanlucmakiola.calendula.domain.resolvedZone
|
||||||
import de.jeanlucmakiola.calendula.domain.shiftedByDays
|
import de.jeanlucmakiola.calendula.domain.shiftedByDays
|
||||||
import de.jeanlucmakiola.calendula.domain.shiftedTo
|
import de.jeanlucmakiola.calendula.domain.shiftedTo
|
||||||
@@ -54,6 +55,9 @@ sealed interface MoveTarget {
|
|||||||
* disagree by a day over which zone the event's first day is resolved in.
|
* disagree by a day over which zone the event's first day is resolved in.
|
||||||
*/
|
*/
|
||||||
data class ByDays(val days: Int) : MoveTarget
|
data class ByDays(val days: Int) : MoveTarget
|
||||||
|
|
||||||
|
/** New start and end instants — a timeline resize, which changes the length. */
|
||||||
|
data class Span(val start: Instant, val end: Instant) : MoveTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,7 +78,7 @@ data class MoveRequest(
|
|||||||
* re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor
|
* re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor
|
||||||
* across the same midnights the occurrence crossed.
|
* across the same midnights the occurrence crossed.
|
||||||
*/
|
*/
|
||||||
data class MoveScopePrompt(val occurrenceOnly: Boolean)
|
data class MoveScopePrompt(val occurrenceOnly: Boolean, val resize: Boolean = false)
|
||||||
|
|
||||||
/** The inverse of a completed move, for the undo action. */
|
/** The inverse of a completed move, for the undo action. */
|
||||||
data class MoveUndo(
|
data class MoveUndo(
|
||||||
@@ -88,12 +92,14 @@ data class MoveUndo(
|
|||||||
sealed interface MoveOutcome {
|
sealed interface MoveOutcome {
|
||||||
/**
|
/**
|
||||||
* Written. [startMillis] is where the dragged occurrence now begins, for the
|
* Written. [startMillis] is where the dragged occurrence now begins, for the
|
||||||
* confirmation message; [undo] is null when the write has no clean inverse.
|
* confirmation message, and [endMillis] where it ends — set only for a resize,
|
||||||
|
* whose message names both; [undo] is null when the write has no clean inverse.
|
||||||
*/
|
*/
|
||||||
data class Moved(
|
data class Moved(
|
||||||
val startMillis: Long,
|
val startMillis: Long,
|
||||||
val isAllDay: Boolean,
|
val isAllDay: Boolean,
|
||||||
val undo: MoveUndo?,
|
val undo: MoveUndo?,
|
||||||
|
val endMillis: Long? = null,
|
||||||
) : MoveOutcome
|
) : MoveOutcome
|
||||||
|
|
||||||
data object Undone : MoveOutcome
|
data object Undone : MoveOutcome
|
||||||
@@ -226,7 +232,10 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
// Still busy: the scope dialog is now the thing in flight.
|
// Still busy: the scope dialog is now the thing in flight.
|
||||||
pending = prepared
|
pending = prepared
|
||||||
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign)
|
_scopePrompt.value = MoveScopePrompt(
|
||||||
|
occurrenceOnly = !prepared.canRealign,
|
||||||
|
resize = request.target is MoveTarget.Span,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -313,6 +322,7 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
val shifted = when (val target = request.target) {
|
val shifted = when (val target = request.target) {
|
||||||
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
|
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
|
||||||
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
|
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
|
||||||
|
is MoveTarget.Span -> original.resizedTo(target.start, target.end, zone)
|
||||||
}
|
}
|
||||||
// A zero-distance drop is not a write.
|
// A zero-distance drop is not a write.
|
||||||
if (shifted == original) return null
|
if (shifted == original) return null
|
||||||
@@ -396,6 +406,7 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
startMillis = movedStartMillis(prepared),
|
startMillis = movedStartMillis(prepared),
|
||||||
isAllDay = prepared.updated.isAllDay,
|
isAllDay = prepared.updated.isAllDay,
|
||||||
undo = undoFor(prepared, scope),
|
undo = undoFor(prepared, scope),
|
||||||
|
endMillis = resizedEndMillis(prepared),
|
||||||
)
|
)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
@@ -480,6 +491,14 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where a resized occurrence now ends; null for a move, which keeps its length. */
|
||||||
|
private fun resizedEndMillis(prepared: PreparedMove): Long? {
|
||||||
|
if (prepared.request.target !is MoveTarget.Span) return null
|
||||||
|
val form = prepared.updated
|
||||||
|
return form.end.toInstant(form.resolvedZone(TimeZone.currentSystemDefault()))
|
||||||
|
.toEpochMilliseconds()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the dragged occurrence now begins, in the same anchoring the views
|
* Where the dragged occurrence now begins, in the same anchoring the views
|
||||||
* read dates back in: UTC midnight for an all-day event.
|
* read dates back in: UTC midnight for an all-day event.
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/** The handle's touch target, centred on the edge it moves. */
|
||||||
|
val ResizeHandleTarget = 48.dp
|
||||||
|
|
||||||
|
private val HANDLE_DOT = 16.dp
|
||||||
|
private val HANDLE_RING = 2.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A grip on one edge of a selected event (#141), dragged without a hold. No
|
||||||
|
* semantics: TalkBack resizes through the event's edit action.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ResizeHandle(
|
||||||
|
color: Color,
|
||||||
|
enabled: Boolean,
|
||||||
|
onStart: (pointerInRoot: Offset) -> Unit,
|
||||||
|
onMove: (pointerInRoot: Offset) -> Unit,
|
||||||
|
onEnd: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val coordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
||||||
|
val currentStart by rememberUpdatedState(onStart)
|
||||||
|
val currentMove by rememberUpdatedState(onMove)
|
||||||
|
val currentEnd by rememberUpdatedState(onEnd)
|
||||||
|
val currentCancel by rememberUpdatedState(onCancel)
|
||||||
|
// Not a key: turning it off mid-resize must not cancel that resize.
|
||||||
|
val currentEnabled by rememberUpdatedState(enabled)
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = modifier
|
||||||
|
.size(ResizeHandleTarget)
|
||||||
|
.clearAndSetSemantics { }
|
||||||
|
.onGloballyPositioned { coordinates[0] = it }
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
awaitEachGesture {
|
||||||
|
val down = awaitFirstDown()
|
||||||
|
if (!currentEnabled) return@awaitEachGesture
|
||||||
|
down.consume()
|
||||||
|
var started = false
|
||||||
|
var ended = false
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent()
|
||||||
|
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||||
|
// Claimed by an ancestor that outranks us — the pinch.
|
||||||
|
if (change.isConsumed) break
|
||||||
|
if (event.changes.count { it.pressed } > 1) break
|
||||||
|
change.consume()
|
||||||
|
if (!change.pressed) {
|
||||||
|
ended = started
|
||||||
|
break
|
||||||
|
}
|
||||||
|
val root = coordinates[0]?.takeIf { it.isAttached }
|
||||||
|
?.localToRoot(change.position) ?: break
|
||||||
|
if (started) {
|
||||||
|
currentMove(root)
|
||||||
|
} else {
|
||||||
|
started = true
|
||||||
|
currentStart(root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
when {
|
||||||
|
ended -> currentEnd()
|
||||||
|
started -> currentCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(HANDLE_DOT)
|
||||||
|
.background(color, CircleShape)
|
||||||
|
.border(HANDLE_RING, MaterialTheme.colorScheme.surface, CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The top bar while an event is selected (#141): says which, and when, with a
|
||||||
|
* way out — the selection otherwise only shows on the grid and in the FAB.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SelectionTopBar(selection: EventSelection) {
|
||||||
|
val event = selection.event
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
|
val whenText = remember(event.start, event.end, use24Hour, locale) {
|
||||||
|
formatSelectionWhen(event, use24Hour, locale)
|
||||||
|
}
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = selection.dismiss) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.event_selection_close),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = event.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = whenText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// The header's own surface, as the view's bar has (#186).
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Fri, 9 Oct · 09:00–10:00", or with both days named for an event that runs
|
||||||
|
* into another: "Fri, 9 Oct, 22:00 – Sat, 10 Oct, 02:00".
|
||||||
|
*/
|
||||||
|
private fun formatSelectionWhen(event: EventInstance, use24Hour: Boolean, locale: Locale): String {
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
val start = event.start.toJavaInstant().atZone(zone)
|
||||||
|
val end = event.end.toJavaInstant().atZone(zone)
|
||||||
|
val day = localizedDateFormatter(locale, "EEEdMMM")
|
||||||
|
val time = timeOfDayFormatter(use24Hour, locale)
|
||||||
|
val endsSameDay = end.toLocalDate() == start.toLocalDate() ||
|
||||||
|
(end.toLocalTime().toSecondOfDay() == 0 && end.toLocalDate() == start.toLocalDate().plusDays(1))
|
||||||
|
return if (endsSameDay) {
|
||||||
|
"${day.format(start)} · ${time.format(start)}–${time.format(end)}"
|
||||||
|
} else {
|
||||||
|
"${day.format(start)}, ${time.format(start)} – ${day.format(end)}, ${time.format(end)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.ScrollState
|
import androidx.compose.foundation.ScrollState
|
||||||
import androidx.compose.foundation.MutatePriority
|
import androidx.compose.foundation.MutatePriority
|
||||||
@@ -20,6 +21,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -27,6 +29,7 @@ import androidx.compose.runtime.withFrameNanos
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.TransformOrigin
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.LayoutCoordinates
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
@@ -35,6 +38,7 @@ import androidx.compose.ui.semantics.clearAndSetSemantics
|
|||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
@@ -97,11 +101,28 @@ data class TimelineDrag(
|
|||||||
* drop lands, so it is dropped once the gesture ends ([finish]).
|
* drop lands, so it is dropped once the gesture ends ([finish]).
|
||||||
*/
|
*/
|
||||||
val edgeOnly: Boolean = false,
|
val edgeOnly: Boolean = false,
|
||||||
|
/**
|
||||||
|
* The minute of its day the gutter badge names — the top edge, or for a
|
||||||
|
* bottom-edge resize the end being dragged (#141).
|
||||||
|
*/
|
||||||
|
val markerMin: Int = startMin,
|
||||||
) {
|
) {
|
||||||
/** What the snap haptics key off: one tick per changed slot, not per frame. */
|
/**
|
||||||
val slot: Pair<LocalDate, Int> get() = date to startMin
|
* What the snap haptics key off: one tick per changed slot, not per frame.
|
||||||
|
* Carries the length, which is all a bottom-edge resize changes.
|
||||||
|
*/
|
||||||
|
val slot: Triple<LocalDate, Int, Int> get() = Triple(date, startMin, eventSpanMin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The event a long press left selected (#141). */
|
||||||
|
data class TimelineSelection(
|
||||||
|
val event: EventInstance,
|
||||||
|
/** Set from a move or resize of it until the grid shows where it landed. */
|
||||||
|
val moved: Boolean = false,
|
||||||
|
/** Selected by letting go of a lifted block, which then keeps that lift as it is. */
|
||||||
|
val fromLift: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One day column's share of a dragged event, ready to draw. Both halves of an
|
* One day column's share of a dragged event, ready to draw. Both halves of an
|
||||||
* event crossing midnight are previewed, whichever half the finger picked up:
|
* event crossing midnight are previewed, whichever half the finger picked up:
|
||||||
@@ -248,6 +269,56 @@ data class TimelineDrop(
|
|||||||
val clipOffsetMin: Int = 0,
|
val clipOffsetMin: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Which end of an event a resize handle moves (#141). */
|
||||||
|
enum class ResizeEdge { Top, Bottom }
|
||||||
|
|
||||||
|
/** Where a finished resize asks its event to begin and end. */
|
||||||
|
data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The span after dragging [edge] to [edgeMin] in day [edgeDay], in minutes from the
|
||||||
|
* start day's midnight. Snapped, and at least one snap step long.
|
||||||
|
*/
|
||||||
|
internal fun resizedSpan(
|
||||||
|
edge: ResizeEdge,
|
||||||
|
startMin: Int,
|
||||||
|
endMin: Int,
|
||||||
|
edgeMin: Float,
|
||||||
|
edgeDay: Int? = null,
|
||||||
|
): Pair<Int, Int> {
|
||||||
|
// An edge dragged into a day past the other end changes nothing.
|
||||||
|
if (edgeDay != null) {
|
||||||
|
val endDay = (endMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
|
||||||
|
val wrongWay = when (edge) {
|
||||||
|
ResizeEdge.Top -> edgeDay > endDay
|
||||||
|
ResizeEdge.Bottom -> edgeDay < startMin.floorDiv(MINUTES_PER_DAY)
|
||||||
|
}
|
||||||
|
if (wrongWay) return startMin to endMin
|
||||||
|
}
|
||||||
|
val snapped = (edgeMin / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
||||||
|
return when (edge) {
|
||||||
|
ResizeEdge.Top -> minOf(snapped, endMin - DRAG_SNAP_MINUTES) to endMin
|
||||||
|
ResizeEdge.Bottom -> startMin to maxOf(snapped, startMin + DRAG_SNAP_MINUTES)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [minutes] snapped, kept at [held] until three quarters of a step away. */
|
||||||
|
internal fun stickySnap(minutes: Float, held: Int?): Int {
|
||||||
|
if (held != null && kotlin.math.abs(minutes - held) < DRAG_SNAP_MINUTES * 0.75f) return held
|
||||||
|
return (minutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far past its column's side the finger has to go before a resize changes day. */
|
||||||
|
private const val COLUMN_HYSTERESIS = 0.25f
|
||||||
|
|
||||||
|
/** The column under [position], kept at [held] until [COLUMN_HYSTERESIS] into another. */
|
||||||
|
internal fun stickyColumn(position: Float, held: Int?, lastIndex: Int): Int {
|
||||||
|
val under = position.toInt().coerceIn(0, lastIndex)
|
||||||
|
if (held == null) return under
|
||||||
|
val stays = position >= held - COLUMN_HYSTERESIS && position < held + 1 + COLUMN_HYSTERESIS
|
||||||
|
return if (stays) held else under
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The timeline's live geometry, republished on every layout. Plain fields rather
|
* The timeline's live geometry, republished on every layout. Plain fields rather
|
||||||
* than snapshot state: it changes on every scroll frame, and the drag loop reads
|
* than snapshot state: it changes on every scroll frame, and the drag loop reads
|
||||||
@@ -320,10 +391,88 @@ class TimelineDragController {
|
|||||||
*/
|
*/
|
||||||
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
|
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
|
||||||
|
|
||||||
|
/** The selected event, if any — see [TimelineSelection]. */
|
||||||
|
var selection: TimelineSelection? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun select(event: EventInstance, moved: Boolean = false, fromLift: Boolean = false) {
|
||||||
|
if (selection?.event?.instanceId != event.instanceId) selectionCovered = false
|
||||||
|
selection = TimelineSelection(event, moved, fromLift)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deselect() {
|
||||||
|
selection = null
|
||||||
|
selectionCovered = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The events of the blocks on screen that show the selection; none for a while ends it. */
|
||||||
|
val selectedCandidates = mutableStateListOf<EventInstance>()
|
||||||
|
|
||||||
|
fun noteSelectedBlock(event: EventInstance, shown: Boolean) {
|
||||||
|
if (shown) selectedCandidates.add(event) else selectedCandidates.remove(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Once a move of the selected event has settled, point the selection at the
|
||||||
|
* row it landed as. A new event id wins: a single moved occurrence is written
|
||||||
|
* as an exception, and another occurrence of its series may share its time.
|
||||||
|
*/
|
||||||
|
fun settleMovedSelection() {
|
||||||
|
val current = selection?.takeIf { it.moved } ?: return
|
||||||
|
if (isDragging || settling != null) return
|
||||||
|
val landed = selectedCandidates.filter { it.instanceId != current.event.instanceId }
|
||||||
|
// Only the original row left: the move was abandoned, and it still stands.
|
||||||
|
val chosen = landed.firstOrNull { it.eventId != current.event.eventId }
|
||||||
|
?: landed.firstOrNull() ?: selectedCandidates.firstOrNull() ?: return
|
||||||
|
selection = current.copy(event = chosen, moved = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the selected block's bottom sits where the action bar would cover it. */
|
||||||
|
var selectionCovered: Boolean by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [block] is the selected event: by instance, by series and start, and
|
||||||
|
* just after a move by title and times, since a moved occurrence gets new ids.
|
||||||
|
*/
|
||||||
|
fun isSelected(block: TimedBlock): Boolean {
|
||||||
|
val current = selection ?: return false
|
||||||
|
val selected = current.event
|
||||||
|
val event = block.event
|
||||||
|
return event.instanceId == selected.instanceId ||
|
||||||
|
(event.eventId == selected.eventId && event.start == selected.start) ||
|
||||||
|
(
|
||||||
|
current.moved &&
|
||||||
|
event.calendarId == selected.calendarId &&
|
||||||
|
event.title.isNotBlank() &&
|
||||||
|
event.title == selected.title &&
|
||||||
|
event.start == selected.start &&
|
||||||
|
event.end == selected.end
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point the selection at [block]'s event, so the bar acts on the row now there. */
|
||||||
|
fun adoptSelection(block: TimedBlock) {
|
||||||
|
val current = selection?.takeIf { !it.moved } ?: return
|
||||||
|
if (current.event != block.event) selection = current.copy(event = block.event)
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether a finger is on a block right now — [settling] is not dragging. */
|
/** Whether a finger is on a block right now — [settling] is not dragging. */
|
||||||
var isDragging: Boolean by mutableStateOf(false)
|
var isDragging: Boolean by mutableStateOf(false)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/** Whether the finger has moved since the pickup; only then may the view auto-scroll. */
|
||||||
|
var pointerMoved: Boolean by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Touch slop, past which the finger counts as having moved. */
|
||||||
|
var slopPx: Float = 0f
|
||||||
|
|
||||||
|
private var pickupPointer = Offset.Zero
|
||||||
|
|
||||||
|
/** The end a settling resize asked for, which the grid has to show before it settles. */
|
||||||
|
private var settledEnd: Instant? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the grid itself now draws the settled drop at its landing slot.
|
* Whether the grid itself now draws the settled drop at its landing slot.
|
||||||
* The copy may only be handed back once this is true, or the source ghost
|
* The copy may only be handed back once this is true, or the source ghost
|
||||||
@@ -332,6 +481,10 @@ class TimelineDragController {
|
|||||||
var settledOnGrid: Boolean by mutableStateOf(false)
|
var settledOnGrid: Boolean by mutableStateOf(false)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/** Whether the settling drop is the selected event, whose block lands still lifted. */
|
||||||
|
var settlingSelected: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The abandoned-move counter, read at the drop so the mark can't be a
|
* The abandoned-move counter, read at the drop so the mark can't be a
|
||||||
* composition behind the tick it is compared against.
|
* composition behind the tick it is compared against.
|
||||||
@@ -361,7 +514,27 @@ class TimelineDragController {
|
|||||||
* The slot the block already occupied when it was picked up — not the same
|
* The slot the block already occupied when it was picked up — not the same
|
||||||
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
|
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
|
||||||
*/
|
*/
|
||||||
private var originSlot: Pair<LocalDate, Int>? = null
|
private var originSlot: Triple<LocalDate, Int, Int>? = null
|
||||||
|
|
||||||
|
/** The edge a resize is dragging, or null for a plain drag (#141). */
|
||||||
|
private var resizeEdge: ResizeEdge? = null
|
||||||
|
|
||||||
|
/** The day the resized event starts on; its edges are minutes from that midnight. */
|
||||||
|
private var resizeDay: LocalDate? = null
|
||||||
|
private var resizeStartMin = 0
|
||||||
|
private var resizeEndMin = 0
|
||||||
|
|
||||||
|
/** How far the finger sits past the edge it grabbed, so the edge doesn't jump to it. */
|
||||||
|
private var resizeGrabMin = 0f
|
||||||
|
|
||||||
|
/** The column the resized edge is in, held until the finger is well into another. */
|
||||||
|
private var resizeColumn: Int? = null
|
||||||
|
|
||||||
|
/** The slot the resized edge sits on, held against a finger trembling between two. */
|
||||||
|
private var resizeSlot: Int? = null
|
||||||
|
|
||||||
|
/** The day, counted from the event's start day, the finger is resizing in. */
|
||||||
|
private var resizeEdgeDay = 0
|
||||||
|
|
||||||
fun begin(
|
fun begin(
|
||||||
block: TimedBlock,
|
block: TimedBlock,
|
||||||
@@ -370,6 +543,8 @@ class TimelineDragController {
|
|||||||
pointerInRoot: Offset,
|
pointerInRoot: Offset,
|
||||||
blockInRoot: Offset,
|
blockInRoot: Offset,
|
||||||
) {
|
) {
|
||||||
|
// The selected event keeps its selection through a drag; any other ends it.
|
||||||
|
if (!isSelected(block)) deselect()
|
||||||
source = block
|
source = block
|
||||||
this.clipOffsetMin = clipOffsetMin
|
this.clipOffsetMin = clipOffsetMin
|
||||||
this.titleLines = titleLines
|
this.titleLines = titleLines
|
||||||
@@ -378,6 +553,8 @@ class TimelineDragController {
|
|||||||
liftedInstanceId = block.event.instanceId
|
liftedInstanceId = block.event.instanceId
|
||||||
grab = pointerInRoot - blockInRoot
|
grab = pointerInRoot - blockInRoot
|
||||||
pointer = pointerInRoot
|
pointer = pointerInRoot
|
||||||
|
pickupPointer = pointerInRoot
|
||||||
|
pointerMoved = false
|
||||||
originSlot = null
|
originSlot = null
|
||||||
recompute()
|
recompute()
|
||||||
originSlot = drag?.slot
|
originSlot = drag?.slot
|
||||||
@@ -385,20 +562,28 @@ class TimelineDragController {
|
|||||||
|
|
||||||
fun move(pointerInRoot: Offset) {
|
fun move(pointerInRoot: Offset) {
|
||||||
pointer = pointerInRoot
|
pointer = pointerInRoot
|
||||||
|
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
|
||||||
recompute()
|
recompute()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancel() {
|
fun cancel() {
|
||||||
|
resizeEdge = null
|
||||||
|
resizeDay = null
|
||||||
|
resizeColumn = null
|
||||||
|
resizeSlot = null
|
||||||
source = null
|
source = null
|
||||||
clipOffsetMin = 0
|
clipOffsetMin = 0
|
||||||
titleLines = 0
|
titleLines = 0
|
||||||
isDragging = false
|
isDragging = false
|
||||||
|
pointerMoved = false
|
||||||
liftedInstanceId = null
|
liftedInstanceId = null
|
||||||
originSlot = null
|
originSlot = null
|
||||||
drag = null
|
drag = null
|
||||||
settling = null
|
settling = null
|
||||||
settledOnGrid = false
|
settledOnGrid = false
|
||||||
settledOrigin = null
|
settledOrigin = null
|
||||||
|
settledEnd = null
|
||||||
|
settlingSelected = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -429,8 +614,11 @@ class TimelineDragController {
|
|||||||
val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt()
|
val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt()
|
||||||
val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset)
|
val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset)
|
||||||
.firstOrNull() ?: return
|
.firstOrNull() ?: return
|
||||||
|
val end = settledEnd
|
||||||
settledOnGrid = blocks.any { block ->
|
settledOnGrid = blocks.any { block ->
|
||||||
block.startMin == slice.startMin &&
|
block.startMin == slice.startMin &&
|
||||||
|
// A resize may keep its start, so its end has to have landed too.
|
||||||
|
(end == null || block.event.end == end) &&
|
||||||
(
|
(
|
||||||
block.event.eventId == landed.event.eventId ||
|
block.event.eventId == landed.event.eventId ||
|
||||||
(
|
(
|
||||||
@@ -452,6 +640,7 @@ 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
|
||||||
|
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
|
||||||
// [noteGrid] can never seat it and it would sit at the top of today for
|
// [noteGrid] can never seat it and it would sit at the top of today for
|
||||||
@@ -463,6 +652,150 @@ class TimelineDragController {
|
|||||||
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
|
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grab one edge of [block] to resize it (#141). The edge follows the finger
|
||||||
|
* across days as a drag does; the other stays where it is.
|
||||||
|
*/
|
||||||
|
fun beginResize(
|
||||||
|
block: TimedBlock,
|
||||||
|
edge: ResizeEdge,
|
||||||
|
zone: TimeZone,
|
||||||
|
titleLines: Int,
|
||||||
|
pointerInRoot: Offset,
|
||||||
|
) {
|
||||||
|
val start = block.event.start.toLocalDateTime(zone)
|
||||||
|
val end = block.event.end.toLocalDateTime(zone)
|
||||||
|
source = block
|
||||||
|
resizeEdge = edge
|
||||||
|
resizeDay = start.date
|
||||||
|
resizeStartMin = start.time.toSecondOfDay() / 60
|
||||||
|
// Wall clock at both ends, as the drop resolves them (DST-safe).
|
||||||
|
resizeEndMin = maxOf(
|
||||||
|
resizeStartMin,
|
||||||
|
(end.date.toEpochDays() - start.date.toEpochDays()).toInt() * MINUTES_PER_DAY +
|
||||||
|
end.time.toSecondOfDay() / 60,
|
||||||
|
)
|
||||||
|
clipOffsetMin = 0
|
||||||
|
this.titleLines = titleLines
|
||||||
|
settling = null
|
||||||
|
isDragging = true
|
||||||
|
pointerMoved = true
|
||||||
|
liftedInstanceId = block.event.instanceId
|
||||||
|
pointer = pointerInRoot
|
||||||
|
// Measured from the edge's own column: the handle overhangs into the next.
|
||||||
|
val edgeDay = if (edge == ResizeEdge.Top) 0 else (resizeEndMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
|
||||||
|
val edgeIndex = geometry.days.indexOf(start.date.plus(edgeDay, DateTimeUnit.DAY))
|
||||||
|
resizeColumn = edgeIndex.takeIf { it >= 0 }
|
||||||
|
?.let { if (geometry.isRtl) geometry.days.lastIndex - it else it }
|
||||||
|
resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
|
||||||
|
if (edge == ResizeEdge.Top) resizeStartMin else resizeEndMin
|
||||||
|
originSlot = null
|
||||||
|
recompute()
|
||||||
|
originSlot = drag?.slot
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End a resize, handing back the event's new span — null when it never
|
||||||
|
* resolved or came back to the length it started at. Settles as [finish] does.
|
||||||
|
*/
|
||||||
|
fun finishResize(zone: TimeZone): TimelineResizeDrop? {
|
||||||
|
val landed = drag
|
||||||
|
val origin = originSlot
|
||||||
|
cancel()
|
||||||
|
if (landed == null || landed.slot == origin) return null
|
||||||
|
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
|
||||||
|
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
|
||||||
|
liftedInstanceId = landed.event.instanceId
|
||||||
|
settledOrigin = landed.event.eventId to landed.event.start
|
||||||
|
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||||
|
val drop = TimelineResizeDrop(
|
||||||
|
event = landed.event,
|
||||||
|
start = wallClockInstant(landed.date, landed.eventStartMin, zone),
|
||||||
|
end = wallClockInstant(landed.date, landed.eventStartMin + landed.eventSpanMin, zone),
|
||||||
|
)
|
||||||
|
settledEnd = drop.end
|
||||||
|
return drop
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the finger is, in minutes from the resized event's start day —
|
||||||
|
* past 1440 in a later column, negative in an earlier one.
|
||||||
|
*/
|
||||||
|
private fun pointerResizeMinutes(): Float? {
|
||||||
|
val grid = geometry.grid?.takeIf { it.isAttached } ?: return null
|
||||||
|
val day = resizeDay ?: return null
|
||||||
|
val hourPx = geometry.hourPx
|
||||||
|
val columnPx = geometry.columnWidthPx
|
||||||
|
val days = geometry.days
|
||||||
|
if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return null
|
||||||
|
val origin = grid.positionInRoot()
|
||||||
|
val column = stickyColumn(
|
||||||
|
position = (pointer.x - origin.x) / columnPx,
|
||||||
|
held = resizeColumn,
|
||||||
|
lastIndex = days.lastIndex,
|
||||||
|
)
|
||||||
|
resizeColumn = column
|
||||||
|
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
||||||
|
val dayOffset = (days[dayIndex].toEpochDays() - day.toEpochDays()).toInt()
|
||||||
|
resizeEdgeDay = dayOffset
|
||||||
|
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recomputeResize(edge: ResizeEdge) {
|
||||||
|
val block = source ?: return
|
||||||
|
val day = resizeDay ?: return
|
||||||
|
val raw = pointerResizeMinutes() ?: return
|
||||||
|
val slot = stickySnap(raw - resizeGrabMin, resizeSlot)
|
||||||
|
resizeSlot = slot
|
||||||
|
val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat(), resizeEdgeDay)
|
||||||
|
val days = geometry.days
|
||||||
|
val hourPx = geometry.hourPx
|
||||||
|
val columnPx = geometry.columnWidthPx
|
||||||
|
val origin = geometry.grid?.takeIf { it.isAttached }?.positionInRoot() ?: return
|
||||||
|
// Which column the start day sits in, possibly off either end of the view.
|
||||||
|
val startIndex = (day.toEpochDays() - days.first().toEpochDays()).toInt()
|
||||||
|
val visible = -startIndex..days.lastIndex - startIndex
|
||||||
|
val slices = dragSlices(start, end - start, within = visible)
|
||||||
|
drag = TimelineDrag(
|
||||||
|
event = block.event,
|
||||||
|
date = day,
|
||||||
|
startMin = start,
|
||||||
|
eventStartMin = start,
|
||||||
|
eventSpanMin = end - start,
|
||||||
|
titleLines = titleLines,
|
||||||
|
pieces = slices.map { slice ->
|
||||||
|
val dayIndex = startIndex + slice.dayOffset
|
||||||
|
val col = if (geometry.isRtl) days.lastIndex - dayIndex else dayIndex
|
||||||
|
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
|
||||||
|
TimelineDragPiece(
|
||||||
|
topLeftInRoot = Offset(x = origin.x + col * columnPx, y = origin.y + bounds.top),
|
||||||
|
sizePx = IntSize(
|
||||||
|
(columnPx - geometry.columnGapPx).roundToInt(),
|
||||||
|
bounds.height.roundToInt(),
|
||||||
|
),
|
||||||
|
continuesBefore = slice.continuesBefore,
|
||||||
|
continuesAfter = slice.continuesAfter,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
edgeOnly = slices.isEmpty(),
|
||||||
|
markerMin = if (edge == ResizeEdge.Top) {
|
||||||
|
start.mod(MINUTES_PER_DAY)
|
||||||
|
} else {
|
||||||
|
end - (end - 1).floorDiv(MINUTES_PER_DAY) * MINUTES_PER_DAY
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Note whether the action bar would cover the selected block's bottom handle (#141). */
|
||||||
|
fun noteSelectedBottom(block: LayoutCoordinates, extraPx: Float, clearancePx: Float) {
|
||||||
|
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return
|
||||||
|
if (!block.isAttached) return
|
||||||
|
val port = viewport.boundsInRoot()
|
||||||
|
val bottom = block.positionInRoot().y + block.size.height + extraPx
|
||||||
|
val covered = bottom > port.bottom - clearancePx
|
||||||
|
if (covered != selectionCovered) selectionCovered = covered
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop ghosting the source — the grid draws the drop itself by now — while
|
* Stop ghosting the source — the grid draws the drop itself by now — while
|
||||||
* the copy is still dissolving. Un-ghosting at [release] instead would leave
|
* the copy is still dissolving. Un-ghosting at [release] instead would leave
|
||||||
@@ -477,6 +810,8 @@ class TimelineDragController {
|
|||||||
fun release() {
|
fun release() {
|
||||||
settling = null
|
settling = null
|
||||||
settledOnGrid = false
|
settledOnGrid = false
|
||||||
|
settledEnd = null
|
||||||
|
settlingSelected = false
|
||||||
handOver()
|
handOver()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,6 +821,10 @@ class TimelineDragController {
|
|||||||
* no move events while auto-scroll walks the content underneath it.
|
* no move events while auto-scroll walks the content underneath it.
|
||||||
*/
|
*/
|
||||||
fun recompute() {
|
fun recompute() {
|
||||||
|
resizeEdge?.let {
|
||||||
|
recomputeResize(it)
|
||||||
|
return
|
||||||
|
}
|
||||||
val block = source ?: return
|
val block = source ?: return
|
||||||
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
|
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
|
||||||
val hourPx = geometry.hourPx
|
val hourPx = geometry.hourPx
|
||||||
@@ -577,6 +916,7 @@ fun rememberTimelineDragController(): TimelineDragController {
|
|||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
|
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
|
||||||
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
|
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
|
||||||
|
controller.slopPx = LocalViewConfiguration.current.touchSlop
|
||||||
controller.abandonedTicks = LocalEventMove.current?.abandoned
|
controller.abandonedTicks = LocalEventMove.current?.abandoned
|
||||||
return controller
|
return controller
|
||||||
}
|
}
|
||||||
@@ -613,14 +953,13 @@ fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
|
|||||||
* of its own; `toInstant` resolves it forward by the missing hour, and the block
|
* of its own; `toInstant` resolves it forward by the missing hour, and the block
|
||||||
* visibly settles there.
|
* visibly settles there.
|
||||||
*/
|
*/
|
||||||
fun TimelineDrop.startInstant(zone: TimeZone): Instant {
|
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
|
||||||
// Wall clock throughout, and past this day at either end: a block's top edge
|
wallClockInstant(date, startMin - clipOffsetMin, zone)
|
||||||
// can be dragged above its midnight and a tail's below the next one, so the
|
|
||||||
// start is resolved as a day plus a minute of it rather than as an offset
|
/** [minutes] past [date]'s midnight, either way past it, as wall clock in [zone] (DST-safe). */
|
||||||
// from an instant — which would shift by an hour across a DST boundary.
|
fun wallClockInstant(date: LocalDate, minutes: Int, zone: TimeZone): Instant {
|
||||||
val fromMidnight = startMin - clipOffsetMin
|
val day = date.plus(minutes.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
|
||||||
val day = date.plus(fromMidnight.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
|
val minute = minutes.mod(MINUTES_PER_DAY)
|
||||||
val minute = fromMidnight.mod(MINUTES_PER_DAY)
|
|
||||||
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone)
|
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,8 +999,10 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
|||||||
// Both live here rather than beside the controller: they read [drag], which
|
// Both live here rather than beside the controller: they read [drag], which
|
||||||
// changes every frame, and this composable is the one that is meant to.
|
// changes every frame, and this composable is the one that is meant to.
|
||||||
DragSnapHaptics(controller.drag?.slot)
|
DragSnapHaptics(controller.drag?.slot)
|
||||||
LaunchedEffect(controller.isDragging) {
|
// Only once the finger has moved: a long press held near an edge must not
|
||||||
if (controller.isDragging) controller.autoScroll()
|
// scroll the drop target out from under a finger that only means to select.
|
||||||
|
LaunchedEffect(controller.isDragging && controller.pointerMoved) {
|
||||||
|
if (controller.isDragging && controller.pointerMoved) controller.autoScroll()
|
||||||
}
|
}
|
||||||
// Hold the landed copy until the write is done — including the time a
|
// Hold the landed copy until the write is done — including the time a
|
||||||
// recurring drop's scope dialog is up — and then until the grid draws the
|
// recurring drop's scope dialog is up — and then until the grid draws the
|
||||||
@@ -693,7 +1034,8 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
|||||||
// Landed: the copy sinks back to the grid's own plane while the write runs.
|
// Landed: the copy sinks back to the grid's own plane while the write runs.
|
||||||
val landed = controller.drag == null
|
val landed = controller.drag == null
|
||||||
val lift by animateFloatAsState(
|
val lift by animateFloatAsState(
|
||||||
targetValue = if (landed || reduceMotion) 0f else 1f,
|
// A selected event's block is lifted too: sinking first made it shrink, then grow back.
|
||||||
|
targetValue = if (reduceMotion || (landed && !controller.settlingSelected)) 0f else 1f,
|
||||||
label = "drag-lift",
|
label = "drag-lift",
|
||||||
)
|
)
|
||||||
val copyAlpha by animateFloatAsState(
|
val copyAlpha by animateFloatAsState(
|
||||||
@@ -880,3 +1222,29 @@ private fun DragCopy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How far a selected block is lifted, 0 to 1 (#141); [snap] takes it over at once. */
|
||||||
|
@Composable
|
||||||
|
fun selectionLift(selected: Boolean, snap: Boolean = false): Float {
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
val spec = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
|
||||||
|
val raise = remember { Animatable(if (selected) 1f else 0f) }
|
||||||
|
LaunchedEffect(selected) {
|
||||||
|
val target = if (selected) 1f else 0f
|
||||||
|
if (reduceMotion || (selected && snap)) raise.snapTo(target) else raise.animateTo(target, spec)
|
||||||
|
}
|
||||||
|
return raise.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A selected block raised off the grid, the way a dragged copy is: scaled from
|
||||||
|
* its top edge so the text doesn't creep, with a shadow in the block's shape.
|
||||||
|
*/
|
||||||
|
fun Modifier.selectionLift(raise: Float, shape: Shape): Modifier = graphicsLayer {
|
||||||
|
transformOrigin = TransformOrigin(0.5f, 0f)
|
||||||
|
scaleX = 1f + 0.02f * raise
|
||||||
|
scaleY = 1f + 0.02f * raise
|
||||||
|
shadowElevation = 6.dp.toPx() * raise
|
||||||
|
this.shape = shape
|
||||||
|
clip = false
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.detail
|
package de.jeanlucmakiola.calendula.ui.detail
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -41,7 +37,6 @@ import androidx.compose.material.icons.filled.Public
|
|||||||
import androidx.compose.material.icons.filled.Repeat
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -51,7 +46,6 @@ import androidx.compose.material3.SnackbarHost
|
|||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -67,7 +61,6 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.LinkAnnotation
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
@@ -90,13 +83,15 @@ import de.jeanlucmakiola.calendula.domain.Availability
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
||||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
|
||||||
import de.jeanlucmakiola.calendula.ui.common.FieldCopier
|
import de.jeanlucmakiola.calendula.ui.common.FieldCopier
|
||||||
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
|
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier
|
import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier
|
||||||
@@ -104,7 +99,6 @@ import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.floret.components.OptionCard
|
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
@@ -162,43 +156,15 @@ fun EventDetailScreen(
|
|||||||
val onShareClick = {
|
val onShareClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val uri = viewModel.shareUri()
|
val uri = viewModel.shareUri()
|
||||||
val sent = uri != null && runCatching {
|
val sent = uri != null && context.sendEventShare(uri, shareChooserTitle)
|
||||||
val send = Intent(Intent.ACTION_SEND).apply {
|
|
||||||
type = "text/calendar"
|
|
||||||
putExtra(Intent.EXTRA_STREAM, uri)
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
context.startActivity(Intent.createChooser(send, shareChooserTitle))
|
|
||||||
}.isSuccess
|
|
||||||
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
|
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
|
||||||
}
|
}
|
||||||
Unit
|
Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||||
// upgrade in place. Granting continues straight into the tapped action —
|
// upgrade in place and continues straight into the tapped action.
|
||||||
// edit, delete, or duplicate — held here until the result comes back.
|
val requireWrite = rememberWriteGate()
|
||||||
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
|
|
||||||
val writePermissionLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
|
||||||
) { granted ->
|
|
||||||
if (granted) pendingWrite?.invoke()
|
|
||||||
pendingWrite = null
|
|
||||||
}
|
|
||||||
val hasWritePermission = {
|
|
||||||
ContextCompat.checkSelfPermission(
|
|
||||||
context,
|
|
||||||
Manifest.permission.WRITE_CALENDAR,
|
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
}
|
|
||||||
val requireWrite: (() -> Unit) -> Unit = { action ->
|
|
||||||
if (hasWritePermission()) {
|
|
||||||
action()
|
|
||||||
} else {
|
|
||||||
pendingWrite = action
|
|
||||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
|
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
|
||||||
val onEditClick = { requireWrite(onEdit) }
|
val onEditClick = { requireWrite(onEdit) }
|
||||||
// Duplicate reads the loaded detail into a create form and hands it up; the
|
// Duplicate reads the loaded detail into a create form and hands it up; the
|
||||||
@@ -322,70 +288,6 @@ fun EventDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete confirmation. Recurring events choose between cancelling just the
|
|
||||||
* tapped occurrence (default), truncating the series from it onwards, and
|
|
||||||
* removing the whole series.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun DeleteEventDialog(
|
|
||||||
isRecurring: Boolean,
|
|
||||||
onConfirm: (RecurringWriteScope) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
if (isRecurring) R.string.event_delete_recurring_title
|
|
||||||
else R.string.event_delete_title,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
if (isRecurring) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_delete_option_occurrence),
|
|
||||||
onClick = { scope = RecurringWriteScope.ThisEvent },
|
|
||||||
selected = scope == RecurringWriteScope.ThisEvent,
|
|
||||||
)
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_delete_option_following),
|
|
||||||
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
|
|
||||||
selected = scope == RecurringWriteScope.ThisAndFollowing,
|
|
||||||
)
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_delete_option_series),
|
|
||||||
onClick = { scope = RecurringWriteScope.AllEvents },
|
|
||||||
selected = scope == RecurringWriteScope.AllEvents,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text(stringResource(R.string.event_delete_body))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.event_detail_delete),
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text(stringResource(R.string.dialog_cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventDetailContent(
|
private fun EventDetailContent(
|
||||||
state: EventDetailUiState.Success,
|
state: EventDetailUiState.Success,
|
||||||
|
|||||||
@@ -11,12 +11,11 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
import de.jeanlucmakiola.calendula.ui.common.deleteEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.toEditForm
|
import de.jeanlucmakiola.calendula.ui.common.stageEventShare
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -113,14 +112,7 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_deleteState.value = DeleteUiState.Deleting
|
_deleteState.value = DeleteUiState.Deleting
|
||||||
_deleteState.value = try {
|
_deleteState.value = try {
|
||||||
when (scope) {
|
repository.deleteEvent(target.eventId, target.beginMillis, scope)
|
||||||
RecurringWriteScope.AllEvents ->
|
|
||||||
repository.deleteEvent(target.eventId)
|
|
||||||
RecurringWriteScope.ThisEvent ->
|
|
||||||
repository.deleteOccurrence(target.eventId, target.beginMillis)
|
|
||||||
RecurringWriteScope.ThisAndFollowing ->
|
|
||||||
repository.deleteEventFromOccurrence(target.eventId, target.beginMillis)
|
|
||||||
}
|
|
||||||
DeleteUiState.Deleted
|
DeleteUiState.Deleted
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
@@ -144,40 +136,16 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
suspend fun shareUri(): Uri? {
|
suspend fun shareUri(): Uri? {
|
||||||
val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null
|
val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null
|
||||||
return runCatching {
|
return withContext(io) { icsExporter.stageEventShare(detail) }
|
||||||
withContext(io) {
|
|
||||||
val ics = IcsWriter().writeCalendar(
|
|
||||||
events = listOf(detail.toShareIcsEvent()),
|
|
||||||
dtStamp = Clock.System.now(),
|
|
||||||
)
|
|
||||||
icsExporter.stageShareFile(shareFileName(detail.instance.title), ics)
|
|
||||||
}
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a create form seeded from the open event so the user can save a
|
* A create form seeded from the open event, to save a copy as a new event
|
||||||
* copy as a new, independent event (#52). Returns null when nothing is
|
* (#52) — see [toDuplicateForm]. Null when nothing is loaded.
|
||||||
* loaded.
|
|
||||||
*
|
|
||||||
* The recurrence is dropped — a duplicate is a single event; anyone who
|
|
||||||
* wants a series can re-add one in the edit form that opens. The source
|
|
||||||
* calendar is kept only when it's writable, otherwise the id is cleared so
|
|
||||||
* the create resolves to the last-used/first-writable calendar — which lets
|
|
||||||
* a read-only event (a WebCal subscription, a birthday) be copied into an
|
|
||||||
* editable calendar. The occurrence's own times carry over unchanged.
|
|
||||||
*/
|
*/
|
||||||
fun duplicateForm(): EventForm? {
|
fun duplicateForm(): EventForm? {
|
||||||
val loaded = state.value as? EventDetailUiState.Success ?: return null
|
val loaded = state.value as? EventDetailUiState.Success ?: return null
|
||||||
val detail = loaded.detail
|
return loaded.detail.toDuplicateForm(loaded.canModify, TimeZone.currentSystemDefault())
|
||||||
return detail.toEditForm(
|
|
||||||
beginMillis = detail.instance.start.toEpochMilliseconds(),
|
|
||||||
endMillis = detail.instance.end.toEpochMilliseconds(),
|
|
||||||
zone = TimeZone.currentSystemDefault(),
|
|
||||||
).copy(
|
|
||||||
rrule = null,
|
|
||||||
calendarId = detail.instance.calendarId.takeIf { loaded.canModify },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||||
@@ -228,13 +196,3 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE
|
const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
|
|
||||||
private fun shareFileName(title: String): String {
|
|
||||||
val base = title.trim()
|
|
||||||
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
|
|
||||||
.replace(' ', '_')
|
|
||||||
.take(40)
|
|
||||||
.ifBlank { "event" }
|
|
||||||
return "$base.ics"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -59,11 +59,28 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PickUp
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.SelectionBarClearance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ResizeEdge
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ResizeHandle
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ResizeHandleTarget
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineResizeDrop
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PublishSelection
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventSelectAllowed
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.selectionLift
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.BiasAlignment
|
import androidx.compose.ui.BiasAlignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
|
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
|
||||||
import de.jeanlucmakiola.calendula.ui.common.StayPut
|
import de.jeanlucmakiola.calendula.ui.common.StayPut
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import androidx.compose.runtime.withFrameNanos
|
import androidx.compose.runtime.withFrameNanos
|
||||||
@@ -79,6 +96,7 @@ import androidx.compose.ui.draw.alpha
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.ui.layout.layout
|
import androidx.compose.ui.layout.layout
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
@@ -511,6 +529,14 @@ private fun WeekContent(
|
|||||||
val move = LocalEventMove.current
|
val move = LocalEventMove.current
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
val onDrop: (TimelineDrop) -> Unit = { drop ->
|
val onDrop: (TimelineDrop) -> Unit = { drop ->
|
||||||
|
// A selected event stays selected where it landed (#141).
|
||||||
|
if (dragController.selection?.event?.instanceId == drop.event.instanceId) {
|
||||||
|
val start = drop.startInstant(zone)
|
||||||
|
dragController.select(
|
||||||
|
drop.event.copy(start = start, end = start + (drop.event.end - drop.event.start)),
|
||||||
|
moved = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
move?.move(
|
move?.move(
|
||||||
MoveRequest(
|
MoveRequest(
|
||||||
eventId = drop.event.eventId,
|
eventId = drop.event.eventId,
|
||||||
@@ -521,6 +547,51 @@ private fun WeekContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A resize keeps its event selected, at its new length (#141).
|
||||||
|
val onResize: (TimelineResizeDrop) -> Unit = { drop ->
|
||||||
|
dragController.select(
|
||||||
|
drop.event.copy(start = drop.start, end = drop.end),
|
||||||
|
moved = true,
|
||||||
|
)
|
||||||
|
move?.move(
|
||||||
|
MoveRequest(
|
||||||
|
eventId = drop.event.eventId,
|
||||||
|
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||||
|
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||||
|
target = MoveTarget.Span(drop.start, drop.end),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection (#141) ends on back, a page or view change, or any tap.
|
||||||
|
val selection = dragController.selection
|
||||||
|
PublishSelection(
|
||||||
|
event = selection?.event,
|
||||||
|
busy = dragController.isDragging,
|
||||||
|
compact = dragController.selectionCovered,
|
||||||
|
onDismiss = dragController::deselect,
|
||||||
|
)
|
||||||
|
BackHandler(enabled = selection != null) { dragController.deselect() }
|
||||||
|
LaunchedEffect(pagerState, dragController) {
|
||||||
|
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) {
|
||||||
|
dragController.settleMovedSelection()
|
||||||
|
}
|
||||||
|
val orphaned = selection != null && dragController.selectedCandidates.isEmpty() &&
|
||||||
|
!dragController.isDragging && dragController.settling == null
|
||||||
|
LaunchedEffect(orphaned) {
|
||||||
|
if (!orphaned) return@LaunchedEffect
|
||||||
|
// A beat for a re-read to bring the event back under a new row.
|
||||||
|
delay(SELECTION_ORPHAN_GRACE_MILLIS)
|
||||||
|
dragController.deselect()
|
||||||
|
}
|
||||||
|
val openEvent: (EventInstance) -> Unit = { event ->
|
||||||
|
dragController.deselect()
|
||||||
|
onEventClick(event)
|
||||||
|
}
|
||||||
|
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
CompositionLocalProvider(LocalOpeningTimeline provides timelineScroll) {
|
CompositionLocalProvider(LocalOpeningTimeline provides timelineScroll) {
|
||||||
// Loading and failure concern the provider as a whole, so they stand in
|
// Loading and failure concern the provider as a whole, so they stand in
|
||||||
@@ -541,10 +612,11 @@ private fun WeekContent(
|
|||||||
today = today,
|
today = today,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
onEventClick = openEvent,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
|
onResize = onResize,
|
||||||
)
|
)
|
||||||
} else WeekPager(
|
} else WeekPager(
|
||||||
pagerState = pagerState,
|
pagerState = pagerState,
|
||||||
@@ -555,10 +627,11 @@ private fun WeekContent(
|
|||||||
initialStripHeight = handoffStrip ?: s.allDayStripHeight(),
|
initialStripHeight = handoffStrip ?: s.allDayStripHeight(),
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
onEventClick = openEvent,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
|
onResize = onResize,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -585,6 +658,7 @@ private fun WeekPager(
|
|||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
|
onResize: (TimelineResizeDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
|
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
|
||||||
val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
|
val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
|
||||||
@@ -633,6 +707,7 @@ private fun WeekPager(
|
|||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
|
onResize = onResize,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -667,6 +742,7 @@ private fun TimelineMorphFrame(
|
|||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
|
onResize: (TimelineResizeDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
val fromState by morph.fromState.collectAsStateWithLifecycle()
|
val fromState by morph.fromState.collectAsStateWithLifecycle()
|
||||||
val toState by morph.toState.collectAsStateWithLifecycle()
|
val toState by morph.toState.collectAsStateWithLifecycle()
|
||||||
@@ -722,6 +798,7 @@ private fun TimelineMorphFrame(
|
|||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
|
onResize = onResize,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -815,6 +892,7 @@ internal fun WeekSuccess(
|
|||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
|
onResize: (TimelineResizeDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
WeekFrame(
|
WeekFrame(
|
||||||
weekStart = state.weekStart,
|
weekStart = state.weekStart,
|
||||||
@@ -834,6 +912,7 @@ internal fun WeekSuccess(
|
|||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
|
onResize = onResize,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -856,6 +935,7 @@ private fun WeekPage(
|
|||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
|
onResize: (TimelineResizeDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
val geometry = remember(state.weekStart, state.days.size) {
|
val geometry = remember(state.weekStart, state.days.size) {
|
||||||
ColumnGeometry.of(DaySpan(state.weekStart, state.days.size))
|
ColumnGeometry.of(DaySpan(state.weekStart, state.days.size))
|
||||||
@@ -875,6 +955,7 @@ private fun WeekPage(
|
|||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
|
onResize = onResize,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -898,6 +979,7 @@ private fun TimelinePageContent(
|
|||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
|
onResize: (TimelineResizeDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
Column(
|
||||||
@@ -928,6 +1010,7 @@ private fun TimelinePageContent(
|
|||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
|
onResize = onResize,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1157,6 +1240,7 @@ private fun Timeline(
|
|||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
|
onResize: (TimelineResizeDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
val fullViewportHeight = remember(geometry) { intArrayOf(-1) }
|
val fullViewportHeight = remember(geometry) { intArrayOf(-1) }
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
@@ -1254,6 +1338,7 @@ private fun Timeline(
|
|||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
|
onResize = onResize,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
modifier = Modifier.fillMaxHeight(),
|
modifier = Modifier.fillMaxHeight(),
|
||||||
)
|
)
|
||||||
@@ -1276,6 +1361,7 @@ private fun DayColumnCard(
|
|||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
|
onResize: (TimelineResizeDrop) -> Unit,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -1324,6 +1410,10 @@ private fun DayColumnCard(
|
|||||||
// blocks are consumed by their own handler first. Snaps to hour.
|
// blocks are consumed by their own handler first. Snaps to hour.
|
||||||
.pointerInput(date) {
|
.pointerInput(date) {
|
||||||
detectTapGestures { offset ->
|
detectTapGestures { offset ->
|
||||||
|
if (dragController.selection != null) {
|
||||||
|
dragController.deselect()
|
||||||
|
return@detectTapGestures
|
||||||
|
}
|
||||||
if (currentEdges.value.tap(offset.y)) return@detectTapGestures
|
if (currentEdges.value.tap(offset.y)) return@detectTapGestures
|
||||||
currentOnCreateAt.value(
|
currentOnCreateAt.value(
|
||||||
date,
|
date,
|
||||||
@@ -1383,6 +1473,7 @@ private fun DayColumnCard(
|
|||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
onClick = { onEventClick(block.event) },
|
onClick = { onEventClick(block.event) },
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
|
onResize = onResize,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.laneSlot(place)
|
.laneSlot(place)
|
||||||
.offset(y = place.y + topInset)
|
.offset(y = place.y + topInset)
|
||||||
@@ -1427,6 +1518,7 @@ private fun EventBlock(
|
|||||||
dragController: TimelineDragController,
|
dragController: TimelineDragController,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
|
onResize: (TimelineResizeDrop) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
@@ -1481,68 +1573,169 @@ private fun EventBlock(
|
|||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
val moveAction = eventMoveAction(block.event)
|
val moveAction = eventMoveAction(block.event)
|
||||||
val draggable = eventDragAllowed(block.event)
|
val draggable = eventDragAllowed(block.event)
|
||||||
|
val selectable = eventSelectAllowed()
|
||||||
|
val selected = dragController.isSelected(block)
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
// The drop takes this offset back off, so a tail clipped at midnight lands
|
// The drop takes this offset back off, so a tail clipped at midnight lands
|
||||||
// where the event's own start belongs (#253).
|
// where the event's own start belongs (#253).
|
||||||
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
|
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
|
||||||
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
|
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
|
||||||
val topInsetPx = with(density) { topInset.toPx() }
|
val topInsetPx = with(density) { topInset.toPx() }
|
||||||
|
// Long press (#141): move to drag, let go to select; selected blocks drag at once.
|
||||||
val dragModifier = rememberEventDragSource(
|
val dragModifier = rememberEventDragSource(
|
||||||
enabled = draggable,
|
enabled = selectable,
|
||||||
key = block.event.instanceId,
|
key = block.event.instanceId,
|
||||||
|
immediate = selected && draggable,
|
||||||
onPickUp = { pointer, blockRoot, _ ->
|
onPickUp = { pointer, blockRoot, _ ->
|
||||||
dragController.begin(
|
if (draggable) {
|
||||||
block = block,
|
dragController.begin(
|
||||||
clipOffsetMin = clipOffset,
|
block = block,
|
||||||
titleLines = if (showTitle) titleMaxLines else 0,
|
clipOffsetMin = clipOffset,
|
||||||
pointerInRoot = pointer,
|
titleLines = if (showTitle) titleMaxLines else 0,
|
||||||
// Measured off the block's placement rather than off where the
|
pointerInRoot = pointer,
|
||||||
// hour grid seats it: the copy is drawn from that placement and
|
// Measured off the block's placement rather than off where the
|
||||||
// re-seated the same half-gap down (#267).
|
// hour grid seats it: the copy is drawn from that placement and
|
||||||
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
|
// re-seated the same half-gap down (#267).
|
||||||
)
|
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
|
||||||
|
)
|
||||||
|
PickUp.Drag
|
||||||
|
} else {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
dragController.select(block.event)
|
||||||
|
PickUp.Hold
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onMove = dragController::move,
|
onMove = dragController::move,
|
||||||
onDrop = { dragController.finish()?.let(onDrop) },
|
onDrop = { dragController.finish()?.let(onDrop) },
|
||||||
onCancel = dragController::cancel,
|
onCancel = dragController::cancel,
|
||||||
|
onRelease = { dragController.select(block.event, fromLift = draggable) },
|
||||||
)
|
)
|
||||||
val lifted = draggable && dragController.ghosts(block)
|
val lifted = draggable && dragController.ghosts(block)
|
||||||
val ghost = ghostAlpha(lifted)
|
// Released in place, the block takes over the copy's lift without a dip.
|
||||||
|
val takesOverLift = selected && dragController.selection?.fromLift == true
|
||||||
|
val ghostFade = ghostAlpha(lifted)
|
||||||
|
val ghost = if (takesOverLift && !lifted) 1f else ghostFade
|
||||||
|
val 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() }
|
||||||
|
val barClearancePx = with(density) { SelectionBarClearance.toPx() }
|
||||||
|
val tracksBar = selected && !cuts.bottom
|
||||||
|
// Not while its own write is pending: it still shows the old times.
|
||||||
|
LaunchedEffect(selected, block.event, lifted) {
|
||||||
|
if (selected && !lifted) dragController.adoptSelection(block)
|
||||||
|
}
|
||||||
|
// Kept composed while hidden: removing them would cancel their own resize.
|
||||||
|
DisposableEffect(selected, block.event) {
|
||||||
|
if (selected) dragController.noteSelectedBlock(block.event, true)
|
||||||
|
onDispose { if (selected) dragController.noteSelectedBlock(block.event, false) }
|
||||||
|
}
|
||||||
|
val showHandles = selected && draggable
|
||||||
|
val handlesShown = !lifted && !dragController.isDragging
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed && !selected) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
|
.zIndex(if (selected) 1f else 0f)
|
||||||
// The source stays put as a ghost while its floating copy travels.
|
// The source stays put as a ghost while its floating copy travels.
|
||||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||||
.eventSurface(paint, shape, cuts)
|
.then(if (raise > 0f) Modifier.selectionLift(raise, shape) else Modifier)
|
||||||
.clickable(onClick = onClick)
|
.then(
|
||||||
// After clickable, so it is the inner node and wins the main pass;
|
if (tracksBar) {
|
||||||
// the tap still works, since a drag consumes the up.
|
Modifier.onGloballyPositioned {
|
||||||
.then(dragModifier)
|
dragController.noteSelectedBottom(it, revealExtraPx, barClearancePx)
|
||||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
|
}
|
||||||
.semantics {
|
} else {
|
||||||
contentDescription = "$title, $timeLabel"
|
Modifier
|
||||||
if (moveAction != null) customActions = listOf(moveAction)
|
},
|
||||||
},
|
),
|
||||||
) {
|
) {
|
||||||
Column {
|
Box(
|
||||||
if (showTitle) {
|
modifier = Modifier
|
||||||
BlockTitle(
|
.fillMaxSize()
|
||||||
title = title,
|
.eventSurface(paint, shape, cuts)
|
||||||
maxLines = titleMaxLines,
|
.clickable(onClick = onClick)
|
||||||
color = paint.titleInk,
|
// After clickable, so it is the inner node and wins the main pass;
|
||||||
textDecoration = paint.decoration,
|
// the tap still works, since a drag consumes the up.
|
||||||
fontWeight = paint.titleWeight,
|
.then(dragModifier)
|
||||||
|
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = "$title, $timeLabel"
|
||||||
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
if (showTitle) {
|
||||||
|
BlockTitle(
|
||||||
|
title = title,
|
||||||
|
maxLines = titleMaxLines,
|
||||||
|
color = paint.titleInk,
|
||||||
|
textDecoration = paint.decoration,
|
||||||
|
fontWeight = paint.titleWeight,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showTime) {
|
||||||
|
BlockTimeLabel(
|
||||||
|
label = shownTime,
|
||||||
|
color = paint.secondaryInk,
|
||||||
|
maxLines = timeMaxLines,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showHandles) {
|
||||||
|
val accent = eventAccent(block.event.color, dark, LocalSoftenColors.current)
|
||||||
|
val handleLines = if (showTitle) titleMaxLines else 0
|
||||||
|
val begin = { edge: ResizeEdge ->
|
||||||
|
{ pointer: Offset ->
|
||||||
|
dragController.beginResize(block, edge, zone, handleLines, pointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val end = { dragController.finishResize(zone)?.let(onResize) }
|
||||||
|
val handleIn by animateFloatAsState(
|
||||||
|
targetValue = if (handlesShown) 1f else 0f,
|
||||||
|
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
|
||||||
|
label = "handles",
|
||||||
|
)
|
||||||
|
val handleAlpha = Modifier.graphicsLayer {
|
||||||
|
alpha = handleIn.coerceIn(0f, 1f)
|
||||||
|
scaleX = 0.5f + 0.5f * handleIn
|
||||||
|
scaleY = 0.5f + 0.5f * handleIn
|
||||||
|
}
|
||||||
|
if (!cuts.top) {
|
||||||
|
ResizeHandle(
|
||||||
|
color = accent,
|
||||||
|
enabled = handlesShown,
|
||||||
|
onStart = begin(ResizeEdge.Top),
|
||||||
|
onMove = dragController::move,
|
||||||
|
onEnd = { end() },
|
||||||
|
onCancel = dragController::cancel,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.offset(x = HANDLE_INSET - ResizeHandleTarget / 2, y = -ResizeHandleTarget / 2)
|
||||||
|
.then(handleAlpha),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (showTime) {
|
if (!cuts.bottom) {
|
||||||
BlockTimeLabel(
|
ResizeHandle(
|
||||||
label = shownTime,
|
color = accent,
|
||||||
color = paint.secondaryInk,
|
enabled = handlesShown,
|
||||||
maxLines = timeMaxLines,
|
onStart = begin(ResizeEdge.Bottom),
|
||||||
|
onMove = dragController::move,
|
||||||
|
onEnd = { end() },
|
||||||
|
onCancel = dragController::cancel,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.offset(x = ResizeHandleTarget / 2 - HANDLE_INSET, y = ResizeHandleTarget / 2)
|
||||||
|
.then(handleAlpha),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val SELECTION_ORPHAN_GRACE_MILLIS = 500L
|
||||||
|
|
||||||
|
/** How far in from the block's side a resize handle's centre sits. */
|
||||||
|
private val HANDLE_INSET = 16.dp
|
||||||
|
|
||||||
private val SKELETON_HEADER_HEIGHT = 36.dp
|
private val SKELETON_HEADER_HEIGHT = 36.dp
|
||||||
private val SKELETON_HEADER_PADDING = 8.dp
|
private val SKELETON_HEADER_PADDING = 8.dp
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ internal fun WeekViewPreview(
|
|||||||
onOpenDay = {},
|
onOpenDay = {},
|
||||||
onCreateAt = { _, _ -> },
|
onCreateAt = { _, _ -> },
|
||||||
onDrop = {},
|
onDrop = {},
|
||||||
|
onResize = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,12 @@
|
|||||||
<string name="event_move_occurrence_only">Moving the whole series would change which days it falls on, so only this event can move.</string>
|
<string name="event_move_occurrence_only">Moving the whole series would change which days it falls on, so only this event can move.</string>
|
||||||
<!-- %1$s is the new date and time, e.g. "Fri, 7 Aug, 09:00". -->
|
<!-- %1$s is the new date and time, e.g. "Fri, 7 Aug, 09:00". -->
|
||||||
<string name="event_move_done">Moved to %1$s</string>
|
<string name="event_move_done">Moved to %1$s</string>
|
||||||
|
<string name="event_resize_done">Changed to %1$s</string>
|
||||||
|
<string name="event_resize_recurring_title">Change recurring event</string>
|
||||||
|
<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_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>
|
||||||
<string name="event_move_failed">Couldn\'t move the event</string>
|
<string name="event_move_failed">Couldn\'t move the event</string>
|
||||||
|
|||||||
@@ -152,4 +152,49 @@ class EventShiftTest {
|
|||||||
val original = form()
|
val original = form()
|
||||||
assertThat(original.shiftedByDays(0, berlin)).isEqualTo(original)
|
assertThat(original.shiftedByDays(0, berlin)).isEqualTo(original)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resizing sets each edge on its own`() {
|
||||||
|
val original = form()
|
||||||
|
val end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)).toInstant(berlin)
|
||||||
|
|
||||||
|
val resized = original.resizedTo(original.start.toInstant(berlin), end, berlin)
|
||||||
|
|
||||||
|
assertThat(resized.start).isEqualTo(original.start)
|
||||||
|
assertThat(resized.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a resize past midnight carries the end onto the next day`() {
|
||||||
|
val original = form()
|
||||||
|
val end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(1, 0)).toInstant(berlin)
|
||||||
|
|
||||||
|
val resized = original.resizedTo(original.start.toInstant(berlin), end, berlin)
|
||||||
|
|
||||||
|
assertThat(resized.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(1, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a pinned event is resized in its own zone`() {
|
||||||
|
val original = form(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
||||||
|
timezone = "America/New_York",
|
||||||
|
)
|
||||||
|
// "15:30 Berlin" is 09:30 in New York.
|
||||||
|
val start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(15, 30)).toInstant(berlin)
|
||||||
|
|
||||||
|
val resized = original.resizedTo(start, original.end.toInstant(newYork), berlin)
|
||||||
|
|
||||||
|
assertThat(resized.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
|
||||||
|
assertThat(resized.end).isEqualTo(original.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day event ignores a resize`() {
|
||||||
|
val allDay = form(isAllDay = true)
|
||||||
|
val start = allDay.start.toInstant(berlin)
|
||||||
|
|
||||||
|
assertThat(allDay.resizedTo(start, start, berlin)).isEqualTo(allDay)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,17 @@ class RescheduleViewModelTest {
|
|||||||
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis + 3_600_000L)),
|
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis + 3_600_000L)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** The end dragged an hour later, the start left where it is. */
|
||||||
|
private fun endOneHourLater() = MoveRequest(
|
||||||
|
eventId = 42L,
|
||||||
|
beginMillis = beginMillis,
|
||||||
|
endMillis = endMillis,
|
||||||
|
target = MoveTarget.Span(
|
||||||
|
Instant.fromEpochMilliseconds(beginMillis),
|
||||||
|
Instant.fromEpochMilliseconds(endMillis + 3_600_000L),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a one-off drop writes straight through with no scope prompt`(
|
fun `a one-off drop writes straight through with no scope prompt`(
|
||||||
@TempDir tempDir: Path,
|
@TempDir tempDir: Path,
|
||||||
@@ -595,4 +606,82 @@ class RescheduleViewModelTest {
|
|||||||
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
|
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
|
||||||
assertThat(vm.abandoned.value).isEqualTo(0)
|
assertThat(vm.abandoned.value).isEqualTo(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a resize writes the new length and reports both ends`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
|
||||||
|
vm.move(endOneHourLater())
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val (_, original, updated) = fake.updatedEvents.single()
|
||||||
|
assertThat(updated.start).isEqualTo(original.start)
|
||||||
|
assertThat(updated.end).isNotEqualTo(original.end)
|
||||||
|
val moved = vm.outcome.value as MoveOutcome.Moved
|
||||||
|
assertThat(moved.startMillis).isEqualTo(beginMillis)
|
||||||
|
assertThat(moved.endMillis).isEqualTo(endMillis + 3_600_000L)
|
||||||
|
assertThat(moved.undo).isNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a moved event reports no end, so its chip keeps the move wording`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
|
||||||
|
vm.move(oneHourLater())
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat((vm.outcome.value as MoveOutcome.Moved).endMillis).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resizing a recurring event asks for the scope, as a resize`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
|
||||||
|
vm.move(endOneHourLater())
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(vm.scopePrompt.value)
|
||||||
|
.isEqualTo(MoveScopePrompt(occurrenceOnly = false, resize = true))
|
||||||
|
vm.moveWithScope(RecurringWriteScope.AllEvents)
|
||||||
|
advanceUntilIdle()
|
||||||
|
// The start stays put, so the rule needs no realigning.
|
||||||
|
assertThat(fake.updatedEvents.single().third.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pulling a recurring event's start back over midnight resizes only the occurrence`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
val sundayEvening = LocalDateTime(LocalDate(2026, 6, 7), LocalTime(22, 0))
|
||||||
|
.toInstant(TimeZone.currentSystemDefault())
|
||||||
|
|
||||||
|
vm.move(
|
||||||
|
MoveRequest(
|
||||||
|
eventId = 42L,
|
||||||
|
beginMillis = beginMillis,
|
||||||
|
endMillis = endMillis,
|
||||||
|
target = MoveTarget.Span(sundayEvening, Instant.fromEpochMilliseconds(endMillis)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Not a whole-day shift, so the series anchor can't follow.
|
||||||
|
assertThat(vm.scopePrompt.value)
|
||||||
|
.isEqualTo(MoveScopePrompt(occurrenceOnly = true, resize = true))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/** Where a dragged resize handle leaves an event's ends (#141). */
|
||||||
|
class ResizedSpanTest {
|
||||||
|
|
||||||
|
// 09:00–10:00, in minutes from its day's midnight.
|
||||||
|
private val start = 9 * 60
|
||||||
|
private val end = 10 * 60
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the bottom edge snaps and leaves the start alone`() {
|
||||||
|
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, 11 * 60 + 8f))
|
||||||
|
.isEqualTo(start to 11 * 60 + 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the top edge snaps and leaves the end alone`() {
|
||||||
|
assertThat(resizedSpan(ResizeEdge.Top, start, end, 8 * 60 - 7f))
|
||||||
|
.isEqualTo(8 * 60 to end)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an edge stops one snap step short of the other end`() {
|
||||||
|
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, 7 * 60f))
|
||||||
|
.isEqualTo(start to start + DRAG_SNAP_MINUTES)
|
||||||
|
assertThat(resizedSpan(ResizeEdge.Top, start, end, 12 * 60f))
|
||||||
|
.isEqualTo(end - DRAG_SNAP_MINUTES to end)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the bottom edge runs on past midnight`() {
|
||||||
|
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, 24 * 60 + 90f))
|
||||||
|
.isEqualTo(start to 24 * 60 + 90)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the top edge runs back into the day before`() {
|
||||||
|
assertThat(resizedSpan(ResizeEdge.Top, start, end, -60f))
|
||||||
|
.isEqualTo(-60 to end)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `wall clock minutes past either midnight land on the neighbouring day`() {
|
||||||
|
val zone = TimeZone.of("Europe/Berlin")
|
||||||
|
val day = LocalDate(2026, 10, 9)
|
||||||
|
assertThat(wallClockInstant(day, 24 * 60 + 30, zone))
|
||||||
|
.isEqualTo(LocalDateTime(LocalDate(2026, 10, 10), LocalTime(0, 30)).toInstant(zone))
|
||||||
|
assertThat(wallClockInstant(day, -30, zone))
|
||||||
|
.isEqualTo(LocalDateTime(LocalDate(2026, 10, 8), LocalTime(23, 30)).toInstant(zone))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a finger drifting just over a column border keeps the edge on its day`() {
|
||||||
|
assertThat(stickyColumn(position = 3.1f, held = 2, lastIndex = 6)).isEqualTo(2)
|
||||||
|
assertThat(stickyColumn(position = 1.9f, held = 2, lastIndex = 6)).isEqualTo(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a finger well into the next column takes the edge there`() {
|
||||||
|
assertThat(stickyColumn(position = 3.4f, held = 2, lastIndex = 6)).isEqualTo(3)
|
||||||
|
assertThat(stickyColumn(position = 1.6f, held = 2, lastIndex = 6)).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the first column is the one under the finger`() {
|
||||||
|
assertThat(stickyColumn(position = 4.9f, held = null, lastIndex = 6)).isEqualTo(4)
|
||||||
|
assertThat(stickyColumn(position = 9f, held = null, lastIndex = 6)).isEqualTo(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a finger resting between two slots keeps the one it is on`() {
|
||||||
|
// 09:08 rounds to 09:15, but from 09:00 it is not far enough to leave.
|
||||||
|
assertThat(stickySnap(9 * 60 + 8f, held = 9 * 60)).isEqualTo(9 * 60)
|
||||||
|
assertThat(stickySnap(9 * 60 + 12f, held = 9 * 60)).isEqualTo(9 * 60 + 15)
|
||||||
|
assertThat(stickySnap(9 * 60 + 8f, held = null)).isEqualTo(9 * 60 + 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an edge dragged into a day past the other end changes nothing`() {
|
||||||
|
// The start into the day after a same-day event ends.
|
||||||
|
assertThat(resizedSpan(ResizeEdge.Top, start, end, 24 * 60 + 9 * 60f, edgeDay = 1))
|
||||||
|
.isEqualTo(start to end)
|
||||||
|
// The end into the day before it starts.
|
||||||
|
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, -60f, edgeDay = -1))
|
||||||
|
.isEqualTo(start to end)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an edge in the event's own days still clamps against the other end`() {
|
||||||
|
assertThat(resizedSpan(ResizeEdge.Top, start, end, 12 * 60f, edgeDay = 0))
|
||||||
|
.isEqualTo(end - DRAG_SNAP_MINUTES to end)
|
||||||
|
// An overnight event's start may move anywhere up to its end's day.
|
||||||
|
val overnightEnd = 24 * 60 + 2 * 60
|
||||||
|
assertThat(resizedSpan(ResizeEdge.Top, start, overnightEnd, 24 * 60 + 60f, edgeDay = 1))
|
||||||
|
.isEqualTo(24 * 60 + 60 to overnightEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user