Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76cdc1c535 | ||
|
|
68c0a262fa | ||
|
|
39e13e86d2 | ||
|
|
1cc7f40f7e | ||
|
|
8a15325fdc | ||
|
|
7f977971ed | ||
|
|
632155c016 | ||
|
|
8834884d4e | ||
|
|
236372d740 | ||
|
|
678a526cb9 | ||
|
|
84fd51baf6 | ||
|
|
450b8b0107 | ||
|
|
8078fb4f10 | ||
|
|
46b88cc03e |
@@ -15,7 +15,7 @@ jobs:
|
||||
detect:
|
||||
# Gitea only; see the same guard in release.yaml.
|
||||
if: github.repository_owner == 'makiolaj'
|
||||
runs-on: docker
|
||||
runs-on: light
|
||||
outputs:
|
||||
is_beta: ${{ steps.v.outputs.is_beta }}
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
beta:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.is_beta == 'true'
|
||||
runs-on: docker
|
||||
runs-on: gradle
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
# secrets. repository_owner differs between the two forges regardless of
|
||||
# URL, proxy or instance rename, so this closes it permanently.
|
||||
if: github.repository_owner == 'makiolaj'
|
||||
runs-on: docker
|
||||
runs-on: light
|
||||
outputs:
|
||||
is_release: ${{ steps.v.outputs.is_release }}
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
release:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: docker
|
||||
runs-on: gradle
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
|
||||
@@ -7,14 +7,6 @@ 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
|
||||
- **The agenda always starts today.** Coming from the month or day view, the agenda used to start at the day you had open there, so "Next 30 days" could show the 1st to the 30th of the month. It now opens on today with your chosen range. Backing out of a day you opened from the agenda still returns you to where you were ([#367]).
|
||||
- **Multi-day events dim on the days already behind you.** With past events dimmed, an event running over several days stayed at full strength on its earlier days until it ended. Those days now dim in the agenda, month, week and day views ([#367]).
|
||||
|
||||
## [2.22.5] — 2026-10-07
|
||||
|
||||
### Fixed
|
||||
@@ -1856,6 +1848,4 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#361]: https://codeberg.org/jlmakiola/calendula/issues/361
|
||||
[#363]: https://codeberg.org/jlmakiola/calendula/issues/363
|
||||
[#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
|
||||
|
||||
@@ -32,8 +32,8 @@ android {
|
||||
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
|
||||
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
|
||||
// See docs/RELEASING.md.
|
||||
versionCode = 2230001
|
||||
versionName = "2.23.0-beta.1"
|
||||
versionCode = 2220599
|
||||
versionName = "2.22.5"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -265,20 +265,3 @@ fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
||||
add(EventFormProblem.RecurrenceEndsBeforeStart)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A create form seeded from this occurrence, to save a copy as a new,
|
||||
* independent event (#52). The recurrence is dropped — a duplicate is a single
|
||||
* event. The source calendar is kept only when [canModify], otherwise the id is
|
||||
* cleared so the create resolves to a writable calendar, which lets a read-only
|
||||
* event be copied into an editable one. [instance]'s own times carry over.
|
||||
*/
|
||||
fun EventDetail.toDuplicateForm(canModify: Boolean, zone: TimeZone): EventForm =
|
||||
toEditForm(
|
||||
beginMillis = instance.start.toEpochMilliseconds(),
|
||||
endMillis = instance.end.toEpochMilliseconds(),
|
||||
zone = zone,
|
||||
).copy(
|
||||
rrule = null,
|
||||
calendarId = instance.calendarId.takeIf { canModify },
|
||||
)
|
||||
|
||||
@@ -51,13 +51,3 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
|
||||
val span = end.toInstant(zone) - start.toInstant(zone)
|
||||
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone))
|
||||
}
|
||||
|
||||
/** The form with new start and end, each in the form's own zone — a resize. All-day is untouched. */
|
||||
fun EventForm.resizedTo(newStart: Instant, newEnd: Instant, deviceZone: TimeZone): EventForm {
|
||||
if (isAllDay) return this
|
||||
val zone = resolvedZone(deviceZone)
|
||||
return copy(
|
||||
start = newStart.toLocalDateTime(zone),
|
||||
end = newEnd.toLocalDateTime(zone),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,11 +55,7 @@ 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
|
||||
@@ -150,9 +146,6 @@ fun CalendarHost(
|
||||
mutableStateOf(listOf(defaultView))
|
||||
}
|
||||
var navigationVersion by remember { mutableIntStateOf(0) }
|
||||
// True after backing out of a day opened from the view now shown, so the
|
||||
// agenda returns to where it was instead of today (#367).
|
||||
var returnedFromDay by remember { mutableStateOf(false) }
|
||||
val view = viewStack.last()
|
||||
// The date every view opens on and carries along (#184); tapping a day
|
||||
// anywhere focuses it and opens the day view there.
|
||||
@@ -170,7 +163,6 @@ fun CalendarHost(
|
||||
val switchScope = rememberCoroutineScope()
|
||||
val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> Unit = { move ->
|
||||
navigationVersion++
|
||||
returnedFromDay = false
|
||||
viewStack = move(viewStack)
|
||||
}
|
||||
val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } }
|
||||
@@ -330,10 +322,7 @@ fun CalendarHost(
|
||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||
// view so backing out returns there (then home), not to the default.
|
||||
LaunchedEffect(widgetNavRequest) {
|
||||
if (widgetNavRequest != null) {
|
||||
navigationVersion++
|
||||
returnedFromDay = false
|
||||
}
|
||||
if (widgetNavRequest != null) navigationVersion++
|
||||
when (val req = widgetNavRequest) {
|
||||
is WidgetNavRequest.OpenDate -> {
|
||||
// Drill the day view in over the widget's view: drop any overlay
|
||||
@@ -390,9 +379,6 @@ 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,
|
||||
@@ -438,14 +424,11 @@ fun CalendarHost(
|
||||
if (reduceMotion) {
|
||||
BackHandler(enabled = viewBackEnabled) {
|
||||
navigationVersion++
|
||||
returnedFromDay = viewStack.last() == CalendarView.Day
|
||||
viewStack = viewStack.dropLast(1)
|
||||
}
|
||||
} else {
|
||||
PredictiveBackHandler(enabled = viewBackEnabled) { events ->
|
||||
val previous = viewStack[viewStack.size - 2]
|
||||
// Set before the seek, which already composes the previous view.
|
||||
returnedFromDay = viewStack.last() == CalendarView.Day
|
||||
try {
|
||||
events.collect { viewTransition.seekTo(it.progress, targetState = previous) }
|
||||
navigationVersion++
|
||||
@@ -534,25 +517,7 @@ fun CalendarHost(
|
||||
showTodayButton = 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)
|
||||
},
|
||||
floatingActionButton = { CalendarFab(chromeHost, todayInToolbar) },
|
||||
) { innerPadding ->
|
||||
SharedTransitionLayout(
|
||||
Modifier
|
||||
@@ -595,7 +560,6 @@ fun CalendarHost(
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
restorePosition = returnedFromDay,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -607,15 +571,7 @@ 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(),
|
||||
actionFailure = eventActionFailureMessage(selectionFailure),
|
||||
onActionFailureShown = eventActions::consumeFailure,
|
||||
)
|
||||
|
||||
// The delete prompt and failure chip of a selected event's actions (#141).
|
||||
EventSelectionHost(host = chromeHost, viewModel = eventActions)
|
||||
EventMoveHost(reschedule, modifier = Modifier.fillMaxSize())
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.ui.common.ScreenActions
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScreenChrome
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
|
||||
import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -37,16 +38,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableIntState
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -61,7 +53,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.isPastOn
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
@@ -90,23 +82,11 @@ fun AgendaScreen(
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
modifier: Modifier = Modifier,
|
||||
restorePosition: Boolean = false,
|
||||
viewModel: AgendaViewModel = hiltViewModel(),
|
||||
) {
|
||||
// Opens on today, not on the day another view was on (#367). Backing out of
|
||||
// a day opened from here, or a recreated screen, returns to the day last at
|
||||
// the top. The top day still goes back to the shared focus.
|
||||
// Opens on the focused date and hands back the day scrolled to the top.
|
||||
val focus = LocalViewFocus.current
|
||||
val opened = rememberSaveable { mutableStateOf(false) }
|
||||
val restoreTop = remember(viewModel) {
|
||||
if (restorePosition || opened.value) {
|
||||
viewModel.lastTopDate
|
||||
} else {
|
||||
viewModel.goToToday()
|
||||
null
|
||||
}
|
||||
}
|
||||
SideEffect { opened.value = true }
|
||||
EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) }
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||
@@ -126,19 +106,20 @@ fun AgendaScreen(
|
||||
// list scroll, not an anchor change — "today" has to bring the list back to
|
||||
// the top as well, and stay offered while it is scrolled away (#305).
|
||||
val listState = rememberLazyListState()
|
||||
// Past days load above the anchor (#367), so the anchor's row counts as the
|
||||
// start of the list.
|
||||
val anchorIndex = remember { mutableIntStateOf(0) }
|
||||
val scrolledAway by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex != anchorIndex.intValue ||
|
||||
listState.firstVisibleItemScrollOffset > 0
|
||||
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
|
||||
}
|
||||
}
|
||||
// A new window is rendered from its first day, so the list belongs at the
|
||||
// top. Keyed on the anchor the days arrived with, not the one just
|
||||
// requested: scrolling before the new rows compose leaves the list keyed to
|
||||
// a row that reappears further down, and it follows it there.
|
||||
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) }
|
||||
val jumpToToday: () -> Unit = {
|
||||
// Off today, moving the anchor is what repositions the list; on today
|
||||
// Off today, moving the anchor is what resets the list (above); on today
|
||||
// the window doesn't change, so the scroll back is the whole action.
|
||||
if (isOnToday) scope.launch { listState.animateScrollToItem(anchorIndex.intValue) }
|
||||
if (isOnToday) scope.launch { listState.animateScrollToItem(0) }
|
||||
viewModel.goToToday()
|
||||
(state as? AgendaUiState.Success)?.today?.let { focus?.date = it }
|
||||
}
|
||||
@@ -207,17 +188,11 @@ fun AgendaScreen(
|
||||
pastDisplay = pastDisplay,
|
||||
showToday = showToday,
|
||||
listState = listState,
|
||||
anchorIndex = anchorIndex,
|
||||
initialTopDate = restoreTop,
|
||||
onLoadEarlier = viewModel::loadEarlier,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onTopDay = if (successState?.anchor == anchor) {
|
||||
{
|
||||
focus?.date = it
|
||||
viewModel.rememberTopDay(it)
|
||||
}
|
||||
{ focus?.date = it }
|
||||
} else null,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
@@ -319,9 +294,6 @@ internal fun AgendaContent(
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
anchorIndex: MutableIntState = remember { mutableIntStateOf(0) },
|
||||
initialTopDate: LocalDate? = null,
|
||||
onLoadEarlier: () -> Unit = {},
|
||||
onTopDay: ((LocalDate) -> Unit)? = null,
|
||||
) {
|
||||
when (state) {
|
||||
@@ -336,34 +308,28 @@ internal fun AgendaContent(
|
||||
// away (or fade) as they end while the screen stays open.
|
||||
val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
state.days.mapNotNull { day ->
|
||||
val remaining = day.events.filterNot { it.isPastOn(day.date, now, state.zone) }
|
||||
val remaining = day.events.filterNot { it.hasEnded(now) }
|
||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||
}
|
||||
} else {
|
||||
state.days
|
||||
}
|
||||
// Anchor today with a "nothing left today" placeholder — but only when
|
||||
// the agenda actually opens on today; a jumped-to date has no today
|
||||
// in it, so anchoring there would be misleading (#35).
|
||||
// the window actually starts on today; a jumped-to date has no today in
|
||||
// it, so anchoring there would be misleading (#35).
|
||||
val days = anchorTodayIfMissing(
|
||||
days = filtered,
|
||||
today = state.today,
|
||||
enabled = showToday && state.anchor == state.today,
|
||||
)
|
||||
// Past days alone don't count as anything coming up.
|
||||
if (days.none { it.date >= state.anchor }) {
|
||||
if (days.isEmpty()) {
|
||||
// Nothing to scroll: drop the position a previous list left, so
|
||||
// the today FAB doesn't linger over an empty screen.
|
||||
LaunchedEffect(Unit) { listState.scrollToItem(0) }
|
||||
SideEffect { anchorIndex.intValue = 0 }
|
||||
AgendaEmpty(modifier)
|
||||
} else {
|
||||
AgendaList(
|
||||
days = days,
|
||||
anchor = state.anchor,
|
||||
anchorIndex = anchorIndex,
|
||||
initialTopDate = initialTopDate,
|
||||
onLoadEarlier = onLoadEarlier,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
listState = listState,
|
||||
@@ -383,10 +349,6 @@ internal fun AgendaContent(
|
||||
@Composable
|
||||
private fun AgendaList(
|
||||
days: List<AgendaDay>,
|
||||
anchor: LocalDate,
|
||||
anchorIndex: MutableIntState,
|
||||
initialTopDate: LocalDate?,
|
||||
onLoadEarlier: () -> Unit,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
listState: LazyListState,
|
||||
@@ -397,49 +359,19 @@ private fun AgendaList(
|
||||
onTopDay: ((LocalDate) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val itemDates = agendaItemDates(days)
|
||||
val anchorRow = itemDates.indexOfFirst { it >= anchor }.coerceAtLeast(0)
|
||||
SideEffect { anchorIndex.intValue = anchorRow }
|
||||
// A new anchor opens the list on its row from the first frame, with past
|
||||
// days above. Keyed on the anchor the days arrived with, not the one just
|
||||
// requested: scrolling before the new rows compose leaves the list keyed to
|
||||
// a row that reappears further down, and it follows it there. The first run
|
||||
// may restore a remembered day instead.
|
||||
val pendingRestore = remember { arrayOf(initialTopDate) }
|
||||
DisposableEffect(anchor) {
|
||||
val target = pendingRestore[0]
|
||||
pendingRestore[0] = null
|
||||
val index = target?.let { date -> itemDates.indexOfFirst { it >= date } }
|
||||
?.takeIf { it >= 0 }
|
||||
?: anchorRow
|
||||
listState.requestScrollToItem(index)
|
||||
onDispose {}
|
||||
}
|
||||
// Scrolling up near the first loaded day loads earlier days.
|
||||
val currentLoadEarlier by rememberUpdatedState(onLoadEarlier)
|
||||
val loadOnScrollUp = remember(listState) {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
if (available.y > 0 && listState.firstVisibleItemIndex < LOAD_EARLIER_WITHIN) {
|
||||
currentLoadEarlier()
|
||||
}
|
||||
return Offset.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onTopDay != null) {
|
||||
val currentItemDates by rememberUpdatedState(itemDates)
|
||||
val itemDates by rememberUpdatedState(agendaItemDates(days))
|
||||
val currentOnTopDay by rememberUpdatedState(onTopDay)
|
||||
// The day the list opened on is where the view already is; only a
|
||||
// scroll moves it on.
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.firstVisibleItemIndex }
|
||||
.drop(1)
|
||||
.collect { index -> currentItemDates.getOrNull(index)?.let(currentOnTopDay) }
|
||||
.collect { index -> itemDates.getOrNull(index)?.let(currentOnTopDay) }
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = modifier.nestedScroll(loadOnScrollUp),
|
||||
modifier = modifier,
|
||||
state = listState,
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||
@@ -469,7 +401,7 @@ private fun AgendaList(
|
||||
day = day.date,
|
||||
zone = zone,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimPast && event.isPastOn(day.date, now, zone),
|
||||
dimmed = dimPast && event.hasEnded(now),
|
||||
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
@@ -480,9 +412,6 @@ private fun AgendaList(
|
||||
}
|
||||
}
|
||||
|
||||
/** How close to the first loaded row an upward scroll starts loading earlier days. */
|
||||
private const val LOAD_EARLIER_WITHIN = 10
|
||||
|
||||
/** The day of each item [AgendaList] lays out for [days], in its order. */
|
||||
internal fun agendaItemDates(days: List<AgendaDay>): List<LocalDate> =
|
||||
days.flatMap { day ->
|
||||
|
||||
@@ -93,21 +93,22 @@ fun groupAgendaDays(
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure [today] surfaces in the agenda even when it carries no (remaining)
|
||||
* events, by inserting an empty-event [AgendaDay] in date order that the agenda
|
||||
* Ensure [today] surfaces as the first agenda day even when it carries no
|
||||
* (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget
|
||||
* renders as a "nothing left today" placeholder. A no-op unless [enabled], and
|
||||
* when today already has its own day in [days]. Keeps today anchored so a glance
|
||||
* tells today's events apart from a future day's (issue #35).
|
||||
* when today already has its own day in [days]. Keeps today anchored at the top
|
||||
* so a glance tells today's events apart from a future day's (issue #35).
|
||||
*/
|
||||
fun anchorTodayIfMissing(
|
||||
days: List<AgendaDay>,
|
||||
today: LocalDate,
|
||||
enabled: Boolean,
|
||||
): List<AgendaDay> {
|
||||
if (!enabled || days.any { it.date == today }) return days
|
||||
val at = days.indexOfFirst { it.date > today }.let { if (it < 0) days.size else it }
|
||||
return days.take(at) + AgendaDay(today, emptyList()) + days.drop(at)
|
||||
}
|
||||
): List<AgendaDay> =
|
||||
if (enabled && days.none { it.date == today }) {
|
||||
listOf(AgendaDay(today, emptyList())) + days
|
||||
} else {
|
||||
days
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
||||
@@ -117,10 +118,8 @@ sealed interface AgendaUiState {
|
||||
data object Loading : AgendaUiState
|
||||
data class Failure(val reason: FailureReason) : AgendaUiState
|
||||
data class Success(
|
||||
/** The day the agenda opens on (today, or a jumped-to date). */
|
||||
/** First day of the loaded window (today, or a jumped-to date). */
|
||||
val anchor: LocalDate,
|
||||
/** First loaded day, before [anchor] once past days are loaded (#367). */
|
||||
val windowStart: LocalDate,
|
||||
val today: LocalDate,
|
||||
val days: List<AgendaDay>,
|
||||
/** The range currently in effect — the saved default or a session override. */
|
||||
|
||||
@@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
@@ -32,7 +31,6 @@ import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlin.time.Instant
|
||||
@@ -90,16 +88,8 @@ class AgendaViewModel @Inject constructor(
|
||||
private val todayDate: LocalDate
|
||||
get() = currentDate()
|
||||
|
||||
// The anchor and how many days before it are loaded, moved together so a
|
||||
// jump never queries a window that pairs the new anchor with the old span.
|
||||
private val _window = MutableStateFlow(AgendaWindow(todayDate, PAST_CHUNK_DAYS))
|
||||
val anchor: StateFlow<LocalDate> = _window
|
||||
.map { it.anchor }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, todayDate)
|
||||
|
||||
/** The day last scrolled to the top, so a return to the agenda lands back on it. */
|
||||
var lastTopDate: LocalDate? = null
|
||||
private set
|
||||
private val _anchor = MutableStateFlow(todayDate)
|
||||
val anchor: StateFlow<LocalDate> = _anchor
|
||||
|
||||
private var lastToday = todayDate
|
||||
|
||||
@@ -111,9 +101,7 @@ class AgendaViewModel @Inject constructor(
|
||||
.onEach { date ->
|
||||
val old = lastToday
|
||||
lastToday = date
|
||||
if (old != date && _window.value.anchor == old) {
|
||||
_window.value = _window.value.copy(anchor = date)
|
||||
}
|
||||
if (old != date && _anchor.value == old) _anchor.value = date
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
|
||||
|
||||
@@ -123,17 +111,9 @@ class AgendaViewModel @Inject constructor(
|
||||
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
||||
|
||||
val state: StateFlow<AgendaUiState> =
|
||||
combine(
|
||||
_window,
|
||||
agendaSettings,
|
||||
_rangeOverride,
|
||||
weekStart,
|
||||
settingsPrefs.pastEventDisplay,
|
||||
) { window, settings, override, weekStart, pastDisplay ->
|
||||
combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart ->
|
||||
AgendaParams(
|
||||
anchor = window.anchor,
|
||||
// Hidden past events leave nothing to scroll back to (#367).
|
||||
pastDays = if (pastDisplay == PastEventDisplay.HIDE) 0 else window.pastDays,
|
||||
anchor = anchor,
|
||||
range = override ?: settings.range,
|
||||
rangeIsOverride = override != null && override != settings.range,
|
||||
weekStart = weekStart,
|
||||
@@ -142,8 +122,8 @@ class AgendaViewModel @Inject constructor(
|
||||
}
|
||||
.flatMapLatest { params ->
|
||||
val window = agendaRange(
|
||||
params.windowStart,
|
||||
params.pastDays + params.range.dayCount(params.anchor, params.weekStart) - 1,
|
||||
params.anchor,
|
||||
params.range.dayCount(params.anchor, params.weekStart) - 1,
|
||||
zone,
|
||||
)
|
||||
combine(
|
||||
@@ -163,30 +143,12 @@ class AgendaViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
fun goToToday() {
|
||||
goToDate(todayDate)
|
||||
_anchor.value = todayDate
|
||||
}
|
||||
|
||||
/** Jump the agenda window to start on a specific date (drawer jump-to-date). */
|
||||
fun goToDate(date: LocalDate) {
|
||||
if (_window.value.anchor == date) return
|
||||
_window.value = AgendaWindow(date, PAST_CHUNK_DAYS)
|
||||
lastTopDate = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the window [PAST_CHUNK_DAYS] further back (#367). Ignored while the
|
||||
* previous extension is still loading, so a scroll that keeps asking only
|
||||
* moves one chunk at a time.
|
||||
*/
|
||||
fun loadEarlier() {
|
||||
val loaded = (state.value as? AgendaUiState.Success)?.windowStart ?: return
|
||||
val window = _window.value
|
||||
if (loaded != window.start) return
|
||||
_window.value = window.copy(pastDays = window.pastDays + PAST_CHUNK_DAYS)
|
||||
}
|
||||
|
||||
fun rememberTopDay(date: LocalDate) {
|
||||
lastTopDate = date
|
||||
_anchor.value = date
|
||||
}
|
||||
|
||||
/** Temporarily override the agenda range for this session (the bottom-left pill). */
|
||||
@@ -199,20 +161,13 @@ class AgendaViewModel @Inject constructor(
|
||||
val showBar: Boolean,
|
||||
)
|
||||
|
||||
private data class AgendaWindow(val anchor: LocalDate, val pastDays: Int) {
|
||||
val start: LocalDate get() = anchor.minus(pastDays, DateTimeUnit.DAY)
|
||||
}
|
||||
|
||||
private data class AgendaParams(
|
||||
val anchor: LocalDate,
|
||||
val pastDays: Int,
|
||||
val range: AgendaRange,
|
||||
val rangeIsOverride: Boolean,
|
||||
val weekStart: DayOfWeek,
|
||||
val showRangeBar: Boolean,
|
||||
) {
|
||||
val windowStart: LocalDate get() = anchor.minus(pastDays, DateTimeUnit.DAY)
|
||||
}
|
||||
)
|
||||
|
||||
private fun buildState(
|
||||
params: AgendaParams,
|
||||
@@ -226,10 +181,9 @@ class AgendaViewModel @Inject constructor(
|
||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
val days = groupAgendaDays(params.windowStart, rangeEnd, instances, zone)
|
||||
val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
|
||||
return AgendaUiState.Success(
|
||||
anchor = anchor,
|
||||
windowStart = params.windowStart,
|
||||
today = today,
|
||||
days = days,
|
||||
range = params.range,
|
||||
@@ -241,9 +195,6 @@ class AgendaViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** How many days each scroll back into the past loads (#367). */
|
||||
internal const val PAST_CHUNK_DAYS = 30
|
||||
|
||||
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */
|
||||
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> {
|
||||
val from = anchor.atStartOfDayIn(zone)
|
||||
|
||||
@@ -51,7 +51,6 @@ private fun sampleAgendaState(today: LocalDate, zone: TimeZone): AgendaUiState.S
|
||||
val windowEnd = today.plus(PREVIEW_WINDOW_DAYS, DateTimeUnit.DAY)
|
||||
return AgendaUiState.Success(
|
||||
anchor = today,
|
||||
windowStart = today,
|
||||
today = today,
|
||||
days = groupAgendaDays(today, windowEnd, sampleAgendaEvents(today, zone), zone),
|
||||
range = AgendaRange.Custom(PREVIEW_WINDOW_DAYS),
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
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
|
||||
@@ -25,9 +16,6 @@ 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
|
||||
@@ -36,7 +24,6 @@ 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
|
||||
|
||||
/**
|
||||
@@ -66,31 +53,12 @@ class ScreenActions(
|
||||
val onCreate: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* An event a long press left selected (#141), which the host answers with the
|
||||
* action bar in place of the FAB. [dismiss] clears it on the screen that owns it.
|
||||
*/
|
||||
@Stable
|
||||
class EventSelection(val event: EventInstance, val dismiss: () -> Unit)
|
||||
|
||||
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
|
||||
@Stable
|
||||
class ChromeHost {
|
||||
var chrome by mutableStateOf<ScreenChrome?>(null)
|
||||
private set
|
||||
|
||||
var selection by mutableStateOf<EventSelection?>(null)
|
||||
internal set
|
||||
|
||||
/** Whether the selected event is being dragged or resized right now. */
|
||||
var selectionBusy by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
/** Whether the bar would cover the selected event, and so draws compact. */
|
||||
var selectionCompact by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
|
||||
var actions: ScreenActions? = null
|
||||
private set
|
||||
|
||||
@@ -112,74 +80,10 @@ fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
|
||||
if (slot.current) SideEffect { slot.host.publish(chrome, actions) }
|
||||
}
|
||||
|
||||
/** 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,
|
||||
selectedView: CalendarView,
|
||||
quickSwitchViews: List<CalendarView>,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
) {
|
||||
val effects = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||
AnimatedContent(
|
||||
targetState = host.selection,
|
||||
contentKey = { it != null },
|
||||
transitionSpec = {
|
||||
(fadeIn(effects) + slideInVertically { -it / 4 })
|
||||
.togetherWith(fadeOut(effects) + slideOutVertically { -it / 4 })
|
||||
},
|
||||
label = "top-bar",
|
||||
) { selection ->
|
||||
if (selection != null) {
|
||||
SelectionTopBar(selection)
|
||||
} else {
|
||||
ViewTopBar(
|
||||
host = host,
|
||||
selectedView = selectedView,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
onCycleView = onCycleView,
|
||||
onOpenDrawer = onOpenDrawer,
|
||||
onOpenSearch = onOpenSearch,
|
||||
showTodayButton = showTodayButton,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The top bar every calendar screen shares. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ViewTopBar(
|
||||
fun CalendarTopBar(
|
||||
host: ChromeHost,
|
||||
selectedView: CalendarView,
|
||||
quickSwitchViews: List<CalendarView>,
|
||||
@@ -232,26 +136,16 @@ private fun ViewTopBar(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** The FAB column every calendar screen shares. */
|
||||
@Composable
|
||||
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean, selection: FabSelection? = null) {
|
||||
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) {
|
||||
val chrome = host.chrome ?: return
|
||||
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,
|
||||
)
|
||||
}
|
||||
CalendarFabColumn(
|
||||
todayVisible = chrome.awayFromToday && !todayInToolbar,
|
||||
todayText = chrome.todayText,
|
||||
onToday = { host.actions?.onToday?.invoke() },
|
||||
onCreate = { host.actions?.onCreate?.invoke() },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
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
|
||||
@@ -24,38 +15,15 @@ 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. 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.
|
||||
* the view isn't anchored on today.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@@ -64,14 +32,13 @@ fun CalendarFabColumn(
|
||||
todayText: String,
|
||||
onToday: () -> Unit,
|
||||
onCreate: () -> Unit,
|
||||
selection: FabSelection? = null,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = todayVisible && selection == null,
|
||||
visible = todayVisible,
|
||||
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()),
|
||||
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()),
|
||||
) {
|
||||
@@ -81,71 +48,11 @@ fun CalendarFabColumn(
|
||||
text = { Text(todayText) },
|
||||
)
|
||||
}
|
||||
// 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 = onCreate) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = stringResource(R.string.event_edit_new_title),
|
||||
)
|
||||
FloatingActionButton(
|
||||
onClick = selection?.onToggle ?: onCreate,
|
||||
modifier = Modifier.semantics { contentDescription = label },
|
||||
) {
|
||||
FabGlyph(selecting = selection != null, expanded = selection?.expanded == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "+", or with an event selected a chevron that turns to open and fold the bar. */
|
||||
@Composable
|
||||
private fun FabGlyph(selecting: Boolean, expanded: Boolean) {
|
||||
val spatial = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
|
||||
val swap by animateFloatAsState(if (selecting) 1f else 0f, spatial, label = "fab-swap")
|
||||
val turn by animateFloatAsState(if (expanded) 0f else 180f, spatial, label = "fab-turn")
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
rotationZ = 90f * swap
|
||||
scaleX = 1f - 0.5f * swap
|
||||
scaleY = 1f - 0.5f * swap
|
||||
alpha = (1f - swap).coerceIn(0f, 1f)
|
||||
},
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
rotationZ = turn - 90f * (1f - swap)
|
||||
scaleX = 0.5f + 0.5f * swap
|
||||
scaleY = 0.5f + 0.5f * swap
|
||||
alpha = swap.coerceIn(0f, 1f)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import javax.inject.Inject
|
||||
|
||||
/** A delete waiting on its confirmation dialog. */
|
||||
data class DeletePrompt(val event: EventInstance, val isRecurring: Boolean)
|
||||
|
||||
/** How an action on a selected event ended, when it has something to say. */
|
||||
enum class EventActionFailure { LoadFailed, ShareFailed, DeleteFailed, WriteDenied, Gone }
|
||||
|
||||
/** Duplicate, share and delete for a selected event (#141), loading it on demand. */
|
||||
@HiltViewModel
|
||||
class EventActionsViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
private val icsExporter: IcsExporter,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
/** Every calendar by id, for what a selected event's bar may offer. */
|
||||
val calendars: StateFlow<Map<Long, CalendarSource>> = repository.calendars()
|
||||
.map { list -> list.associateBy { it.id } }
|
||||
.catch { emit(emptyMap()) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptyMap(),
|
||||
)
|
||||
|
||||
private val _deletePrompt = MutableStateFlow<DeletePrompt?>(null)
|
||||
val deletePrompt: StateFlow<DeletePrompt?> = _deletePrompt.asStateFlow()
|
||||
|
||||
private val _failure = MutableStateFlow<EventActionFailure?>(null)
|
||||
val failure: StateFlow<EventActionFailure?> = _failure.asStateFlow()
|
||||
|
||||
/** A create form copying [event], or null when it can't be read. */
|
||||
suspend fun duplicateForm(event: EventInstance): EventForm? {
|
||||
val detail = load(event) ?: return null
|
||||
val canModify = calendars.value[event.calendarId]?.canModifyContents == true
|
||||
return detail.toDuplicateForm(canModify, TimeZone.currentSystemDefault())
|
||||
}
|
||||
|
||||
/** A staged `.ics` for [event], or null — reported — when it can't be built. */
|
||||
suspend fun shareUri(event: EventInstance): Uri? {
|
||||
val uri = load(event)?.let { withContext(io) { icsExporter.stageEventShare(it) } }
|
||||
if (uri == null && _failure.value == null) _failure.value = EventActionFailure.ShareFailed
|
||||
return uri
|
||||
}
|
||||
|
||||
/** Ask to delete [event]; the dialog it needs depends on whether it recurs. */
|
||||
fun requestDelete(event: EventInstance) {
|
||||
viewModelScope.launch {
|
||||
val detail = load(event) ?: return@launch
|
||||
_deletePrompt.value = DeletePrompt(event, isRecurring = !detail.rrule.isNullOrBlank())
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmDelete(scope: RecurringWriteScope) {
|
||||
val prompt = _deletePrompt.value ?: return
|
||||
_deletePrompt.value = null
|
||||
viewModelScope.launch {
|
||||
val event = prompt.event
|
||||
_failure.value = try {
|
||||
repository.deleteEvent(event.eventId, event.start.toEpochMilliseconds(), scope)
|
||||
null
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: SecurityException) {
|
||||
EventActionFailure.WriteDenied
|
||||
} catch (e: NoSuchEventException) {
|
||||
EventActionFailure.Gone
|
||||
} catch (e: Exception) {
|
||||
EventActionFailure.DeleteFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelDelete() {
|
||||
_deletePrompt.value = null
|
||||
}
|
||||
|
||||
/** The chooser itself couldn't open — nothing on the device takes an `.ics`. */
|
||||
fun reportShareFailed() {
|
||||
_failure.value = EventActionFailure.ShareFailed
|
||||
}
|
||||
|
||||
fun consumeFailure() {
|
||||
_failure.value = null
|
||||
}
|
||||
|
||||
private suspend fun load(event: EventInstance): EventDetail? = try {
|
||||
val detail = repository.eventDetail(event.eventId)
|
||||
// The row holds the series start; the actions work on this occurrence.
|
||||
detail.copy(instance = detail.instance.copy(start = event.start, end = event.end))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: NoSuchEventException) {
|
||||
_failure.value = EventActionFailure.Gone
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
_failure.value = EventActionFailure.LoadFailed
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -24,49 +24,31 @@ 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) -> PickUp,
|
||||
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> Unit,
|
||||
onMove: (pointerInRoot: Offset) -> Unit,
|
||||
onDrop: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onRelease: (() -> Unit)? = null,
|
||||
immediate: Boolean = false,
|
||||
): Modifier = rememberPickUpSurface(
|
||||
): Modifier = rememberDragSurface(
|
||||
enabled = enabled,
|
||||
key = key,
|
||||
immediate = immediate,
|
||||
onPickUp = { _, pointerInRoot, nodeInRoot, size -> onPickUp(pointerInRoot, nodeInRoot, size) },
|
||||
onPickUp = { _, pointerInRoot, nodeInRoot, size ->
|
||||
onPickUp(pointerInRoot, nodeInRoot, size)
|
||||
true
|
||||
},
|
||||
onMove = onMove,
|
||||
onDrop = onDrop,
|
||||
onCancel = onCancel,
|
||||
onRelease = onRelease,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -83,29 +65,6 @@ 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
|
||||
@@ -115,42 +74,27 @@ private fun rememberPickUpSurface(
|
||||
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 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 heldStill = withTimeoutOrNull(holdMillis) {
|
||||
while (true) {
|
||||
val main = awaitPointerEvent()
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
result
|
||||
}
|
||||
if (outcome == false) return@awaitEachGesture
|
||||
} == null
|
||||
if (!heldStill) return@awaitEachGesture
|
||||
|
||||
val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture
|
||||
val took = currentPickUp(
|
||||
@@ -159,11 +103,8 @@ private fun rememberPickUpSurface(
|
||||
layout.positionInRoot(),
|
||||
layout.size,
|
||||
)
|
||||
if (took == PickUp.None) return@awaitEachGesture
|
||||
// Moved already, for an immediate pickup: it can't be a release.
|
||||
var moved = outcome == true
|
||||
if (!took) return@awaitEachGesture
|
||||
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
|
||||
@@ -175,31 +116,18 @@ private fun rememberPickUpSurface(
|
||||
if (change.isConsumed) break
|
||||
if (!change.pressed) {
|
||||
change.consume()
|
||||
if (took == PickUp.Hold || (!moved && currentRelease != null)) {
|
||||
released = true
|
||||
} else {
|
||||
dropped = true
|
||||
}
|
||||
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.
|
||||
when {
|
||||
released -> {
|
||||
if (took == PickUp.Drag) currentCancel()
|
||||
currentRelease?.invoke()
|
||||
}
|
||||
dropped -> currentDrop()
|
||||
took == PickUp.Drag -> currentCancel()
|
||||
}
|
||||
if (dropped) currentDrop() else currentCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,12 +48,7 @@ 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,
|
||||
actionFailure: String? = null,
|
||||
onActionFailureShown: () -> Unit = {},
|
||||
) {
|
||||
fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) {
|
||||
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
|
||||
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
|
||||
val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle()
|
||||
@@ -63,9 +58,7 @@ fun EventMoveHost(
|
||||
|
||||
prompt?.let { pending ->
|
||||
RecurringScopeDialog(
|
||||
title = stringResource(
|
||||
if (pending.resize) R.string.event_resize_recurring_title else R.string.event_move_recurring_title,
|
||||
),
|
||||
title = stringResource(R.string.event_move_recurring_title),
|
||||
onSelect = viewModel::moveWithScope,
|
||||
onDismiss = viewModel::cancelScope,
|
||||
allowSeries = !pending.occurrenceOnly,
|
||||
@@ -76,17 +69,9 @@ fun EventMoveHost(
|
||||
|
||||
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 -> actionFailure
|
||||
is MoveOutcome.Moved -> if (resizedLabel != null) {
|
||||
stringResource(R.string.event_resize_done, resizedLabel)
|
||||
} else {
|
||||
stringResource(R.string.event_move_done, movedLabel.orEmpty())
|
||||
}
|
||||
null -> null
|
||||
is MoveOutcome.Moved -> 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)
|
||||
@@ -111,12 +96,6 @@ fun EventMoveHost(
|
||||
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
|
||||
@@ -134,7 +113,7 @@ fun EventMoveHost(
|
||||
// write anyway, so offering it would be a button that does nothing.
|
||||
val undo = content.undo?.takeIf { !writeInFlight }
|
||||
SnackChip(
|
||||
visible = outcome != null || actionFailure != null,
|
||||
visible = outcome != null,
|
||||
message = content.message,
|
||||
maxWidth = chipMaxWidth,
|
||||
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
|
||||
@@ -163,7 +142,3 @@ private fun formatMovedTo(
|
||||
return localizedDateFormatter(locale, skeleton)
|
||||
.format(Instant.ofEpochMilli(startMillis).atZone(zone))
|
||||
}
|
||||
|
||||
/** "10:30" — the end of a resized event, read in the device zone like its start. */
|
||||
private fun formatTime(millis: Long, use24Hour: Boolean, locale: Locale): String =
|
||||
timeOfDayFormatter(use24Hour, locale).format(Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()))
|
||||
|
||||
@@ -131,13 +131,6 @@ 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.
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** The bottom band the selection bar and FAB cover, margin included. */
|
||||
val SelectionBarClearance = FabSize + SnackChipMargin
|
||||
|
||||
/** The FAB's role while an event is selected (#141): its action bar's toggle. Null otherwise. */
|
||||
@Composable
|
||||
fun rememberFabSelection(
|
||||
host: ChromeHost,
|
||||
viewModel: EventActionsViewModel,
|
||||
onEdit: (EventInstance) -> Unit,
|
||||
onDuplicate: (EventForm) -> Unit,
|
||||
chipShown: Boolean,
|
||||
onDismissChip: () -> Unit,
|
||||
): FabSelection? {
|
||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val requireWrite = rememberWriteGate()
|
||||
val chooserTitle = stringResource(R.string.event_share_chooser_title)
|
||||
|
||||
// What the FAB has been told for this selection, over what the coverage says.
|
||||
var chosen by remember(host.selection?.event) { mutableStateOf<Boolean?>(null) }
|
||||
// Folded while a chip is up, which then sits beside the FAB as usual.
|
||||
val expanded = !chipShown && (chosen ?: !host.selectionCompact)
|
||||
|
||||
val currentEdit by rememberUpdatedState(onEdit)
|
||||
val currentDuplicate by rememberUpdatedState(onDuplicate)
|
||||
val currentDismissChip by rememberUpdatedState(onDismissChip)
|
||||
val currentWrite by rememberUpdatedState(requireWrite)
|
||||
|
||||
val selection = host.selection ?: return null
|
||||
val event = selection.event
|
||||
val calendar = calendars[event.calendarId]
|
||||
val canEdit = calendar?.canModifyContents == true
|
||||
val canDelete = canEdit && calendar?.isManaged == false
|
||||
return remember(selection, expanded, chipShown, canEdit, canDelete) {
|
||||
FabSelection(
|
||||
expanded = expanded,
|
||||
onToggle = {
|
||||
if (chipShown) currentDismissChip()
|
||||
chosen = !expanded
|
||||
},
|
||||
bar = {
|
||||
EventSelectionBar(
|
||||
canEdit = canEdit,
|
||||
canDelete = canDelete,
|
||||
onEdit = {
|
||||
currentWrite {
|
||||
selection.dismiss()
|
||||
currentEdit(event)
|
||||
}
|
||||
},
|
||||
onDuplicate = {
|
||||
currentWrite {
|
||||
scope.launch {
|
||||
val form = viewModel.duplicateForm(event) ?: return@launch
|
||||
selection.dismiss()
|
||||
currentDuplicate(form)
|
||||
}
|
||||
}
|
||||
},
|
||||
onShare = {
|
||||
scope.launch {
|
||||
val uri = viewModel.shareUri(event) ?: return@launch
|
||||
if (!context.sendEventShare(uri, chooserTitle)) viewModel.reportShareFailed()
|
||||
}
|
||||
},
|
||||
onDelete = { currentWrite { viewModel.requestDelete(event) } },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** What a failed action on a selected event says, for the move host's chip to show (#141). */
|
||||
@Composable
|
||||
fun eventActionFailureMessage(failure: EventActionFailure?): String? = when (failure) {
|
||||
null -> null
|
||||
EventActionFailure.LoadFailed -> stringResource(R.string.event_action_load_failed)
|
||||
EventActionFailure.ShareFailed -> stringResource(R.string.event_share_failed)
|
||||
EventActionFailure.DeleteFailed -> stringResource(R.string.event_delete_failed)
|
||||
EventActionFailure.WriteDenied -> stringResource(R.string.event_delete_write_denied)
|
||||
EventActionFailure.Gone -> stringResource(R.string.event_move_gone)
|
||||
}
|
||||
|
||||
/** The delete confirmation a selected event's Delete brings up (#141). */
|
||||
@Composable
|
||||
fun EventSelectionHost(host: ChromeHost, viewModel: EventActionsViewModel) {
|
||||
val deletePrompt by viewModel.deletePrompt.collectAsStateWithLifecycle()
|
||||
deletePrompt?.let { prompt ->
|
||||
DeleteEventDialog(
|
||||
isRecurring = prompt.isRecurring,
|
||||
onConfirm = { writeScope ->
|
||||
host.selection?.takeIf { it.event == prompt.event }?.dismiss()
|
||||
viewModel.confirmDelete(writeScope)
|
||||
},
|
||||
onDismiss = viewModel::cancelDelete,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -19,15 +19,12 @@ 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. */
|
||||
@@ -70,7 +67,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?.markerMin }
|
||||
derivedStateOf { dragController.drag?.startMin }
|
||||
}
|
||||
val hourAlpha by animateFloatAsState(
|
||||
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
|
||||
@@ -110,25 +107,12 @@ fun HourGutter(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Held while it fades, so it goes out where it was rather than blinking off.
|
||||
val badgeMin = remember { mutableStateOf<Int?>(null) }
|
||||
dragStartMin?.let { badgeMin.value = it }
|
||||
val badgeIn by animateFloatAsState(
|
||||
targetValue = if (dragStartMin != null) 1f else 0f,
|
||||
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||
label = "gutterBadge",
|
||||
)
|
||||
badgeMin.value?.takeIf { badgeIn > 0f }?.let { startMin ->
|
||||
dragStartMin?.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)
|
||||
@@ -137,8 +121,7 @@ fun HourGutter(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
// A resized end can sit on midnight: 00:00, not a clamped 23:59.
|
||||
text = formatGutterTime(startMin % MINUTES_PER_DAY, use24Hour, locale),
|
||||
text = formatGutterTime(startMin, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -5,11 +5,6 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
@@ -21,17 +16,6 @@ import kotlin.time.Instant
|
||||
*/
|
||||
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
|
||||
|
||||
/**
|
||||
* Whether [this] counts as past where it's drawn on [day]: once it has ended,
|
||||
* or on any day before the one [now] falls in, so a multi-day event still
|
||||
* running doesn't stay undimmed on the days already behind it.
|
||||
*/
|
||||
fun EventInstance.isPastOn(
|
||||
day: LocalDate,
|
||||
now: Instant,
|
||||
zone: TimeZone = TimeZone.currentSystemDefault(),
|
||||
): Boolean = hasEnded(now) || day < now.toLocalDateTime(zone).date
|
||||
|
||||
/** Opacity applied to a completed/past event chip when it is dimmed. */
|
||||
const val EventDimAlpha = 0.4f
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ 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
|
||||
@@ -55,9 +54,6 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +74,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, val resize: Boolean = false)
|
||||
data class MoveScopePrompt(val occurrenceOnly: Boolean)
|
||||
|
||||
/** The inverse of a completed move, for the undo action. */
|
||||
data class MoveUndo(
|
||||
@@ -92,14 +88,12 @@ data class MoveUndo(
|
||||
sealed interface MoveOutcome {
|
||||
/**
|
||||
* Written. [startMillis] is where the dragged occurrence now begins, for the
|
||||
* 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.
|
||||
* confirmation message; [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
|
||||
@@ -232,10 +226,7 @@ class RescheduleViewModel @Inject constructor(
|
||||
} else {
|
||||
// Still busy: the scope dialog is now the thing in flight.
|
||||
pending = prepared
|
||||
_scopePrompt.value = MoveScopePrompt(
|
||||
occurrenceOnly = !prepared.canRealign,
|
||||
resize = request.target is MoveTarget.Span,
|
||||
)
|
||||
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign)
|
||||
}
|
||||
}
|
||||
return true
|
||||
@@ -322,7 +313,6 @@ 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
|
||||
@@ -406,7 +396,6 @@ class RescheduleViewModel @Inject constructor(
|
||||
startMillis = movedStartMillis(prepared),
|
||||
isAllDay = prepared.updated.isAllDay,
|
||||
undo = undoFor(prepared, scope),
|
||||
endMillis = resizedEndMillis(prepared),
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -491,14 +480,6 @@ 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.
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** The handle's touch target, centred on the edge it moves. */
|
||||
val ResizeHandleTarget = 48.dp
|
||||
|
||||
private val HANDLE_DOT = 16.dp
|
||||
private val HANDLE_RING = 2.dp
|
||||
|
||||
/**
|
||||
* A grip on one edge of a selected event (#141), dragged without a hold. No
|
||||
* semantics: TalkBack resizes through the event's edit action.
|
||||
*/
|
||||
@Composable
|
||||
fun ResizeHandle(
|
||||
color: Color,
|
||||
enabled: Boolean,
|
||||
onStart: (pointerInRoot: Offset) -> Unit,
|
||||
onMove: (pointerInRoot: Offset) -> Unit,
|
||||
onEnd: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val coordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
||||
val currentStart by rememberUpdatedState(onStart)
|
||||
val currentMove by rememberUpdatedState(onMove)
|
||||
val currentEnd by rememberUpdatedState(onEnd)
|
||||
val currentCancel by rememberUpdatedState(onCancel)
|
||||
// Not a key: turning it off mid-resize must not cancel that resize.
|
||||
val currentEnabled by rememberUpdatedState(enabled)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.size(ResizeHandleTarget)
|
||||
.clearAndSetSemantics { }
|
||||
.onGloballyPositioned { coordinates[0] = it }
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
if (!currentEnabled) return@awaitEachGesture
|
||||
down.consume()
|
||||
var started = false
|
||||
var ended = false
|
||||
try {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
// Claimed by an ancestor that outranks us — the pinch.
|
||||
if (change.isConsumed) break
|
||||
if (event.changes.count { it.pressed } > 1) break
|
||||
change.consume()
|
||||
if (!change.pressed) {
|
||||
ended = started
|
||||
break
|
||||
}
|
||||
val root = coordinates[0]?.takeIf { it.isAttached }
|
||||
?.localToRoot(change.position) ?: break
|
||||
if (started) {
|
||||
currentMove(root)
|
||||
} else {
|
||||
started = true
|
||||
currentStart(root)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
when {
|
||||
ended -> currentEnd()
|
||||
started -> currentCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(HANDLE_DOT)
|
||||
.background(color, CircleShape)
|
||||
.border(HANDLE_RING, MaterialTheme.colorScheme.surface, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import java.time.ZoneId
|
||||
import java.util.Locale
|
||||
import kotlin.time.toJavaInstant
|
||||
|
||||
/**
|
||||
* The top bar while an event is selected (#141): says which, and when, with a
|
||||
* way out — the selection otherwise only shows on the grid and in the FAB.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SelectionTopBar(selection: EventSelection) {
|
||||
val event = selection.event
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val whenText = remember(event.start, event.end, use24Hour, locale) {
|
||||
formatSelectionWhen(event, use24Hour, locale)
|
||||
}
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = selection.dismiss) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.event_selection_close),
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = event.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = whenText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
// The header's own surface, as the view's bar has (#186).
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Fri, 9 Oct · 09:00–10:00", or with both days named for an event that runs
|
||||
* into another: "Fri, 9 Oct, 22:00 – Sat, 10 Oct, 02:00".
|
||||
*/
|
||||
private fun formatSelectionWhen(event: EventInstance, use24Hour: Boolean, locale: Locale): String {
|
||||
val zone = ZoneId.systemDefault()
|
||||
val start = event.start.toJavaInstant().atZone(zone)
|
||||
val end = event.end.toJavaInstant().atZone(zone)
|
||||
val day = localizedDateFormatter(locale, "EEEdMMM")
|
||||
val time = timeOfDayFormatter(use24Hour, locale)
|
||||
val endsSameDay = end.toLocalDate() == start.toLocalDate() ||
|
||||
(end.toLocalTime().toSecondOfDay() == 0 && end.toLocalDate() == start.toLocalDate().plusDays(1))
|
||||
return if (endsSameDay) {
|
||||
"${day.format(start)} · ${time.format(start)}–${time.format(end)}"
|
||||
} else {
|
||||
"${day.format(start)}, ${time.format(start)} – ${day.format(end)}, ${time.format(end)}"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -21,7 +20,6 @@ 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
|
||||
@@ -29,7 +27,6 @@ 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
|
||||
@@ -38,7 +35,6 @@ 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
|
||||
@@ -101,28 +97,11 @@ 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.
|
||||
* Carries the length, which is all a bottom-edge resize changes.
|
||||
*/
|
||||
val slot: Triple<LocalDate, Int, Int> get() = Triple(date, startMin, eventSpanMin)
|
||||
/** What the snap haptics key off: one tick per changed slot, not per frame. */
|
||||
val slot: Pair<LocalDate, Int> get() = date to startMin
|
||||
}
|
||||
|
||||
/** 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:
|
||||
@@ -269,56 +248,6 @@ data class TimelineDrop(
|
||||
val clipOffsetMin: Int = 0,
|
||||
)
|
||||
|
||||
/** Which end of an event a resize handle moves (#141). */
|
||||
enum class ResizeEdge { Top, Bottom }
|
||||
|
||||
/** Where a finished resize asks its event to begin and end. */
|
||||
data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant)
|
||||
|
||||
/**
|
||||
* The span after dragging [edge] to [edgeMin] in day [edgeDay], in minutes from the
|
||||
* start day's midnight. Snapped, and at least one snap step long.
|
||||
*/
|
||||
internal fun resizedSpan(
|
||||
edge: ResizeEdge,
|
||||
startMin: Int,
|
||||
endMin: Int,
|
||||
edgeMin: Float,
|
||||
edgeDay: Int? = null,
|
||||
): Pair<Int, Int> {
|
||||
// An edge dragged into a day past the other end changes nothing.
|
||||
if (edgeDay != null) {
|
||||
val endDay = (endMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
|
||||
val wrongWay = when (edge) {
|
||||
ResizeEdge.Top -> edgeDay > endDay
|
||||
ResizeEdge.Bottom -> edgeDay < startMin.floorDiv(MINUTES_PER_DAY)
|
||||
}
|
||||
if (wrongWay) return startMin to endMin
|
||||
}
|
||||
val snapped = (edgeMin / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
||||
return when (edge) {
|
||||
ResizeEdge.Top -> minOf(snapped, endMin - DRAG_SNAP_MINUTES) to endMin
|
||||
ResizeEdge.Bottom -> startMin to maxOf(snapped, startMin + DRAG_SNAP_MINUTES)
|
||||
}
|
||||
}
|
||||
|
||||
/** [minutes] snapped, kept at [held] until three quarters of a step away. */
|
||||
internal fun stickySnap(minutes: Float, held: Int?): Int {
|
||||
if (held != null && kotlin.math.abs(minutes - held) < DRAG_SNAP_MINUTES * 0.75f) return held
|
||||
return (minutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
||||
}
|
||||
|
||||
/** How far past its column's side the finger has to go before a resize changes day. */
|
||||
private const val COLUMN_HYSTERESIS = 0.25f
|
||||
|
||||
/** The column under [position], kept at [held] until [COLUMN_HYSTERESIS] into another. */
|
||||
internal fun stickyColumn(position: Float, held: Int?, lastIndex: Int): Int {
|
||||
val under = position.toInt().coerceIn(0, lastIndex)
|
||||
if (held == null) return under
|
||||
val stays = position >= held - COLUMN_HYSTERESIS && position < held + 1 + COLUMN_HYSTERESIS
|
||||
return if (stays) held else under
|
||||
}
|
||||
|
||||
/**
|
||||
* The timeline's live geometry, republished on every layout. Plain fields rather
|
||||
* than snapshot state: it changes on every scroll frame, and the drag loop reads
|
||||
@@ -391,88 +320,10 @@ class TimelineDragController {
|
||||
*/
|
||||
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
|
||||
|
||||
/** The selected event, if any — see [TimelineSelection]. */
|
||||
var selection: TimelineSelection? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
fun select(event: EventInstance, moved: Boolean = false, fromLift: Boolean = false) {
|
||||
if (selection?.event?.instanceId != event.instanceId) selectionCovered = false
|
||||
selection = TimelineSelection(event, moved, fromLift)
|
||||
}
|
||||
|
||||
fun deselect() {
|
||||
selection = null
|
||||
selectionCovered = false
|
||||
}
|
||||
|
||||
/** The events of the blocks on screen that show the selection; none for a while ends it. */
|
||||
val selectedCandidates = mutableStateListOf<EventInstance>()
|
||||
|
||||
fun noteSelectedBlock(event: EventInstance, shown: Boolean) {
|
||||
if (shown) selectedCandidates.add(event) else selectedCandidates.remove(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Once a move of the selected event has settled, point the selection at the
|
||||
* row it landed as. A new event id wins: a single moved occurrence is written
|
||||
* as an exception, and another occurrence of its series may share its time.
|
||||
*/
|
||||
fun settleMovedSelection() {
|
||||
val current = selection?.takeIf { it.moved } ?: return
|
||||
if (isDragging || settling != null) return
|
||||
val landed = selectedCandidates.filter { it.instanceId != current.event.instanceId }
|
||||
// Only the original row left: the move was abandoned, and it still stands.
|
||||
val chosen = landed.firstOrNull { it.eventId != current.event.eventId }
|
||||
?: landed.firstOrNull() ?: selectedCandidates.firstOrNull() ?: return
|
||||
selection = current.copy(event = chosen, moved = false)
|
||||
}
|
||||
|
||||
/** Whether the selected block's bottom sits where the action bar would cover it. */
|
||||
var selectionCovered: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Whether [block] is the selected event: by instance, by series and start, and
|
||||
* just after a move by title and times, since a moved occurrence gets new ids.
|
||||
*/
|
||||
fun isSelected(block: TimedBlock): Boolean {
|
||||
val current = selection ?: return false
|
||||
val selected = current.event
|
||||
val event = block.event
|
||||
return event.instanceId == selected.instanceId ||
|
||||
(event.eventId == selected.eventId && event.start == selected.start) ||
|
||||
(
|
||||
current.moved &&
|
||||
event.calendarId == selected.calendarId &&
|
||||
event.title.isNotBlank() &&
|
||||
event.title == selected.title &&
|
||||
event.start == selected.start &&
|
||||
event.end == selected.end
|
||||
)
|
||||
}
|
||||
|
||||
/** Point the selection at [block]'s event, so the bar acts on the row now there. */
|
||||
fun adoptSelection(block: TimedBlock) {
|
||||
val current = selection?.takeIf { !it.moved } ?: return
|
||||
if (current.event != block.event) selection = current.copy(event = block.event)
|
||||
}
|
||||
|
||||
/** Whether a finger is on a block right now — [settling] is not dragging. */
|
||||
var isDragging: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/** Whether the finger has moved since the pickup; only then may the view auto-scroll. */
|
||||
var pointerMoved: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/** Touch slop, past which the finger counts as having moved. */
|
||||
var slopPx: Float = 0f
|
||||
|
||||
private var pickupPointer = Offset.Zero
|
||||
|
||||
/** The end a settling resize asked for, which the grid has to show before it settles. */
|
||||
private var settledEnd: Instant? = null
|
||||
|
||||
/**
|
||||
* Whether the grid itself now draws the settled drop at its landing slot.
|
||||
* The copy may only be handed back once this is true, or the source ghost
|
||||
@@ -481,10 +332,6 @@ 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.
|
||||
@@ -514,27 +361,7 @@ 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: Triple<LocalDate, Int, Int>? = null
|
||||
|
||||
/** The edge a resize is dragging, or null for a plain drag (#141). */
|
||||
private var resizeEdge: ResizeEdge? = null
|
||||
|
||||
/** The day the resized event starts on; its edges are minutes from that midnight. */
|
||||
private var resizeDay: LocalDate? = null
|
||||
private var resizeStartMin = 0
|
||||
private var resizeEndMin = 0
|
||||
|
||||
/** How far the finger sits past the edge it grabbed, so the edge doesn't jump to it. */
|
||||
private var resizeGrabMin = 0f
|
||||
|
||||
/** The column the resized edge is in, held until the finger is well into another. */
|
||||
private var resizeColumn: Int? = null
|
||||
|
||||
/** The slot the resized edge sits on, held against a finger trembling between two. */
|
||||
private var resizeSlot: Int? = null
|
||||
|
||||
/** The day, counted from the event's start day, the finger is resizing in. */
|
||||
private var resizeEdgeDay = 0
|
||||
private var originSlot: Pair<LocalDate, Int>? = null
|
||||
|
||||
fun begin(
|
||||
block: TimedBlock,
|
||||
@@ -543,8 +370,6 @@ 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
|
||||
@@ -553,8 +378,6 @@ class TimelineDragController {
|
||||
liftedInstanceId = block.event.instanceId
|
||||
grab = pointerInRoot - blockInRoot
|
||||
pointer = pointerInRoot
|
||||
pickupPointer = pointerInRoot
|
||||
pointerMoved = false
|
||||
originSlot = null
|
||||
recompute()
|
||||
originSlot = drag?.slot
|
||||
@@ -562,28 +385,20 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -614,11 +429,8 @@ 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 ||
|
||||
(
|
||||
@@ -640,7 +452,6 @@ 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
|
||||
@@ -652,150 +463,6 @@ 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
|
||||
@@ -810,8 +477,6 @@ class TimelineDragController {
|
||||
fun release() {
|
||||
settling = null
|
||||
settledOnGrid = false
|
||||
settledEnd = null
|
||||
settlingSelected = false
|
||||
handOver()
|
||||
}
|
||||
|
||||
@@ -821,10 +486,6 @@ 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
|
||||
@@ -916,7 +577,6 @@ 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
|
||||
}
|
||||
@@ -953,13 +613,14 @@ 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 =
|
||||
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)
|
||||
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)
|
||||
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone)
|
||||
}
|
||||
|
||||
@@ -999,10 +660,8 @@ 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)
|
||||
// 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()
|
||||
LaunchedEffect(controller.isDragging) {
|
||||
if (controller.isDragging) 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
|
||||
@@ -1034,8 +693,7 @@ 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(
|
||||
// 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,
|
||||
targetValue = if (landed || reduceMotion) 0f else 1f,
|
||||
label = "drag-lift",
|
||||
)
|
||||
val copyAlpha by animateFloatAsState(
|
||||
@@ -1222,29 +880,3 @@ private fun DragCopy(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How far a selected block is lifted, 0 to 1 (#141); [snap] takes it over at once. */
|
||||
@Composable
|
||||
fun selectionLift(selected: Boolean, snap: Boolean = false): Float {
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val spec = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
|
||||
val raise = remember { Animatable(if (selected) 1f else 0f) }
|
||||
LaunchedEffect(selected) {
|
||||
val target = if (selected) 1f else 0f
|
||||
if (reduceMotion || (selected && snap)) raise.snapTo(target) else raise.animateTo(target, spec)
|
||||
}
|
||||
return raise.value
|
||||
}
|
||||
|
||||
/**
|
||||
* A selected block raised off the grid, the way a dragged copy is: scaled from
|
||||
* its top edge so the text doesn't creep, with a shadow in the block's shape.
|
||||
*/
|
||||
fun Modifier.selectionLift(raise: Float, shape: Shape): Modifier = graphicsLayer {
|
||||
transformOrigin = TransformOrigin(0.5f, 0f)
|
||||
scaleX = 1f + 0.02f * raise
|
||||
scaleY = 1f + 0.02f * raise
|
||||
shadowElevation = 6.dp.toPx() * raise
|
||||
this.shape = shape
|
||||
clip = false
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
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
|
||||
@@ -37,6 +41,7 @@ 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
|
||||
@@ -46,6 +51,7 @@ 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
|
||||
@@ -61,6 +67,7 @@ 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
|
||||
@@ -83,15 +90,13 @@ 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
|
||||
@@ -99,6 +104,7 @@ 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
|
||||
@@ -156,15 +162,43 @@ fun EventDetailScreen(
|
||||
val onShareClick = {
|
||||
scope.launch {
|
||||
val uri = viewModel.shareUri()
|
||||
val sent = uri != null && context.sendEventShare(uri, shareChooserTitle)
|
||||
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
|
||||
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||
// upgrade in place and continues straight into the tapped action.
|
||||
val requireWrite = rememberWriteGate()
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
|
||||
val onEditClick = { requireWrite(onEdit) }
|
||||
// Duplicate reads the loaded detail into a create form and hands it up; the
|
||||
@@ -288,6 +322,70 @@ fun EventDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete confirmation. Recurring events choose between cancelling just the
|
||||
* tapped occurrence (default), truncating the series from it onwards, and
|
||||
* removing the whole series.
|
||||
*/
|
||||
@Composable
|
||||
private fun DeleteEventDialog(
|
||||
isRecurring: Boolean,
|
||||
onConfirm: (RecurringWriteScope) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (isRecurring) R.string.event_delete_recurring_title
|
||||
else R.string.event_delete_title,
|
||||
),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
if (isRecurring) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_occurrence),
|
||||
onClick = { scope = RecurringWriteScope.ThisEvent },
|
||||
selected = scope == RecurringWriteScope.ThisEvent,
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_following),
|
||||
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
|
||||
selected = scope == RecurringWriteScope.ThisAndFollowing,
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_series),
|
||||
onClick = { scope = RecurringWriteScope.AllEvents },
|
||||
selected = scope == RecurringWriteScope.AllEvents,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(stringResource(R.string.event_delete_body))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.event_detail_delete),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.dialog_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun EventDetailContent(
|
||||
state: EventDetailUiState.Success,
|
||||
|
||||
@@ -11,11 +11,12 @@ 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.toDuplicateForm
|
||||
import de.jeanlucmakiola.calendula.ui.common.deleteEvent
|
||||
import de.jeanlucmakiola.calendula.ui.common.stageEventShare
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||
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
|
||||
@@ -112,7 +113,14 @@ class EventDetailViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
_deleteState.value = DeleteUiState.Deleting
|
||||
_deleteState.value = try {
|
||||
repository.deleteEvent(target.eventId, target.beginMillis, scope)
|
||||
when (scope) {
|
||||
RecurringWriteScope.AllEvents ->
|
||||
repository.deleteEvent(target.eventId)
|
||||
RecurringWriteScope.ThisEvent ->
|
||||
repository.deleteOccurrence(target.eventId, target.beginMillis)
|
||||
RecurringWriteScope.ThisAndFollowing ->
|
||||
repository.deleteEventFromOccurrence(target.eventId, target.beginMillis)
|
||||
}
|
||||
DeleteUiState.Deleted
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -136,16 +144,40 @@ class EventDetailViewModel @Inject constructor(
|
||||
*/
|
||||
suspend fun shareUri(): Uri? {
|
||||
val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null
|
||||
return withContext(io) { icsExporter.stageEventShare(detail) }
|
||||
return runCatching {
|
||||
withContext(io) {
|
||||
val ics = IcsWriter().writeCalendar(
|
||||
events = listOf(detail.toShareIcsEvent()),
|
||||
dtStamp = Clock.System.now(),
|
||||
)
|
||||
icsExporter.stageShareFile(shareFileName(detail.instance.title), ics)
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* A create form seeded from the open event, to save a copy as a new event
|
||||
* (#52) — see [toDuplicateForm]. Null when nothing is loaded.
|
||||
* 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.
|
||||
*/
|
||||
fun duplicateForm(): EventForm? {
|
||||
val loaded = state.value as? EventDetailUiState.Success ?: return null
|
||||
return loaded.detail.toDuplicateForm(loaded.canModify, TimeZone.currentSystemDefault())
|
||||
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 },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||
@@ -196,3 +228,13 @@ 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"
|
||||
}
|
||||
|
||||
@@ -171,7 +171,6 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.isPastOn
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
|
||||
@@ -1941,7 +1940,7 @@ private fun SplitDots(
|
||||
modifier = Modifier
|
||||
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
||||
.size(SPLIT_DOT_SIZE)
|
||||
.alpha(if (dimCutoff != null && event.isPastOn(date, dimCutoff)) EventDimAlpha else 1f)
|
||||
.alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f)
|
||||
.background(eventAccent(event.color, dark, soften), CircleShape),
|
||||
)
|
||||
}
|
||||
@@ -1955,7 +1954,7 @@ private fun SplitDots(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.morphBounds(MonthMorphKey.Overflow(date))
|
||||
.alpha(if (allEnded(hidden, date, dimCutoff)) EventDimAlpha else 1f),
|
||||
.alpha(if (allEnded(hidden, dimCutoff)) EventDimAlpha else 1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1998,7 +1997,7 @@ private fun SplitMonthAgendaPane(
|
||||
day = day,
|
||||
zone = state.zone,
|
||||
position = positionOf(index, events.size),
|
||||
dimmed = dimCutoff != null && event.isPastOn(day, dimCutoff),
|
||||
dimmed = dimCutoff != null && event.hasEnded(dimCutoff),
|
||||
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day)),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
@@ -2067,7 +2066,7 @@ internal fun SplitDayPane(
|
||||
day = day,
|
||||
zone = zone,
|
||||
position = positionOf(index, dayEvents.size),
|
||||
dimmed = dimCutoff != null && event.isPastOn(day, dimCutoff),
|
||||
dimmed = dimCutoff != null && event.hasEnded(dimCutoff),
|
||||
onClick = { onEventClick(event) },
|
||||
// An event added to or removed from the day you are
|
||||
// already looking at moves its neighbours rather
|
||||
@@ -2543,7 +2542,6 @@ private fun MonthWeekRow(
|
||||
}
|
||||
OverflowDots(
|
||||
events = hiddenEvents,
|
||||
day = d,
|
||||
total = hidden,
|
||||
dark = dark,
|
||||
rowHeight = overflowRow,
|
||||
@@ -2868,9 +2866,7 @@ private fun MonthBar(
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
// A bar is one piece, so it dims once the last day it covers has passed.
|
||||
val dimmed = dimCutoff != null &&
|
||||
(event.hasEnded(dimCutoff) || days?.let { event.isPastOn(it.last(), dimCutoff) } == true)
|
||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||
val paint = eventPaint(event, dark)
|
||||
// The same title/secondary ink pairing the week and day blocks use, with
|
||||
// the time on the quieter half.
|
||||
@@ -2923,7 +2919,6 @@ private fun MonthBar(
|
||||
@Composable
|
||||
private fun OverflowDots(
|
||||
events: List<EventInstance>,
|
||||
day: LocalDate,
|
||||
total: Int,
|
||||
dark: Boolean,
|
||||
rowHeight: Dp,
|
||||
@@ -2942,7 +2937,7 @@ private fun OverflowDots(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(OVERFLOW_DOT_SIZE)
|
||||
.alpha(if (allEnded(byColor.getValue(argb), day, dimCutoff)) EventDimAlpha else 1f)
|
||||
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
||||
.background(eventAccent(argb, dark, soften), CircleShape),
|
||||
)
|
||||
}
|
||||
@@ -2952,15 +2947,15 @@ private fun OverflowDots(
|
||||
text = "+$extra",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.alpha(if (allEnded(events, day, dimCutoff)) EventDimAlpha else 1f),
|
||||
modifier = Modifier.alpha(if (allEnded(events, dimCutoff)) EventDimAlpha else 1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True when dimming is on and every one of [events] is already over on [day]. */
|
||||
private fun allEnded(events: List<EventInstance>, day: LocalDate, dimCutoff: Instant?): Boolean =
|
||||
dimCutoff != null && events.isNotEmpty() && events.all { it.isPastOn(day, dimCutoff) }
|
||||
/** True when dimming is on and every one of [events] is already over. */
|
||||
private fun allEnded(events: List<EventInstance>, dimCutoff: Instant?): Boolean =
|
||||
dimCutoff != null && events.isNotEmpty() && events.all { it.hasEnded(dimCutoff) }
|
||||
|
||||
@Composable
|
||||
private fun MonthGridLoading() {
|
||||
|
||||
@@ -59,28 +59,11 @@ 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
|
||||
@@ -96,7 +79,6 @@ 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
|
||||
@@ -116,6 +98,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventPaint
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventSurface
|
||||
@@ -152,7 +135,6 @@ import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.isPastOn
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
@@ -529,14 +511,6 @@ 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,
|
||||
@@ -547,51 +521,6 @@ 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
|
||||
@@ -612,11 +541,10 @@ private fun WeekContent(
|
||||
today = today,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
onEventClick = openEvent,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
} else WeekPager(
|
||||
pagerState = pagerState,
|
||||
@@ -627,11 +555,10 @@ private fun WeekContent(
|
||||
initialStripHeight = handoffStrip ?: s.allDayStripHeight(),
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
onEventClick = openEvent,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -658,7 +585,6 @@ private fun WeekPager(
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
onResize: (TimelineResizeDrop) -> Unit,
|
||||
) {
|
||||
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
|
||||
val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
|
||||
@@ -707,7 +633,6 @@ private fun WeekPager(
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -742,7 +667,6 @@ 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()
|
||||
@@ -798,7 +722,6 @@ private fun TimelineMorphFrame(
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -892,7 +815,6 @@ internal fun WeekSuccess(
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
onResize: (TimelineResizeDrop) -> Unit,
|
||||
) {
|
||||
WeekFrame(
|
||||
weekStart = state.weekStart,
|
||||
@@ -912,7 +834,6 @@ internal fun WeekSuccess(
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -935,7 +856,6 @@ 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))
|
||||
@@ -955,7 +875,6 @@ private fun WeekPage(
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -979,7 +898,6 @@ private fun TimelinePageContent(
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
onResize: (TimelineResizeDrop) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
@@ -1010,7 +928,6 @@ private fun TimelinePageContent(
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1159,7 +1076,6 @@ private fun AllDayStrip(
|
||||
key(bar.event.eventId, bar.event.start) {
|
||||
AllDayBar(
|
||||
event = bar.event,
|
||||
lastDay = bar.toRange.endInclusive,
|
||||
dark = dark,
|
||||
onClick = { onEventClick(bar.event) },
|
||||
modifier = Modifier
|
||||
@@ -1195,15 +1111,13 @@ private fun AllDayStrip(
|
||||
@Composable
|
||||
private fun AllDayBar(
|
||||
event: EventInstance,
|
||||
lastDay: LocalDate,
|
||||
dark: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
// A bar is one piece, so it dims once the last day it covers here has passed.
|
||||
val dimmed = dimCutoff != null && event.isPastOn(lastDay, dimCutoff)
|
||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||
val paint = eventPaint(event, dark)
|
||||
Box(
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
@@ -1240,7 +1154,6 @@ 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()
|
||||
@@ -1338,7 +1251,6 @@ private fun Timeline(
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
scrollState = scrollState,
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
)
|
||||
@@ -1361,7 +1273,6 @@ private fun DayColumnCard(
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
onResize: (TimelineResizeDrop) -> Unit,
|
||||
scrollState: ScrollState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -1410,10 +1321,6 @@ 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,
|
||||
@@ -1473,7 +1380,6 @@ private fun DayColumnCard(
|
||||
dragController = dragController,
|
||||
onClick = { onEventClick(block.event) },
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
modifier = Modifier
|
||||
.laneSlot(place)
|
||||
.offset(y = place.y + topInset)
|
||||
@@ -1518,7 +1424,6 @@ 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) }
|
||||
@@ -1569,173 +1474,72 @@ private fun EventBlock(
|
||||
if (showTime) metrics.timeLine else 0.dp
|
||||
val timeMaxLines = if (showTime) blockTimeLines(shownTime, textWidth, spare) else 1
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && block.event.isPastOn(date, dimCutoff)
|
||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||
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 = selectable,
|
||||
enabled = draggable,
|
||||
key = block.event.instanceId,
|
||||
immediate = selected && draggable,
|
||||
onPickUp = { pointer, blockRoot, _ ->
|
||||
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
|
||||
}
|
||||
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),
|
||||
)
|
||||
},
|
||||
onMove = dragController::move,
|
||||
onDrop = { dragController.finish()?.let(onDrop) },
|
||||
onCancel = dragController::cancel,
|
||||
onRelease = { dragController.select(block.event, fromLift = draggable) },
|
||||
)
|
||||
val lifted = draggable && dragController.ghosts(block)
|
||||
// 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
|
||||
val ghost = ghostAlpha(lifted)
|
||||
Box(
|
||||
modifier = (if (dimmed && !selected) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.zIndex(if (selected) 1f else 0f)
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
// The source stays put as a ghost while its floating copy travels.
|
||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||
.then(if (raise > 0f) Modifier.selectionLift(raise, shape) else Modifier)
|
||||
.then(
|
||||
if (tracksBar) {
|
||||
Modifier.onGloballyPositioned {
|
||||
dragController.noteSelectedBottom(it, revealExtraPx, barClearancePx)
|
||||
}
|
||||
} 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)
|
||||
},
|
||||
) {
|
||||
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),
|
||||
Column {
|
||||
if (showTitle) {
|
||||
BlockTitle(
|
||||
title = title,
|
||||
maxLines = titleMaxLines,
|
||||
color = paint.titleInk,
|
||||
textDecoration = paint.decoration,
|
||||
fontWeight = paint.titleWeight,
|
||||
)
|
||||
}
|
||||
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),
|
||||
if (showTime) {
|
||||
BlockTimeLabel(
|
||||
label = shownTime,
|
||||
color = paint.secondaryInk,
|
||||
maxLines = timeMaxLines,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val SELECTION_ORPHAN_GRACE_MILLIS = 500L
|
||||
|
||||
/** How far in from the block's side a resize handle's centre sits. */
|
||||
private val HANDLE_INSET = 16.dp
|
||||
|
||||
private val SKELETON_HEADER_HEIGHT = 36.dp
|
||||
private val SKELETON_HEADER_PADDING = 8.dp
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ internal fun WeekViewPreview(
|
||||
onOpenDay = {},
|
||||
onCreateAt = { _, _ -> },
|
||||
onDrop = {},
|
||||
onResize = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +302,7 @@
|
||||
<string name="agenda_range_month">٣٠ يومًا القادمة</string>
|
||||
<string name="settings_month_view_style">نمط عرض الشهر</string>
|
||||
<string name="month_style_paged">صفحات</string>
|
||||
<string name="month_style_paged_summary">الشهر الواحد يملأ الشاشة. اسحب لليسار أو لليمين لتغيير الشهر.</string>
|
||||
<string name="month_style_paged_summary">الشهر الواحد يملأ الشاشة. اسحب لليسار أو لليمين لتغيير الشهر، أو اسحب للأعلى لعرض جدول الشهر.</string>
|
||||
<string name="month_style_split">تقسيم</string>
|
||||
<string name="settings_color_unsupported">السماح بالألوان في التقويمات غير المدعومة</string>
|
||||
<string name="settings_color_unsupported_hint">بعض التقويمات (مثل البعض من CalDAV) لا تنشر أي مجموعة ألوان؛ لون حدث مخصص قد يزال أو يستبدل في مزامنته التالية. هذا قيد على تلك التقويمات، وليس شيئًا يمكن لـ Calendula إصلاحه.</string>
|
||||
@@ -732,7 +732,7 @@
|
||||
<string name="event_detail_title">العنوان</string>
|
||||
<string name="settings_licences">تراخيص مفتوحة المصدر</string>
|
||||
<string name="settings_licences_subtitle">المشاريع التي بنيت Calendula عليها</string>
|
||||
<string name="licences_title">تراخيص مفتوحة المصدر</string>
|
||||
<string name="licences_title">التراخيص مفتوحة المصدر</string>
|
||||
<string name="licences_intro">يتضمن Calendula العمل أدناه. اضغط على أي إدخال للحصول على شفرة المصدر الخاصة به.</string>
|
||||
<string name="licences_footer">يتم تضمين كل مشروع هنا دون تعديل، بموجب الترخيص الموضح.</string>
|
||||
<string name="state_failure_permission_body">يقرأ Calendula أحداثك مباشرةً من تقويم النظام، لذلك فأنه يحتاج للوصول إلى التقويم لعرض أي شيء.</string>
|
||||
@@ -778,4 +778,30 @@
|
||||
<string name="color_name_amber">أصفر كهرماني</string>
|
||||
<string name="calendars_restore_unavailable">لا تقويم يمكنه استيعاب الأحداث المستوردة بعد.</string>
|
||||
<string name="calendars_description_label">الوصف</string>
|
||||
<string name="app_name_calendar_alias">التقويم</string>
|
||||
<plurals name="view_multi_day">
|
||||
<item quantity="zero">%d يوم</item>
|
||||
<item quantity="one">%d يوم</item>
|
||||
<item quantity="two">%d يومان</item>
|
||||
<item quantity="few">%d أيام</item>
|
||||
<item quantity="many">%d يوم</item>
|
||||
<item quantity="other">%d يوم</item>
|
||||
</plurals>
|
||||
<string name="settings_visible_hours_start">من</string>
|
||||
<string name="settings_visible_hours_end">حتى</string>
|
||||
<string name="settings_multi_day_count">عرض الأيام المتعدّدة</string>
|
||||
<string name="settings_multi_day_count_hint">كم عدد الأيام التي يعرضها طريقة العرض متعدد الأيام جنبًا إلى جنب. اليوم يُفتح في العمود الأول.</string>
|
||||
<string name="settings_visible_hours">الساعات الظاهرة</string>
|
||||
<string name="settings_visible_hours_summary">حدّد مدى من الساعات للشاشة عند فتح طريقة عرض الأسبوع، متعددة الأيام أو اليوم</string>
|
||||
<string name="settings_visible_hours_on_summary">%1$s – %2$s يملأ الشاشة عند فتح طريقة عرض</string>
|
||||
<string name="settings_visible_hours_start_hint">الساعة في أعلى الشاشة عند فتح طريقة عرض الأسبوع، متعددة الأيام أو اليوم. لا تزال الساعات السابقة على بُعد تمرير.</string>
|
||||
<string name="settings_visible_hours_end_hint">الساعة في أسفل الشاشة عند فتح طريقة عرض. لا تزال الساعات اللاحقة على بُعد تمرير، و عمليات التكبير بالضّم حتى يتم فتح طريقة العرض مرة أخرى.</string>
|
||||
<string name="month_open_week">فتح الأسبوع %1$d</string>
|
||||
<string name="settings_timeline_scale_by_visible_hours">ضبط بواسطة الساعات الظاهرة</string>
|
||||
<string name="month_split_clear_filter">إظهار جدول الشهر كامل</string>
|
||||
<string name="timeline_earlier_events">أحداث سابقة</string>
|
||||
<string name="timeline_later_events">أحداث لاحقة</string>
|
||||
<string name="reminder_day_today">اليوم</string>
|
||||
<string name="event_detail_open_in_maps">فتح في الخرائط</string>
|
||||
<string name="link_open_failed">لا تطبيق يمكنه فتح هذا الرابط</string>
|
||||
</resources>
|
||||
|
||||
@@ -671,4 +671,5 @@
|
||||
<string name="state_failure_all_hidden_action">Кіраваньне календарамі</string>
|
||||
<string name="state_failure_no_import_target">Ніводны з календароў ня можа прымаць новыя падзеі.</string>
|
||||
<string name="state_failure_no_import_target_body">Календары адключаныя, у рэжыме «толькі чытаньне», знаходзяцца пад кіраваньнем іншай праграмы, або не сынхранізуюцца на гэтай прыладзе. Уключэце каляндар, які можа прымаць падзеі, або дадайце лякальны.</string>
|
||||
<string name="app_name_calendar_alias">Каляндар</string>
|
||||
</resources>
|
||||
|
||||
@@ -719,7 +719,7 @@
|
||||
<string name="onboarding_hidden_title">Vaše kalendáře jsou vypnuté</string>
|
||||
<string name="onboarding_hidden_body">Calendula zobrazuje kalendáře, které má tento telefon zapnuté, což není momentálně žádný. Zapněte si kalendáře, které chcete vidět – vaše ostatní aplikace kalendáře je pak také zobrazí.</string>
|
||||
<string name="onboarding_hidden_switch_all_button">Zapnout všechny kalendáře</string>
|
||||
<string name="onboarding_hidden_skip_button">Teď ne</string>
|
||||
<string name="onboarding_hidden_skip_button">Později</string>
|
||||
<string name="onboarding_hidden_failed">To se nepodařilo zapnout. V Nastavení → Kalendáře jsou stejné přepínače.</string>
|
||||
<string name="onboarding_calendars_create_failed">Kalendář se nepodařilo vytvořit. Úložiště kalendáře ve vašem telefonu jej odmítlo vytvořit.</string>
|
||||
<string name="calendars_description_label">Popis</string>
|
||||
@@ -736,4 +736,21 @@
|
||||
<string name="color_name_purple">Purpurová</string>
|
||||
<string name="color_name_pink">Růžová</string>
|
||||
<string name="calendars_restore_unavailable">Žádný z kalendářů zatím nedokáže importované události přijmout.</string>
|
||||
<string name="app_name_calendar_alias">Kalendář</string>
|
||||
<string name="month_open_week">Otevřít týden %1$d</string>
|
||||
<string name="month_split_clear_filter">Zobrazit celou měsíční agendu</string>
|
||||
<string name="timeline_earlier_events">Dřívější události</string>
|
||||
<string name="timeline_later_events">Pozdější události</string>
|
||||
<string name="event_detail_open_in_maps">Otevřít na mapě</string>
|
||||
<string name="link_open_failed">Žádná aplikace nemůže tento odkaz otevřít</string>
|
||||
<string name="reminder_day_today">Dnes</string>
|
||||
<plurals name="view_multi_day">
|
||||
<item quantity="one">%d den</item>
|
||||
<item quantity="few">%d dny</item>
|
||||
<item quantity="many">%d dnů</item>
|
||||
<item quantity="other">%d dnů</item>
|
||||
</plurals>
|
||||
<string name="settings_multi_day_count">Vícedenní zobrazení</string>
|
||||
<string name="settings_multi_day_count_hint">Počet dní zobrazujících se ve vícedenním přehledu vedle sebe. Dnešek se otevírá v prvním sloupci.</string>
|
||||
<string name="settings_timeline_scale_by_visible_hours">Nastaveno v části Viditelné hodiny</string>
|
||||
</resources>
|
||||
|
||||
@@ -715,4 +715,16 @@
|
||||
<item quantity="one">%d Termin konnte nicht hinzugefügt werden.</item>
|
||||
<item quantity="other">%d Termine konnten nicht hinzugefügt werden.</item>
|
||||
</plurals>
|
||||
<string name="app_name_calendar_alias">Kalender</string>
|
||||
<string name="timeline_earlier_events">Frühere Termine</string>
|
||||
<string name="timeline_later_events">Spätere Termine</string>
|
||||
<string name="link_open_failed">Keine App kann diesen Link öffnen</string>
|
||||
<string name="reminder_day_today">Heute</string>
|
||||
<plurals name="view_multi_day">
|
||||
<item quantity="one">%d Tag</item>
|
||||
<item quantity="other">%d Tage</item>
|
||||
</plurals>
|
||||
<string name="settings_visible_hours">Sichtbare Stunden</string>
|
||||
<string name="settings_visible_hours_start">Von</string>
|
||||
<string name="settings_visible_hours_end">Bis</string>
|
||||
</resources>
|
||||
|
||||
@@ -672,4 +672,5 @@
|
||||
<item quantity="many">Les événements %d n\'ont pas pu être ajoutés.</item>
|
||||
<item quantity="other">Les événements %d n\'ont pas pu être ajoutés.</item>
|
||||
</plurals>
|
||||
<string name="app_name_calendar_alias">Calendrier</string>
|
||||
</resources>
|
||||
|
||||
@@ -243,7 +243,7 @@
|
||||
<string name="settings_time_format_auto">Automatico</string>
|
||||
<string name="settings_time_format_12h">12 ore</string>
|
||||
<string name="settings_time_format_24h">24 ore</string>
|
||||
<string name="settings_hour_lines">Righe per le ore</string>
|
||||
<string name="settings_hour_lines">Grglia oraria</string>
|
||||
<string name="settings_hour_lines_summary">Mostra una riga separatrice per ogni ora nella vista Settimana e Giorno</string>
|
||||
<string name="settings_dim_completed">Attenua gli eventi completati</string>
|
||||
<string name="settings_dim_completed_summary">Attenua gli eventi conclusi nella vista Mese e Settimana</string>
|
||||
@@ -413,7 +413,7 @@
|
||||
<string name="settings_default_reminder">Promemoria per eventi standard</string>
|
||||
<string name="settings_notifications_subtitle">Promemoria</string>
|
||||
<string name="shortcut_new_event_short">Nuovo evento</string>
|
||||
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente.</string>
|
||||
<string name="event_edit_managed_hint">Gestito da “%1$s” — titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente.</string>
|
||||
<string name="settings_font_headings">Carattere del titolo</string>
|
||||
<string name="settings_font_body">Carattere del corpo</string>
|
||||
<string name="settings_font_system">Lingua di sistema</string>
|
||||
@@ -503,7 +503,7 @@
|
||||
<string name="event_move_undone">Ripianificazione annullata</string>
|
||||
<string name="event_move_failed">Impossibile ripianificare l\'evento</string>
|
||||
<string name="event_move_write_denied">Calendula ha bisogno dei permessi di scrittura per ripianificare gli eventi</string>
|
||||
<string name="event_move_gone">Questo evento è stato eliminato.</string>
|
||||
<string name="event_move_gone">Questo evento è stato eliminato</string>
|
||||
<string name="event_move_blocked_series_end">Non è possibile ripianificare un evento oltre la data di fine della sua serie</string>
|
||||
<string name="event_edit_timezone_device">Fuso orario del dispositivo</string>
|
||||
<string name="event_edit_timezone_device_summary">Segue il fuso orario in cui ti trovi</string>
|
||||
@@ -657,4 +657,83 @@
|
||||
<string name="import_done_failed_label">Non aggiunto</string>
|
||||
<string name="import_done_failed_note">Alcuni eventi non possono essere aggiunti e sono stati esclusi.</string>
|
||||
<string name="import_done_stopped_title">Importazione interrotta</string>
|
||||
<string name="app_name_calendar_alias">Calendario</string>
|
||||
<string name="state_failure_permission_body">Calendula legge i tuoi eventi direttamente dal calendario di sistema, per questo ha bisogno dell\'accesso al calendario per poterli visualizzare.</string>
|
||||
<string name="state_failure_no_calendars_body">Aggiungi un account a questo dispositivo e i relativi calendari verranno visualizzati qui.</string>
|
||||
<string name="state_failure_all_hidden">Tutti i tuoi calendari sono disattivati.</string>
|
||||
<string name="state_failure_all_hidden_body">Riattiva almeno un calendario per visualizzarne gli eventi.</string>
|
||||
<string name="state_failure_no_import_target">Nessun calendario può accettare nuovi eventi.</string>
|
||||
<string name="state_failure_no_import_target_body">I calendari attivi sono di sola lettura, gestiti da un\'altra app o non sincronizzati con questo dispositivo. Attiva un calendario in cui è possibile inserire eventi oppure aggiungine uno locale.</string>
|
||||
<string name="state_failure_event_not_found">Questo evento non è più disponibile.</string>
|
||||
<string name="state_failure_event_not_found_body">Potrebbe essere stato cancellato oppure spostato in un calendario disattivato.</string>
|
||||
<string name="state_failure_provider_body">Il calendario di sistema non ha risposto. Riprova a breve.</string>
|
||||
<string name="timeline_earlier_events">Eventi precedenti</string>
|
||||
<string name="timeline_later_events">Eventi successivi</string>
|
||||
<string name="month_open_week">Settimana aperta %1$d</string>
|
||||
<string name="month_split_clear_filter">Visualizza il calendario completo del mese</string>
|
||||
<string name="event_detail_open_in_maps">Apri su Maps</string>
|
||||
<string name="link_open_failed">Nessuna app è in grado di aprire questo link</string>
|
||||
<string name="calendar_unnamed">(Senza nome)</string>
|
||||
<string name="onboarding_calendars_title">Un posto dove inserire i tuoi eventi</string>
|
||||
<string name="onboarding_calendars_body">Questo dispositivo non ha ancora un calendario. Calendula scrive sui calendari già presenti sul telefono — ne serve almeno uno.</string>
|
||||
<string name="onboarding_calendars_local_button">Crea un calendario su questo dispositivo</string>
|
||||
<string name="onboarding_calendars_caldav_button">Sincronizzazione con un server CalDAV</string>
|
||||
<string name="onboarding_calendars_skip_button">Non ora</string>
|
||||
<string name="onboarding_calendars_benefit_local_title">Rimane su questo telefono</string>
|
||||
<string name="onboarding_calendars_benefit_local_body">Il calendario del dispositivo non si sincronizza con nessun altro dispositivo, quindi esegui un backup se gli eventi sono importanti.</string>
|
||||
<string name="onboarding_calendars_benefit_sync_title">Oppure sincronizza da un account</string>
|
||||
<string name="onboarding_calendars_benefit_sync_body">Calendula mostra quali account sono già sincronizzati. I server CalDAV necessitano di DAVx⁵ per eseguire la sincronizzazione.</string>
|
||||
<string name="onboarding_calendars_created_title">“%1$s” è pronto</string>
|
||||
<string name="onboarding_calendars_created_body">Si trova su questo dispositivo e gli eventi nuovi vengono inseriti lì, a meno che non si scelga un altro calendario.</string>
|
||||
<string name="onboarding_calendars_created_more_title">C\'è spazio per altro</string>
|
||||
<string name="onboarding_calendars_created_more_body">Da Impostazioni → Calendari puoi crearne quanti ne vuoi e rinominare o cambiare il colore di quelli già presenti.</string>
|
||||
<string name="onboarding_calendars_created_continue">Continua</string>
|
||||
<string name="onboarding_hidden_title">I vostri calendari sono disattivati</string>
|
||||
<string name="onboarding_hidden_body">Calendula mostra i calendari attivi su questo telefono e, al momento, non ce n’è nessuno. Attiva quelli che desideri visualizzare: anche le altre app di calendario li mostreranno nuovamente.</string>
|
||||
<string name="onboarding_hidden_switch_all_button">Attivali tutti</string>
|
||||
<string name="onboarding_hidden_skip_button">Non ora</string>
|
||||
<string name="onboarding_hidden_failed">Non sono riuscito ad attivarlo. In Impostazioni → Calendari ci sono gli stessi pulsanti di attivazione.</string>
|
||||
<string name="onboarding_calendars_create_failed">Impossibile creare il calendario. La memoria del calendario del tuo telefono non lo ha accettato.</string>
|
||||
<string name="reminder_day_today">Oggi</string>
|
||||
<plurals name="view_multi_day">
|
||||
<item quantity="one">Giorno</item>
|
||||
<item quantity="many">Giorni</item>
|
||||
<item quantity="other">Giorno</item>
|
||||
</plurals>
|
||||
<string name="settings_multi_day_count">Visualizzazione su più giorni</string>
|
||||
<string name="settings_multi_day_count_hint">La vista più giorni mostra, affiancati, i giorni. La data odierna è visualizzata nella prima colonna.</string>
|
||||
<string name="settings_timeline_scale_by_visible_hours">Imposta in base alle ore di visibilità</string>
|
||||
<string name="settings_visible_hours">Orari di apertura</string>
|
||||
<string name="settings_visible_hours_summary">Adatta la gamma di ore alla schermata all\'apertura della visualizzazione settimanale, plurigiornaliera o giornaliera</string>
|
||||
<string name="settings_visible_hours_on_summary">%1$s – %2$s riempie lo schermo all’apertura di una vista</string>
|
||||
<string name="settings_visible_hours_start">Da</string>
|
||||
<string name="settings_visible_hours_end">Fino a</string>
|
||||
<string name="settings_visible_hours_start_hint">L\'ora visualizzata nella parte superiore dello schermo quando si apre la vista settimanale, plurigiornaliera o giornaliera. Le ore precedenti sono comunque accessibili scorrendo la schermata.</string>
|
||||
<string name="settings_visible_hours_end_hint">L\'ora visualizzata nella parte inferiore dello schermo quando si apre una schermata. Le ore successive sono ancora raggiungibili scorrendo lo schermo, mentre pizzicando lo schermo si esegue lo zoom fino a quando la schermata non viene riaperta.</string>
|
||||
<string name="settings_licences">Licenze open source</string>
|
||||
<string name="settings_licences_subtitle">I progetti su cui si basa Calendula</string>
|
||||
<string name="calendars_description_label">Descrizione</string>
|
||||
<string name="color_name_mauve">Rosa malva</string>
|
||||
<string name="color_name_red">Rosso</string>
|
||||
<string name="color_name_orange">Arancione</string>
|
||||
<string name="color_name_amber">Ambra</string>
|
||||
<string name="color_name_olive">Oliva</string>
|
||||
<string name="color_name_green">Verde</string>
|
||||
<string name="color_name_teal">Verde acqua</string>
|
||||
<string name="color_name_cyan">Ciano</string>
|
||||
<string name="color_name_blue">Blu</string>
|
||||
<string name="color_name_indigo">Indaco</string>
|
||||
<string name="color_name_purple">Viola</string>
|
||||
<string name="color_name_pink">Rosa</string>
|
||||
<string name="calendars_restore_unavailable">Al momento nessun calendario è in grado di gestire gli eventi importati.</string>
|
||||
<plurals name="import_done_stopped">
|
||||
<item quantity="one">Il calendario ha smesso di accettare eventi a metà dell\'operazione. L\'evento %d presente nel file non è stato aggiunto.</item>
|
||||
<item quantity="many">Il calendari hanno smesso di accettare eventi a metà dell\'operazione. Gli eventi %d presenti nel file non sono stati aggiunti.</item>
|
||||
<item quantity="other">Il calendario ha smesso di accettare eventi a metà dell\'operazione. L\'evento %d presente nel file non è stato aggiunto.</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_failed">
|
||||
<item quantity="one">Non è stato possibile aggiungere l\'evento %d.</item>
|
||||
<item quantity="many">Non è stato possibile aggiungere gli eventi %d.</item>
|
||||
<item quantity="other">Non è stato possibile aggiungere l\'evento %d.</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
|
||||
@@ -377,7 +377,7 @@
|
||||
<string name="settings_today_toolbar">Knop Vandaag in werkbalk</string>
|
||||
<string name="settings_today_toolbar_summary">Een knop om naar vandaag te gaan tonen in de werkbalk, in plaats van een zwevende knop</string>
|
||||
<string name="settings_app_name">Appnaam</string>
|
||||
<string name="settings_app_name_summary">Calendula als “Calendar” tonen in je launcher. Alleen de naam in de launcher verandert; het pictogram kan na het wisselen op een andere plek komen te staan.</string>
|
||||
<string name="settings_app_name_summary">Calendula als “Agenda” tonen in je launcher. Alleen de naam in de launcher verandert; het pictogram kan na het wisselen op een andere plek komen te staan.</string>
|
||||
<string name="settings_time_format">Tijdnotatie</string>
|
||||
<string name="settings_time_format_auto">Automatisch</string>
|
||||
<string name="settings_time_format_12h">12-uursnotatie (2:00 PM)</string>
|
||||
@@ -441,7 +441,7 @@
|
||||
<string name="settings_month_header">Maandweergave</string>
|
||||
<string name="settings_month_view_style">Stijl van maandweergave</string>
|
||||
<string name="month_style_paged">Pagina\'s</string>
|
||||
<string name="month_style_paged_summary">Eén maand vult het scherm. Veeg naar links of rechts om van maand te wisselen.</string>
|
||||
<string name="month_style_paged_summary">Eén maand vult het scherm. Veeg naar links of rechts om van maand te wisselen, of veeg omhoog om de agenda van deze maand te tonen.</string>
|
||||
<string name="month_style_continuous">Doorlopende maanden</string>
|
||||
<string name="month_style_continuous_summary">Elke maand staat onder een eigen kop, met wat ruimte tot de volgende maand.</string>
|
||||
<string name="month_style_dense">Naadloze weken</string>
|
||||
@@ -694,4 +694,26 @@
|
||||
<string name="special_dates_default_title_birthday">Verjaardag van {name} ({year})</string>
|
||||
<string name="special_dates_default_title_anniversary">Jubileum van {name} ({year})</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
<string name="app_name_calendar_alias">Agenda</string>
|
||||
<string name="month_open_week">Week %1$d openen</string>
|
||||
<string name="month_split_clear_filter">Agenda van de hele maand tonen</string>
|
||||
<string name="timeline_earlier_events">Eerdere gebeurtenissen</string>
|
||||
<string name="timeline_later_events">Latere gebeurtenissen</string>
|
||||
<string name="event_detail_open_in_maps">Op kaart tonen</string>
|
||||
<string name="link_open_failed">Er is geen app die deze link kan openen</string>
|
||||
<string name="reminder_day_today">Vandaag</string>
|
||||
<plurals name="view_multi_day">
|
||||
<item quantity="one">%d dag</item>
|
||||
<item quantity="other">%d dagen</item>
|
||||
</plurals>
|
||||
<string name="settings_multi_day_count">Meerdaagse weergave</string>
|
||||
<string name="settings_multi_day_count_hint">Hoeveel dagen de meerdaagse weergave naast elkaar toont. Vandaag staat in de eerste kolom.</string>
|
||||
<string name="settings_timeline_scale_by_visible_hours">Ingesteld via Zichtbare uren</string>
|
||||
<string name="settings_visible_hours">Zichtbare uren</string>
|
||||
<string name="settings_visible_hours_summary">Een reeks uren passend op het scherm tonen bij het openen van de week-, meerdaagse of dagweergave</string>
|
||||
<string name="settings_visible_hours_on_summary">%1$s – %2$s vult het scherm bij het openen van een weergave</string>
|
||||
<string name="settings_visible_hours_start">Van</string>
|
||||
<string name="settings_visible_hours_end">Tot</string>
|
||||
<string name="settings_visible_hours_start_hint">Het uur bovenaan het scherm bij het openen van de week-, meerdaagse of dagweergave. Eerdere uren blijven bereikbaar door te scrollen.</string>
|
||||
<string name="settings_visible_hours_end_hint">Het uur onderaan het scherm bij het openen van een weergave. Latere uren blijven bereikbaar door te scrollen, en in- of uitzoomen met twee vingers geldt totdat de weergave opnieuw wordt geopend.</string>
|
||||
</resources>
|
||||
|
||||
@@ -97,12 +97,6 @@
|
||||
<string name="event_move_occurrence_only">Moving the whole series would change which days it falls on, so only this event can move.</string>
|
||||
<!-- %1$s is the new date and time, e.g. "Fri, 7 Aug, 09:00". -->
|
||||
<string name="event_move_done">Moved to %1$s</string>
|
||||
<string name="event_resize_done">Changed to %1$s</string>
|
||||
<string name="event_resize_recurring_title">Change recurring event</string>
|
||||
<string name="event_selection_actions">Event actions</string>
|
||||
<string name="event_selection_hide">Hide event actions</string>
|
||||
<string name="event_selection_close">Deselect event</string>
|
||||
<string name="event_action_load_failed">Couldn\'t open the event</string>
|
||||
<string name="event_move_undo">Undo</string>
|
||||
<string name="event_move_undone">Move undone</string>
|
||||
<string name="event_move_failed">Couldn\'t move the event</string>
|
||||
|
||||
@@ -152,49 +152,4 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ class AnchorTodayTest {
|
||||
|
||||
private val today = LocalDate(2026, 6, 17)
|
||||
private val tomorrow = LocalDate(2026, 6, 18)
|
||||
private val yesterday = LocalDate(2026, 6, 16)
|
||||
|
||||
@Test
|
||||
fun `disabled leaves the days untouched even when today is absent`() {
|
||||
@@ -37,18 +36,4 @@ class AnchorTodayTest {
|
||||
val days = listOf(AgendaDay(today, emptyList()), AgendaDay(tomorrow, emptyList()))
|
||||
assertThat(anchorTodayIfMissing(days, today, enabled = true)).isEqualTo(days)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled slots today in after loaded past days`() {
|
||||
val days = listOf(AgendaDay(yesterday, emptyList()), AgendaDay(tomorrow, emptyList()))
|
||||
val result = anchorTodayIfMissing(days, today, enabled = true)
|
||||
assertThat(result.map { it.date }).containsExactly(yesterday, today, tomorrow).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled appends today after only past days`() {
|
||||
val days = listOf(AgendaDay(yesterday, emptyList()))
|
||||
val result = anchorTodayIfMissing(days, today, enabled = true)
|
||||
assertThat(result.map { it.date }).containsExactly(yesterday, today).inOrder()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
class IsPastOnTest {
|
||||
|
||||
private val zone = TimeZone.of("Europe/Berlin")
|
||||
private val today = LocalDate(2026, 10, 8)
|
||||
private val now = Instant.parse("2026-10-08T12:00:00Z")
|
||||
|
||||
// All-day Oct 6–9, still running today.
|
||||
private val summit = EventInstance(
|
||||
instanceId = 1,
|
||||
eventId = 1,
|
||||
calendarId = 1,
|
||||
title = "Summit",
|
||||
start = Instant.parse("2026-10-06T00:00:00Z"),
|
||||
end = Instant.parse("2026-10-10T00:00:00Z"),
|
||||
isAllDay = true,
|
||||
color = 0,
|
||||
location = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `an ongoing multi-day event is past on the days already behind`() {
|
||||
assertThat(summit.isPastOn(LocalDate(2026, 10, 7), now, zone)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ongoing multi-day event is not past today or later`() {
|
||||
assertThat(summit.isPastOn(today, now, zone)).isFalse()
|
||||
assertThat(summit.isPastOn(LocalDate(2026, 10, 9), now, zone)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event that ended earlier today is past`() {
|
||||
val standup = summit.copy(
|
||||
isAllDay = false,
|
||||
start = Instant.parse("2026-10-08T07:00:00Z"),
|
||||
end = Instant.parse("2026-10-08T07:15:00Z"),
|
||||
)
|
||||
assertThat(standup.isPastOn(today, now, zone)).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -115,17 +115,6 @@ 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,
|
||||
@@ -606,82 +595,4 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** Where a dragged resize handle leaves an event's ends (#141). */
|
||||
class ResizedSpanTest {
|
||||
|
||||
// 09:00–10:00, in minutes from its day's midnight.
|
||||
private val start = 9 * 60
|
||||
private val end = 10 * 60
|
||||
|
||||
@Test
|
||||
fun `the bottom edge snaps and leaves the start alone`() {
|
||||
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, 11 * 60 + 8f))
|
||||
.isEqualTo(start to 11 * 60 + 15)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the top edge snaps and leaves the end alone`() {
|
||||
assertThat(resizedSpan(ResizeEdge.Top, start, end, 8 * 60 - 7f))
|
||||
.isEqualTo(8 * 60 to end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edge stops one snap step short of the other end`() {
|
||||
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, 7 * 60f))
|
||||
.isEqualTo(start to start + DRAG_SNAP_MINUTES)
|
||||
assertThat(resizedSpan(ResizeEdge.Top, start, end, 12 * 60f))
|
||||
.isEqualTo(end - DRAG_SNAP_MINUTES to end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bottom edge runs on past midnight`() {
|
||||
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, 24 * 60 + 90f))
|
||||
.isEqualTo(start to 24 * 60 + 90)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the top edge runs back into the day before`() {
|
||||
assertThat(resizedSpan(ResizeEdge.Top, start, end, -60f))
|
||||
.isEqualTo(-60 to end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wall clock minutes past either midnight land on the neighbouring day`() {
|
||||
val zone = TimeZone.of("Europe/Berlin")
|
||||
val day = LocalDate(2026, 10, 9)
|
||||
assertThat(wallClockInstant(day, 24 * 60 + 30, zone))
|
||||
.isEqualTo(LocalDateTime(LocalDate(2026, 10, 10), LocalTime(0, 30)).toInstant(zone))
|
||||
assertThat(wallClockInstant(day, -30, zone))
|
||||
.isEqualTo(LocalDateTime(LocalDate(2026, 10, 8), LocalTime(23, 30)).toInstant(zone))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a finger drifting just over a column border keeps the edge on its day`() {
|
||||
assertThat(stickyColumn(position = 3.1f, held = 2, lastIndex = 6)).isEqualTo(2)
|
||||
assertThat(stickyColumn(position = 1.9f, held = 2, lastIndex = 6)).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a finger well into the next column takes the edge there`() {
|
||||
assertThat(stickyColumn(position = 3.4f, held = 2, lastIndex = 6)).isEqualTo(3)
|
||||
assertThat(stickyColumn(position = 1.6f, held = 2, lastIndex = 6)).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first column is the one under the finger`() {
|
||||
assertThat(stickyColumn(position = 4.9f, held = null, lastIndex = 6)).isEqualTo(4)
|
||||
assertThat(stickyColumn(position = 9f, held = null, lastIndex = 6)).isEqualTo(6)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a finger resting between two slots keeps the one it is on`() {
|
||||
// 09:08 rounds to 09:15, but from 09:00 it is not far enough to leave.
|
||||
assertThat(stickySnap(9 * 60 + 8f, held = 9 * 60)).isEqualTo(9 * 60)
|
||||
assertThat(stickySnap(9 * 60 + 12f, held = 9 * 60)).isEqualTo(9 * 60 + 15)
|
||||
assertThat(stickySnap(9 * 60 + 8f, held = null)).isEqualTo(9 * 60 + 15)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edge dragged into a day past the other end changes nothing`() {
|
||||
// The start into the day after a same-day event ends.
|
||||
assertThat(resizedSpan(ResizeEdge.Top, start, end, 24 * 60 + 9 * 60f, edgeDay = 1))
|
||||
.isEqualTo(start to end)
|
||||
// The end into the day before it starts.
|
||||
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, -60f, edgeDay = -1))
|
||||
.isEqualTo(start to end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edge in the event's own days still clamps against the other end`() {
|
||||
assertThat(resizedSpan(ResizeEdge.Top, start, end, 12 * 60f, edgeDay = 0))
|
||||
.isEqualTo(end - DRAG_SNAP_MINUTES to end)
|
||||
// An overnight event's start may move anywhere up to its end's day.
|
||||
val overnightEnd = 24 * 60 + 2 * 60
|
||||
assertThat(resizedSpan(ResizeEdge.Top, start, overnightEnd, 24 * 60 + 60f, edgeDay = 1))
|
||||
.isEqualTo(24 * 60 + 60 to overnightEnd)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user