diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eac8dc1..a56bcfe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Long press an event to select it.** In the day, 3-day and week views, a long press lifts the event and selects it. Keep your finger down and move to drag it straight away. A selected event has handles at its top and bottom: drag them to change when it starts or ends, in 15-minute steps and across midnight if you want. The bar at the top edits, duplicates, shares or deletes it, and tapping elsewhere or going back deselects it. Read-only events can be selected but not moved or resized ([#141]). - **Scroll back to earlier days in the agenda.** Scroll up past today to see earlier days' events, loaded a month at a time. Past days follow the past-events setting: Dim fades them, and with Hide the agenda still starts at today ([#367]). ### Fixed @@ -1857,3 +1858,4 @@ automatically, with zero telemetry and no internet permission. [#364]: https://codeberg.org/jlmakiola/calendula/issues/364 [#367]: https://codeberg.org/jlmakiola/calendula/issues/367 [#373]: https://codeberg.org/jlmakiola/calendula/issues/373 +[#141]: https://codeberg.org/jlmakiola/calendula/issues/141 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt index 3d0fde71..5617c206 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -265,3 +265,20 @@ fun EventForm.problems(): Set = 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 }, + ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt index 84a810a2..8ec429a7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt @@ -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), + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index a18abae0..1a769156 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -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. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt index 173d29d2..508dd5f8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt @@ -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(null) private set + var selection by mutableStateOf(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() + 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, + 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, + ) + } } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt index bc686171..63e576fd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt @@ -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() + 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) + }, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt new file mode 100644 index 00000000..500cade7 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt @@ -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" +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt new file mode 100644 index 00000000..f05a5e76 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt @@ -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> = 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(null) + val deletePrompt: StateFlow = _deletePrompt.asStateFlow() + + private val _failure = MutableStateFlow(null) + val failure: StateFlow = _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 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt index 12bf70f7..ee282d4f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt @@ -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() + } } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt index 2bed9342..abf39782 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt @@ -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())) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt index c49456b8..92b8eae1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt @@ -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. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionBar.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionBar.kt new file mode 100644 index 00000000..f161c52c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionBar.kt @@ -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)) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionHost.kt new file mode 100644 index 00000000..34f4603c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionHost.kt @@ -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(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, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt index 67c944cc..5b94deac 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt @@ -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(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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt index 003506ba..d30a5e8b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt @@ -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. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ResizeHandle.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ResizeHandle.kt new file mode 100644 index 00000000..429d81c5 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ResizeHandle.kt @@ -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(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), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SelectionTopBar.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SelectionTopBar.kt new file mode 100644 index 00000000..7e460912 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SelectionTopBar.kt @@ -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)}" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index 0f2fae46..b93f87ef 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -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 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 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 { + // 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? 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() + + 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? = null + private var originSlot: Triple? = 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() + 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 5c5c7dcb..7e94e35e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt index cd068cbf..9f60ab74 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt @@ -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" -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index abc49568..55fc236b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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() } 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt index d336cdf3..ace125e7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt @@ -58,6 +58,7 @@ internal fun WeekViewPreview( onOpenDay = {}, onCreateAt = { _, _ -> }, onDrop = {}, + onResize = {}, ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 52c50504..dfe552fc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -97,6 +97,12 @@ Moving the whole series would change which days it falls on, so only this event can move. Moved to %1$s + Changed to %1$s + Change recurring event + Event actions + Hide event actions + Deselect event + Couldn\'t open the event Undo Move undone Couldn\'t move the event diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt index 53c96ca4..a626b7ab 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt @@ -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) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt index a22c6f27..ef1fa46e 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt @@ -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)) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ResizedSpanTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ResizedSpanTest.kt new file mode 100644 index 00000000..6102d284 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ResizedSpanTest.kt @@ -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) + } +}