Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99970b3bb9 | ||
|
|
97dbdcad0c | ||
|
|
f50095ab1e | ||
|
|
06959e7a69 | ||
|
|
3fe2cd4e9a | ||
|
|
e195160f8c | ||
|
|
679e3fc849 | ||
|
|
5e397d8882 | ||
|
|
8ac4f0316a | ||
|
|
51ac3e4a4d | ||
|
|
c1d93a236e | ||
|
|
7d48e13b1c | ||
|
|
f5ec696bc3 | ||
|
|
d40bf2c3ee | ||
|
|
a64fbc9e70 | ||
|
|
9377e5d3ba | ||
|
|
eefd9430e0 | ||
|
|
38a860aafd | ||
|
|
c8d3d21ba3 | ||
|
|
e7f3afe581 | ||
|
|
44f91a963a | ||
|
|
a48becefc0 | ||
|
|
4747926e27 |
@@ -265,3 +265,20 @@ fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
||||
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)
|
||||
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.rememberReduceMotion
|
||||
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.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.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||
@@ -386,6 +390,9 @@ fun CalendarHost(
|
||||
// survives view switches; the calendar surfaces read it out of LocalEventMove.
|
||||
val reschedule: RescheduleViewModel = hiltViewModel()
|
||||
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 key = longArrayOf(
|
||||
event.eventId,
|
||||
@@ -527,7 +534,25 @@ fun CalendarHost(
|
||||
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 ->
|
||||
SharedTransitionLayout(
|
||||
Modifier
|
||||
@@ -582,7 +607,15 @@ fun CalendarHost(
|
||||
|
||||
// Scope prompt + confirmation/undo snackbar for a dropped event, declared
|
||||
// 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
|
||||
// detail screen draws on top, and closing it returns to the results.
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
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.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -16,6 +25,9 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
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.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
@@ -24,6 +36,7 @@ import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
@@ -53,12 +66,31 @@ class ScreenActions(
|
||||
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. */
|
||||
@Stable
|
||||
class ChromeHost {
|
||||
var chrome by mutableStateOf<ScreenChrome?>(null)
|
||||
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
|
||||
private set
|
||||
|
||||
@@ -80,8 +112,35 @@ fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
|
||||
if (slot.current) SideEffect { slot.host.publish(chrome, actions) }
|
||||
}
|
||||
|
||||
/** The top bar every calendar screen shares. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
/** Hands the screen's selected [event] to the host, for as long as it stays selected. */
|
||||
@Composable
|
||||
fun PublishSelection(event: EventInstance?, busy: Boolean, compact: Boolean, onDismiss: () -> Unit) {
|
||||
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
|
||||
fun CalendarTopBar(
|
||||
host: ChromeHost,
|
||||
@@ -91,6 +150,43 @@ fun CalendarTopBar(
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
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
|
||||
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
|
||||
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) {
|
||||
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean, selection: FabSelection? = null) {
|
||||
val chrome = host.chrome ?: return
|
||||
CalendarFabColumn(
|
||||
todayVisible = chrome.awayFromToday && !todayInToolbar,
|
||||
todayText = chrome.todayText,
|
||||
onToday = { host.actions?.onToday?.invoke() },
|
||||
onCreate = { host.actions?.onCreate?.invoke() },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = !host.selectionBusy,
|
||||
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(),
|
||||
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(),
|
||||
) {
|
||||
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
|
||||
|
||||
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.scaleOut
|
||||
import androidx.compose.animation.shrinkHorizontally
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
@@ -15,15 +24,38 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.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.unit.dp
|
||||
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
|
||||
* 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)
|
||||
@Composable
|
||||
@@ -32,13 +64,14 @@ fun CalendarFabColumn(
|
||||
todayText: String,
|
||||
onToday: () -> Unit,
|
||||
onCreate: () -> Unit,
|
||||
selection: FabSelection? = null,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = todayVisible,
|
||||
visible = todayVisible && selection == null,
|
||||
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()),
|
||||
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()),
|
||||
) {
|
||||
@@ -48,11 +81,71 @@ fun CalendarFabColumn(
|
||||
text = { Text(todayText) },
|
||||
)
|
||||
}
|
||||
FloatingActionButton(onClick = onCreate) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = stringResource(R.string.event_edit_new_title),
|
||||
// Held past the selection clearing, so the bar has something to fold away.
|
||||
val heldBar = remember { mutableStateOf<(@Composable () -> Unit)?>(null) }
|
||||
if (selection != null && heldBar.value !== selection.bar) heldBar.value = selection.bar
|
||||
// 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. */
|
||||
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
|
||||
* `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an
|
||||
* ancestor consumes or the finger leaves the block, which a
|
||||
* `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.
|
||||
*
|
||||
* [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
|
||||
fun rememberEventDragSource(
|
||||
enabled: Boolean,
|
||||
key: Any?,
|
||||
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> Unit,
|
||||
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> PickUp,
|
||||
onMove: (pointerInRoot: Offset) -> Unit,
|
||||
onDrop: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
): Modifier = rememberDragSurface(
|
||||
onRelease: (() -> Unit)? = null,
|
||||
immediate: Boolean = false,
|
||||
): Modifier = rememberPickUpSurface(
|
||||
enabled = enabled,
|
||||
key = key,
|
||||
onPickUp = { _, pointerInRoot, nodeInRoot, size ->
|
||||
onPickUp(pointerInRoot, nodeInRoot, size)
|
||||
true
|
||||
},
|
||||
immediate = immediate,
|
||||
onPickUp = { _, pointerInRoot, nodeInRoot, size -> onPickUp(pointerInRoot, nodeInRoot, size) },
|
||||
onMove = onMove,
|
||||
onDrop = onDrop,
|
||||
onCancel = onCancel,
|
||||
onRelease = onRelease,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -65,6 +83,29 @@ fun rememberDragSurface(
|
||||
onMove: (pointerInRoot: Offset) -> Unit,
|
||||
onDrop: () -> 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 {
|
||||
if (!enabled) return Modifier
|
||||
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
|
||||
@@ -74,27 +115,42 @@ fun rememberDragSurface(
|
||||
val currentMove by rememberUpdatedState(onMove)
|
||||
val currentDrop by rememberUpdatedState(onDrop)
|
||||
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
|
||||
.onGloballyPositioned { coordinates[0] = it }
|
||||
.pointerInput(key) {
|
||||
val slop = viewConfiguration.touchSlop
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val origin = down.position
|
||||
val heldStill = withTimeoutOrNull(holdMillis) {
|
||||
while (true) {
|
||||
val immediate = currentImmediate
|
||||
// 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 change = main.changes.firstOrNull { it.id == down.id } ?: break
|
||||
if (!change.pressed) break
|
||||
if (main.changes.count { it.pressed } > 1) break
|
||||
if ((change.position - origin).getDistance() > tolerance) break
|
||||
// An ancestor's claim only becomes visible once the whole
|
||||
// main pass has run, so look again on the final pass.
|
||||
val final = awaitPointerEvent(PointerEventPass.Final)
|
||||
if (final.changes.any { it.isConsumed }) break
|
||||
val change = main.changes.firstOrNull { it.id == down.id }
|
||||
val distance = change?.let { (it.position - origin).getDistance() } ?: 0f
|
||||
result = when {
|
||||
change == null || !change.pressed -> false
|
||||
main.changes.count { it.pressed } > 1 -> false
|
||||
immediate && distance > slop && !change.isConsumed -> {
|
||||
change.consume()
|
||||
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
|
||||
if (!heldStill) return@awaitEachGesture
|
||||
result
|
||||
}
|
||||
if (outcome == false) return@awaitEachGesture
|
||||
|
||||
val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture
|
||||
val took = currentPickUp(
|
||||
@@ -103,8 +159,11 @@ fun rememberDragSurface(
|
||||
layout.positionInRoot(),
|
||||
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 released = false
|
||||
try {
|
||||
// Driven on the initial pass, which runs parent → child: an
|
||||
// ancestor that outranks us (the pinch) has already consumed
|
||||
@@ -116,18 +175,31 @@ fun rememberDragSurface(
|
||||
if (change.isConsumed) break
|
||||
if (!change.pressed) {
|
||||
change.consume()
|
||||
dropped = true
|
||||
if (took == PickUp.Hold || (!moved && currentRelease != null)) {
|
||||
released = true
|
||||
} else {
|
||||
dropped = true
|
||||
}
|
||||
break
|
||||
}
|
||||
if (event.changes.count { it.pressed } > 1) break
|
||||
change.consume()
|
||||
if (took == PickUp.Hold) continue
|
||||
if ((change.position - origin).getDistance() > slop) moved = true
|
||||
coordinates[0]?.takeIf { it.isAttached }
|
||||
?.let { currentMove(it.localToRoot(change.position)) }
|
||||
}
|
||||
} finally {
|
||||
// Also reached when the pointer node is disposed mid-drag —
|
||||
// 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.
|
||||
*/
|
||||
@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 outcome by viewModel.outcome.collectAsStateWithLifecycle()
|
||||
val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle()
|
||||
@@ -58,7 +63,9 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
|
||||
|
||||
prompt?.let { pending ->
|
||||
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,
|
||||
onDismiss = viewModel::cancelScope,
|
||||
allowSeries = !pending.occurrenceOnly,
|
||||
@@ -69,9 +76,17 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
|
||||
|
||||
val moved = outcome as? MoveOutcome.Moved
|
||||
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) {
|
||||
null -> null
|
||||
is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty())
|
||||
null -> actionFailure
|
||||
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.WriteDenied -> stringResource(R.string.event_move_write_denied)
|
||||
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)
|
||||
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()) {
|
||||
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.
|
||||
val undo = content.undo?.takeIf { !writeInFlight }
|
||||
SnackChip(
|
||||
visible = outcome != null,
|
||||
visible = outcome != null || actionFailure != null,
|
||||
message = content.message,
|
||||
maxWidth = chipMaxWidth,
|
||||
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
|
||||
@@ -142,3 +163,7 @@ private fun formatMovedTo(
|
||||
return localizedDateFormatter(locale, skeleton)
|
||||
.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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.getValue
|
||||
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.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
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
|
||||
|
||||
/** Width of the hour gutter down the start edge of the day and week timelines. */
|
||||
@@ -67,7 +70,7 @@ fun HourGutter(
|
||||
val locale = currentLocale()
|
||||
// Derived: the drag is rewritten every frame, its snapped start once a slot.
|
||||
val dragStartMin by remember(dragController) {
|
||||
derivedStateOf { dragController.drag?.startMin }
|
||||
derivedStateOf { dragController.drag?.markerMin }
|
||||
}
|
||||
val hourAlpha by animateFloatAsState(
|
||||
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)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = top)
|
||||
.graphicsLayer {
|
||||
alpha = badgeIn
|
||||
scaleX = 0.8f + 0.2f * badgeIn
|
||||
scaleY = 0.8f + 0.2f * badgeIn
|
||||
}
|
||||
.height(BADGE_HEIGHT)
|
||||
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||
.padding(horizontal = 4.dp)
|
||||
@@ -121,7 +137,8 @@ fun HourGutter(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
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,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.calendula.domain.allowsEventMove
|
||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.problems
|
||||
import de.jeanlucmakiola.calendula.domain.realignRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.resizedTo
|
||||
import de.jeanlucmakiola.calendula.domain.resolvedZone
|
||||
import de.jeanlucmakiola.calendula.domain.shiftedByDays
|
||||
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.
|
||||
*/
|
||||
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
|
||||
* 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. */
|
||||
data class MoveUndo(
|
||||
@@ -88,12 +92,14 @@ data class MoveUndo(
|
||||
sealed interface MoveOutcome {
|
||||
/**
|
||||
* 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(
|
||||
val startMillis: Long,
|
||||
val isAllDay: Boolean,
|
||||
val undo: MoveUndo?,
|
||||
val endMillis: Long? = null,
|
||||
) : MoveOutcome
|
||||
|
||||
data object Undone : MoveOutcome
|
||||
@@ -226,7 +232,10 @@ class RescheduleViewModel @Inject constructor(
|
||||
} else {
|
||||
// Still busy: the scope dialog is now the thing in flight.
|
||||
pending = prepared
|
||||
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign)
|
||||
_scopePrompt.value = MoveScopePrompt(
|
||||
occurrenceOnly = !prepared.canRealign,
|
||||
resize = request.target is MoveTarget.Span,
|
||||
)
|
||||
}
|
||||
}
|
||||
return true
|
||||
@@ -313,6 +322,7 @@ class RescheduleViewModel @Inject constructor(
|
||||
val shifted = when (val target = request.target) {
|
||||
is MoveTarget.Start -> original.shiftedTo(target.instant, 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.
|
||||
if (shifted == original) return null
|
||||
@@ -396,6 +406,7 @@ class RescheduleViewModel @Inject constructor(
|
||||
startMillis = movedStartMillis(prepared),
|
||||
isAllDay = prepared.updated.isAllDay,
|
||||
undo = undoFor(prepared, scope),
|
||||
endMillis = resizedEndMillis(prepared),
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -480,6 +491,14 @@ class RescheduleViewModel @Inject constructor(
|
||||
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
|
||||
* 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
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.MutatePriority
|
||||
@@ -20,6 +21,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -27,6 +29,7 @@ import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
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.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
@@ -97,11 +101,28 @@ data class TimelineDrag(
|
||||
* drop lands, so it is dropped once the gesture ends ([finish]).
|
||||
*/
|
||||
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
|
||||
* event crossing midnight are previewed, whichever half the finger picked up:
|
||||
@@ -248,6 +269,56 @@ data class TimelineDrop(
|
||||
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
|
||||
* 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)
|
||||
|
||||
/** 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. */
|
||||
var isDragging: Boolean by mutableStateOf(false)
|
||||
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.
|
||||
* 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)
|
||||
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
|
||||
* 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
|
||||
* 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(
|
||||
block: TimedBlock,
|
||||
@@ -370,6 +543,8 @@ class TimelineDragController {
|
||||
pointerInRoot: Offset,
|
||||
blockInRoot: Offset,
|
||||
) {
|
||||
// The selected event keeps its selection through a drag; any other ends it.
|
||||
if (!isSelected(block)) deselect()
|
||||
source = block
|
||||
this.clipOffsetMin = clipOffsetMin
|
||||
this.titleLines = titleLines
|
||||
@@ -378,6 +553,8 @@ class TimelineDragController {
|
||||
liftedInstanceId = block.event.instanceId
|
||||
grab = pointerInRoot - blockInRoot
|
||||
pointer = pointerInRoot
|
||||
pickupPointer = pointerInRoot
|
||||
pointerMoved = false
|
||||
originSlot = null
|
||||
recompute()
|
||||
originSlot = drag?.slot
|
||||
@@ -385,20 +562,28 @@ class TimelineDragController {
|
||||
|
||||
fun move(pointerInRoot: Offset) {
|
||||
pointer = pointerInRoot
|
||||
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
|
||||
recompute()
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
resizeEdge = null
|
||||
resizeDay = null
|
||||
resizeColumn = null
|
||||
resizeSlot = null
|
||||
source = null
|
||||
clipOffsetMin = 0
|
||||
titleLines = 0
|
||||
isDragging = false
|
||||
pointerMoved = false
|
||||
liftedInstanceId = null
|
||||
originSlot = null
|
||||
drag = null
|
||||
settling = null
|
||||
settledOnGrid = false
|
||||
settledOrigin = null
|
||||
settledEnd = null
|
||||
settlingSelected = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -429,8 +614,11 @@ class TimelineDragController {
|
||||
val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt()
|
||||
val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset)
|
||||
.firstOrNull() ?: return
|
||||
val end = settledEnd
|
||||
settledOnGrid = blocks.any { block ->
|
||||
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 ||
|
||||
(
|
||||
@@ -452,6 +640,7 @@ class TimelineDragController {
|
||||
val clipOffset = clipOffsetMin
|
||||
cancel()
|
||||
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
|
||||
// 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
|
||||
@@ -463,6 +652,150 @@ class TimelineDragController {
|
||||
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
|
||||
* the copy is still dissolving. Un-ghosting at [release] instead would leave
|
||||
@@ -477,6 +810,8 @@ class TimelineDragController {
|
||||
fun release() {
|
||||
settling = null
|
||||
settledOnGrid = false
|
||||
settledEnd = null
|
||||
settlingSelected = false
|
||||
handOver()
|
||||
}
|
||||
|
||||
@@ -486,6 +821,10 @@ class TimelineDragController {
|
||||
* no move events while auto-scroll walks the content underneath it.
|
||||
*/
|
||||
fun recompute() {
|
||||
resizeEdge?.let {
|
||||
recomputeResize(it)
|
||||
return
|
||||
}
|
||||
val block = source ?: return
|
||||
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
|
||||
val hourPx = geometry.hourPx
|
||||
@@ -577,6 +916,7 @@ fun rememberTimelineDragController(): TimelineDragController {
|
||||
val density = LocalDensity.current
|
||||
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
|
||||
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
|
||||
controller.slopPx = LocalViewConfiguration.current.touchSlop
|
||||
controller.abandonedTicks = LocalEventMove.current?.abandoned
|
||||
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
|
||||
* visibly settles there.
|
||||
*/
|
||||
fun TimelineDrop.startInstant(zone: TimeZone): Instant {
|
||||
// Wall clock throughout, and past this day at either end: a block's top edge
|
||||
// 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
|
||||
// from an instant — which would shift by an hour across a DST boundary.
|
||||
val fromMidnight = startMin - clipOffsetMin
|
||||
val day = date.plus(fromMidnight.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
|
||||
val minute = fromMidnight.mod(MINUTES_PER_DAY)
|
||||
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
|
||||
wallClockInstant(date, startMin - clipOffsetMin, zone)
|
||||
|
||||
/** [minutes] past [date]'s midnight, either way past it, as wall clock in [zone] (DST-safe). */
|
||||
fun wallClockInstant(date: LocalDate, minutes: Int, zone: TimeZone): Instant {
|
||||
val day = date.plus(minutes.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
|
||||
val minute = minutes.mod(MINUTES_PER_DAY)
|
||||
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone)
|
||||
}
|
||||
|
||||
@@ -660,8 +999,10 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
// 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.
|
||||
DragSnapHaptics(controller.drag?.slot)
|
||||
LaunchedEffect(controller.isDragging) {
|
||||
if (controller.isDragging) controller.autoScroll()
|
||||
// Only once the finger has moved: a long press held near an edge must not
|
||||
// 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
|
||||
// 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.
|
||||
val landed = controller.drag == null
|
||||
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",
|
||||
)
|
||||
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
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.Schedule
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -51,7 +46,6 @@ import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
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.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
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.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
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.copyOnLongPress
|
||||
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.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.floret.components.OptionCard
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
@@ -162,43 +156,15 @@ fun EventDetailScreen(
|
||||
val onShareClick = {
|
||||
scope.launch {
|
||||
val uri = viewModel.shareUri()
|
||||
val sent = uri != null && runCatching {
|
||||
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
|
||||
val sent = uri != null && context.sendEventShare(uri, shareChooserTitle)
|
||||
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||
// upgrade in place. Granting continues straight into the tapped action —
|
||||
// edit, delete, or duplicate — held here until the result comes back.
|
||||
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)
|
||||
}
|
||||
}
|
||||
// upgrade in place and continues straight into the tapped action.
|
||||
val requireWrite = rememberWriteGate()
|
||||
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
|
||||
val onEditClick = { requireWrite(onEdit) }
|
||||
// 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
|
||||
private fun EventDetailContent(
|
||||
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.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
|
||||
import de.jeanlucmakiola.calendula.ui.common.deleteEvent
|
||||
import de.jeanlucmakiola.calendula.ui.common.stageEventShare
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.time.Clock
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -113,14 +112,7 @@ class EventDetailViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
_deleteState.value = DeleteUiState.Deleting
|
||||
_deleteState.value = try {
|
||||
when (scope) {
|
||||
RecurringWriteScope.AllEvents ->
|
||||
repository.deleteEvent(target.eventId)
|
||||
RecurringWriteScope.ThisEvent ->
|
||||
repository.deleteOccurrence(target.eventId, target.beginMillis)
|
||||
RecurringWriteScope.ThisAndFollowing ->
|
||||
repository.deleteEventFromOccurrence(target.eventId, target.beginMillis)
|
||||
}
|
||||
repository.deleteEvent(target.eventId, target.beginMillis, scope)
|
||||
DeleteUiState.Deleted
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -144,40 +136,16 @@ class EventDetailViewModel @Inject constructor(
|
||||
*/
|
||||
suspend fun shareUri(): Uri? {
|
||||
val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null
|
||||
return runCatching {
|
||||
withContext(io) {
|
||||
val ics = IcsWriter().writeCalendar(
|
||||
events = listOf(detail.toShareIcsEvent()),
|
||||
dtStamp = Clock.System.now(),
|
||||
)
|
||||
icsExporter.stageShareFile(shareFileName(detail.instance.title), ics)
|
||||
}
|
||||
}.getOrNull()
|
||||
return withContext(io) { icsExporter.stageEventShare(detail) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a create form seeded from the open event so the user can save a
|
||||
* copy as a new, independent event (#52). Returns null when nothing is
|
||||
* 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.
|
||||
* A create form seeded from the open event, to save a copy as a new event
|
||||
* (#52) — see [toDuplicateForm]. Null when nothing is loaded.
|
||||
*/
|
||||
fun duplicateForm(): EventForm? {
|
||||
val loaded = state.value as? EventDetailUiState.Success ?: return null
|
||||
val detail = loaded.detail
|
||||
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 },
|
||||
)
|
||||
return loaded.detail.toDuplicateForm(loaded.canModify, TimeZone.currentSystemDefault())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/** 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.rememberUpdatedState
|
||||
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.BiasAlignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
|
||||
import de.jeanlucmakiola.calendula.ui.common.StayPut
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
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.clipToBounds
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
@@ -511,6 +529,14 @@ private fun WeekContent(
|
||||
val move = LocalEventMove.current
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
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(
|
||||
MoveRequest(
|
||||
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) {
|
||||
CompositionLocalProvider(LocalOpeningTimeline provides timelineScroll) {
|
||||
// Loading and failure concern the provider as a whole, so they stand in
|
||||
@@ -541,10 +612,11 @@ private fun WeekContent(
|
||||
today = today,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onEventClick = openEvent,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
} else WeekPager(
|
||||
pagerState = pagerState,
|
||||
@@ -555,10 +627,11 @@ private fun WeekContent(
|
||||
initialStripHeight = handoffStrip ?: s.allDayStripHeight(),
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onEventClick = openEvent,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -585,6 +658,7 @@ private fun WeekPager(
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
onResize: (TimelineResizeDrop) -> Unit,
|
||||
) {
|
||||
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
|
||||
val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
|
||||
@@ -633,6 +707,7 @@ private fun WeekPager(
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -667,6 +742,7 @@ private fun TimelineMorphFrame(
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
onResize: (TimelineResizeDrop) -> Unit,
|
||||
) {
|
||||
val fromState by morph.fromState.collectAsStateWithLifecycle()
|
||||
val toState by morph.toState.collectAsStateWithLifecycle()
|
||||
@@ -722,6 +798,7 @@ private fun TimelineMorphFrame(
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -815,6 +892,7 @@ internal fun WeekSuccess(
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
onResize: (TimelineResizeDrop) -> Unit,
|
||||
) {
|
||||
WeekFrame(
|
||||
weekStart = state.weekStart,
|
||||
@@ -834,6 +912,7 @@ internal fun WeekSuccess(
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -856,6 +935,7 @@ private fun WeekPage(
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
onResize: (TimelineResizeDrop) -> Unit,
|
||||
) {
|
||||
val geometry = remember(state.weekStart, state.days.size) {
|
||||
ColumnGeometry.of(DaySpan(state.weekStart, state.days.size))
|
||||
@@ -875,6 +955,7 @@ private fun WeekPage(
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -898,6 +979,7 @@ private fun TimelinePageContent(
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
onResize: (TimelineResizeDrop) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
@@ -928,6 +1010,7 @@ private fun TimelinePageContent(
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1157,6 +1240,7 @@ private fun Timeline(
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
onResize: (TimelineResizeDrop) -> Unit,
|
||||
) {
|
||||
val fullViewportHeight = remember(geometry) { intArrayOf(-1) }
|
||||
val dark = isSystemInDarkTheme()
|
||||
@@ -1254,6 +1338,7 @@ private fun Timeline(
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
scrollState = scrollState,
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
)
|
||||
@@ -1276,6 +1361,7 @@ private fun DayColumnCard(
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
onResize: (TimelineResizeDrop) -> Unit,
|
||||
scrollState: ScrollState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -1324,6 +1410,10 @@ private fun DayColumnCard(
|
||||
// blocks are consumed by their own handler first. Snaps to hour.
|
||||
.pointerInput(date) {
|
||||
detectTapGestures { offset ->
|
||||
if (dragController.selection != null) {
|
||||
dragController.deselect()
|
||||
return@detectTapGestures
|
||||
}
|
||||
if (currentEdges.value.tap(offset.y)) return@detectTapGestures
|
||||
currentOnCreateAt.value(
|
||||
date,
|
||||
@@ -1383,6 +1473,7 @@ private fun DayColumnCard(
|
||||
dragController = dragController,
|
||||
onClick = { onEventClick(block.event) },
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
modifier = Modifier
|
||||
.laneSlot(place)
|
||||
.offset(y = place.y + topInset)
|
||||
@@ -1427,6 +1518,7 @@ private fun EventBlock(
|
||||
dragController: TimelineDragController,
|
||||
onClick: () -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
onResize: (TimelineResizeDrop) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
@@ -1481,68 +1573,169 @@ private fun EventBlock(
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val moveAction = eventMoveAction(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
|
||||
// where the event's own start belongs (#253).
|
||||
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
|
||||
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
|
||||
val topInsetPx = with(density) { topInset.toPx() }
|
||||
// Long press (#141): move to drag, let go to select; selected blocks drag at once.
|
||||
val dragModifier = rememberEventDragSource(
|
||||
enabled = draggable,
|
||||
enabled = selectable,
|
||||
key = block.event.instanceId,
|
||||
immediate = selected && draggable,
|
||||
onPickUp = { pointer, blockRoot, _ ->
|
||||
dragController.begin(
|
||||
block = block,
|
||||
clipOffsetMin = clipOffset,
|
||||
titleLines = if (showTitle) titleMaxLines else 0,
|
||||
pointerInRoot = pointer,
|
||||
// Measured off the block's placement rather than off where the
|
||||
// hour grid seats it: the copy is drawn from that placement and
|
||||
// re-seated the same half-gap down (#267).
|
||||
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
|
||||
)
|
||||
if (draggable) {
|
||||
dragController.begin(
|
||||
block = block,
|
||||
clipOffsetMin = clipOffset,
|
||||
titleLines = if (showTitle) titleMaxLines else 0,
|
||||
pointerInRoot = pointer,
|
||||
// Measured off the block's placement rather than off where the
|
||||
// hour grid seats it: the copy is drawn from that placement and
|
||||
// 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,
|
||||
onDrop = { dragController.finish()?.let(onDrop) },
|
||||
onCancel = dragController::cancel,
|
||||
onRelease = { dragController.select(block.event, fromLift = draggable) },
|
||||
)
|
||||
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(
|
||||
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.
|
||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||
.eventSurface(paint, shape, cuts)
|
||||
.clickable(onClick = onClick)
|
||||
// After clickable, so it is the inner node and wins the main pass;
|
||||
// the tap still works, since a drag consumes the up.
|
||||
.then(dragModifier)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
|
||||
.semantics {
|
||||
contentDescription = "$title, $timeLabel"
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
},
|
||||
.then(if (raise > 0f) Modifier.selectionLift(raise, shape) else Modifier)
|
||||
.then(
|
||||
if (tracksBar) {
|
||||
Modifier.onGloballyPositioned {
|
||||
dragController.noteSelectedBottom(it, revealExtraPx, barClearancePx)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
if (showTitle) {
|
||||
BlockTitle(
|
||||
title = title,
|
||||
maxLines = titleMaxLines,
|
||||
color = paint.titleInk,
|
||||
textDecoration = paint.decoration,
|
||||
fontWeight = paint.titleWeight,
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.eventSurface(paint, shape, cuts)
|
||||
.clickable(onClick = onClick)
|
||||
// After clickable, so it is the inner node and wins the main pass;
|
||||
// the tap still works, since a drag consumes the up.
|
||||
.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) {
|
||||
BlockTimeLabel(
|
||||
label = shownTime,
|
||||
color = paint.secondaryInk,
|
||||
maxLines = timeMaxLines,
|
||||
if (!cuts.bottom) {
|
||||
ResizeHandle(
|
||||
color = accent,
|
||||
enabled = handlesShown,
|
||||
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_PADDING = 8.dp
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ internal fun WeekViewPreview(
|
||||
onOpenDay = {},
|
||||
onCreateAt = { _, _ -> },
|
||||
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>
|
||||
<!-- %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_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_undone">Move undone</string>
|
||||
<string name="event_move_failed">Couldn\'t move the event</string>
|
||||
|
||||
@@ -152,4 +152,49 @@ class EventShiftTest {
|
||||
val original = form()
|
||||
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)),
|
||||
)
|
||||
|
||||
/** 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
|
||||
fun `a one-off drop writes straight through with no scope prompt`(
|
||||
@TempDir tempDir: Path,
|
||||
@@ -595,4 +606,82 @@ class RescheduleViewModelTest {
|
||||
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
|
||||
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