Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
+3 |
9ccc303a2a | ||
|
|
a5c4a73de1 |
No files matched your search
@@ -7,10 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.24.0] — 2026-11-06
|
||||
|
||||
### Added
|
||||
- **Six new home-screen widgets.** Quick add (1×1) opens a new event on today with the same defaults as the app's + button ([#435]). Date (1×1) shows today's weekday and day number on a rounded shape and opens today ([#376]). Week strip (4×1) shows this week's days with a dot in each event's colour and today filled ([#436]). Upcoming (2×2) shows today's date over your next events, as many as fit ([#437]). Today lists today's events; once they're over it says "Nothing left today" and shows tomorrow's first one or two ([#438]). Countdown counts down to your next timed event, or to an event or series you pick, like a holiday in 12 days ([#439]).
|
||||
- **A month and day widget.** A compact month grid sits beside the events of one day. Tap a day in the grid to see its events, and tap the list to open that day in the app ([#441]).
|
||||
- **Each widget has its own settings.** Placing a widget, or long-pressing it and choosing Edit, opens a settings screen with a live preview. Pick a solid, tinted or transparent background, a light or dark theme or the app's, and which calendars it shows. Widgets that list events also have their time range, text size and how past events look. Settings → Widgets lists the widgets on your home screen so you can change them from there. These options used to be shared by all widgets; widgets you already placed keep what you had set ([#292]).
|
||||
- **Turn off start times in the month view.** Settings → Views → Month → Start times hides the time in front of event titles, so narrow fonts keep more of the title. With it on, the time now only shows when the whole title, or at least its first 12 characters, still fits beside it ([#476]).
|
||||
- **Drag all-day events.** In the day, 3-day and week views, long-press an event in the all-day strip and move it sideways to another day, or down into the grid to turn it into a timed event ([#296]).
|
||||
- **Traditional Chinese**, started as a community translation through [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/). It covers only a few screens so far, so the rest still shows in English until it fills out. Pick it under Settings → Language. Thanks to AventineC.
|
||||
|
||||
### Changed
|
||||
- **The agenda and month widgets have a new look.** The agenda puts each day in its own card, with a round date pill beside the first event and today's pill filled. The month widget has rounded day cells, weekday pills, shaded days outside the month and a + for a new event. The agenda's refresh button is gone, since widgets now update on their own ([#71], [#90]).
|
||||
@@ -18,11 +23,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **The widget picker shows what each widget looks like.** On Android 15 and later the previews are the real widgets with sample events, in your language and colours; on older versions they are redrawn to match.
|
||||
- **Widgets are listed by their name alone,** without "Calendula" in front, and each has a short description in the picker ([#71]).
|
||||
- **Past events in widgets stay readable when dimmed.** Their background fades further and their text less ([#90]).
|
||||
- **Events from midnight to midnight show as all-day events.** A timed event that starts and ends at midnight now sits in the all-day strip, shows as "All day" in the agenda and widgets, and its reminder names the day instead of 00:00 – 00:00. The event itself stays as it was saved ([#296]).
|
||||
- **Cancelled and tentative events are marked.** Cancelled events are struck through and listed last, like declined invites, and tentative ones get the outline used for unanswered invites. Cancelled one-off events and series no longer disappear ([#388]).
|
||||
- **One notification per event.** An event with several reminders now keeps a single notification, which each later reminder updates in place ([#463]).
|
||||
- **Go further back and forward in time.** Every view and the date picker now reach from 1600 to 2399 ([#457]).
|
||||
- **The split month agenda only lists days with events.** In other months it no longer starts with an empty 1st, and a month with nothing in it says so in one row ([#142]).
|
||||
|
||||
### Fixed
|
||||
- **Widgets show your changes straight away.** After you added, edited or deleted an event, a widget could keep showing the old events until something else redrew it ([#420], [#229]).
|
||||
- **Widgets follow the app's language.** After you picked a language in Settings, widgets kept the old one until something redrew them ([#90]).
|
||||
- **The month widget handles busy months.** It could show "Can't show content", or only one or two events per day. Each day now shows as many events as its height fits, and the rest fold into +N ([#372]).
|
||||
- **Short events late in the day are fully visible.** An event like 23:58–23:59 used to be cut down to a sliver at the bottom of the day, week and 3-day views. It now keeps its usual minimum height and sits flush against midnight ([#483]).
|
||||
- **The month view no longer crashes after going before 1900** in the day or week view ([#457]).
|
||||
- **Month pages keep updating.** After a small swipe that snapped back, a month page could stop showing new or edited events, and opening an event from the split view could show its old times ([#440], [#454]).
|
||||
- **Smoother expand and collapse in the split month view.** The day cells morph cleanly between the two layouts, and week numbers now animate along with them ([#473]).
|
||||
- **Back clears the selected day in the split month view.** With a day picked, the back gesture used to close the app; it now goes back to the whole month's agenda first ([#142]).
|
||||
|
||||
## [2.23.1] — 2026-10-28
|
||||
|
||||
### Changed
|
||||
- **The split month view fits more events.** With a day selected, its date is no longer shown twice: the filter chip now stands in for the day's header. Tapping the chip opens the day, and its ✕ clears the filter. The day headers in the month agenda below the grid take less room too ([#142]).
|
||||
|
||||
### Fixed
|
||||
- **Event dots in the split month grid stay in place on busy days.** A day with a "+N" next to its dots used to push them further down the cell ([#142]).
|
||||
|
||||
## [2.23.0] — 2026-10-21
|
||||
|
||||
@@ -1856,6 +1879,7 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#146]: https://codeberg.org/jlmakiola/calendula/issues/146
|
||||
[#267]: https://codeberg.org/jlmakiola/calendula/issues/267
|
||||
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239
|
||||
[#296]: https://codeberg.org/jlmakiola/calendula/issues/296
|
||||
[#297]: https://codeberg.org/jlmakiola/calendula/issues/297
|
||||
[#298]: https://codeberg.org/jlmakiola/calendula/issues/298
|
||||
[#309]: https://codeberg.org/jlmakiola/calendula/issues/309
|
||||
@@ -1905,6 +1929,7 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#306]: https://codeberg.org/jlmakiola/calendula/issues/306
|
||||
[#178]: https://codeberg.org/jlmakiola/calendula/issues/178
|
||||
[#116]: https://codeberg.org/jlmakiola/calendula/issues/116
|
||||
[#388]: https://codeberg.org/jlmakiola/calendula/issues/388
|
||||
[#413]: https://codeberg.org/jlmakiola/calendula/issues/413
|
||||
[#71]: https://codeberg.org/jlmakiola/calendula/issues/71
|
||||
[#90]: https://codeberg.org/jlmakiola/calendula/issues/90
|
||||
@@ -1919,4 +1944,11 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#437]: https://codeberg.org/jlmakiola/calendula/issues/437
|
||||
[#438]: https://codeberg.org/jlmakiola/calendula/issues/438
|
||||
[#439]: https://codeberg.org/jlmakiola/calendula/issues/439
|
||||
[#440]: https://codeberg.org/jlmakiola/calendula/issues/440
|
||||
[#441]: https://codeberg.org/jlmakiola/calendula/issues/441
|
||||
[#454]: https://codeberg.org/jlmakiola/calendula/issues/454
|
||||
[#457]: https://codeberg.org/jlmakiola/calendula/issues/457
|
||||
[#463]: https://codeberg.org/jlmakiola/calendula/issues/463
|
||||
[#473]: https://codeberg.org/jlmakiola/calendula/issues/473
|
||||
[#476]: https://codeberg.org/jlmakiola/calendula/issues/476
|
||||
[#483]: https://codeberg.org/jlmakiola/calendula/issues/483
|
||||
@@ -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 = 2240002
|
||||
versionName = "2.24.0-beta.2"
|
||||
versionCode = 2240099
|
||||
versionName = "2.24.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -567,8 +567,31 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||
queryInstances(beginMillis, endMillis, null, null)
|
||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
||||
val listed = queryInstances(beginMillis, endMillis, null, null)
|
||||
// A provider that does expand cancelled events already listed them.
|
||||
val shown = listed.mapTo(HashSet()) { it.eventId }
|
||||
val cancelled = queryCancelledOneOffs(beginMillis, endMillis).filter { it.eventId !in shown }
|
||||
return if (cancelled.isEmpty()) listed else (listed + cancelled).sortedBy { it.start }
|
||||
}
|
||||
|
||||
/** Cancelled one-off events overlapping the window, which AOSP leaves out of Instances (#388). */
|
||||
private fun queryCancelledOneOffs(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
||||
val selection = "${CalendarContract.Events.STATUS} = ${CalendarContract.Events.STATUS_CANCELED} AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||
"${CalendarContract.Events.RRULE} IS NULL AND ${CalendarContract.Events.RDATE} IS NULL AND " +
|
||||
"${CalendarContract.Events.ORIGINAL_ID} IS NULL AND " +
|
||||
"${CalendarContract.Events.ORIGINAL_SYNC_ID} IS NULL AND " +
|
||||
"${CalendarContract.Events.DTSTART} < ? AND " +
|
||||
"COALESCE(${CalendarContract.Events.DTEND}, ${CalendarContract.Events.DTSTART}) >= ?"
|
||||
return resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
CancelledEventProjection.COLUMNS,
|
||||
selection,
|
||||
arrayOf(endMillis.toString(), beginMillis.toString()),
|
||||
null,
|
||||
)?.use { c -> c.mapRowsNotNull { toCancelledOneOff() } } ?: emptyList()
|
||||
}
|
||||
|
||||
override fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||
// The series' own rows plus its exceptions, so an occurrence edited on
|
||||
@@ -587,13 +610,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
args: Array<String>?,
|
||||
): List<EventInstance> {
|
||||
ensureObserversRegistered()
|
||||
// Hide cancelled occurrences: "delete only this event" writes a
|
||||
// cancelled exception for the one instance (#47). A cancelled event or
|
||||
// series of its own stays, crossed out (#388), so only exception rows
|
||||
// are filtered. A NULL status is a normal, un-cancelled event, so it
|
||||
// must survive the filter — a bare `!= CANCELED` would drop it (NULL !=
|
||||
// 2 is NULL, not true). DELETED = 0: a provider that soft-deletes may
|
||||
// keep the row's instances until a purge (#364).
|
||||
// Hide deleted occurrences (cancelled exceptions, #47) but keep a cancelled
|
||||
// event of its own (#388); NULL status is a normal event. DELETED: #364.
|
||||
val visible = "(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED} OR " +
|
||||
"(${CalendarContract.Instances.ORIGINAL_ID} IS NULL AND " +
|
||||
@@ -627,7 +645,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
.toTypedArray()
|
||||
val selection = "($match) AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
|
||||
"${CalendarContract.Events.ORIGINAL_ID} IS NULL AND " +
|
||||
"${CalendarContract.Events.ORIGINAL_SYNC_ID} IS NULL"
|
||||
return resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
SearchProjection.COLUMNS,
|
||||
|
||||
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.FormTime
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.forNewEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.nearestTo
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
@@ -132,7 +133,8 @@ internal fun buildEventInsertValues(
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||
if (form.status != EventStatus.Confirmed) put(CalendarContract.Events.STATUS, form.status.toProviderStatus())
|
||||
form.status.forNewEvent().takeIf { it != EventStatus.Confirmed }
|
||||
?.let { put(CalendarContract.Events.STATUS, it.toProviderStatus()) }
|
||||
form.location.trim().takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
form.description.trim().takeIf { it.isNotEmpty() }
|
||||
@@ -283,7 +285,7 @@ internal fun buildOccurrenceExceptionValues(
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||
put(CalendarContract.Events.STATUS, form.status.toProviderStatus())
|
||||
put(CalendarContract.Events.STATUS, form.status.forNewEvent().toProviderStatus())
|
||||
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
|
||||
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
|
||||
if (form.colorKey == null) putAll(eventColorColumns(null, form.color))
|
||||
@@ -359,7 +361,8 @@ internal fun buildMovedMasterValues(
|
||||
}
|
||||
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
|
||||
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
|
||||
snapshot.status?.takeIf { it != CalendarContract.Events.STATUS_CANCELED }
|
||||
?.let { put(CalendarContract.Events.STATUS, it) }
|
||||
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
|
||||
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
|
||||
}
|
||||
@@ -863,7 +866,7 @@ internal fun buildImportedEventValues(
|
||||
// timed rows keep the event's own zone.
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
|
||||
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
|
||||
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
|
||||
put(CalendarContract.Events.STATUS, event.status.forNewEvent().toProviderStatus())
|
||||
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
@@ -42,6 +42,13 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A [CancelledEventProjection] row as an instance. Its id is the negated event
|
||||
* id, so it can't collide with a provider instance id.
|
||||
*/
|
||||
internal fun ColumnReader.toCancelledOneOff(): EventInstance? =
|
||||
toEventInstance()?.let { it.copy(instanceId = -it.eventId) }
|
||||
|
||||
/**
|
||||
* `SELF_ATTENDEE_STATUS` as the calendar surfaces read it: a tentative "maybe"
|
||||
* counts as going, and everything that is not an open or refused invitation —
|
||||
|
||||
@@ -79,6 +79,28 @@ internal object InstanceProjection {
|
||||
const val IDX_STATUS = 11
|
||||
}
|
||||
|
||||
/**
|
||||
* A cancelled one-off straight from the Events table, laid out on
|
||||
* [InstanceProjection]'s indices so [toCancelledOneOff] reuses its mapper. AOSP
|
||||
* writes no Instances rows for a cancelled event (#388).
|
||||
*/
|
||||
internal object CancelledEventProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Events._ID,
|
||||
CalendarContract.Events._ID,
|
||||
CalendarContract.Events.CALENDAR_ID,
|
||||
CalendarContract.Events.TITLE,
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.DTEND,
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events.EVENT_COLOR,
|
||||
CalendarContract.Events.CALENDAR_COLOR,
|
||||
CalendarContract.Events.EVENT_LOCATION,
|
||||
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||
CalendarContract.Events.STATUS,
|
||||
)
|
||||
}
|
||||
|
||||
internal object EventDetailProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Events._ID,
|
||||
|
||||
@@ -146,6 +146,7 @@ class SettingsPrefs @Inject constructor(
|
||||
val pastEventDisplay: Flow<PastEventDisplay> = prefs.flow(SettingsKeys.pastEventDisplay)
|
||||
val dimCompletedEvents: Flow<Boolean> = prefs.flow(SettingsKeys.dimCompletedEvents)
|
||||
val showWeekNumbers: Flow<Boolean> = prefs.flow(SettingsKeys.showWeekNumbers)
|
||||
val monthShowStartTimes: Flow<Boolean> = prefs.flow(SettingsKeys.monthShowStartTimes)
|
||||
val monthViewStyle: Flow<MonthViewStyle> = prefs.flow(SettingsKeys.monthViewStyle)
|
||||
val timelineScale: Flow<TimelineScale> = prefs.flow(SettingsKeys.timelineScale)
|
||||
val multiDayCount: Flow<Int> = prefs.flow(SettingsKeys.multiDayCount)
|
||||
@@ -212,6 +213,7 @@ class SettingsPrefs @Inject constructor(
|
||||
suspend fun setPastEventDisplay(mode: PastEventDisplay) = prefs.set(SettingsKeys.pastEventDisplay, mode)
|
||||
suspend fun setDimCompletedEvents(enabled: Boolean) = prefs.set(SettingsKeys.dimCompletedEvents, enabled)
|
||||
suspend fun setShowWeekNumbers(enabled: Boolean) = prefs.set(SettingsKeys.showWeekNumbers, enabled)
|
||||
suspend fun setMonthShowStartTimes(enabled: Boolean) = prefs.set(SettingsKeys.monthShowStartTimes, enabled)
|
||||
suspend fun setMonthViewStyle(style: MonthViewStyle) = prefs.set(SettingsKeys.monthViewStyle, style)
|
||||
suspend fun setTimelineScale(scale: TimelineScale) = prefs.set(SettingsKeys.timelineScale, scale)
|
||||
suspend fun setMultiDayCount(count: Int) = prefs.set(SettingsKeys.multiDayCount, count)
|
||||
@@ -445,6 +447,9 @@ object SettingsKeys {
|
||||
*/
|
||||
val showWeekNumbers: Pref<Boolean> = booleanPref("show_week_numbers", false)
|
||||
|
||||
/** Whether Month chips may show the start time before the title when wide enough (#219, #476). */
|
||||
val monthShowStartTimes: Pref<Boolean> = booleanPref("month_show_start_times", true)
|
||||
|
||||
/** How the Month view lays itself out (#38, #53). */
|
||||
val monthViewStyle: Pref<MonthViewStyle> = enumPref("month_view_style", MonthViewStyle.Paged)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.runsMidnightToMidnight
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
@@ -11,6 +12,8 @@ import java.time.format.FormatStyle
|
||||
import java.time.format.TextStyle
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.Locale
|
||||
import kotlinx.datetime.toKotlinTimeZone
|
||||
import kotlin.time.Instant as KInstant
|
||||
|
||||
/**
|
||||
* The one line of time context in a reminder notification. Pure so it can be
|
||||
@@ -32,7 +35,8 @@ import java.util.Locale
|
||||
* - all-day, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end)
|
||||
*
|
||||
* All-day instances store UTC midnights with an exclusive end, so they are read
|
||||
* in UTC and the end day is the last *covered* day (issue #308).
|
||||
* in UTC and the end day is the last *covered* day (issue #308). A timed event
|
||||
* from local midnight to local midnight reads like one too, in [zone] (#296).
|
||||
*/
|
||||
fun reminderTimeText(
|
||||
beginMillis: Long,
|
||||
@@ -47,11 +51,17 @@ fun reminderTimeText(
|
||||
tomorrowLabel: String,
|
||||
yesterdayLabel: String,
|
||||
): String {
|
||||
if (isAllDay) {
|
||||
val wholeDays = !isAllDay && runsMidnightToMidnight(
|
||||
KInstant.fromEpochMilliseconds(beginMillis),
|
||||
KInstant.fromEpochMilliseconds(endMillis),
|
||||
zone.toKotlinTimeZone(),
|
||||
)
|
||||
if (isAllDay || wholeDays) {
|
||||
val dayZone = if (isAllDay) ZoneOffset.UTC else zone
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
// (atZone().toLocalDate() instead of LocalDate.ofInstant — API 34+)
|
||||
val firstDay = Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
val firstDay = Instant.ofEpochMilli(beginMillis).atZone(dayZone).toLocalDate()
|
||||
val lastDay = Instant.ofEpochMilli(endMillis).atZone(dayZone).toLocalDate()
|
||||
.minusDays(1)
|
||||
.coerceAtLeast(firstDay)
|
||||
val start = relativeDayPrefix(
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Lower values appear first in lists and get the upper visible lanes in grids. */
|
||||
fun EventInstance.displayPriority(zone: TimeZone): Int = when {
|
||||
isCrossedOut -> 3
|
||||
spansMultipleDays(zone) -> 0
|
||||
isAllDay -> 1
|
||||
coversWholeDays(zone) -> 1
|
||||
else -> 2
|
||||
}
|
||||
|
||||
/** Shared order for agenda and split day lists. Grid bars add their lane geometry. */
|
||||
fun eventDisplayComparator(zone: TimeZone): Comparator<EventInstance> =
|
||||
compareBy<EventInstance> { it.displayPriority(zone) }
|
||||
.thenBy { it.start }
|
||||
.thenBy { it.title }
|
||||
.thenBy { it.instanceId }
|
||||
/**
|
||||
* Shared order for agenda and split day lists. Grid bars add their lane geometry.
|
||||
* Each event's priority is worked out once, not on every comparison.
|
||||
*/
|
||||
fun List<EventInstance>.sortedForDisplay(zone: TimeZone): List<EventInstance> =
|
||||
map { Triple(it, it.displayPriority(zone), it.displayStart(zone)) }
|
||||
.sortedWith(
|
||||
compareBy<Triple<EventInstance, Int, Instant>> { it.second }
|
||||
.thenBy { it.third }
|
||||
.thenBy { it.first.title }
|
||||
.thenBy { it.first.instanceId },
|
||||
)
|
||||
.map { it.first }
|
||||
|
||||
/**
|
||||
* Where an event starts for ordering: a whole-day event at local midnight of its
|
||||
* first day, since an all-day one is stored at UTC midnight and would otherwise
|
||||
* sort around timed events by the device's offset (#296).
|
||||
*/
|
||||
internal fun EventInstance.displayStart(zone: TimeZone): Instant =
|
||||
if (coversWholeDays(zone)) spanFirstDay(zone).atStartOfDayIn(zone) else start
|
||||
@@ -248,11 +248,20 @@ internal fun allDayFormTimes(start: LocalDate, endExclusive: LocalDate?): Pair<F
|
||||
internal fun String?.normalizedRrule(): String? = this?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }
|
||||
|
||||
/**
|
||||
* Whether this form may be saved as a single modified occurrence. A cancelled
|
||||
* exception row is how Android records a deleted occurrence, so a cancelled one
|
||||
* would vanish rather than show struck through (#388).
|
||||
* Whether this form may be saved apart from its series, as one modified
|
||||
* occurrence or as a split-off "this & following" tail. A cancelled exception
|
||||
* row is how Android records a deleted occurrence, and Google reads an inserted
|
||||
* cancelled event as deleted, so either would vanish rather than show struck
|
||||
* through (#388).
|
||||
*/
|
||||
fun EventForm.canSaveAsOccurrence(): Boolean = status != EventStatus.Cancelled
|
||||
fun EventForm.canSaveApartFromSeries(): Boolean = status != EventStatus.Cancelled
|
||||
|
||||
/**
|
||||
* The status a newly inserted row may carry. Google reads an inserted cancelled
|
||||
* event as deleted, so every new row (a copy, an import, a calendar move or an
|
||||
* exception) starts confirmed instead (#388).
|
||||
*/
|
||||
fun EventStatus.forNewEvent(): EventStatus = if (this == EventStatus.Cancelled) EventStatus.Confirmed else this
|
||||
|
||||
/**
|
||||
* Whether this form's times mean something else in [target] — a timed event
|
||||
@@ -323,4 +332,5 @@ fun EventDetail.toDuplicateForm(canModify: Boolean, zone: TimeZone): EventForm =
|
||||
).copy(
|
||||
rrule = null,
|
||||
calendarId = instance.calendarId.takeIf { canModify },
|
||||
status = status.forNewEvent(),
|
||||
)
|
||||
@@ -173,8 +173,11 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
|
||||
return copy(start = movedStart, end = (startAt + span).toFormTime(zone))
|
||||
}
|
||||
|
||||
/** The form with new start and end, each in the form's own zone — a resize. All-day is untouched. */
|
||||
/**
|
||||
* The form with new start and end, each in the form's own zone — a resize. An
|
||||
* all-day event becomes timed: one dragged out of the all-day strip.
|
||||
*/
|
||||
fun EventForm.resizedTo(newStart: Instant, newEnd: Instant, deviceZone: TimeZone): EventForm {
|
||||
if (isAllDay) return this
|
||||
return withTimes(newStart, newEnd, resolvedZone(deviceZone))
|
||||
val timed = copy(isAllDay = false)
|
||||
return timed.withTimes(newStart, newEnd, timed.resolvedZone(deviceZone))
|
||||
}
|
||||
@@ -150,6 +150,20 @@ fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
|
||||
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
|
||||
spanFirstDay(zone) != spanLastDay(zone)
|
||||
|
||||
/**
|
||||
* Whether this event fills whole days in [zone], so the grids and lists lay it
|
||||
* out with the all-day events: an all-day event, or a timed one running from
|
||||
* local midnight to local midnight (#296). The event itself stays timed.
|
||||
*/
|
||||
fun EventInstance.coversWholeDays(zone: TimeZone): Boolean = isAllDay || runsMidnightToMidnight(start, end, zone)
|
||||
|
||||
/** Whether a timed span from [start] to [end] runs from local midnight to local midnight in [zone] (#296). */
|
||||
fun runsMidnightToMidnight(start: Instant, end: Instant, zone: TimeZone): Boolean =
|
||||
end > start && start.isStartOfDay(zone) && end.isStartOfDay(zone)
|
||||
|
||||
private fun Instant.isStartOfDay(zone: TimeZone): Boolean =
|
||||
this == toLocalDateTime(zone).date.atStartOfDayIn(zone)
|
||||
|
||||
data class EventDetail(
|
||||
val instance: EventInstance,
|
||||
val description: String?,
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.allDayFormTimes
|
||||
import de.jeanlucmakiola.calendula.domain.forNewEvent
|
||||
import de.jeanlucmakiola.calendula.domain.normalizedRrule
|
||||
import de.jeanlucmakiola.calendula.domain.toFormTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
@@ -35,7 +36,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||
description = description.orEmpty(),
|
||||
reminders = semanticReminderMinutes(),
|
||||
availability = availability,
|
||||
status = status,
|
||||
status = status.forNewEvent(),
|
||||
rrule = recurrenceRule.normalizedRrule(),
|
||||
)
|
||||
}
|
||||
@@ -59,11 +59,16 @@ import java.util.Locale
|
||||
// split style can list a day with exactly the same visual language instead of
|
||||
// growing a parallel set of event rows.
|
||||
|
||||
/**
|
||||
* A day's header; tapping it opens the Day view. [compact] trims the padding for
|
||||
* the month view's split pane, where vertical space is short (#142).
|
||||
*/
|
||||
@Composable
|
||||
internal fun AgendaDayHeader(
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
compact: Boolean = false,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
@@ -79,7 +84,12 @@ internal fun AgendaDayHeader(
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||
modifier = Modifier.padding(
|
||||
start = 28.dp,
|
||||
end = 28.dp,
|
||||
top = if (compact) 8.dp else 16.dp,
|
||||
bottom = if (compact) 4.dp else 8.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -220,10 +230,11 @@ internal fun LazyListScope.agendaDays(
|
||||
dimmed: (EventInstance, LocalDate) -> Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
compact: Boolean = false,
|
||||
) {
|
||||
days.forEach { day ->
|
||||
stickyHeader(key = "header-${day.date}") {
|
||||
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
||||
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay, compact = compact)
|
||||
}
|
||||
if (day.events.isEmpty()) {
|
||||
item(key = "placeholder-${day.date}") {
|
||||
@@ -251,6 +262,6 @@ internal fun LazyListScope.agendaDays(
|
||||
)
|
||||
}
|
||||
}
|
||||
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
||||
item(key = "gap-${day.date}") { Spacer(Modifier.height(if (compact) 4.dp else 8.dp)) }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.FabClearance
|
||||
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
|
||||
@@ -432,7 +433,7 @@ private fun AgendaList(
|
||||
modifier = modifier.nestedScroll(loadOnScrollUp),
|
||||
state = listState,
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = FabClearance),
|
||||
) {
|
||||
agendaDays(
|
||||
days = days,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
||||
import de.jeanlucmakiola.calendula.domain.sortedForDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
||||
@@ -18,7 +19,7 @@ import kotlin.time.Instant
|
||||
* surface only formats the instants into its own locale/24h string.
|
||||
*/
|
||||
sealed interface AgendaTimeLabel {
|
||||
/** An all-day event, or a whole in-between day of a multi-day span. */
|
||||
/** An event filling whole days (#296), or a whole in-between day of a multi-day span. */
|
||||
data object AllDay : AgendaTimeLabel
|
||||
/** The first day of a multi-day timed event: when it begins. */
|
||||
data class Starts(val start: Instant) : AgendaTimeLabel
|
||||
@@ -30,7 +31,7 @@ sealed interface AgendaTimeLabel {
|
||||
|
||||
/** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */
|
||||
fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel {
|
||||
if (event.isAllDay) return AgendaTimeLabel.AllDay
|
||||
if (event.coversWholeDays(zone)) return AgendaTimeLabel.AllDay
|
||||
val firstDay = event.spanFirstDay(zone)
|
||||
val lastDay = event.spanLastDay(zone)
|
||||
return when {
|
||||
@@ -87,7 +88,7 @@ fun groupAgendaDays(
|
||||
return byDay.map { (date, dayEvents) ->
|
||||
AgendaDay(
|
||||
date = date,
|
||||
events = dayEvents.sortedWith(eventDisplayComparator(zone)),
|
||||
events = dayEvents.sortedForDisplay(zone),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ import de.jeanlucmakiola.calendula.R
|
||||
/** The standard FAB's size, shared by the selection bar that unrolls from it (#141). */
|
||||
val FabSize = 56.dp
|
||||
|
||||
/** Bottom inset that lets a scrolling view's last row or hour scroll clear of the FAB. */
|
||||
val FabClearance = 96.dp
|
||||
|
||||
/** What the FAB is while an event is selected (#141): the toggle for its action bar. */
|
||||
@Stable
|
||||
class FabSelection(
|
||||
|
||||
@@ -68,7 +68,6 @@ fun EventMoveHost(
|
||||
),
|
||||
onSelect = viewModel::moveWithScope,
|
||||
onDismiss = viewModel::cancelScope,
|
||||
allowOccurrence = !pending.seriesOnly,
|
||||
allowSeries = !pending.occurrenceOnly,
|
||||
reason = stringResource(R.string.event_move_occurrence_only)
|
||||
.takeIf { pending.occurrenceOnly },
|
||||
@@ -92,6 +91,7 @@ fun EventMoveHost(
|
||||
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
|
||||
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
|
||||
MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end)
|
||||
MoveOutcome.BlockedCancelledSeries -> stringResource(R.string.event_move_blocked_cancelled_series)
|
||||
MoveOutcome.Failed -> stringResource(R.string.event_move_failed)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,15 +3,12 @@ package de.jeanlucmakiola.calendula.ui.common
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
|
||||
@@ -45,33 +42,17 @@ fun inlineTimeLabel(time: String?, title: String, timeInk: Color): AnnotatedStri
|
||||
append(title)
|
||||
}
|
||||
|
||||
/**
|
||||
* The characters of title that have to survive a time prefix for it to be worth
|
||||
* its place — lowercase Latin of average advance, priced at the surface's own
|
||||
* text style rather than guessed in dp.
|
||||
*/
|
||||
private const val TITLE_SAMPLE = "notepad"
|
||||
|
||||
/** The widest wall-clock time in either convention: two-digit hour, and a meridiem in 12-hour. */
|
||||
private const val SAMPLE_HOUR = 12
|
||||
private const val SAMPLE_MINUTE = 45
|
||||
|
||||
/**
|
||||
* The narrowest run of [style] text that may carry a time in front of a title:
|
||||
* wide enough for the widest time in the current convention plus [TITLE_SAMPLE].
|
||||
*
|
||||
* Measured rather than a device breakpoint, so it follows the font scale, the
|
||||
* 12/24-hour setting, the locale's own time format and whatever else has
|
||||
* already been taken off the width.
|
||||
* The widest time the current convention writes, for pricing a time prefix by
|
||||
* measurement: it follows the 12/24-hour setting and the locale's own format.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberInlineTimeWidth(style: TextStyle): Dp {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
fun rememberWidestTimeSample(): String {
|
||||
val locale = currentLocale()
|
||||
val sample = formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, LocalUse24HourFormat.current, locale)
|
||||
return remember(sample, style, density, measurer) {
|
||||
val label = inlineTimeLabel(sample, TITLE_SAMPLE, Color.Unspecified)
|
||||
with(density) { measurer.measure(label, style).size.width.toDp() }
|
||||
}
|
||||
val is24Hour = LocalUse24HourFormat.current
|
||||
return remember(is24Hour, locale) { formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, is24Hour, locale) }
|
||||
}
|
||||
@@ -56,7 +56,8 @@ private const val DIMMED_HOUR_ALPHA = 0.3f
|
||||
* The timeline's hour gutter. Scrolls in sync with the day columns through the
|
||||
* shared [scrollState], and while [dragController] holds a lifted block it dims
|
||||
* the hour labels and floats a badge with the drag's current start time at the
|
||||
* row the block would land on.
|
||||
* row the block would land on. Its content ends in the same [FabClearance]
|
||||
* the day columns do, or the two drift apart at the bottom of the scroll.
|
||||
*/
|
||||
@Composable
|
||||
fun HourGutter(
|
||||
@@ -86,7 +87,8 @@ fun HourGutter(
|
||||
.width(GUTTER_WIDTH)
|
||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(scrollState),
|
||||
.verticalScroll(scrollState)
|
||||
.padding(bottom = FabClearance),
|
||||
) {
|
||||
Column {
|
||||
(0 until 24).forEach { h ->
|
||||
|
||||
@@ -70,27 +70,24 @@ data class OutOfViewEvents(val above: TimedBlock?, val below: TimedBlock?) {
|
||||
/** Which edge of the viewport a hidden event sits past. */
|
||||
enum class ViewportEdge { Top, Bottom }
|
||||
|
||||
/** Where [block]'s rendered bottom lands, in minutes — short blocks keep a floor. */
|
||||
internal fun renderedEndMin(block: TimedBlock): Float =
|
||||
maxOf(block.endMin.toFloat(), block.startMin + MIN_EVENT_FRACTION * 60f)
|
||||
|
||||
/**
|
||||
* The blocks of one column lying out of a viewport spanning [topMin]..[bottomMin]
|
||||
* (minutes of the day), nearest first. A block leaves this set as soon as any
|
||||
* part of its rendered height enters the viewport.
|
||||
* part of its [renderedSpan] enters the viewport.
|
||||
*/
|
||||
fun outOfViewEvents(
|
||||
blocks: List<TimedBlock>,
|
||||
topMin: Float,
|
||||
bottomMin: Float,
|
||||
): OutOfViewEvents {
|
||||
val above = blocks
|
||||
.filter { renderedEndMin(it) <= topMin }
|
||||
.maxWithOrNull(compareBy<TimedBlock>({ renderedEndMin(it) }, { it.startMin }))
|
||||
val below = blocks
|
||||
.filter { it.startMin >= bottomMin }
|
||||
.minWithOrNull(compareBy<TimedBlock>({ it.startMin }, { -renderedEndMin(it) }))
|
||||
return OutOfViewEvents(above, below)
|
||||
val spans = blocks.map { it to it.renderedSpan() }
|
||||
val above = spans
|
||||
.filter { (_, span) -> span.bottomMin <= topMin }
|
||||
.maxWithOrNull(compareBy({ it.second.bottomMin }, { it.first.startMin }))
|
||||
val below = spans
|
||||
.filter { (_, span) -> span.topMin >= bottomMin }
|
||||
.minWithOrNull(compareBy({ it.second.topMin }, { -it.second.bottomMin }))
|
||||
return OutOfViewEvents(above?.first, below?.first)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,8 +104,9 @@ fun revealScrollTarget(
|
||||
marginPx: Float,
|
||||
maxScroll: Int,
|
||||
): Int {
|
||||
val startPx = block.startMin / 60f * hourPx
|
||||
val endPx = renderedEndMin(block) / 60f * hourPx
|
||||
val span = block.renderedSpan()
|
||||
val startPx = span.topMin / 60f * hourPx
|
||||
val endPx = span.bottomMin / 60f * hourPx
|
||||
val target = when (edge) {
|
||||
ViewportEdge.Top -> startPx - marginPx
|
||||
ViewportEdge.Bottom -> minOf(endPx + marginPx - viewportPx, startPx - marginPx)
|
||||
@@ -293,6 +291,7 @@ private fun EdgeBar(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
block ?: return
|
||||
val span = remember(block) { block.renderedSpan() }
|
||||
val color = eventFill(block.event.color, dark, LocalSoftenColors.current)
|
||||
val label = stringResource(
|
||||
if (edge == ViewportEdge.Top) R.string.timeline_earlier_events else R.string.timeline_later_events,
|
||||
@@ -303,8 +302,8 @@ private fun EdgeBar(
|
||||
.height(EDGE_TOUCH_HEIGHT)
|
||||
.graphicsLayer {
|
||||
val gapPx = when (edge) {
|
||||
ViewportEdge.Top -> scrollState.value - renderedEndMin(block) / 60f * hourPx
|
||||
ViewportEdge.Bottom -> block.startMin / 60f * hourPx -
|
||||
ViewportEdge.Top -> scrollState.value - span.bottomMin / 60f * hourPx
|
||||
ViewportEdge.Bottom -> span.topMin / 60f * hourPx -
|
||||
(scrollState.value + scrollState.viewportSize)
|
||||
}
|
||||
alpha = edgeBarAlpha(gapPx, fadeLeadPx)
|
||||
|
||||
@@ -6,7 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.domain.canSaveAsOccurrence
|
||||
import de.jeanlucmakiola.calendula.domain.canSaveApartFromSeries
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||
@@ -58,7 +58,10 @@ sealed interface MoveTarget {
|
||||
*/
|
||||
data class ByDays(val days: Int) : MoveTarget
|
||||
|
||||
/** New start and end instants — a timeline resize, which changes the length. */
|
||||
/**
|
||||
* New start and end instants — a timeline resize, which changes the length,
|
||||
* or an all-day event dragged into the time grid, which becomes timed.
|
||||
*/
|
||||
data class Span(val start: Instant, val end: Instant) : MoveTarget
|
||||
}
|
||||
|
||||
@@ -80,12 +83,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,
|
||||
/** A cancelled series, whose occurrences can't move on their own (#388). */
|
||||
val seriesOnly: Boolean = false,
|
||||
)
|
||||
data class MoveScopePrompt(val occurrenceOnly: Boolean, val resize: Boolean = false)
|
||||
|
||||
/** The inverse of a completed move, for the undo action. */
|
||||
data class MoveUndo(
|
||||
@@ -120,6 +118,12 @@ sealed interface MoveOutcome {
|
||||
/** The drop would push the series past its own `UNTIL`, generating nothing. */
|
||||
data object BlockedSeriesEnd : MoveOutcome
|
||||
|
||||
/**
|
||||
* A cancelled series only moves as a whole, and this drop would change which
|
||||
* days it falls on (#388).
|
||||
*/
|
||||
data object BlockedCancelledSeries : MoveOutcome
|
||||
|
||||
data object Failed : MoveOutcome
|
||||
}
|
||||
|
||||
@@ -209,7 +213,10 @@ class RescheduleViewModel @Inject constructor(
|
||||
val canRealign: Boolean,
|
||||
/** The series row's DTSTART date after the move — what its `UNTIL` must clear. */
|
||||
val newAnchorDate: LocalDate,
|
||||
)
|
||||
) {
|
||||
/** A timed event's new length, rather than an all-day one dragged into the grid. */
|
||||
val isResize: Boolean get() = request.target is MoveTarget.Span && !original.isAllDay
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a drop, unless one is already being written. False means nothing will
|
||||
@@ -233,12 +240,13 @@ class RescheduleViewModel @Inject constructor(
|
||||
busy = false
|
||||
return@launch
|
||||
}
|
||||
val seriesOnly = !prepared.original.canSaveAsOccurrence()
|
||||
if (!prepared.isRecurring) {
|
||||
// A cancelled series only moves as a whole (#388).
|
||||
val seriesOnly = !prepared.original.canSaveApartFromSeries()
|
||||
if (!prepared.isRecurring || (seriesOnly && prepared.canRealign)) {
|
||||
write(prepared, RecurringWriteScope.AllEvents)
|
||||
busy = false
|
||||
} else if (seriesOnly && !prepared.canRealign) {
|
||||
// Neither the occurrence nor the series can take this move.
|
||||
} else if (seriesOnly) {
|
||||
_outcome.value = MoveOutcome.BlockedCancelledSeries
|
||||
_abandoned.value += 1
|
||||
busy = false
|
||||
} else {
|
||||
@@ -246,8 +254,7 @@ class RescheduleViewModel @Inject constructor(
|
||||
pending = prepared
|
||||
_scopePrompt.value = MoveScopePrompt(
|
||||
occurrenceOnly = !prepared.canRealign,
|
||||
resize = request.target is MoveTarget.Span,
|
||||
seriesOnly = seriesOnly,
|
||||
resize = prepared.isResize,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -259,7 +266,6 @@ class RescheduleViewModel @Inject constructor(
|
||||
val prepared = pending ?: return
|
||||
// Guard against the dialog ever offering a scope the rule can't carry.
|
||||
if (!prepared.canRealign && scope != RecurringWriteScope.ThisEvent) return
|
||||
if (scope == RecurringWriteScope.ThisEvent && !prepared.original.canSaveAsOccurrence()) return
|
||||
pending = null
|
||||
_scopePrompt.value = null
|
||||
viewModelScope.launch {
|
||||
@@ -472,9 +478,9 @@ class RescheduleViewModel @Inject constructor(
|
||||
val anchorZone = if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone)
|
||||
val anchorMinute = detail.instance.start.toLocalDateTime(anchorZone)
|
||||
.time.toMillisecondOfDay() / MILLIS_PER_MINUTE
|
||||
val delta = (
|
||||
shifted.start.time.toMillisecondOfDay() - original.start.time.toMillisecondOfDay()
|
||||
) / MILLIS_PER_MINUTE
|
||||
// An all-day form's time is a placeholder; its anchor sits at midnight.
|
||||
val fromMillis = if (original.isAllDay) 0 else original.start.time.toMillisecondOfDay()
|
||||
val delta = (shifted.start.time.toMillisecondOfDay() - fromMillis) / MILLIS_PER_MINUTE
|
||||
return anchorMinute + delta in 0 until MINUTES_PER_DAY
|
||||
}
|
||||
|
||||
@@ -507,7 +513,7 @@ class RescheduleViewModel @Inject constructor(
|
||||
|
||||
/** 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
|
||||
if (!prepared.isResize) return null
|
||||
val form = prepared.updated
|
||||
return form.endInstant(form.resolvedZone(zoneSource.current())).toEpochMilliseconds()
|
||||
}
|
||||
|
||||
@@ -78,6 +78,9 @@ import kotlin.time.Instant
|
||||
/** Start granularity a dragged block snaps to, matching the big calendar apps. */
|
||||
const val DRAG_SNAP_MINUTES: Int = 15
|
||||
|
||||
/** How far below a strip drag's timed block top the finger holds it, in minutes. */
|
||||
private const val STRIP_GRAB_MINUTES = 15
|
||||
|
||||
/** How close to a timeline edge the finger must get before the view scrolls. */
|
||||
private val AUTO_SCROLL_EDGE = 64.dp
|
||||
|
||||
@@ -126,6 +129,21 @@ data class TimelineDrag(
|
||||
val slot: Triple<LocalDate, Int, Int> get() = Triple(date, startMin, eventSpanMin)
|
||||
}
|
||||
|
||||
/**
|
||||
* An event lifted off the all-day strip: held over the strip it moves [dayShift]
|
||||
* days and stays whole-day, held over the time grid it becomes a timed block.
|
||||
*/
|
||||
data class StripDrag(val event: EventInstance, val dayShift: Int = 0, val overGrid: Boolean = false)
|
||||
|
||||
/** Where a strip drag asks its event to go. */
|
||||
sealed interface StripDrop {
|
||||
val event: EventInstance
|
||||
|
||||
data class Days(override val event: EventInstance, val days: Int) : StripDrop
|
||||
|
||||
data class Timed(override val event: EventInstance, val start: Instant, val end: Instant) : StripDrop
|
||||
}
|
||||
|
||||
/** The event a long press left selected (#141). */
|
||||
data class TimelineSelection(
|
||||
val event: EventInstance,
|
||||
@@ -249,9 +267,10 @@ internal fun dragPieceBounds(
|
||||
): DragPieceBounds {
|
||||
val top = if (slice.continuesBefore) 0f else insetPx
|
||||
val bottom = if (slice.continuesAfter) 0f else insetPx
|
||||
val full = maxOf(slice.spanMin / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
|
||||
val span = renderedSpan(slice.startMin, slice.startMin + slice.spanMin, slice.continuesAfter)
|
||||
val full = (span.bottomMin - span.topMin) / 60f * hourPx
|
||||
return DragPieceBounds(
|
||||
top = slice.startMin / 60f * hourPx + top,
|
||||
top = span.topMin / 60f * hourPx + top,
|
||||
height = (full - top - bottom).coerceAtLeast(0f),
|
||||
)
|
||||
}
|
||||
@@ -309,12 +328,13 @@ internal fun draftStartMin(minutes: Float): Int =
|
||||
internal fun draftBlock(draft: EventInstance, date: LocalDate, zone: TimeZone): TimedBlock? {
|
||||
// Clipped to the day the way the grid lays out events, so it's drawn the same across a DST change (#387).
|
||||
// The end is exclusive, so a draft ending at midnight belongs to the day before.
|
||||
val dayEnd = date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||
val start = maxOf(draft.start, date.atStartOfDayIn(zone))
|
||||
val end = minOf(draft.end, date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone))
|
||||
val end = minOf(draft.end, dayEnd)
|
||||
if (end <= start) return null
|
||||
val startMin = wallClockMinutes(start, date, zone)
|
||||
val endMin = wallClockEndMinutes(startMin, start, end, date, zone).coerceAtMost(MINUTES_PER_DAY)
|
||||
return TimedBlock(draft, startMin, endMin, lane = 0, laneCount = 1)
|
||||
return TimedBlock(draft, startMin, endMin, lane = 0, laneCount = 1, continuesAfter = draft.end > dayEnd)
|
||||
}
|
||||
|
||||
/** Which end of an event a resize handle moves (#141). */
|
||||
@@ -629,6 +649,28 @@ class TimelineDragController {
|
||||
/** The resize in progress, or null for a plain drag (#141). */
|
||||
private var resize: ResizeSession? = null
|
||||
|
||||
/** The strip drag in progress; over the grid it drives [drag] through a timed stand-in. */
|
||||
var stripDrag: StripDrag? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/** A strip drop held at its new days until the strip redraws the event there. */
|
||||
var stripSettling: StripDrag? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/** Whether the strip now draws [stripSettling] at its new days itself. */
|
||||
var stripSeated: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/** What [abandonedTicks] stood at when [stripSettling] was let go. */
|
||||
var stripAbandonedMark: Int = 0
|
||||
private set
|
||||
|
||||
/** The shown day the finger picked the strip bar up on. */
|
||||
private var stripGrabIndex = 0
|
||||
|
||||
/** Whether a strip drag over the grid has cleared its top edge, and may now scroll up. */
|
||||
private var stripScrollArmed = false
|
||||
|
||||
fun begin(
|
||||
block: TimedBlock,
|
||||
clipOffsetMin: Int,
|
||||
@@ -667,6 +709,69 @@ class TimelineDragController {
|
||||
begin(block, clipOffsetMin = 0, zone, titleLines = 1, pointerInRoot = pointerInRoot, blockInRoot = blockInRoot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift [event] off the all-day strip. Over the time grid it is dragged as a
|
||||
* block [draftMinutes] long, whose top sits a little above the finger.
|
||||
*/
|
||||
fun beginStrip(event: EventInstance, zone: TimeZone, pointerInRoot: Offset) {
|
||||
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
|
||||
if (geometry.columnWidthPx <= 0f || geometry.days.isEmpty()) return
|
||||
deselect()
|
||||
if (stripSettling?.event?.instanceId == event.instanceId) releaseStrip()
|
||||
stripScrollArmed = false
|
||||
val timed = event.copy(isAllDay = false, end = event.start + draftMinutes.minutes)
|
||||
source = TimedBlock(timed, 0, draftMinutes, lane = 0, laneCount = 1)
|
||||
clipOffsetMin = 0
|
||||
dragZone = zone
|
||||
dragDuration = draftMinutes.minutes
|
||||
dragKept = null
|
||||
titleLines = 1
|
||||
settling = null
|
||||
isDragging = true
|
||||
liftedInstanceId = event.instanceId
|
||||
grab = Offset(0f, geometry.hourPx * STRIP_GRAB_MINUTES / 60f)
|
||||
pointer = pointerInRoot
|
||||
pickupPointer = pointerInRoot
|
||||
pointerMoved = false
|
||||
originSlot = null
|
||||
stripGrabIndex = dayIndexAt(pointerInRoot.x, grid.positionInRoot().x)
|
||||
stripDrag = StripDrag(event)
|
||||
}
|
||||
|
||||
/** The shown day under [x], clamped to the days on screen. */
|
||||
private fun dayIndexAt(x: Float, originX: Float): Int =
|
||||
column(((x - originX) / geometry.columnWidthPx).toInt().coerceIn(0, geometry.days.lastIndex))
|
||||
|
||||
/** End a strip drag, handing back where it landed — null when it went nowhere. */
|
||||
fun finishStrip(): StripDrop? {
|
||||
val held = stripDrag ?: return null
|
||||
val landed = drag
|
||||
val zone = dragZone
|
||||
cancel()
|
||||
if (held.overGrid && landed != null && zone != null && !landed.edgeOnly) {
|
||||
settle(landed)
|
||||
val start = TimelineDrop(landed.event, landed.date, landed.startMin).startInstant(zone)
|
||||
return StripDrop.Timed(held.event, start, start + draftMinutes.minutes)
|
||||
}
|
||||
if (held.overGrid || held.dayShift == 0) return null
|
||||
stripSettling = held
|
||||
stripSeated = false
|
||||
stripAbandonedMark = abandonedTicks?.value ?: 0
|
||||
drops++
|
||||
return StripDrop.Days(held.event, held.dayShift)
|
||||
}
|
||||
|
||||
/** Let the held strip drop go, once the strip shows the event at its new days or the move failed. */
|
||||
fun releaseStrip() {
|
||||
stripSettling = null
|
||||
stripSeated = false
|
||||
}
|
||||
|
||||
/** Note that the strip draws the settling drop at its new days. */
|
||||
fun seatStrip() {
|
||||
if (stripSettling != null) stripSeated = true
|
||||
}
|
||||
|
||||
fun move(pointerInRoot: Offset) {
|
||||
pointer = pointerInRoot
|
||||
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
|
||||
@@ -675,6 +780,7 @@ class TimelineDragController {
|
||||
|
||||
fun cancel() {
|
||||
resize = null
|
||||
stripDrag = null
|
||||
source = null
|
||||
clipOffsetMin = 0
|
||||
dragZone = null
|
||||
@@ -755,12 +861,16 @@ class TimelineDragController {
|
||||
|
||||
/** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */
|
||||
private fun settle(landed: TimelineDrag, end: Instant? = null) {
|
||||
// Landed on midnight to midnight, the event moves to the all-day strip (#296).
|
||||
val leavesGrid = !landed.event.isDraft && landed.eventSpanMin > 0 &&
|
||||
landed.eventStartMin.mod(MINUTES_PER_DAY) == 0 &&
|
||||
landed.eventSpanMin % MINUTES_PER_DAY == 0
|
||||
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
|
||||
// the whole settle timeout, long after the event moved off-screen.
|
||||
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
|
||||
settling = if (landed.edgeOnly || leavesGrid) landed.copy(pieces = emptyList()) else landed
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledOrigin = landed.event.eventId to landed.event.start
|
||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||
@@ -795,7 +905,9 @@ class TimelineDragController {
|
||||
val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY))
|
||||
session.column = edgeIndex.takeIf { it >= 0 }
|
||||
?.let { column(it) }
|
||||
session.grabMin = (pointerResizeMinutes(session) ?: 0f) -
|
||||
// The top handle sits on the drawn top, which a short block near midnight lifts (#483).
|
||||
val lift = if (edge == ResizeEdge.Top) block.startMin - block.renderedSpan().topMin else 0f
|
||||
session.grabMin = (pointerResizeMinutes(session) ?: 0f) + lift -
|
||||
if (edge == ResizeEdge.Top) session.startMin else session.endMin
|
||||
originSlot = null
|
||||
recompute()
|
||||
@@ -855,7 +967,9 @@ class TimelineDragController {
|
||||
session.column = held
|
||||
val dayOffset = (days[column(held)].toEpochDays() - session.day.toEpochDays()).toInt()
|
||||
session.edgeDay = dayOffset
|
||||
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
|
||||
// Held to its column's midnight: the clearance under it is not the next day.
|
||||
val minutes = ((pointer.y - origin.y) / hourPx * 60f).coerceAtMost(MINUTES_PER_DAY.toFloat())
|
||||
return dayOffset * MINUTES_PER_DAY + minutes
|
||||
}
|
||||
|
||||
private fun recomputeResize(session: ResizeSession) {
|
||||
@@ -928,6 +1042,29 @@ class TimelineDragController {
|
||||
recomputeResize(it)
|
||||
return
|
||||
}
|
||||
stripDrag?.let {
|
||||
recomputeStrip(it)
|
||||
return
|
||||
}
|
||||
recomputeDrag()
|
||||
}
|
||||
|
||||
/** Over the strip the bar moves by whole days; past its bottom edge it is a timed block. */
|
||||
private fun recomputeStrip(held: StripDrag) {
|
||||
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
|
||||
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return
|
||||
if (geometry.columnWidthPx <= 0f || geometry.days.isEmpty()) return
|
||||
if (pointer.y >= viewport.boundsInRoot().top) {
|
||||
stripDrag = held.copy(overGrid = true)
|
||||
recomputeDrag()
|
||||
} else {
|
||||
drag = null
|
||||
val shift = dayIndexAt(pointer.x, grid.positionInRoot().x) - stripGrabIndex
|
||||
stripDrag = held.copy(dayShift = shift, overGrid = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun recomputeDrag() {
|
||||
val block = source ?: return
|
||||
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
|
||||
val hourPx = geometry.hourPx
|
||||
@@ -1013,11 +1150,18 @@ class TimelineDragController {
|
||||
}
|
||||
|
||||
private fun edgeStep(): Float {
|
||||
val strip = stripDrag
|
||||
if (strip?.overGrid == false) return 0f
|
||||
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return 0f
|
||||
val edge = geometry.edgePx
|
||||
if (edge <= 0f) return 0f
|
||||
val bounds = viewport.boundsInRoot()
|
||||
val fromTop = pointer.y - bounds.top
|
||||
// Coming down out of the strip the finger starts inside the top edge.
|
||||
if (strip != null && !stripScrollArmed) {
|
||||
if (fromTop < edge) return 0f
|
||||
stripScrollArmed = true
|
||||
}
|
||||
val fromBottom = bounds.bottom - pointer.y
|
||||
return when {
|
||||
fromTop < edge -> -(edge - fromTop) / edge * geometry.stepPx
|
||||
@@ -1077,10 +1221,6 @@ fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int {
|
||||
fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean =
|
||||
event.start < day.atStartOfDayIn(zone)
|
||||
|
||||
/** True when the event runs past [day]'s midnight, so this block is cut there. */
|
||||
fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
|
||||
event.end > day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||
|
||||
/**
|
||||
* The instant a drop asks for — the *event's* new start, so the tail of an
|
||||
* overnight event drops back onto the day its own start belongs to. Read in the
|
||||
@@ -1177,7 +1317,7 @@ 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, controller.drops)
|
||||
DragSnapHaptics(controller.drag?.slot ?: controller.stripDrag?.dayShift, controller.drops)
|
||||
// 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) {
|
||||
@@ -1201,6 +1341,15 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
controller.release()
|
||||
}
|
||||
|
||||
// The same hold for a bar moved along the all-day strip.
|
||||
LaunchedEffect(controller.stripSettling, moveInFlight, controller.stripSeated, abandoned) {
|
||||
if (controller.stripSettling == null || moveInFlight) return@LaunchedEffect
|
||||
if (abandoned == controller.stripAbandonedMark) {
|
||||
delay(if (controller.stripSeated) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
|
||||
}
|
||||
controller.releaseStrip()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
|
||||
@@ -43,14 +43,21 @@ data class TimelineHours(
|
||||
|
||||
/**
|
||||
* The scroll offset that puts [startHour] at the top of a timeline laid out
|
||||
* with [maxValue] scroll range over a [viewportPx] viewport, or null while that
|
||||
* with [maxValue] scroll range over a [viewportPx] viewport, [clearancePx] of
|
||||
* which is the empty inset under midnight, or null while that
|
||||
* layout is not yet at [hourPx] per hour — the scale changes a frame before the
|
||||
* content grows to match it, and scrolling against the old layout would land on
|
||||
* the wrong hour.
|
||||
*/
|
||||
internal fun startHourScroll(maxValue: Int, viewportPx: Int, startHour: Int, hourPx: Float): Int? {
|
||||
internal fun startHourScroll(
|
||||
maxValue: Int,
|
||||
viewportPx: Int,
|
||||
startHour: Int,
|
||||
hourPx: Float,
|
||||
clearancePx: Int = 0,
|
||||
): Int? {
|
||||
if (viewportPx <= 0 || maxValue < 0 || maxValue == Int.MAX_VALUE) return null
|
||||
val laidOutHourPx = (maxValue + viewportPx) / 24f
|
||||
val laidOutHourPx = (maxValue + viewportPx - clearancePx) / 24f
|
||||
if (abs(laidOutHourPx - hourPx) > 1f) return null
|
||||
return (startHour * laidOutHourPx).roundToInt().coerceIn(0, maxValue)
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
|
||||
/**
|
||||
* How tall one hour is drawn in the week and day timelines (#56).
|
||||
@@ -59,8 +61,9 @@ sealed interface TimelineScale {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hour height for this scale. [viewportHeight] is the visible height of the
|
||||
* scrolling timeline and is only consulted by [TimelineScale.FitDay].
|
||||
* Hour height for this scale. [viewportHeight] is the height the day is fitted
|
||||
* to, the scrolling timeline's [dayFitHeight], and is not consulted by the
|
||||
* fixed presets.
|
||||
*
|
||||
* The fit-day result is clamped: below [FIT_DAY_MIN] the 24 gutter labels stop
|
||||
* being legible, and above [FIT_DAY_MAX] a short landscape day would stretch its
|
||||
@@ -85,6 +88,13 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
|
||||
.coerceAtLeast(fillHourHeight(viewportHeight))
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of a [viewportHeight]-tall timeline the day is fitted to: the
|
||||
* [FabClearance] under midnight, which lets the last hour scroll clear of the
|
||||
* FAB (#483), is left out. Pass this, not the raw viewport, to [hourHeight].
|
||||
*/
|
||||
fun dayFitHeight(viewportHeight: Dp): Dp = (viewportHeight - FabClearance).coerceAtLeast(0.dp)
|
||||
|
||||
/**
|
||||
* The minute of the day a tap [offsetY] px down a timeline column means, snapped
|
||||
* to the hour it landed in.
|
||||
@@ -105,6 +115,28 @@ fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
|
||||
*/
|
||||
const val MIN_EVENT_FRACTION = 26f / 60f
|
||||
|
||||
/** Where a timed block is drawn down its day, in minutes from midnight. */
|
||||
data class RenderedSpan(val topMin: Float, val bottomMin: Float)
|
||||
|
||||
/**
|
||||
* Where a block of [startMin]..[endMin] is drawn, at least [MIN_EVENT_FRACTION]
|
||||
* of an hour tall. A short block near midnight rises until that floor ends at
|
||||
* midnight instead of running off the day, unless it [continuesAfter] into the
|
||||
* next one (#483).
|
||||
*/
|
||||
fun renderedSpan(startMin: Int, endMin: Int, continuesAfter: Boolean): RenderedSpan {
|
||||
val floor = MIN_EVENT_FRACTION * 60f
|
||||
val bottom = maxOf(endMin.toFloat(), startMin + floor)
|
||||
return if (continuesAfter || bottom <= MINUTES_PER_DAY) {
|
||||
RenderedSpan(startMin.toFloat(), bottom)
|
||||
} else {
|
||||
RenderedSpan(MINUTES_PER_DAY - floor, MINUTES_PER_DAY.toFloat())
|
||||
}
|
||||
}
|
||||
|
||||
/** This block's [renderedSpan]. */
|
||||
fun TimedBlock.renderedSpan(): RenderedSpan = renderedSpan(startMin, endMin, continuesAfter)
|
||||
|
||||
/** Smallest hour height [TimelineScale.FitDay] will resolve to. */
|
||||
val FIT_DAY_MIN = 24.dp
|
||||
|
||||
|
||||
@@ -32,16 +32,21 @@ val LocalTimelineScroll = staticCompositionLocalOf<TimelineScrollMemory?> { null
|
||||
|
||||
/** A timeline's scroll, and where it opens before it can scroll there. */
|
||||
@Stable
|
||||
class TimelineScroll(val state: ScrollState, noonPending: Boolean, private val openingHour: Int?) {
|
||||
class TimelineScroll(
|
||||
val state: ScrollState,
|
||||
noonPending: Boolean,
|
||||
private val openingHour: Int?,
|
||||
private val clearancePx: Float = 0f,
|
||||
) {
|
||||
/** Whether it is still to be put on noon, which it only can be once laid out. */
|
||||
var noonPending by mutableStateOf(noonPending)
|
||||
internal set
|
||||
|
||||
/** The top of what it shows, or will once open, over [content] pixels in a [viewport] pixels high. */
|
||||
/** The top of what it shows, or will once open, over [content] pixels of day in a [viewport] pixels high. */
|
||||
fun top(viewport: Float, content: Float): Float =
|
||||
if (noonPending) {
|
||||
(openingHour?.let { it * content / 24f } ?: (content - viewport) / 2f)
|
||||
.coerceIn(0f, (content - viewport).coerceAtLeast(0f))
|
||||
.coerceIn(0f, (content + clearancePx - viewport).coerceAtLeast(0f))
|
||||
} else state.value.toFloat()
|
||||
}
|
||||
|
||||
@@ -58,6 +63,7 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
|
||||
val density = LocalDensity.current
|
||||
val zoom = LocalTimelineZoom.current
|
||||
val topPx = with(density) { timelineTop.toPx() }
|
||||
val clearancePx = with(density) { FabClearance.roundToPx() }
|
||||
val remembered = memory?.midnightY
|
||||
val scrollState = rememberScrollState(
|
||||
initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0,
|
||||
@@ -67,6 +73,7 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
|
||||
scrollState,
|
||||
noonPending = remembered == null,
|
||||
openingHour = zoom.hours.takeIf { it.enabled }?.startHour,
|
||||
clearancePx = clearancePx.toFloat(),
|
||||
)
|
||||
}
|
||||
val currentTop by rememberUpdatedState(topPx)
|
||||
@@ -80,20 +87,20 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
|
||||
val startHour = zoom.open()
|
||||
if (startHour == null) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||
scrollState.scrollTo(((scrollState.maxValue - clearancePx) / 2).coerceAtLeast(0))
|
||||
} else {
|
||||
// Fitted to the scroll viewport itself: the layout resolves scales
|
||||
// against the viewport plus the all-day strip, which would push the
|
||||
// end hour below the screen.
|
||||
val viewport = snapshotFlow { scrollState.viewportSize }.first { it > 0 }
|
||||
val hourHeight = zoom.scale.hourHeight(with(density) { viewport.toDp() })
|
||||
val hourHeight = zoom.scale.hourHeight(dayFitHeight(with(density) { viewport.toDp() }))
|
||||
zoom.holdFit(hourHeight)
|
||||
val hourPx = with(density) { hourHeight.toPx() }
|
||||
val target = withTimeoutOrNull(FIT_LAYOUT_TIMEOUT_MILLIS) {
|
||||
snapshotFlow {
|
||||
startHourScroll(scrollState.maxValue, scrollState.viewportSize, startHour, hourPx)
|
||||
startHourScroll(scrollState.maxValue, scrollState.viewportSize, startHour, hourPx, clearancePx)
|
||||
}.filterNotNull().first()
|
||||
} ?: (startHour * (scrollState.maxValue + scrollState.viewportSize) / 24)
|
||||
} ?: (startHour * (scrollState.maxValue + scrollState.viewportSize - clearancePx) / 24)
|
||||
.coerceIn(0, scrollState.maxValue.coerceAtLeast(0))
|
||||
scrollState.scrollTo(target)
|
||||
}
|
||||
|
||||
@@ -118,7 +118,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.canSaveAsOccurrence
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
@@ -452,12 +451,12 @@ fun EventEditScreen(
|
||||
|
||||
if (state?.saveState == SaveUiState.AwaitingScope) {
|
||||
// A changed rule rules out the single occurrence — an exception row
|
||||
// can't carry its own rule — and so does a cancelled one (#388).
|
||||
// can't carry its own rule.
|
||||
RecurringScopeDialog(
|
||||
title = stringResource(R.string.event_edit_recurring_title),
|
||||
onSelect = viewModel::saveWithScope,
|
||||
onDismiss = viewModel::consumeSaveResult,
|
||||
allowOccurrence = state?.recurrenceChanged != true && state?.form?.canSaveAsOccurrence() != false,
|
||||
allowOccurrence = state?.recurrenceChanged != true,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
||||
import de.jeanlucmakiola.calendula.domain.canSaveAsOccurrence
|
||||
import de.jeanlucmakiola.calendula.domain.canSaveApartFromSeries
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
@@ -770,7 +770,10 @@ class EventEditViewModel @Inject constructor(
|
||||
// Managed events are a yearly series whose editable fields (reminders,
|
||||
// notes, location) live on the series row — never offer the scope dialog,
|
||||
// which would split the series into an exception the sync then reverts.
|
||||
if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar) {
|
||||
// A cancelled one only saves as the whole series (#388).
|
||||
if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar &&
|
||||
form.canSaveApartFromSeries()
|
||||
) {
|
||||
_saveState.value = SaveUiState.AwaitingScope
|
||||
return
|
||||
}
|
||||
@@ -781,7 +784,6 @@ class EventEditViewModel @Inject constructor(
|
||||
fun saveWithScope(scope: RecurringWriteScope) {
|
||||
val current = state.value ?: return
|
||||
if (current.saveState != SaveUiState.AwaitingScope) return
|
||||
if (scope == RecurringWriteScope.ThisEvent && !current.form.canSaveAsOccurrence()) return
|
||||
performSave(current.form, scope)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,17 +2,28 @@ package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth
|
||||
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWidestTimeSample
|
||||
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
||||
import kotlinx.datetime.TimeZone
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The start time a chip shows before its title (#219), or null when it has none
|
||||
* to show: an all-day chip never carries a time, and neither does a bar carried
|
||||
* in from an earlier week, whose start is not in this segment.
|
||||
* to show: a chip filling whole days never carries a time (#296), and neither
|
||||
* does a bar carried in from an earlier week, whose start is not in this segment.
|
||||
*/
|
||||
internal fun monthChipTime(
|
||||
event: EventInstance,
|
||||
@@ -21,11 +32,75 @@ internal fun monthChipTime(
|
||||
is24Hour: Boolean,
|
||||
locale: Locale,
|
||||
): String? {
|
||||
if (event.isAllDay || continuesLeft) return null
|
||||
if (event.coversWholeDays(zone) || continuesLeft) return null
|
||||
return formatTimeOfDay(event.start, zone, is24Hour, locale)
|
||||
}
|
||||
|
||||
/** The narrowest chip that may show a time — the text it needs, plus its chrome. */
|
||||
/** How many characters of a longer title a chip must keep beside its start time (#476). */
|
||||
internal const val MONTH_TITLE_MIN_CHARS = 12
|
||||
|
||||
/**
|
||||
* The part of [title] that has to stay readable beside a start time: all of it,
|
||||
* or its first [MONTH_TITLE_MIN_CHARS] characters, counted in code points so an
|
||||
* emoji is never cut in half.
|
||||
*/
|
||||
internal fun monthTitleToKeep(title: String): String {
|
||||
if (title.codePointCount(0, title.length) <= MONTH_TITLE_MIN_CHARS) return title
|
||||
return title.substring(0, title.offsetByCodePoints(0, MONTH_TITLE_MIN_CHARS))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a chip shows its start time (#219, #476): only with the setting
|
||||
* [enabled], and only when [availableWidth] (the chip less its chrome) holds
|
||||
* [labelWidth], the widest time followed by [monthTitleToKeep] of the title.
|
||||
*/
|
||||
internal fun monthChipShowsTime(
|
||||
enabled: Boolean,
|
||||
availableWidth: Float,
|
||||
labelWidth: Float,
|
||||
): Boolean = enabled && labelWidth <= availableWidth
|
||||
|
||||
/**
|
||||
* Prices month chips' time labels for [monthChipShowsTime] in the chips' own
|
||||
* text style. One per grid; each title is measured once for its lifetime.
|
||||
*/
|
||||
@Stable
|
||||
internal class MonthChipTimeFit(
|
||||
private val measurer: TextMeasurer,
|
||||
private val style: TextStyle,
|
||||
private val density: Density,
|
||||
private val timeSample: String,
|
||||
) {
|
||||
private val chromePx = with(density) { MONTH_CHIP_CHROME.toPx() }
|
||||
private val labelWidths = HashMap<String, Int>()
|
||||
|
||||
fun showsTime(chipWidth: Dp, title: String): Boolean {
|
||||
if (labelWidths.size >= MAX_CACHED_TITLES) labelWidths.clear()
|
||||
val labelWidth = labelWidths.getOrPut(title) {
|
||||
val label = inlineTimeLabel(timeSample, monthTitleToKeep(title), Color.Unspecified)
|
||||
measurer.measure(label, style, softWrap = false, maxLines = 1).size.width
|
||||
}
|
||||
return monthChipShowsTime(
|
||||
enabled = true,
|
||||
availableWidth = with(density) { chipWidth.toPx() } - chromePx,
|
||||
labelWidth = labelWidth.toFloat(),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_CACHED_TITLES = 1000
|
||||
}
|
||||
}
|
||||
|
||||
/** The grid's [MonthChipTimeFit], or null when start times are switched off (#476). */
|
||||
@Composable
|
||||
internal fun rememberMonthTimeChipWidth(): Dp =
|
||||
rememberInlineTimeWidth(MaterialTheme.typography.labelSmall) + MONTH_CHIP_CHROME
|
||||
internal fun rememberMonthChipTimeFit(showStartTimes: Boolean): MonthChipTimeFit? {
|
||||
if (!showStartTimes) return null
|
||||
val measurer = rememberTextMeasurer(cacheSize = 0)
|
||||
val style = MaterialTheme.typography.labelSmall.trimmedLines()
|
||||
val density = LocalDensity.current
|
||||
val timeSample = rememberWidestTimeSample()
|
||||
return remember(measurer, style, density, timeSample) {
|
||||
MonthChipTimeFit(measurer, style, density, timeSample)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,10 @@ internal sealed interface MonthMorphKey {
|
||||
|
||||
data class DayNumber(val date: LocalDate) : MonthMorphKey
|
||||
|
||||
data class WeekNumber(val weekStart: LocalDate) : MonthMorphKey
|
||||
|
||||
data class CellRipple(val date: LocalDate) : MonthMorphKey
|
||||
|
||||
/**
|
||||
* One event *on one day*. Keyed by date as well as instance because a
|
||||
* multi-day event has a dot on every day it covers but only one bar, drawn
|
||||
@@ -111,8 +115,8 @@ internal fun morphInFlight(): Boolean {
|
||||
*
|
||||
* Rendering in place puts everything back in one z-order and one clip, which is
|
||||
* what makes the grid read as a single surface changing shape rather than a
|
||||
* stack of pieces sliding past each other. Nothing here needs to escape its
|
||||
* ancestors: the cells, marks and markers all travel within the grid.
|
||||
* stack of pieces sliding past each other. Event marks are the exception: they
|
||||
* render in the overlay so the expanding day backgrounds cannot cover them.
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
@@ -141,6 +145,7 @@ internal fun Modifier.morphElement(key: MonthMorphKey): Modifier {
|
||||
@Composable
|
||||
internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
|
||||
val morph = LocalMonthMorph.current ?: return this
|
||||
val isEvent = key is MonthMorphKey.Event
|
||||
return with(morph.shared) {
|
||||
this@morphBounds.sharedBounds(
|
||||
sharedContentState = rememberSharedContentState(key),
|
||||
@@ -148,7 +153,8 @@ internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
||||
renderInOverlayDuringTransition = false,
|
||||
renderInOverlayDuringTransition = isEvent,
|
||||
zIndexInOverlay = if (isEvent) 1f else 0f,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,12 @@ package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.indication
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.animation.EnterExitState
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
@@ -12,6 +17,7 @@ import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaDays
|
||||
import de.jeanlucmakiola.calendula.ui.common.FabClearance
|
||||
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
|
||||
@@ -62,6 +68,7 @@ import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
@@ -156,10 +163,9 @@ 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.agenda.AgendaDayHeader
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaDayLabel
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaDayLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
@@ -278,6 +284,7 @@ fun MonthScreen(
|
||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||
val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle()
|
||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||
val showStartTimes by viewModel.showStartTimes.collectAsStateWithLifecycle()
|
||||
// The instant before which an event counts as completed, or null when dimming
|
||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||
// screen while the setting is off (it stays null regardless of the tick).
|
||||
@@ -525,17 +532,20 @@ fun MonthScreen(
|
||||
) {
|
||||
if (scrolling) {
|
||||
ContinuousMonthContent(
|
||||
state = continuousState,
|
||||
// Held on the skeleton until the start-time setting has
|
||||
// loaded, so chips don't redraw without their times.
|
||||
state = if (showStartTimes == null) ContinuousMonthUiState.Loading else continuousState,
|
||||
listState = listState,
|
||||
dense = dense,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
showStartTimes = showStartTimes == true,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
} else {
|
||||
SplitMonthContent(
|
||||
state = state,
|
||||
state = if (showStartTimes == null) MonthUiState.Loading else state,
|
||||
selected = selectedDate,
|
||||
initiallyExpanded = viewStyle == MonthViewStyle.Paged,
|
||||
expanded = splitExpanded,
|
||||
@@ -544,6 +554,7 @@ fun MonthScreen(
|
||||
weekStart = weekStart,
|
||||
monthState = viewModel::month,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
showStartTimes = showStartTimes == true,
|
||||
showToday = showToday,
|
||||
onPaneSwipeNext = { viewModel.stepSplitPane(1) },
|
||||
onPaneSwipePrev = { viewModel.stepSplitPane(-1) },
|
||||
@@ -565,7 +576,7 @@ fun MonthScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
MonthDragOverlay(chipDrag)
|
||||
MonthDragOverlay(chipDrag, showStartTimes = showStartTimes == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -578,11 +589,11 @@ private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L
|
||||
|
||||
/** The chip in flight, drawn over the grid and following the finger. */
|
||||
@Composable
|
||||
private fun MonthDragOverlay(controller: MonthDragController) {
|
||||
private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Boolean) {
|
||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
val density = LocalDensity.current
|
||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||
val timeFit = rememberMonthChipTimeFit(showStartTimes)
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val moveInFlight = moveInFlight()
|
||||
// Here rather than beside the controller: this reads [drag], which changes
|
||||
@@ -644,7 +655,8 @@ private fun MonthDragOverlay(controller: MonthDragController) {
|
||||
// cut-out of it has the room — which a slice of a multi-day bar has
|
||||
// not, however wide the bar was.
|
||||
time = drag.time,
|
||||
showTime = with(density) { drag.sizePx.width.toDp() } >= timeChipWidth,
|
||||
timeFit = timeFit,
|
||||
chipWidth = with(density) { drag.sizePx.width.toDp() },
|
||||
modifier = Modifier
|
||||
// Absolute: these are root coordinates, and the direction-aware
|
||||
// offset would mirror them across the screen in an RTL layout.
|
||||
@@ -681,6 +693,7 @@ private fun ContinuousMonthContent(
|
||||
listState: LazyListState,
|
||||
dense: Boolean,
|
||||
showWeekNumbers: Boolean,
|
||||
showStartTimes: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
@@ -697,6 +710,7 @@ private fun ContinuousMonthContent(
|
||||
state = state,
|
||||
listState = listState,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
showStartTimes = showStartTimes,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
@@ -705,6 +719,7 @@ private fun ContinuousMonthContent(
|
||||
state = state,
|
||||
listState = listState,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
showStartTimes = showStartTimes,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
@@ -877,6 +892,7 @@ private val DENSE_HEADER_GAP = 4.dp
|
||||
internal fun MonthGrid(
|
||||
state: MonthUiState.Success,
|
||||
showWeekNumbers: Boolean,
|
||||
showStartTimes: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
/** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */
|
||||
@@ -901,13 +917,13 @@ internal fun MonthGrid(
|
||||
val month = state.month
|
||||
// Once per grid: the value depends on the typography, the density, the
|
||||
// locale and the 24-hour setting, none of which vary by row (#219).
|
||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||
val timeFit = rememberMonthChipTimeFit(showStartTimes)
|
||||
state.weeks.forEach { week ->
|
||||
MonthWeekRow(
|
||||
week = week,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
timeChipWidth = timeChipWidth,
|
||||
timeFit = timeFit,
|
||||
rowHeight = rowHeight,
|
||||
inMonth = { it.month == month.month && it.year == month.year },
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
@@ -943,18 +959,19 @@ internal fun ContinuousMonthGrid(
|
||||
state: ContinuousMonthUiState.Success,
|
||||
listState: LazyListState,
|
||||
showWeekNumbers: Boolean,
|
||||
showStartTimes: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val monthCount = remember { continuousMonthCount() }
|
||||
val todayMonth = remember(state.today) { state.today.yearMonth }
|
||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||
val timeFit = rememberMonthChipTimeFit(showStartTimes)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
contentPadding = PaddingValues(bottom = 96.dp),
|
||||
contentPadding = PaddingValues(bottom = FabClearance),
|
||||
) {
|
||||
repeat(monthCount) { index ->
|
||||
val month = yearMonthForIndex(index)
|
||||
@@ -972,7 +989,7 @@ internal fun ContinuousMonthGrid(
|
||||
weekStart = state.weekStart,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
timeChipWidth = timeChipWidth,
|
||||
timeFit = timeFit,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
@@ -994,7 +1011,7 @@ private fun ContinuousMonthBlock(
|
||||
weekStart: DayOfWeek,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
timeChipWidth: Dp,
|
||||
timeFit: MonthChipTimeFit?,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
@@ -1015,7 +1032,7 @@ private fun ContinuousMonthBlock(
|
||||
week = week,
|
||||
today = today,
|
||||
zone = zone,
|
||||
timeChipWidth = timeChipWidth,
|
||||
timeFit = timeFit,
|
||||
rowHeight = CONTINUOUS_ROW_HEIGHT,
|
||||
inMonth = { it.month == month.month && it.year == month.year },
|
||||
// The block owns its month alone: a day from either
|
||||
@@ -1081,12 +1098,13 @@ internal fun DenseMonthGrid(
|
||||
state: ContinuousMonthUiState.Success,
|
||||
listState: LazyListState,
|
||||
showWeekNumbers: Boolean,
|
||||
showStartTimes: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
|
||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||
val timeFit = rememberMonthChipTimeFit(showStartTimes)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier
|
||||
@@ -1099,7 +1117,7 @@ internal fun DenseMonthGrid(
|
||||
.padding(top = DENSE_HEADER_GAP),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
contentPadding = PaddingValues(bottom = 96.dp),
|
||||
contentPadding = PaddingValues(bottom = FabClearance),
|
||||
) {
|
||||
items(count = weekCount, key = { it }) { index ->
|
||||
val week = state.weeksByIndex[index]
|
||||
@@ -1110,7 +1128,7 @@ internal fun DenseMonthGrid(
|
||||
week = week,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
timeChipWidth = timeChipWidth,
|
||||
timeFit = timeFit,
|
||||
rowHeight = CONTINUOUS_ROW_HEIGHT,
|
||||
// Every day in the stream belongs to a month equally — there
|
||||
// is no "other month" to recede here.
|
||||
@@ -1202,6 +1220,7 @@ private fun SplitMonthContent(
|
||||
weekStart: DayOfWeek,
|
||||
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
|
||||
showWeekNumbers: Boolean,
|
||||
showStartTimes: Boolean,
|
||||
showToday: Boolean,
|
||||
onPaneSwipeNext: () -> Unit,
|
||||
onPaneSwipePrev: () -> Unit,
|
||||
@@ -1212,9 +1231,12 @@ private fun SplitMonthContent(
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateEvent: (LocalDate) -> Unit,
|
||||
) {
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val haptics = LocalHapticFeedback.current
|
||||
// Registered first so the expand handler below wins while the grid is open.
|
||||
BackHandler(enabled = selected != null && !expanded) {
|
||||
onClearSelection()
|
||||
}
|
||||
BackHandler(enabled = expanded != initiallyExpanded) {
|
||||
onExpandedChange(initiallyExpanded)
|
||||
}
|
||||
@@ -1283,8 +1305,9 @@ private fun SplitMonthContent(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
// Both faces fill the same page — the grid grows into exactly the
|
||||
// room the pane gives up — so there is no size change to contain.
|
||||
// The travel between them is the shared elements' job.
|
||||
transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
|
||||
// Fading the faces made the shared cell backgrounds briefly
|
||||
// disappear beneath their incoming counterparts.
|
||||
transitionSpec = { EnterTransition.None togetherWith ExitTransition.None },
|
||||
label = "split-expand",
|
||||
) { isExpanded ->
|
||||
// The view switch's morph stays off a face until it has fully
|
||||
@@ -1315,6 +1338,7 @@ private fun SplitMonthContent(
|
||||
state = page,
|
||||
selected = pageSelected,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
showStartTimes = showStartTimes,
|
||||
expandDrag = expandDrag,
|
||||
onPickDay = onPickDay,
|
||||
// A tapped chip is asking for that event, not for its
|
||||
@@ -1340,6 +1364,9 @@ private fun SplitMonthContent(
|
||||
state = page,
|
||||
paneReady = paneReady,
|
||||
selected = pageSelected,
|
||||
selectionIndicatorVisible = reduceMotion ||
|
||||
(transition.currentState == EnterExitState.Visible &&
|
||||
transition.targetState == EnterExitState.Visible),
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
showToday = showToday,
|
||||
expandDrag = expandDrag,
|
||||
@@ -1383,6 +1410,7 @@ private fun SplitMonthCollapsed(
|
||||
state: MonthUiState.Success,
|
||||
paneReady: Boolean,
|
||||
selected: LocalDate?,
|
||||
selectionIndicatorVisible: Boolean,
|
||||
showWeekNumbers: Boolean,
|
||||
showToday: Boolean,
|
||||
expandDrag: Modifier,
|
||||
@@ -1404,6 +1432,7 @@ private fun SplitMonthCollapsed(
|
||||
SplitMonthGrid(
|
||||
state = state,
|
||||
selected = selected,
|
||||
selectionIndicatorVisible = selectionIndicatorVisible,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onSelectDay = onSelectDay,
|
||||
modifier = Modifier.height(splitGridHeight(state.weeks.size)),
|
||||
@@ -1431,24 +1460,13 @@ private fun SplitMonthCollapsed(
|
||||
.then(paneModifier)
|
||||
.then(rememberSplitPaneSwipe(onPaneSwipeNext, onPaneSwipePrev)),
|
||||
) {
|
||||
InputChip(
|
||||
selected = true,
|
||||
onClick = onClearSelection,
|
||||
label = { Text(agendaDayLabel(selected, state.today)) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.month_split_clear_filter),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
)
|
||||
SplitDayPane(
|
||||
date = selected,
|
||||
today = state.today,
|
||||
events = state.instancesByDay[selected],
|
||||
zone = state.zone,
|
||||
onOpenDay = onOpenDay,
|
||||
onClearSelection = onClearSelection,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
@@ -1495,6 +1513,7 @@ private fun SplitMonthExpanded(
|
||||
/** Marked nowhere here; it only anchors the outline's morph. */
|
||||
selected: LocalDate?,
|
||||
showWeekNumbers: Boolean,
|
||||
showStartTimes: Boolean,
|
||||
expandDrag: Modifier,
|
||||
onPickDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
@@ -1507,6 +1526,7 @@ private fun SplitMonthExpanded(
|
||||
MonthGrid(
|
||||
state = state,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
showStartTimes = showStartTimes,
|
||||
onOpenDay = onPickDay,
|
||||
onEventClick = onEventClick,
|
||||
selected = selected,
|
||||
@@ -1620,6 +1640,7 @@ private val SPLIT_PANE_EASING = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
|
||||
internal fun SplitMonthGrid(
|
||||
state: MonthUiState.Success,
|
||||
selected: LocalDate?,
|
||||
selectionIndicatorVisible: Boolean = true,
|
||||
showWeekNumbers: Boolean,
|
||||
onSelectDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -1665,6 +1686,7 @@ internal fun SplitMonthGrid(
|
||||
// removes that without timing anything against the
|
||||
// page transition.
|
||||
isSelected = day == selected && inMonth,
|
||||
selectionIndicatorVisible = selectionIndicatorVisible,
|
||||
inMonth = inMonth,
|
||||
relativeToToday = todayCol?.let { col - it },
|
||||
rippleProgress = { ripple.value },
|
||||
@@ -1693,6 +1715,7 @@ private fun SplitDayCell(
|
||||
hidden: List<EventInstance>,
|
||||
isToday: Boolean,
|
||||
isSelected: Boolean,
|
||||
selectionIndicatorVisible: Boolean,
|
||||
inMonth: Boolean,
|
||||
relativeToToday: Int?,
|
||||
rippleProgress: () -> Float,
|
||||
@@ -1722,7 +1745,7 @@ private fun SplitDayCell(
|
||||
// pace it lingered after the thing it marks had already moved.
|
||||
val outlineSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||
val selection = animateFloatAsState(
|
||||
targetValue = if (isSelected) 1f else 0f,
|
||||
targetValue = if (isSelected && selectionIndicatorVisible) 1f else 0f,
|
||||
animationSpec = if (reduceMotion) snap() else outlineSpec,
|
||||
label = "split-day-selection",
|
||||
)
|
||||
@@ -1734,12 +1757,19 @@ private fun SplitDayCell(
|
||||
// built — and so the pill can be tagged for the morph on its own. Tagged as
|
||||
// one piece with its contents inside, the dots would be nested shared
|
||||
// elements within a shared element and travel twice.
|
||||
// The cell keeps the click and selection semantics, so TalkBack reads the
|
||||
// day number with them; the topmost layer only draws the ripple.
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.todayRipple(relativeToToday, rippleProgress)
|
||||
.clip(CELL_SHAPE)
|
||||
.selectable(selected = isSelected, onClick = onClick),
|
||||
.selectable(
|
||||
selected = isSelected,
|
||||
interactionSource = interaction,
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Box(
|
||||
@@ -1801,6 +1831,13 @@ private fun SplitDayCell(
|
||||
Spacer(Modifier.height(2.dp))
|
||||
SplitDots(date = date, events = events, hidden = hidden, dark = dark)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.morphElement(MonthMorphKey.CellRipple(date))
|
||||
.clip(CELL_SHAPE)
|
||||
.indication(interaction, LocalIndication.current),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1822,7 +1859,10 @@ private fun SplitDots(
|
||||
if (events.isEmpty()) return
|
||||
val soften = LocalSoftenColors.current
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
// Held to the dots' height so a "+N" doesn't grow the row and push the dots
|
||||
// down; the label overflows it evenly instead (#142).
|
||||
Row(
|
||||
modifier = Modifier.height(SPLIT_DOT_SIZE),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -1857,6 +1897,7 @@ private fun SplitDots(
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.wrapContentHeight(unbounded = true)
|
||||
.morphBounds(MonthMorphKey.Overflow(date))
|
||||
.alpha(if (allEnded(hidden, date, dimCutoff)) EventDimAlpha else 1f),
|
||||
)
|
||||
@@ -1877,10 +1918,10 @@ private fun SplitMonthAgendaPane(
|
||||
}
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val listState = key(state.month) { rememberLazyListState() }
|
||||
LazyColumn(state = listState, contentPadding = PaddingValues(bottom = 96.dp)) {
|
||||
LazyColumn(state = listState, contentPadding = PaddingValues(bottom = FabClearance)) {
|
||||
// A fixed first item, so a day added above the first listed one shows
|
||||
// up rather than landing above a list that holds its scroll position.
|
||||
item(key = "top") { Spacer(Modifier.height(8.dp)) }
|
||||
item(key = "top") { Spacer(Modifier.height(4.dp)) }
|
||||
if (days.isEmpty()) {
|
||||
val start = agendaStartForMonth(state.month, state.today)
|
||||
item(key = "empty") {
|
||||
@@ -1901,6 +1942,7 @@ private fun SplitMonthAgendaPane(
|
||||
dimmed = { event, day -> dimCutoff != null && event.isPastOn(day, dimCutoff) },
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
compact = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1908,7 +1950,8 @@ private fun SplitMonthAgendaPane(
|
||||
/**
|
||||
* The selected day's events, in the agenda's own row vocabulary so the two
|
||||
* surfaces read as one app. The date header opens the full Day view, matching
|
||||
* the Week and Agenda headers (#37).
|
||||
* the Week and Agenda headers (#37). It is shown as the filter chip itself,
|
||||
* whose ✕ clears the day filter back to the whole month (#142).
|
||||
*/
|
||||
@Composable
|
||||
internal fun SplitDayPane(
|
||||
@@ -1918,6 +1961,7 @@ internal fun SplitDayPane(
|
||||
events: List<EventInstance>?,
|
||||
zone: TimeZone,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onClearSelection: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateEvent: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -1929,7 +1973,23 @@ internal fun SplitDayPane(
|
||||
val enter = itemEnter()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
Column(modifier = modifier) {
|
||||
AgendaDayHeader(date = date, today = today, onOpenDay = onOpenDay)
|
||||
InputChip(
|
||||
selected = true,
|
||||
onClick = { onOpenDay(date) },
|
||||
label = {
|
||||
Text(agendaDayLabel(date, today), style = MaterialTheme.typography.titleSmall)
|
||||
},
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.month_split_clear_filter),
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onClearSelection),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
)
|
||||
AnimatedContent(
|
||||
targetState = date to events,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
@@ -1952,7 +2012,7 @@ internal fun SplitDayPane(
|
||||
else -> LazyColumn(
|
||||
// Bottom inset clears the FAB stack so the last row stays
|
||||
// tappable.
|
||||
contentPadding = PaddingValues(bottom = 96.dp),
|
||||
contentPadding = PaddingValues(bottom = FabClearance),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = dayEvents,
|
||||
@@ -2108,8 +2168,8 @@ private fun MonthWeekRow(
|
||||
today: LocalDate,
|
||||
/** The zone the grid was laid out in — never re-read here (see [MonthUiState.Success.zone]). */
|
||||
zone: TimeZone,
|
||||
/** The narrowest chip that may carry a start time, measured once per grid (#219). */
|
||||
timeChipWidth: Dp,
|
||||
/** Prices chips' start times, once per grid; null when they are switched off (#219, #476). */
|
||||
timeFit: MonthChipTimeFit?,
|
||||
/** The height this row was given, which decides its lanes — see [laneCapFor]. */
|
||||
rowHeight: Dp,
|
||||
inMonth: (LocalDate) -> Boolean,
|
||||
@@ -2235,8 +2295,8 @@ private fun MonthWeekRow(
|
||||
),
|
||||
),
|
||||
) {
|
||||
// What a chip has to spend, against the [timeChipWidth] a start time
|
||||
// costs it (#219).
|
||||
// What a chip has to spend, against what [timeFit] says its start
|
||||
// time costs it (#219).
|
||||
val colW = maxWidth / 7
|
||||
// Held here rather than read at the offset: the dots are placed
|
||||
// inside a plain lambda, which is no longer in this scope. The box
|
||||
@@ -2326,7 +2386,8 @@ private fun MonthWeekRow(
|
||||
continuesRight = span.continuesRight,
|
||||
days = week.days.subList(span.startCol, span.endCol + 1),
|
||||
time = chipTimes[span.event.instanceId],
|
||||
showTime = colW * cols >= timeChipWidth,
|
||||
timeFit = timeFit,
|
||||
chipWidth = colW * cols,
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * span.startCol,
|
||||
@@ -2403,7 +2464,8 @@ private fun MonthWeekRow(
|
||||
continuesRight = false,
|
||||
days = listOf(d),
|
||||
time = chipTimes[ev.instanceId],
|
||||
showTime = colW >= timeChipWidth,
|
||||
timeFit = timeFit,
|
||||
chipWidth = colW,
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * col,
|
||||
@@ -2501,6 +2563,7 @@ private fun MonthWeekRow(
|
||||
}
|
||||
}
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.morphElement(MonthMorphKey.CellRipple(d))
|
||||
.clip(CELL_SHAPE)
|
||||
.clickable {
|
||||
// Cleared on read: a click with no fresh down
|
||||
@@ -2651,6 +2714,7 @@ private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.morphElement(MonthMorphKey.WeekNumber(weekStart))
|
||||
.clip(CELL_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||
.then(if (onOpenWeek != null) Modifier.clickable(onClickLabel = openLabel) {
|
||||
@@ -2729,7 +2793,7 @@ private fun shortMonthName(date: LocalDate): String {
|
||||
|
||||
/**
|
||||
* A filled event pill/bar — softened (or raw) fill, title clipped to one line,
|
||||
* with the start time before it where the chip is wide enough ([showTime], #219).
|
||||
* with the start time before it where the chip is wide enough ([timeFit], #219, #476).
|
||||
*/
|
||||
@Composable
|
||||
private fun MonthBar(
|
||||
@@ -2746,10 +2810,13 @@ private fun MonthBar(
|
||||
days: List<LocalDate>? = null,
|
||||
/** The chip's start time, or null where it has none — see `monthChipTime` (#219). */
|
||||
time: String? = null,
|
||||
/** Whether this chip has the width to draw [time]; a screen reader gets it either way. */
|
||||
showTime: Boolean = false,
|
||||
/** Decides whether [time] is drawn at [chipWidth]; null draws none. A screen reader gets it either way. */
|
||||
timeFit: MonthChipTimeFit? = null,
|
||||
/** The chip's full width, chrome included. */
|
||||
chipWidth: Dp = 0.dp,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val showTime = time != null && timeFit != null && timeFit.showsTime(chipWidth, title)
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
// A bar is one piece, so it dims once the last day it covers has passed.
|
||||
val dimmed = dimCutoff != null &&
|
||||
|
||||
@@ -31,6 +31,7 @@ import kotlinx.datetime.yearMonth
|
||||
internal fun MonthStylePreview(
|
||||
style: MonthViewStyle,
|
||||
weekStart: DayOfWeek,
|
||||
showStartTimes: Boolean,
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -44,6 +45,7 @@ internal fun MonthStylePreview(
|
||||
MonthViewStyle.Paged -> MonthGrid(
|
||||
state = sample.month,
|
||||
showWeekNumbers = false,
|
||||
showStartTimes = showStartTimes,
|
||||
onOpenDay = {},
|
||||
onEventClick = {},
|
||||
)
|
||||
@@ -55,6 +57,7 @@ internal fun MonthStylePreview(
|
||||
),
|
||||
),
|
||||
showWeekNumbers = false,
|
||||
showStartTimes = showStartTimes,
|
||||
onOpenDay = {},
|
||||
onEventClick = {},
|
||||
)
|
||||
@@ -67,6 +70,7 @@ internal fun MonthStylePreview(
|
||||
initialFirstVisibleItemIndex = weekIndexOf(today, weekStart) - 1,
|
||||
),
|
||||
showWeekNumbers = false,
|
||||
showStartTimes = showStartTimes,
|
||||
onOpenDay = {},
|
||||
onEventClick = {},
|
||||
)
|
||||
@@ -83,6 +87,7 @@ internal fun MonthStylePreview(
|
||||
events = sample.month.instancesByDay[today].orEmpty(),
|
||||
zone = zone,
|
||||
onOpenDay = {},
|
||||
onClearSelection = {},
|
||||
onEventClick = {},
|
||||
onCreateEvent = {},
|
||||
modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT),
|
||||
|
||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.data.time.currentDate
|
||||
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
||||
@@ -20,7 +21,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarSpan
|
||||
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
|
||||
import de.jeanlucmakiola.calendula.domain.isCrossedOut
|
||||
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||
import de.jeanlucmakiola.calendula.domain.sortedForDisplay
|
||||
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
|
||||
@@ -91,6 +92,16 @@ class MonthViewModel @Inject constructor(
|
||||
initialValue = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether chips may show their start time before the title (#476; display
|
||||
* only). Null until the setting has loaded, so the grid can wait for it.
|
||||
*/
|
||||
val showStartTimes: StateFlow<Boolean?> = settingsPrefs.monthShowStartTimes
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/** How the grid is laid out and navigated (#38, #53). */
|
||||
val viewStyle: StateFlow<MonthViewStyle> = settingsPrefs.monthViewStyle
|
||||
.stateWhileSubscribed(
|
||||
@@ -491,7 +502,7 @@ internal fun layoutCalendarWeek(
|
||||
// crossing midnight into the next week row covers a single day in each, and
|
||||
// counting per row drew it as two unrelated pills (#253).
|
||||
val (bars, singles) = weekEvents.partition { ev ->
|
||||
ev.isAllDay || ev.spansMultipleDays(zone)
|
||||
ev.coversWholeDays(zone) || ev.spansMultipleDays(zone)
|
||||
}
|
||||
val spans = layoutAllDay(bars, days, zone).map { s ->
|
||||
MonthSpan(
|
||||
@@ -531,7 +542,7 @@ internal fun instancesByDay(
|
||||
days.associateWith { day ->
|
||||
instances
|
||||
.filter { it.coversDay(day, zone) }
|
||||
.sortedWith(eventDisplayComparator(zone))
|
||||
.sortedForDisplay(zone)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -220,6 +220,7 @@ class OnboardingViewModel @Inject constructor(
|
||||
prefs.defaultView,
|
||||
prefs.monthViewStyle,
|
||||
prefs.weekStart,
|
||||
prefs.monthShowStartTimes,
|
||||
::ViewChoice,
|
||||
).stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
@@ -464,6 +465,7 @@ data class ViewChoice(
|
||||
val defaultView: CalendarView,
|
||||
val monthViewStyle: MonthViewStyle,
|
||||
val weekStart: WeekStartPref,
|
||||
val monthShowStartTimes: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,6 +63,7 @@ internal fun ViewStep(
|
||||
CalendarView.Month -> MonthStylePreview(
|
||||
style = choice.monthViewStyle,
|
||||
weekStart = weekStart,
|
||||
showStartTimes = choice.monthShowStartTimes,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT)
|
||||
@@ -118,7 +119,12 @@ internal fun MonthStyleStep(
|
||||
navigationIcon = navigationIcon,
|
||||
) {
|
||||
StepPreview(selected = choice.monthViewStyle) { style ->
|
||||
MonthStylePreview(style = style, weekStart = weekStart, height = PREVIEW_HEIGHT)
|
||||
MonthStylePreview(
|
||||
style = style,
|
||||
weekStart = weekStart,
|
||||
showStartTimes = choice.monthShowStartTimes,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
}
|
||||
GroupedHint(stringResource(choice.monthViewStyle.descriptionRes), verticalPadding = 8.dp)
|
||||
options.forEachIndexed { index, style ->
|
||||
|
||||
@@ -94,6 +94,7 @@ import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
@@ -609,7 +610,7 @@ private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString
|
||||
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())
|
||||
}
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val timeText = if (event.isAllDay) {
|
||||
val timeText = if (event.coversWholeDays(TimeZone.currentSystemDefault())) {
|
||||
stringResource(R.string.event_detail_all_day)
|
||||
} else {
|
||||
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
||||
|
||||
@@ -33,6 +33,7 @@ import kotlinx.datetime.DayOfWeek
|
||||
internal fun MonthViewStylePicker(
|
||||
selected: MonthViewStyle,
|
||||
weekStart: DayOfWeek,
|
||||
showStartTimes: Boolean,
|
||||
onSelect: (MonthViewStyle) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
@@ -50,6 +51,7 @@ internal fun MonthViewStylePicker(
|
||||
MonthStylePreview(
|
||||
style = shown,
|
||||
weekStart = weekStart,
|
||||
showStartTimes = showStartTimes,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ data class SettingsUiState(
|
||||
val dimCompletedEvents: Boolean = false,
|
||||
/** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */
|
||||
val showWeekNumbers: Boolean = false,
|
||||
/** Whether Month chips may show start times before their titles (#476). */
|
||||
val monthShowStartTimes: Boolean = true,
|
||||
/** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */
|
||||
val todayButtonInToolbar: Boolean = false,
|
||||
/** Whether events can be dragged to another slot to reschedule them (#68, #173). */
|
||||
|
||||
@@ -101,6 +101,7 @@ class SettingsViewModel @Inject constructor(
|
||||
pastEventDisplay = this[SettingsKeys.pastEventDisplay],
|
||||
dimCompletedEvents = this[SettingsKeys.dimCompletedEvents],
|
||||
showWeekNumbers = this[SettingsKeys.showWeekNumbers],
|
||||
monthShowStartTimes = this[SettingsKeys.monthShowStartTimes],
|
||||
todayButtonInToolbar = this[SettingsKeys.todayButtonInToolbar],
|
||||
dragToReschedule = this[SettingsKeys.dragToReschedule],
|
||||
tapOpensEditor = this[SettingsKeys.tapOpensEditor],
|
||||
@@ -292,6 +293,8 @@ class SettingsViewModel @Inject constructor(
|
||||
|
||||
fun setShowWeekNumbers(enabled: Boolean) = save { setShowWeekNumbers(enabled) }
|
||||
|
||||
fun setMonthShowStartTimes(enabled: Boolean) = save { setMonthShowStartTimes(enabled) }
|
||||
|
||||
fun setTodayButtonInToolbar(enabled: Boolean) = save { setTodayButtonInToolbar(enabled) }
|
||||
|
||||
fun setDragToReschedule(enabled: Boolean) = save { setDragToReschedule(enabled) }
|
||||
|
||||
@@ -160,6 +160,13 @@ internal fun ViewsScreen(
|
||||
checked = state.showWeekNumbers,
|
||||
onCheckedChange = viewModel::setShowWeekNumbers,
|
||||
summary = stringResource(R.string.settings_week_numbers_summary),
|
||||
position = Position.Middle,
|
||||
)
|
||||
GroupedSwitchRow(
|
||||
title = stringResource(R.string.settings_month_start_times),
|
||||
checked = state.monthShowStartTimes,
|
||||
onCheckedChange = viewModel::setMonthShowStartTimes,
|
||||
summary = stringResource(R.string.settings_month_start_times_summary),
|
||||
position = Position.Bottom,
|
||||
)
|
||||
|
||||
@@ -296,6 +303,7 @@ internal fun ViewsScreen(
|
||||
selected = state.monthViewStyle,
|
||||
// The preview is a real grid, so it uses the real week start too.
|
||||
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
||||
showStartTimes = state.monthShowStartTimes,
|
||||
onSelect = viewModel::setMonthViewStyle,
|
||||
onDismiss = { showMonthStyle = false },
|
||||
)
|
||||
@@ -324,6 +332,7 @@ internal fun ViewsScreen(
|
||||
selected = state.weekStart,
|
||||
// The preview is a real grid, so it uses the user's own month style.
|
||||
monthStyle = state.monthViewStyle,
|
||||
showStartTimes = state.monthShowStartTimes,
|
||||
options = WEEK_START_OPTIONS,
|
||||
onSelect = viewModel::setWeekStart,
|
||||
onDismiss = { showWeekStart = false },
|
||||
|
||||
@@ -27,6 +27,7 @@ import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
internal fun WeekStartPicker(
|
||||
selected: WeekStartPref,
|
||||
monthStyle: MonthViewStyle,
|
||||
showStartTimes: Boolean,
|
||||
options: List<WeekStartPref>,
|
||||
onSelect: (WeekStartPref) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
@@ -46,6 +47,7 @@ internal fun WeekStartPicker(
|
||||
MonthStylePreview(
|
||||
style = monthStyle,
|
||||
weekStart = day,
|
||||
showStartTimes = showStartTimes,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.StripDrop
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineResizeDrop
|
||||
@@ -25,6 +26,7 @@ internal class TimelineActions(
|
||||
val onCreateAt: (LocalDate, Int) -> Unit,
|
||||
val onDrop: (TimelineDrop) -> Unit,
|
||||
val onResize: (TimelineResizeDrop) -> Unit,
|
||||
val onStripDrop: (StripDrop) -> Unit,
|
||||
)
|
||||
|
||||
/** The callbacks are read through their latest values, so the holder outlives them. */
|
||||
@@ -37,12 +39,14 @@ internal fun rememberTimelineActions(
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
onResize: (TimelineResizeDrop) -> Unit,
|
||||
onStripDrop: (StripDrop) -> Unit,
|
||||
): TimelineActions {
|
||||
val eventClick = rememberUpdatedState(onEventClick)
|
||||
val openDay = rememberUpdatedState(onOpenDay)
|
||||
val createAt = rememberUpdatedState(onCreateAt)
|
||||
val drop = rememberUpdatedState(onDrop)
|
||||
val resize = rememberUpdatedState(onResize)
|
||||
val stripDrop = rememberUpdatedState(onStripDrop)
|
||||
return remember(scrollState, dragController) {
|
||||
TimelineActions(
|
||||
scrollState = scrollState,
|
||||
@@ -52,6 +56,7 @@ internal fun rememberTimelineActions(
|
||||
onCreateAt = { date, minutes -> createAt.value(date, minutes) },
|
||||
onDrop = { drop.value(it) },
|
||||
onResize = { resize.value(it) },
|
||||
onStripDrop = { stripDrop.value(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -106,9 +106,16 @@ 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.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.ui.layout.ParentDataModifier
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
@@ -125,6 +132,9 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.dateZone
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventPaint
|
||||
@@ -147,10 +157,11 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.StripDrag
|
||||
import de.jeanlucmakiola.calendula.ui.common.StripDrop
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
||||
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
|
||||
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
|
||||
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
|
||||
import de.jeanlucmakiola.calendula.ui.common.ChipCuts
|
||||
import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts
|
||||
@@ -185,8 +196,10 @@ import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
||||
import de.jeanlucmakiola.calendula.ui.common.renderedSpan
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
||||
import de.jeanlucmakiola.calendula.ui.common.dayFitHeight
|
||||
import de.jeanlucmakiola.calendula.ui.common.FabClearance
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
|
||||
@@ -198,6 +211,7 @@ import de.jeanlucmakiola.calendula.ui.common.hourGridCells
|
||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
@@ -601,6 +615,21 @@ private fun WeekContent(
|
||||
)
|
||||
}
|
||||
|
||||
// Off the all-day strip: sideways moves days, into the grid makes it timed.
|
||||
val onStripDrop: (StripDrop) -> Unit = { drop ->
|
||||
move?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = when (drop) {
|
||||
is StripDrop.Days -> MoveTarget.ByDays(drop.days)
|
||||
is StripDrop.Timed -> MoveTarget.Span(drop.start, drop.end)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// A drafted block (#153) opens the new-event form at its own instants: the
|
||||
// end it is drawn to differs on a DST change (#387).
|
||||
val createDraft: (EventInstance) -> Unit = { draft ->
|
||||
@@ -661,6 +690,7 @@ private fun WeekContent(
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
onStripDrop = onStripDrop,
|
||||
)
|
||||
|
||||
Box(modifier = modifier) {
|
||||
@@ -736,7 +766,12 @@ private fun WeekPager(
|
||||
val loaded = pageState as? WeekUiState.Success
|
||||
// Until its days arrive a page shows their frame without events.
|
||||
val empty = remember(pageSpan, today) { emptySpan(pageSpan, today) }
|
||||
val stripHeight = loaded?.allDayStripHeight()
|
||||
// A bar held over other days in the strip makes room there before it is dropped.
|
||||
val held = (dragController.stripDrag ?: dragController.stripSettling)
|
||||
?.takeIf { page == pagerState.currentPage }
|
||||
val zone = LocalTimelineZone.current
|
||||
val shown = remember(loaded, held, zone) { loaded?.withStripPreview(held, zone) }
|
||||
val stripHeight = shown?.allDayStripHeight()
|
||||
DisposableEffect(page, stripHeight) {
|
||||
if (stripHeight != null) stripHeights[page] = stripHeight
|
||||
onDispose { stripHeights.remove(page) }
|
||||
@@ -744,8 +779,8 @@ private fun WeekPager(
|
||||
ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) {
|
||||
WeekPage(
|
||||
// A cached page may predate midnight; today comes off the clock (#373).
|
||||
state = remember(loaded, empty, today) {
|
||||
loaded?.takeIf { it.today != today }?.copy(today = today) ?: loaded ?: empty
|
||||
state = remember(shown, empty, today) {
|
||||
shown?.takeIf { it.today != today }?.copy(today = today) ?: shown ?: empty
|
||||
},
|
||||
headerBias = kind.headerBias,
|
||||
allDayHeight = allDayHeight,
|
||||
@@ -757,6 +792,25 @@ private fun WeekPager(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [this] with the strip bar [held] sideways laid out at the days it is over, so
|
||||
* the bars there move aside live. The bar keeps its own event, and with it its
|
||||
* key and the gesture on it; one moved off the page keeps a lane it is not seen in.
|
||||
*/
|
||||
internal fun WeekUiState.Success.withStripPreview(held: StripDrag?, zone: TimeZone): WeekUiState.Success {
|
||||
if (held == null || held.overGrid || held.dayShift == 0) return this
|
||||
val id = held.event.instanceId
|
||||
val original = allDaySpans.firstOrNull { it.event.instanceId == id } ?: return this
|
||||
val events = allDaySpans.map { if (it.event.instanceId == id) it.event.shiftedDays(held.dayShift, zone) else it.event }
|
||||
val spans = layoutAllDay(events, days, zone).map { if (it.event.instanceId == id) it.copy(event = original.event) else it }
|
||||
return copy(allDaySpans = if (spans.any { it.event.instanceId == id }) spans else spans + original.copy(lane = 0))
|
||||
}
|
||||
|
||||
private fun EventInstance.shiftedDays(days: Int, zone: TimeZone): EventInstance {
|
||||
val dates = dateZone(zone)
|
||||
return copy(start = start.plus(days, DateTimeUnit.DAY, dates), end = end.plus(days, DateTimeUnit.DAY, dates))
|
||||
}
|
||||
|
||||
/** A page with no events yet, for one whose days are still loading. */
|
||||
private fun emptySpan(span: DaySpan, today: LocalDate): WeekUiState.Success {
|
||||
val days = (0 until span.count).map { span.start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) }
|
||||
@@ -892,8 +946,9 @@ private fun WeekFrame(
|
||||
),
|
||||
) {
|
||||
val zoom = LocalTimelineZoom.current
|
||||
val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight
|
||||
else maxHeight
|
||||
val fitHeight = dayFitHeight(
|
||||
if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight,
|
||||
)
|
||||
val hourHeight = zoom.scale.hourHeight(fitHeight)
|
||||
HourGutter(
|
||||
scrollState = actions.scrollState,
|
||||
@@ -992,7 +1047,7 @@ private fun TimelinePageContent(
|
||||
LocalChipGround provides MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
AllDayStrip(geometry = geometry, bars = bars, height = allDayHeight,
|
||||
animatedHeight = animatedAllDayHeight, onEventClick = actions.onEventClick)
|
||||
animatedHeight = animatedAllDayHeight, actions = actions, active = active)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(TIMELINE_TOP_GAP))
|
||||
@@ -1127,9 +1182,29 @@ private fun AllDayStrip(
|
||||
bars: List<PlacedBar>,
|
||||
height: Dp,
|
||||
animatedHeight: (() -> Dp)? = null,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
actions: TimelineActions,
|
||||
active: Boolean,
|
||||
) {
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
val zone = LocalTimelineZone.current
|
||||
val dragController = actions.dragController
|
||||
// Only the page the finger is on draws the drag; the controller is shared.
|
||||
val held = (dragController.stripDrag ?: dragController.stripSettling)?.takeIf { active }
|
||||
val sideways = held?.takeIf { !it.overGrid }
|
||||
val lifted = dragController.liftedInstanceId?.takeIf { active && dragController.stripDrag?.overGrid != false }
|
||||
val settling = dragController.stripSettling?.takeIf { active }
|
||||
// Seated once the re-read draws the event at the days it was dropped on.
|
||||
LaunchedEffect(settling, bars) {
|
||||
if (settling == null) return@LaunchedEffect
|
||||
val target = settling.event.spanFirstDay(zone).plus(settling.dayShift, DateTimeUnit.DAY)
|
||||
val landed = bars.any { bar ->
|
||||
bar.event.instanceId != settling.event.instanceId &&
|
||||
(bar.event.eventId == settling.event.eventId ||
|
||||
(settling.event.title.isNotBlank() && bar.event.title == settling.event.title)) &&
|
||||
bar.event.spanFirstDay(zone) == target
|
||||
}
|
||||
if (landed) dragController.seatStrip()
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -1150,12 +1225,20 @@ private fun AllDayStrip(
|
||||
content = {
|
||||
bars.forEach { bar ->
|
||||
key(bar.event.eventId, bar.event.start) {
|
||||
val moving = sideways?.event?.instanceId == bar.event.instanceId
|
||||
val lane = animatedStripLane(bar.toLane)
|
||||
AllDayBar(
|
||||
event = bar.event,
|
||||
lastDay = bar.toRange.endInclusive,
|
||||
dark = dark,
|
||||
onClick = { onEventClick(bar.event) },
|
||||
dragController = dragController,
|
||||
lifted = moving,
|
||||
ghosted = lifted == bar.event.instanceId,
|
||||
onClick = { actions.onEventClick(bar.event) },
|
||||
onDrop = actions.onStripDrop,
|
||||
modifier = Modifier
|
||||
.then(if (moving) Modifier.zIndex(1f) else Modifier)
|
||||
.then(StripLane(lane))
|
||||
.morphEvent(ViewMorphKey.event(bar.event, bar.keyDate))
|
||||
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||
)
|
||||
@@ -1172,10 +1255,19 @@ private fun AllDayStrip(
|
||||
val progress = geometry.progress()
|
||||
val placed = measurables.mapIndexed { i, measurable ->
|
||||
val bar = bars[i]
|
||||
val extent = geometry.extent(bar.fromRange, bar.toRange, width, 0f)
|
||||
val shift = sideways?.takeIf { it.event.instanceId == bar.event.instanceId }?.dayShift ?: 0
|
||||
val extent = if (shift == 0) {
|
||||
geometry.extent(bar.fromRange, bar.toRange, width, 0f)
|
||||
} else {
|
||||
// The whole event, not the part this page shows: it may come in from off screen.
|
||||
val moved = bar.event.spanFirstDay(zone)..bar.event.spanLastDay(zone)
|
||||
geometry.extent(moved.shiftedBy(shift), moved.shiftedBy(shift), width, 0f)
|
||||
}
|
||||
val left = extent.left.roundToInt()
|
||||
val barWidth = (extent.right.roundToInt() - left).coerceAtLeast(0)
|
||||
val y = (lerp(bar.fromLane.toFloat(), bar.toLane.toFloat(), progress) * rowPx).roundToInt()
|
||||
// Standing still a bar tweens to a new lane, as bars make room for a dragged one.
|
||||
val lane = (measurable.parentData as? StripLane)?.lane?.takeIf { bar.fromLane == bar.toLane }
|
||||
val y = ((lane?.value ?: lerp(bar.fromLane.toFloat(), bar.toLane.toFloat(), progress)) * rowPx).roundToInt()
|
||||
Triple(measurable.measure(Constraints.fixed(barWidth, rowPx.roundToInt())), left, y)
|
||||
}
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
@@ -1185,12 +1277,31 @@ private fun AllDayStrip(
|
||||
}
|
||||
}
|
||||
|
||||
private data class StripLane(val lane: State<Float>) : ParentDataModifier {
|
||||
override fun Density.modifyParentData(parentData: Any?): Any = this@StripLane
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun animatedStripLane(lane: Int): State<Float> {
|
||||
val spec: FiniteAnimationSpec<Float> =
|
||||
if (rememberReduceMotion()) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
return animateFloatAsState(lane.toFloat(), spec, label = "strip-lane")
|
||||
}
|
||||
|
||||
private fun ClosedRange<LocalDate>.shiftedBy(days: Int): ClosedRange<LocalDate> =
|
||||
start.plus(days, DateTimeUnit.DAY)..endInclusive.plus(days, DateTimeUnit.DAY)
|
||||
|
||||
@Composable
|
||||
private fun AllDayBar(
|
||||
event: EventInstance,
|
||||
lastDay: LocalDate,
|
||||
dark: Boolean,
|
||||
dragController: TimelineDragController,
|
||||
lifted: Boolean,
|
||||
ghosted: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onDrop: (StripDrop) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
@@ -1198,8 +1309,29 @@ private fun AllDayBar(
|
||||
// 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 paint = eventPaint(event, dark)
|
||||
val zone = LocalTimelineZone.current
|
||||
val draggable = eventDragAllowed(event)
|
||||
// Long press lifts the bar: sideways moves its days, down into the grid makes it timed.
|
||||
val dragModifier = rememberEventDragSource(
|
||||
enabled = draggable,
|
||||
key = event.instanceId,
|
||||
onPickUp = { pointer, _, _ ->
|
||||
dragController.beginStrip(event, zone, pointer)
|
||||
if (dragController.stripDrag != null) PickUp.Drag else PickUp.None
|
||||
},
|
||||
onMove = dragController::move,
|
||||
onDrop = { dragController.finishStrip()?.let(onDrop) },
|
||||
onCancel = dragController::cancel,
|
||||
onRelease = dragController::cancel,
|
||||
)
|
||||
// Ghosted while it hangs over the grid as a timed block, and until that drop lands.
|
||||
val ghost = ghostAlpha(ghosted)
|
||||
val raise = selectionLift(lifted)
|
||||
Box(
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.alpha(ghost)
|
||||
.selectionLift(raise, EventChipShape)
|
||||
.then(dragModifier)
|
||||
.eventSurface(paint, EventChipShape)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
@@ -1254,7 +1386,9 @@ private fun Timeline(
|
||||
else Modifier.stableMorphViewport(animatedAllDayHeight, fullViewportHeight),
|
||||
),
|
||||
) {
|
||||
val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight
|
||||
val fitHeight = dayFitHeight(
|
||||
if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight,
|
||||
)
|
||||
val hourHeight = zoom.scale.hourHeight(fitHeight)
|
||||
val totalHeight = hourHeight * 24
|
||||
// The pinch sits above the scroll viewport: it has to outrank the
|
||||
@@ -1287,9 +1421,11 @@ private fun Timeline(
|
||||
.fillMaxSize()
|
||||
.then(pinch)
|
||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clip(RoundedCornerShape(TIMELINE_CORNER))
|
||||
.verticalScroll(scrollState)
|
||||
.onGloballyPositioned { if (active) dragController.geometry.viewport = it },
|
||||
.onGloballyPositioned { if (active) dragController.geometry.viewport = it }
|
||||
// Past midnight, outside the columns: nothing there takes a tap or a drop.
|
||||
.padding(bottom = FabClearance),
|
||||
) {
|
||||
TimelineColumns(
|
||||
geometry = geometry,
|
||||
@@ -1325,6 +1461,7 @@ private fun Timeline(
|
||||
visibleTop = visibleTop,
|
||||
viewportHeight = visibleViewport,
|
||||
actions = actions,
|
||||
shape = columnShape(day == geometry.dates.first(), day == geometry.dates.last()),
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
@@ -1332,6 +1469,19 @@ private fun Timeline(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The outer columns round their bottom corner at midnight: the room left below
|
||||
* for the buttons means the rounded window no longer does it there.
|
||||
*/
|
||||
private fun columnShape(first: Boolean, last: Boolean): Shape =
|
||||
if (!first && !last) RectangleShape
|
||||
else RoundedCornerShape(
|
||||
bottomStart = if (first) TIMELINE_CORNER else 0.dp,
|
||||
bottomEnd = if (last) TIMELINE_CORNER else 0.dp,
|
||||
)
|
||||
|
||||
private val TIMELINE_CORNER = 16.dp
|
||||
|
||||
@Composable
|
||||
private fun DayColumnCard(
|
||||
blocks: List<TimedBlock>,
|
||||
@@ -1343,6 +1493,7 @@ private fun DayColumnCard(
|
||||
visibleTop: () -> Float,
|
||||
viewportHeight: () -> Float,
|
||||
actions: TimelineActions,
|
||||
shape: Shape = RectangleShape,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scrollState = actions.scrollState
|
||||
@@ -1373,8 +1524,8 @@ private fun DayColumnCard(
|
||||
val draftSurface = rememberDraftSurface(date, edges, hourPx, dragController, actions.onDrop)
|
||||
Card(
|
||||
// Plain rectangular columns — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
shape = RectangleShape,
|
||||
// rounded scroll viewport, apart from the outer ones' bottoms ([columnShape]).
|
||||
shape = shape,
|
||||
colors = CardDefaults.cardColors(
|
||||
// With the grid on, the container colour moves onto the hour cells
|
||||
// and the column behind them recedes to surface, so the gap between
|
||||
@@ -1414,7 +1565,6 @@ private fun DayColumnCard(
|
||||
// block or handle on top can claim the press first.
|
||||
.then(draftSurface),
|
||||
) {
|
||||
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
||||
// Keyed by event, so a block that changes time or lane is the *same*
|
||||
// composable afterwards and tweens there. The ordinal disambiguates
|
||||
// a column holding two occurrences of one series.
|
||||
@@ -1422,9 +1572,17 @@ private fun DayColumnCard(
|
||||
shown.forEach { block ->
|
||||
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
||||
key(block.event.eventId, ordinal) {
|
||||
val top = hourHeight * (block.startMin / 60f)
|
||||
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||
// Where this block is cut at midnight, which decides both
|
||||
// its shape and the half-gap it gives up to the hour cells.
|
||||
val cuts = remember(block, date, zone) {
|
||||
timedBlockCuts(
|
||||
block.continuesBefore(date, zone),
|
||||
block.continuesAfter,
|
||||
)
|
||||
}
|
||||
val span = remember(block) { block.renderedSpan() }
|
||||
val top = hourHeight * (span.topMin / 60f)
|
||||
val height = hourHeight * ((span.bottomMin - span.topMin) / 60f)
|
||||
val place = animatedBlockPlacement(
|
||||
xFraction = block.lane.toFloat() / block.laneCount,
|
||||
widthFraction = 1f / block.laneCount,
|
||||
@@ -1432,14 +1590,6 @@ private fun DayColumnCard(
|
||||
height = height,
|
||||
landing = dragController.ghosts(block),
|
||||
)
|
||||
// Where this block is cut at midnight, which decides both
|
||||
// its shape and the half-gap it gives up to the hour cells.
|
||||
val cuts = remember(block, date, zone) {
|
||||
timedBlockCuts(
|
||||
block.continuesBefore(date, zone),
|
||||
block.continuesAfter(date, zone),
|
||||
)
|
||||
}
|
||||
val topInset = hourCellBlockInset(showHourGrid, cuts.top)
|
||||
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
|
||||
val blockHeight = (place.height - topInset - bottomInset)
|
||||
@@ -1775,7 +1925,7 @@ private fun rememberDraftSurface(
|
||||
block = block,
|
||||
zone = zone,
|
||||
pointerInRoot = pointer,
|
||||
blockInRoot = node + Offset(0f, startMin / 60f * hour),
|
||||
blockInRoot = node + Offset(0f, block.renderedSpan().topMin / 60f * hour),
|
||||
)
|
||||
true
|
||||
},
|
||||
@@ -1823,8 +1973,10 @@ private fun WeekLoading(dayCount: Int) {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
// Same scale resolution as the loaded timeline, so the skeleton's
|
||||
// columns don't resize the moment the real week arrives.
|
||||
val fitHeight = maxHeight + SKELETON_HEADER_HEIGHT + SKELETON_HEADER_PADDING * 2 -
|
||||
headerHeight - TIMELINE_TOP_GAP
|
||||
val fitHeight = dayFitHeight(
|
||||
maxHeight + SKELETON_HEADER_HEIGHT + SKELETON_HEADER_PADDING * 2 -
|
||||
headerHeight - TIMELINE_TOP_GAP,
|
||||
)
|
||||
val hourHeight = scale.hourHeight(fitHeight)
|
||||
val totalHeight = hourHeight * 24
|
||||
// The skeleton wears the loaded columns' own ground, so the arrival
|
||||
@@ -1837,7 +1989,8 @@ private fun WeekLoading(dayCount: Int) {
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||
.verticalScroll(scrollState),
|
||||
.verticalScroll(scrollState)
|
||||
.padding(bottom = FabClearance),
|
||||
) {
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
repeat(dayCount) {
|
||||
|
||||
@@ -15,6 +15,8 @@ import kotlinx.datetime.LocalDate
|
||||
* minimum tap-target height)
|
||||
* @param lane 0-based column within [laneCount]
|
||||
* @param laneCount number of columns the event's overlap-cluster needs
|
||||
* @param continuesAfter whether the event runs past this day's midnight, so the
|
||||
* block is cut there
|
||||
*/
|
||||
data class TimedBlock(
|
||||
val event: EventInstance,
|
||||
@@ -22,6 +24,7 @@ data class TimedBlock(
|
||||
val endMin: Int,
|
||||
val lane: Int,
|
||||
val laneCount: Int,
|
||||
val continuesAfter: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.dayRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.renderedSpan
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -13,7 +14,9 @@ import de.jeanlucmakiola.calendula.data.time.currentDate
|
||||
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
||||
import de.jeanlucmakiola.calendula.domain.displayPriority
|
||||
import de.jeanlucmakiola.calendula.domain.displayStart
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
|
||||
@@ -206,8 +209,7 @@ abstract class PagedTimelineViewModel(
|
||||
): WeekUiState {
|
||||
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
|
||||
val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
|
||||
val allDay = instances.filter { it.isAllDay }
|
||||
val timed = instances.filterNot { it.isAllDay }
|
||||
val (allDay, timed) = instances.splitWholeDays(zone)
|
||||
return WeekUiState.Success(
|
||||
weekStart = span.start,
|
||||
today = today,
|
||||
@@ -292,20 +294,21 @@ internal fun layoutAllDay(
|
||||
days: List<LocalDate>,
|
||||
zone: TimeZone,
|
||||
): List<AllDaySpan> {
|
||||
data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int)
|
||||
data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int, val priority: Int)
|
||||
|
||||
val raw = events
|
||||
.mapNotNull { ev ->
|
||||
val covered = days.indices.filter { ev.coversDay(days[it], zone) }
|
||||
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last())
|
||||
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last(), ev.displayPriority(zone))
|
||||
}
|
||||
// Declined bars are packed after every other one, so on any day they
|
||||
// cover they land in a lane below it (#230).
|
||||
.sortedWith(
|
||||
compareBy<Raw> { it.event.displayPriority(zone) }
|
||||
compareBy<Raw> { it.priority }
|
||||
.thenBy { it.startCol }
|
||||
.thenBy { it.endCol }
|
||||
.thenBy { it.event.start }
|
||||
.thenBy { it.event.displayStart(zone) }
|
||||
.thenBy { it.event.title }
|
||||
.thenBy { it.event.instanceId },
|
||||
)
|
||||
|
||||
@@ -426,12 +429,21 @@ internal fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean {
|
||||
return coversDay(secondDay, zone)
|
||||
}
|
||||
|
||||
/**
|
||||
* Split events into those for the all-day strip and those for the time grid:
|
||||
* all-day events and timed ones from midnight to midnight go to the strip (#296).
|
||||
*/
|
||||
internal fun List<EventInstance>.splitWholeDays(zone: TimeZone): Pair<List<EventInstance>, List<EventInstance>> =
|
||||
partition { it.coversWholeDays(zone) }
|
||||
|
||||
/**
|
||||
* Clip [events] to a single [day] and assign lanes so overlapping events render
|
||||
* side-by-side. Lane count is computed per overlap-cluster (a maximal run of
|
||||
* chained-overlapping events), matching the common phone week-view behaviour.
|
||||
* Overlap is judged on each block's [renderedSpan], so a short block's floor
|
||||
* (lifted off midnight, #483) never hides another block.
|
||||
*
|
||||
* All-day events are ignored here — they live in the all-day strip.
|
||||
* Takes the timed half of [splitWholeDays]; the rest lives in the all-day strip.
|
||||
*/
|
||||
internal fun layoutDay(
|
||||
events: List<EventInstance>,
|
||||
@@ -441,55 +453,53 @@ internal fun layoutDay(
|
||||
val dayStart = day.atStartOfDayIn(zone)
|
||||
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||
|
||||
data class Raw(val event: EventInstance, val startMin: Int, val endMin: Int)
|
||||
|
||||
val raw = events.asSequence()
|
||||
.filterNot { it.isAllDay }
|
||||
.mapNotNull { ev ->
|
||||
if (ev.start == ev.end) {
|
||||
// Instant event: keep only if the point falls inside this day.
|
||||
if (ev.start < dayStart || ev.start >= dayEnd) return@mapNotNull null
|
||||
val m = wallClockMinutes(ev.start, day, zone)
|
||||
Raw(ev, m, m)
|
||||
TimedBlock(ev, m, m, lane = 0, laneCount = 1)
|
||||
} else {
|
||||
val s = maxOf(ev.start, dayStart)
|
||||
val e = minOf(ev.end, dayEnd)
|
||||
if (e <= s) return@mapNotNull null
|
||||
val startMin = wallClockMinutes(s, day, zone)
|
||||
val endMin = wallClockEndMinutes(startMin, s, e, day, zone).coerceAtMost(MINUTES_PER_DAY)
|
||||
Raw(ev, startMin, endMin)
|
||||
TimedBlock(ev, startMin, endMin, lane = 0, laneCount = 1, continuesAfter = ev.end > dayEnd)
|
||||
}
|
||||
}
|
||||
.sortedWith(compareBy({ it.startMin }, { it.endMin }))
|
||||
.map { it to it.renderedSpan() }
|
||||
.sortedWith(compareBy({ it.second.topMin }, { it.second.bottomMin }))
|
||||
.toList()
|
||||
|
||||
val result = ArrayList<TimedBlock>(raw.size)
|
||||
var i = 0
|
||||
while (i < raw.size) {
|
||||
// Grow a cluster of chained-overlapping events.
|
||||
var clusterEnd = raw[i].endMin
|
||||
var clusterEnd = raw[i].second.bottomMin
|
||||
var j = i + 1
|
||||
while (j < raw.size && raw[j].startMin < clusterEnd) {
|
||||
clusterEnd = maxOf(clusterEnd, raw[j].endMin)
|
||||
while (j < raw.size && raw[j].second.topMin < clusterEnd) {
|
||||
clusterEnd = maxOf(clusterEnd, raw[j].second.bottomMin)
|
||||
j++
|
||||
}
|
||||
val cluster = raw.subList(i, j)
|
||||
// Greedy first-fit column assignment (= max overlap depth in the cluster).
|
||||
val laneEnd = ArrayList<Int>()
|
||||
val laneEnd = ArrayList<Float>()
|
||||
val lanes = IntArray(cluster.size)
|
||||
cluster.forEachIndexed { k, r ->
|
||||
var placed = laneEnd.indexOfFirst { it <= r.startMin }
|
||||
cluster.forEachIndexed { k, (_, span) ->
|
||||
var placed = laneEnd.indexOfFirst { it <= span.topMin }
|
||||
if (placed == -1) {
|
||||
laneEnd.add(r.endMin)
|
||||
laneEnd.add(span.bottomMin)
|
||||
placed = laneEnd.size - 1
|
||||
} else {
|
||||
laneEnd[placed] = r.endMin
|
||||
laneEnd[placed] = span.bottomMin
|
||||
}
|
||||
lanes[k] = placed
|
||||
}
|
||||
val laneCount = laneEnd.size
|
||||
cluster.forEachIndexed { k, r ->
|
||||
result.add(TimedBlock(r.event, r.startMin, r.endMin, lanes[k], laneCount))
|
||||
cluster.forEachIndexed { k, (block, _) ->
|
||||
result.add(block.copy(lane = lanes[k], laneCount = laneCount))
|
||||
}
|
||||
i = j
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ internal fun WeekViewPreview(
|
||||
onCreateAt = { _, _ -> },
|
||||
onDrop = {},
|
||||
onResize = {},
|
||||
onStripDrop = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -75,12 +76,12 @@ private fun sampleWeekState(
|
||||
): WeekUiState.Success {
|
||||
val start = if (dayCount == 7) today.startOfWeek(weekStart) else today
|
||||
val days = (0 until dayCount).map { start.plus(it, DateTimeUnit.DAY) }
|
||||
val events = sampleTimelineEvents(days, today, zone)
|
||||
val (allDay, timed) = sampleTimelineEvents(days, today, zone).splitWholeDays(zone)
|
||||
return WeekUiState.Success(
|
||||
weekStart = start,
|
||||
today = today,
|
||||
days = days,
|
||||
allDaySpans = layoutAllDay(events.filter { it.isAllDay }, days, zone),
|
||||
timedByDay = days.associateWith { layoutDay(events, it, zone) },
|
||||
allDaySpans = layoutAllDay(allDay, days, zone),
|
||||
timedByDay = days.associateWith { layoutDay(timed, it, zone) },
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.widget.countdown
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
||||
import de.jeanlucmakiola.calendula.domain.isCrossedOut
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.TimeZone
|
||||
@@ -75,12 +76,13 @@ fun nextLabelChange(event: EventInstance, now: Instant, zone: TimeZone): Instant
|
||||
}
|
||||
|
||||
/**
|
||||
* The next-event mode's pick: timed, not declined or cancelled, not over; of those, the one
|
||||
* whose countdown changes soonest — a running event's end, else a start.
|
||||
* The next-event mode's pick: timed and not filling whole days (#296), not
|
||||
* declined or cancelled, not over; of those, the one whose countdown changes
|
||||
* soonest — a running event's end, else a start.
|
||||
*/
|
||||
fun pickNextEvent(instances: List<EventInstance>, now: Instant): EventInstance? =
|
||||
fun pickNextEvent(instances: List<EventInstance>, now: Instant, zone: TimeZone): EventInstance? =
|
||||
instances
|
||||
.filter { !it.isAllDay && !it.isCrossedOut && it.end > now }
|
||||
.filter { !it.coversWholeDays(zone) && !it.isCrossedOut && it.end > now }
|
||||
.minByOrNull { target(it, now) }
|
||||
|
||||
/** A pinned event's occurrence to count to: the running or next one that is not cancelled (#388). */
|
||||
|
||||
@@ -75,7 +75,11 @@ internal suspend fun Context.loadCountdown(
|
||||
val repository = widgetEntryPoint().calendarRepository()
|
||||
val event = when (options.mode) {
|
||||
CountdownMode.NEXT ->
|
||||
pickNextEvent(repository.instances(now..now + NextEventHorizon).first().withoutCalendars(load.hiddenCalendars), now)
|
||||
pickNextEvent(
|
||||
repository.instances(now..now + NextEventHorizon).first().withoutCalendars(load.hiddenCalendars),
|
||||
now,
|
||||
systemZone(),
|
||||
)
|
||||
CountdownMode.PINNED -> options.eventId?.let { id ->
|
||||
pickPinnedOccurrence(repository.eventOccurrences(id, now - 1.days..now + PinnedHorizon), now, systemZone())
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||
@@ -210,12 +211,13 @@ private fun whenText(context: Context, event: EventInstance, data: CountdownData
|
||||
val at = if (running) counted.end else counted.start
|
||||
val date = at.toLocalDateTime(systemZone()).date
|
||||
val day = relativeDayLabel(context, date, data.today)
|
||||
val wholeDays = event.coversWholeDays(systemZone())
|
||||
return when {
|
||||
event.isAllDay && running -> context.getString(
|
||||
wholeDays && running -> context.getString(
|
||||
R.string.widget_countdown_until,
|
||||
relativeDayLabel(context, date.minus(1, DateTimeUnit.DAY), data.today),
|
||||
)
|
||||
event.isAllDay -> "$day · ${context.getString(R.string.event_detail_all_day)}"
|
||||
wholeDays -> "$day · ${context.getString(R.string.event_detail_all_day)}"
|
||||
running -> context.getString(R.string.agenda_span_ends, "$day · ${formatWidgetTime(at, data.is24Hour)}")
|
||||
else -> "$day · ${formatWidgetTime(at, data.is24Hour)}"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.widget.split
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.isCrossedOut
|
||||
import de.jeanlucmakiola.calendula.widget.InAppLanguage
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||
@@ -268,7 +269,9 @@ internal fun MonthDayWidgetContent(
|
||||
val day = selected ?: source.today
|
||||
val zone = systemZone()
|
||||
val days = monthGridDays(index, source.weekStart)
|
||||
val busy = groupAgendaDays(days.first(), days.last(), source.instances, zone).map { it.date }.toSet()
|
||||
// Declined and cancelled events are no plan for the day, as on the week strip (#388).
|
||||
val planned = source.instances.filterNot { it.isCrossedOut }
|
||||
val busy = groupAgendaDays(days.first(), days.last(), planned, zone).map { it.date }.toSet()
|
||||
Column(modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 6.dp)) {
|
||||
Header(index, source.today)
|
||||
Spacer(GlanceModifier.height(4.dp))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.widget.week
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.isCrossedOut
|
||||
import de.jeanlucmakiola.calendula.widget.InAppLanguage
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||
@@ -146,8 +146,8 @@ private fun DayTile(date: LocalDate, data: DaysWidgetData.Ready) {
|
||||
else -> GlanceTheme.colors.onSurface
|
||||
}
|
||||
val locale = widgetLocale()
|
||||
// A cancelled event is no plan for the day, so it adds no dot and no count (#388).
|
||||
val events = data.eventsOn(date).filter { it.status != EventStatus.Cancelled }
|
||||
// A declined or cancelled event is no plan for the day, so it adds no dot and no count (#388).
|
||||
val events = data.eventsOn(date).filterNot { it.isCrossedOut }
|
||||
val colors = events.map { it.color }.distinct()
|
||||
val label = localizedDateFormatter(locale, "EEEEdMMMM").format(date.toJavaLocalDate())
|
||||
Column(
|
||||
|
||||
@@ -456,7 +456,7 @@
|
||||
<string name="calendars_restore_action">استعادة من ملف .ics</string>
|
||||
<string name="calendars_restore_hint">استيراد الأحداث من نسخة احتياطية أو تطبيق تقويم آخر.</string>
|
||||
<string name="calendars_auto_backup">النسخ الاحتياطي التلقائي</string>
|
||||
<string name="calendars_auto_backup_hint">قم بتصدير تقويماتك المحلية بشكل دوري إلى مجلد كـ ملف .ics.</string>
|
||||
<string name="calendars_auto_backup_hint">تصدير تقويماتك المحلية بشكل دوري إلى مجلد كـ ملف .ics</string>
|
||||
<string name="calendars_auto_backup_folder">مجلد النسخ الاحتياطي</string>
|
||||
<string name="calendars_auto_backup_folder_unset">اضغط لاختيار مجلد</string>
|
||||
<string name="calendars_auto_backup_every">كل %1$s</string>
|
||||
@@ -638,7 +638,7 @@
|
||||
<item quantity="many">يتم استيراد %d حدث</item>
|
||||
<item quantity="other">يتم استيراد %d حدث</item>
|
||||
</plurals>
|
||||
<string name="event_move_recurring_title">نقل الحدث المتكرر</string>
|
||||
<string name="event_move_recurring_title">نقل الحدث المتكرّر</string>
|
||||
<string name="event_edit_conflict_overwrite_hint">فقط الحقول التي عدلتها هي التي ستحل محل التغيير الخارجي</string>
|
||||
<string name="reorder_drag_handle">اسحب لإعادة الترتيب</string>
|
||||
<string name="settings_drawer_order_hint">اسحب لإعادة ترتيب طرق العرض المدرجة في قائمة التنقل.</string>
|
||||
@@ -665,7 +665,7 @@
|
||||
<string name="duration_hours_minutes">%1$s %2$s</string>
|
||||
<string name="settings_widgets_hint">إعدادات لأدوات الشاشة الرئيسية وعنصر الإعدادات السريعة. أضف أداة عن طريق الضغط لفترة طويلة على شاشتك الرئيسية.</string>
|
||||
<string name="import_warning_attendees">لم يتم استيراد قوائم الضيوف.</string>
|
||||
<string name="settings_reliable_delivery_exempt">معفى من تحسين البطارية — تصل التذكيرات في الوقت المحدد.</string>
|
||||
<string name="settings_reliable_delivery_exempt">معفى من تحسين البطارية — تصل التذكيرات في الوقت المحدّد.</string>
|
||||
<string name="settings_reliable_delivery">توصيل موثوق</string>
|
||||
<string name="settings_allday_reminder_time_hint">تنطلق التذكيرات لأحداث التي تستمر طوال اليوم عند %1$s</string>
|
||||
<string name="agenda_range_override_hint">فقط للآن — يعيد التعيين للمدى المحفوظ خاصتك عند إعادة فتح Calendula.</string>
|
||||
@@ -804,4 +804,30 @@
|
||||
<string name="reminder_day_today">اليوم</string>
|
||||
<string name="event_detail_open_in_maps">فتح في الخرائط</string>
|
||||
<string name="link_open_failed">لا تطبيق يمكنه فتح هذا الرابط</string>
|
||||
<string name="settings_support_kofi">Ko-fi</string>
|
||||
<string name="settings_support_liberapay">Liberapay</string>
|
||||
<string name="settings_support_liberapay_hint">التبرعات المتكررة</string>
|
||||
<string name="settings_support_kofi_hint">الإكراميات لمرة واحدة</string>
|
||||
<string name="settings_theme_black">أسود</string>
|
||||
<string name="settings_theme_black_summary">داكن مع خلفيات سوداء نقية، لشاشات AMOLED</string>
|
||||
<string name="event_resize_done">تم التغيير إلى %1$s</string>
|
||||
<string name="event_resize_recurring_title">تغيير الحدث المتكرّر</string>
|
||||
<string name="event_selection_actions">إجراءات الحدث</string>
|
||||
<string name="event_selection_hide">إخفاء إجراءات الحدث</string>
|
||||
<string name="event_selection_close">إلغاء تحديد الحدث</string>
|
||||
<string name="event_draft_create">إنشاء حدث</string>
|
||||
<string name="event_draft_discard">تجاهل الحدث الجديد</string>
|
||||
<string name="event_action_load_failed">تعذّر فتح الحدث</string>
|
||||
<string name="event_edit_more_actions">المزيد من الإجراءات</string>
|
||||
<string name="event_edit_unsaved_title">تغييرات غير محفوظة</string>
|
||||
<string name="event_edit_unsaved_save">حفظ التغييرات</string>
|
||||
<string name="event_edit_unsaved_discard">تجاهل التغييرات</string>
|
||||
<string name="event_edit_create">إنشاء</string>
|
||||
<string name="settings_tap_opens_details">التفاصيل</string>
|
||||
<string name="settings_tap_opens_editor">نموذج التعديل</string>
|
||||
<string name="process_text_create_event">إنشاء حدث</string>
|
||||
<string name="settings_events_header">الأحداث</string>
|
||||
<string name="settings_tap_opens">النقر بحدث يفتح</string>
|
||||
<string name="settings_tap_opens_hint">الأحداث التي لا يمكنك تعديلها وإشعارات التذكير دائمًا تفتح التفاصيل.</string>
|
||||
<string name="event_edit_unsaved_keep">متابعة التعديل</string>
|
||||
</resources>
|
||||
@@ -708,4 +708,37 @@
|
||||
<string name="onboarding_hidden_failed">Гэты не атрымалася ўключыць. У мэню Налады → Календары знаходзяцца такія самыя перамыкачы.</string>
|
||||
<string name="onboarding_calendars_create_failed">Не атрымалася стварыць каляндар з-за адмовы сховішча вашай прылады.</string>
|
||||
<string name="reminder_day_today">Сёньня</string>
|
||||
<string name="event_resize_done">Зьменена на %1$s</string>
|
||||
<string name="event_resize_recurring_title">Зьмяніць пэрыядычную падзею</string>
|
||||
<string name="event_selection_actions">Дзеяньні падзеі</string>
|
||||
<string name="event_selection_hide">Схаваць дзеяньні падзеі</string>
|
||||
<string name="event_selection_close">Адмяніць выбар падзеі</string>
|
||||
<string name="event_draft_create">Стварыць падзею</string>
|
||||
<string name="event_draft_discard">Адкінуць новую падзею</string>
|
||||
<string name="event_action_load_failed">Не атрымалася адкрыць падзею</string>
|
||||
<string name="event_edit_more_actions">Іншыя дзеяньні</string>
|
||||
<string name="event_edit_unsaved_title">Незахаваныя зьмены</string>
|
||||
<string name="event_edit_unsaved_save">Захаваць зьмены</string>
|
||||
<string name="event_edit_unsaved_discard">Адкінуць зьмены</string>
|
||||
<string name="event_edit_unsaved_keep">Працягнуць рэдагаваньне</string>
|
||||
<string name="event_edit_create">Стварыць</string>
|
||||
<plurals name="view_multi_day">
|
||||
<item quantity="one">%d суткі</item>
|
||||
<item quantity="few">%d сутак</item>
|
||||
<item quantity="many">%d сутак</item>
|
||||
<item quantity="other">%d сутак</item>
|
||||
</plurals>
|
||||
<string name="settings_theme_black">Чорная</string>
|
||||
<string name="settings_theme_black_summary">Цёмная з чыста чорным тлом для экранаў AMOLED</string>
|
||||
<string name="settings_multi_day_count">Паказ некалькіх сутак</string>
|
||||
<string name="settings_multi_day_count_hint">Сколькі сутак бачныя ў рэжыме паказу некалькіх сутак. Сёньня адкрываецца ў першым слупку.</string>
|
||||
<string name="settings_timeline_scale_by_visible_hours">Задаецца праз бачныя гадзіны</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">Ад</string>
|
||||
<string name="settings_visible_hours_end">Да</string>
|
||||
<string name="settings_visible_hours_start_hint">Гадзіна зьверху экрана ў рэжыме прагляду тыдня, некалькіх сутак або сутак. Каб пабачыць ранейшыя гадзіны, трэба прагортка.</string>
|
||||
<string name="settings_visible_hours_end_hint">Апошняя гадзіна ўнізе экрану ў рэжыме прагляду. Каб пабачыць пазьнейшыя гадзіны трэба прагортка. Таксама зьвядзеньнем пальцаў па экране можна зьмяніць маштаб да наступнага адкрыцьця рэжыму.</string>
|
||||
<string name="settings_events_header">Падзеі</string>
|
||||
</resources>
|
||||
@@ -471,7 +471,7 @@
|
||||
<string name="settings_reminders_hint">Vidíte připomínky dvakrát? Zobrazuje je i jiná aplikace kalendáře – v jedné z nich je vypněte.</string>
|
||||
<string name="settings_default_reminder">Výchozí připomínka</string>
|
||||
<string name="settings_default_reminder_allday">Celodenní události</string>
|
||||
<string name="settings_allday_reminder_time">Čas celodenní připomínky</string>
|
||||
<string name="settings_allday_reminder_time">Čas připomínky celodenní události</string>
|
||||
<string name="settings_allday_reminder_time_hint">Připomínky na celodenní události se spouštějí v %1$s</string>
|
||||
<string name="settings_allday_reminder_fires_at">Upozorní v %1$s</string>
|
||||
<string name="reminder_none">Žádná</string>
|
||||
@@ -489,7 +489,7 @@
|
||||
<string name="settings_snooze_duration">Doba odložení</string>
|
||||
<string name="settings_section_calendars">Kalendáře</string>
|
||||
<string name="settings_manage_calendars">Správa kalendářů</string>
|
||||
<string name="settings_manage_calendars_hint">Vytvořte místní kalendáře; spravujte synchronizované kalendáře</string>
|
||||
<string name="settings_manage_calendars_hint">Správa místních a synchronizovaných kalendářů</string>
|
||||
<string name="settings_section_language">Jazyk</string>
|
||||
<string name="settings_language">Jazyk aplikace</string>
|
||||
<string name="settings_language_auto">Výchozí nastavení systému</string>
|
||||
@@ -515,9 +515,9 @@
|
||||
<string name="settings_time_format_hint">Způsob zápisu času v celé aplikaci. Funkce „Automaticky“ se řídí nastavením vašeho systému.</string>
|
||||
<string name="settings_past_events_hint">Jak program zachází s událostmi, které již skončily.</string>
|
||||
<string name="settings_dynamic_color_summary">Aplikace převezme barvy z vaší tapety.</string>
|
||||
<string name="settings_section_special_dates">Speciální data kontaktů</string>
|
||||
<string name="settings_special_dates_enable">Zobrazit data kontaktů</string>
|
||||
<string name="settings_special_dates_enable_hint">Zkopírujte data narozenin a další data svých kontaktů do místních kalendářů. Aplikace čte pouze kontakty v tomto zařízení – nikam se nic nenahrává, vaše kontakty zůstávají beze změny.</string>
|
||||
<string name="settings_section_special_dates">Události kontaktů</string>
|
||||
<string name="settings_special_dates_enable">Zobrazit události kontaktů</string>
|
||||
<string name="settings_special_dates_enable_hint">Zkopírujte data narozenin či dalších událostí svých kontaktů do místních kalendářů. Aplikace čte pouze kontakty v tomto zařízení – nikam se nic nenahrává, vaše kontakty zůstávají beze změny.</string>
|
||||
<string name="settings_special_dates_type_birthday">Narozeniny</string>
|
||||
<string name="settings_special_dates_type_anniversary">Výročí</string>
|
||||
<string name="settings_special_dates_type_custom">Ostatní data</string>
|
||||
@@ -529,11 +529,11 @@
|
||||
<string name="settings_special_dates_never_synced">Zatím nesynchronizováno</string>
|
||||
<string name="settings_special_dates_last_synced">Naposledy synchronizováno %1$s</string>
|
||||
<string name="settings_special_dates_calendar_hint">Barvu a viditelnost jednotlivých kalendářů nastavte v nastavení kalendářů.</string>
|
||||
<string name="settings_calendar_reminders_managed_hint">Nastavte v části Speciální data kontaktu</string>
|
||||
<string name="settings_calendar_reminders_managed_hint">Nastavte v části Události kontaktů</string>
|
||||
<string name="settings_special_dates_paused_title">Pozastaveno</string>
|
||||
<string name="settings_special_dates_paused_hint">Calendula už nemá přístup k vašim kontaktům, tudíž zůstávají bez aktualizace.</string>
|
||||
<string name="settings_special_dates_grant">Udělit přístup</string>
|
||||
<string name="settings_special_dates_disable_title">Vypnout data kontaktů?</string>
|
||||
<string name="settings_special_dates_disable_title">Vypnout události kontaktů?</string>
|
||||
<string name="settings_special_dates_disable_type_message">Tímto se odstraní kalendář „%1$s“ a jeho události. Veškeré připomínky a poznámky, které jste do nich přidali, budou ztraceny.</string>
|
||||
<string name="settings_special_dates_disable_confirm">Vypnout</string>
|
||||
<string name="dialog_save">Uložit</string>
|
||||
@@ -566,7 +566,7 @@
|
||||
<string name="calendars_state_read_only">Pouze pro čtení</string>
|
||||
<string name="calendars_state_not_synced">Není synchronizováno s tímto zařízením</string>
|
||||
<string name="calendars_state_managed">Vyplněno z vašich kontaktů</string>
|
||||
<string name="calendars_managed_delete_locked">Tento kalendář se plní z vašich kontaktů, takže jej aplikace Calendula při příští synchronizaci znovu vytvoří. Chcete-li jej odstranit, vypněte možnost speciálních dat v nabídce Nastavení → Speciální data.</string>
|
||||
<string name="calendars_managed_delete_locked">Tento kalendář se plní ze seznamu vašich kontaktů, takže jej aplikace Calendula při příští synchronizaci znovu vytvoří. Chcete-li jej odstranit, vypněte možnost událostí kontaktů v nabídce Nastavení → Události kontaktů.</string>
|
||||
<string name="calendars_synced_header">Synchronizované kalendáře</string>
|
||||
<string name="calendars_synced_hint">Tyto kontakty pocházejí z účtů ve vašem zařízení. Vytvářejte je a upravujte v příslušné aplikaci.</string>
|
||||
<string name="calendars_manage_in_app">Spravovat v aplikaci</string>
|
||||
@@ -689,7 +689,7 @@
|
||||
<string name="crash_report_open_failed">Nepodařilo se otevřít systém pro hlášení problémů. Zpráva je zkopírována ve vaší schránce.</string>
|
||||
<string name="special_dates_calendar_birthday">Narozeniny</string>
|
||||
<string name="special_dates_calendar_anniversary">Výročí</string>
|
||||
<string name="special_dates_calendar_custom">Speciální data</string>
|
||||
<string name="special_dates_calendar_custom">Ostatní data</string>
|
||||
<string name="special_dates_default_title_birthday">Narozeniny má {name} ({year})</string>
|
||||
<string name="special_dates_default_title_anniversary">Výročí má {name} ({year})</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
@@ -764,4 +764,26 @@
|
||||
<string name="settings_support_kofi_hint">Jednorázové příspěvky</string>
|
||||
<string name="settings_support_liberapay">Liberapay</string>
|
||||
<string name="settings_support_liberapay_hint">Pravidelné příspěvky</string>
|
||||
<string name="event_resize_done">Změněno na %1$s</string>
|
||||
<string name="event_resize_recurring_title">Změnit opakující se událost</string>
|
||||
<string name="event_selection_actions">Akce událostí</string>
|
||||
<string name="event_selection_hide">Skrýt akce událostí</string>
|
||||
<string name="event_selection_close">Zrušit výběr události</string>
|
||||
<string name="event_draft_create">Vytvořit událost</string>
|
||||
<string name="event_draft_discard">Zahodit novou událost</string>
|
||||
<string name="event_action_load_failed">Událost se nepodařilo otevřít</string>
|
||||
<string name="event_edit_more_actions">Další akce</string>
|
||||
<string name="event_edit_unsaved_title">Neuložené změny</string>
|
||||
<string name="event_edit_unsaved_save">Uložit změny</string>
|
||||
<string name="event_edit_unsaved_discard">Zahodit změny</string>
|
||||
<string name="event_edit_unsaved_keep">Pokračovat v úpravách</string>
|
||||
<string name="event_edit_create">Vytvořit</string>
|
||||
<string name="settings_theme_black">Černý</string>
|
||||
<string name="settings_theme_black_summary">Tmavý s čistě černým pozadím, pro AMOLED obrazovky</string>
|
||||
<string name="settings_events_header">Události</string>
|
||||
<string name="settings_tap_opens">Klepnutím na událost se otevře</string>
|
||||
<string name="settings_tap_opens_details">Podrobnosti</string>
|
||||
<string name="settings_tap_opens_editor">Formulář pro úpravu</string>
|
||||
<string name="settings_tap_opens_hint">U upomínek a událostí, které nelze upravit, se vždy otevře stránka s podrobnostmi.</string>
|
||||
<string name="process_text_create_event">Vytvořit událost</string>
|
||||
</resources>
|
||||
@@ -592,7 +592,7 @@
|
||||
<string name="settings_month_header">Monatsansicht</string>
|
||||
<string name="settings_month_view_style">Stil der Monatsansicht</string>
|
||||
<string name="month_style_paged">Seiten</string>
|
||||
<string name="month_style_paged_summary">Ein Monat füllt den Bildschirm. Wische nach links oder rechts, um den Monat zu wechseln.</string>
|
||||
<string name="month_style_paged_summary">Ein Monat füllt den Bildschirm. Wische nach links oder rechts, um den Monat zu wechseln, oder wische nach oben, um die Monatsagenda anzuzeigen.</string>
|
||||
<string name="month_style_continuous">Fortlaufende Monate</string>
|
||||
<string name="month_style_continuous_summary">Jeder Monat steht unter seiner eigenen Überschrift, mit etwas Abstand zum nächsten.</string>
|
||||
<string name="month_style_dense">Nahtlose Wochen</string>
|
||||
@@ -727,4 +727,40 @@
|
||||
<string name="settings_visible_hours">Sichtbare Stunden</string>
|
||||
<string name="settings_visible_hours_start">Von</string>
|
||||
<string name="settings_visible_hours_end">Bis</string>
|
||||
<string name="month_open_week">Woche %1$d öffnen</string>
|
||||
<string name="month_split_clear_filter">Zeig die Agenda des ganzen Monats</string>
|
||||
<string name="event_resize_done">Zu %1$s geändert</string>
|
||||
<string name="event_selection_actions">Termin Aktionen</string>
|
||||
<string name="event_selection_hide">Termin Aktionen verstecken</string>
|
||||
<string name="event_selection_close">Termin abwählen</string>
|
||||
<string name="event_draft_discard">Neuen Termin verwerfen</string>
|
||||
<string name="event_action_load_failed">Termin konnte nicht geöffnet werden</string>
|
||||
<string name="event_edit_unsaved_title">Ungespeicherte Änderungen</string>
|
||||
<string name="event_edit_unsaved_save">Änderungen speichern</string>
|
||||
<string name="event_edit_unsaved_discard">Änderungen verwerfen</string>
|
||||
<string name="event_edit_create">Erstellen</string>
|
||||
<string name="event_detail_open_in_maps">In Maps öffnen</string>
|
||||
<string name="settings_theme_black_summary">Dunkel mit schwarzem Hintergrund, für AMOLED Bildschirme</string>
|
||||
<string name="settings_multi_day_count">Mehrtagesansicht</string>
|
||||
<string name="settings_multi_day_count_hint">Bestimmt, wie viele Tage in der Mehrtagesansicht angezeigt werden sollen. Die erste Spalte ist immer der aktuelle Tag.</string>
|
||||
<string name="settings_timeline_scale_by_visible_hours">Durch sichtbares Zeitfenster bestimmt</string>
|
||||
<string name="settings_visible_hours_summary">Zeigt in Wochen-, Mehrtages- und Tagesansicht anstatt aller Stunden ein bestimmtes Zeitfenster an</string>
|
||||
<string name="settings_visible_hours_on_summary">Ansicht zeigt nur Zeit von %1$s bis %2$s</string>
|
||||
<string name="settings_visible_hours_start_hint">Die Stunde am oberen Bildschirmrand, wenn die Wochen-, Mehrtages- oder Tagesansicht geöffnet wird. Frühere Stunden können durch Scrollen erreicht werden.</string>
|
||||
<string name="settings_visible_hours_end_hint">Die Stunde am unteren Bildschirmrand, wenn eine Ansicht geöffnet wird. Spätere Stunden erreichst du weiterhin durch Scrollen, und das Zoomen per Pinch-Geste bleibt bestehen, bis die Ansicht erneut geöffnet wird.</string>
|
||||
<string name="settings_events_header">Termine</string>
|
||||
<string name="settings_tap_opens">Aktion beim Tippen auf einen Termin</string>
|
||||
<string name="settings_tap_opens_details">Details</string>
|
||||
<string name="settings_tap_opens_editor">Termin-Formular</string>
|
||||
<string name="settings_tap_opens_hint">Termine, die nicht bearbeitet werden können, und Erinnerungsbenachrichtigungen öffnen immer die Details.</string>
|
||||
<string name="settings_support_kofi_hint">Einmalige Spenden</string>
|
||||
<string name="settings_support_liberapay_hint">Regelmäßige Spenden</string>
|
||||
<string name="process_text_create_event">Termin erstellen</string>
|
||||
<string name="settings_support_kofi">Ko-fi</string>
|
||||
<string name="settings_support_liberapay">Liberapay</string>
|
||||
<string name="event_resize_recurring_title">Wiederkehrenden Termin verschieben</string>
|
||||
<string name="event_draft_create">Neuen Termin erstellen</string>
|
||||
<string name="event_edit_more_actions">Weitere Aktionen</string>
|
||||
<string name="event_edit_unsaved_keep">Weiter bearbeiten</string>
|
||||
<string name="settings_theme_black">Zurück</string>
|
||||
</resources>
|
||||
@@ -244,7 +244,7 @@
|
||||
<string name="settings_time_format_12h">12 ore</string>
|
||||
<string name="settings_time_format_24h">24 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_hour_lines_summary">Mostra una linea di separazione per ogni ora in una cella a sé stante nella visualizzazione settimanale e giornaliera</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>
|
||||
<string name="settings_past_events">Eventi passati</string>
|
||||
@@ -422,7 +422,7 @@
|
||||
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
|
||||
<string name="settings_section_views">Vista</string>
|
||||
<string name="settings_quick_switch_header">Pulsante di cambio rapido</string>
|
||||
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere col pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.</string>
|
||||
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere col pulsante in alto a destra, trascinale per riordinarle. Se sono attive meno di due schermate, il pulsante viene nascosto. Le viste disattivate rimangono accessibili dal menu di navigazione.</string>
|
||||
<string name="settings_drawer_order_header">Menù di navigazione</string>
|
||||
<string name="settings_drawer_order_hint">Trascina per riordinare le viste elencate nel menù di navigazione.</string>
|
||||
<string name="reorder_drag_handle">Trascina per riordinare</string>
|
||||
@@ -551,12 +551,14 @@
|
||||
<string name="search_select_all">Seleziona tutti</string>
|
||||
<string name="search_delete_selected">Elimina i selezionati</string>
|
||||
<plurals name="search_delete_title">
|
||||
<item quantity="one">Eliminare l\'evento?</item>
|
||||
<item quantity="other">Eliminare l\'evento?</item>
|
||||
<item quantity="one">Eliminare %d l\'evento?</item>
|
||||
<item quantity="many">Eliminare %d gli eventi?</item>
|
||||
<item quantity="other">Eliminare %d eventi?</item>
|
||||
</plurals>
|
||||
<plurals name="search_delete_done">
|
||||
<item quantity="one">Evento eliminato</item>
|
||||
<item quantity="other">Evento eliminato</item>
|
||||
<item quantity="one">%d evento eliminato</item>
|
||||
<item quantity="many">%d eventi eliminati</item>
|
||||
<item quantity="other">%d eventi eliminati</item>
|
||||
</plurals>
|
||||
<string name="search_delete_partial">%1$d eliminati, %2$d no</string>
|
||||
<string name="search_delete_denied_partial">%1$d eliminati, poi permessi di scrittura negati</string>
|
||||
@@ -598,7 +600,7 @@
|
||||
<string name="settings_month_header">Vista Mese</string>
|
||||
<string name="settings_month_view_style">Stile vista Mese</string>
|
||||
<string name="month_style_paged">Pagine</string>
|
||||
<string name="month_style_paged_summary">Viene mostrato un singolo mese. Scorri a destra o a sinistra per cambiare mese.</string>
|
||||
<string name="month_style_paged_summary">Viene mostrato un singolo mese. Scorri a destra o a sinistra per cambiare mese oppure scorri verso l\'alto per visualizzare l\'agenda mensile.</string>
|
||||
<string name="month_style_continuous">Scorrimento</string>
|
||||
<string name="settings_past_events_hint">Ciò che fa l\'Agenda con eventi conclusi.</string>
|
||||
<string name="settings_dynamic_color_summary">Deduci il colore dell\'app dallo sfondo.</string>
|
||||
@@ -696,9 +698,9 @@
|
||||
<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>
|
||||
<item quantity="one">%d giorno</item>
|
||||
<item quantity="many">%d giorni</item>
|
||||
<item quantity="other">%d giorni</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>
|
||||
@@ -736,4 +738,30 @@
|
||||
<item quantity="many">Non è stato possibile aggiungere gli eventi %d.</item>
|
||||
<item quantity="other">Non è stato possibile aggiungere l\'evento %d.</item>
|
||||
</plurals>
|
||||
<string name="event_draft_create">Crea evento</string>
|
||||
<string name="event_draft_discard">Scarta nuovo evento</string>
|
||||
<string name="event_action_load_failed">Impossibile aprire l\'evento</string>
|
||||
<string name="event_selection_close">Deseleziona evento</string>
|
||||
<string name="event_edit_more_actions">Più azioni</string>
|
||||
<string name="event_edit_unsaved_title">Modifiche non salvate</string>
|
||||
<string name="event_edit_unsaved_save">Salva modifiche</string>
|
||||
<string name="event_edit_unsaved_discard">Scarta modifiche</string>
|
||||
<string name="event_edit_unsaved_keep">Continua a modificare</string>
|
||||
<string name="event_edit_create">Crea</string>
|
||||
<string name="event_resize_done">Modificato in %1$s</string>
|
||||
<string name="event_resize_recurring_title">Modifica evento ricorrente</string>
|
||||
<string name="event_selection_actions">Azioni evento</string>
|
||||
<string name="event_selection_hide">Nascondi azioni evento</string>
|
||||
<string name="settings_theme_black">Nero</string>
|
||||
<string name="settings_theme_black_summary">Scuro con sfondi completamente neri, per schermi AMOLED</string>
|
||||
<string name="settings_events_header">Eventi</string>
|
||||
<string name="settings_tap_opens">Cliccando su un evento si apre</string>
|
||||
<string name="settings_tap_opens_details">Dettagli</string>
|
||||
<string name="settings_tap_opens_editor">Modifica modulo</string>
|
||||
<string name="settings_tap_opens_hint">Gli eventi che non è possibile modificare e le notifiche di promemoria, aprono sempre la pagina dei dettagli.</string>
|
||||
<string name="settings_support_kofi">Ko-fi</string>
|
||||
<string name="settings_support_kofi_hint">Contributi una tantum</string>
|
||||
<string name="settings_support_liberapay">Liberapay</string>
|
||||
<string name="settings_support_liberapay_hint">Donazioni ricorrenti</string>
|
||||
<string name="process_text_create_event">Crea evento</string>
|
||||
</resources>
|
||||
@@ -716,4 +716,30 @@
|
||||
<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>
|
||||
<string name="event_resize_done">Gewijzigd naar %1$s</string>
|
||||
<string name="event_resize_recurring_title">Terugkerende gebeurtenis wijzigen</string>
|
||||
<string name="event_selection_actions">Acties voor gebeurtenis</string>
|
||||
<string name="event_selection_hide">Acties voor gebeurtenis verbergen</string>
|
||||
<string name="event_selection_close">Selectie van gebeurtenis opheffen</string>
|
||||
<string name="event_draft_create">Gebeurtenis maken</string>
|
||||
<string name="event_draft_discard">Nieuwe gebeurtenis verwerpen</string>
|
||||
<string name="event_action_load_failed">Kan de gebeurtenis niet openen</string>
|
||||
<string name="event_edit_more_actions">Meer acties</string>
|
||||
<string name="event_edit_unsaved_title">Niet-opgeslagen wijzigingen</string>
|
||||
<string name="event_edit_unsaved_save">Wijzigingen opslaan</string>
|
||||
<string name="event_edit_unsaved_discard">Wijzigingen verwerpen</string>
|
||||
<string name="event_edit_unsaved_keep">Verder bewerken</string>
|
||||
<string name="event_edit_create">Maken</string>
|
||||
<string name="settings_theme_black">Zwart</string>
|
||||
<string name="settings_theme_black_summary">Donker met volledig zwarte achtergronden, voor AMOLED-schermen</string>
|
||||
<string name="settings_events_header">Gebeurtenissen</string>
|
||||
<string name="settings_tap_opens">Tikken op een gebeurtenis opent</string>
|
||||
<string name="settings_tap_opens_details">Details</string>
|
||||
<string name="settings_tap_opens_editor">Bewerkformulier</string>
|
||||
<string name="settings_tap_opens_hint">Gebeurtenissen die je niet kunt bewerken en meldingen van herinneringen openen altijd de details.</string>
|
||||
<string name="settings_support_kofi">Ko-fi</string>
|
||||
<string name="settings_support_kofi_hint">Eenmalige bijdragen</string>
|
||||
<string name="settings_support_liberapay">Liberapay</string>
|
||||
<string name="settings_support_liberapay_hint">Terugkerende donaties</string>
|
||||
<string name="process_text_create_event">Gebeurtenis maken</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
<string name="app_tagline">一個現代的行事曆。</string>
|
||||
<string name="app_name_calendar_alias">行事曆</string>
|
||||
<string name="state_loading">加載中……</string>
|
||||
<string name="state_retry">重試</string>
|
||||
<string name="state_failure_unknown">出錯了。</string>
|
||||
<string name="state_failure_permission">需使用行事曆。</string>
|
||||
<string name="state_failure_permission_body">Calendula會直接從系統行事曆讀取你的活動,即需要有行事曆存取才能顯示行程。</string>
|
||||
<string name="state_failure_permission_action">授權進入</string>
|
||||
<string name="state_failure_no_calendars">無設定行事曆。</string>
|
||||
<string name="state_failure_no_calendars_body">在這台裝置新增行事曆帳號,行程將會顯示在這。</string>
|
||||
<string name="state_failure_no_calendars_action">開放系統行事曆設定</string>
|
||||
<string name="state_failure_all_hidden">你所有的行事曆都關閉了。</string>
|
||||
<string name="state_failure_all_hidden_body">至少開啟一項行事曆以查看其活動。</string>
|
||||
<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="state_failure_event_not_found">該事項已不存在。</string>
|
||||
<string name="state_failure_event_not_found_body">可能被刪除或被關閉。</string>
|
||||
<string name="state_failure_provider">無法識別該行事曆。</string>
|
||||
</resources>
|
||||
@@ -111,6 +111,7 @@
|
||||
<string name="event_move_write_denied">Calendula needs write access to move events</string>
|
||||
<string name="event_move_gone">That event no longer exists</string>
|
||||
<string name="event_move_blocked_series_end">Can\'t move an event past the end of its series</string>
|
||||
<string name="event_move_blocked_cancelled_series">A cancelled series can only move as a whole, and this would change which days it falls on</string>
|
||||
|
||||
<string name="dialog_cancel">Cancel</string>
|
||||
<string name="dialog_ok">OK</string>
|
||||
@@ -577,6 +578,8 @@
|
||||
<string name="settings_week_start_auto_summary">Currently %1$s</string>
|
||||
<string name="settings_week_numbers">Week numbers</string>
|
||||
<string name="settings_week_numbers_summary">Show calendar-week numbers in month view</string>
|
||||
<string name="settings_month_start_times">Start times</string>
|
||||
<string name="settings_month_start_times_summary">Show the start time before an event\'s title when there is room</string>
|
||||
<string name="settings_today_toolbar">Today button in toolbar</string>
|
||||
<string name="settings_today_toolbar_summary">Show a jump-to-today button in the toolbar instead of a floating button</string>
|
||||
<string name="settings_app_name">App name</string>
|
||||
|
||||
@@ -24,4 +24,5 @@
|
||||
<locale android:name="ru" />
|
||||
<locale android:name="sk" />
|
||||
<locale android:name="zh-CN" />
|
||||
<locale android:name="zh-TW" />
|
||||
</locale-config>
|
||||
@@ -1147,6 +1147,18 @@ class EventWriteMapperTest {
|
||||
assertThat(values).containsEntry(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_TENTATIVE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no new row is written cancelled`() {
|
||||
val cancelled = form().copy(status = EventStatus.Cancelled)
|
||||
assertThat(buildEventInsertValues(cancelled, "uid", cancelled.toWriteTimes(berlin)))
|
||||
.doesNotContainKey(CalendarContract.Events.STATUS)
|
||||
assertThat(buildOccurrenceExceptionValues(cancelled, originalInstanceMillis = 0L, zone = berlin))
|
||||
.containsEntry(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CONFIRMED)
|
||||
val snapshot = masterSnapshot().copy(status = CalendarContract.Events.STATUS_CANCELED)
|
||||
assertThat(buildMovedMasterValues(snapshot, targetCalendarId = 9L, uid = "u@calendula"))
|
||||
.doesNotContainKey(CalendarContract.Events.STATUS)
|
||||
}
|
||||
|
||||
// --- buildMovedMasterValues (calendar move: verbatim series copy) ---
|
||||
|
||||
private fun masterSnapshot(
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.calendar
|
||||
import android.provider.CalendarContract
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
@@ -31,6 +32,12 @@ class ImportedEventValuesTest {
|
||||
private fun values(event: ParsedIcsEvent, palette: List<EventColorOption> = emptyList()) =
|
||||
buildImportedEventValues(event, calendarId = 7L, uid = "u@x", palette = palette)
|
||||
|
||||
@Test
|
||||
fun `a cancelled event is imported confirmed`() {
|
||||
assertThat(values(allDay().copy(status = EventStatus.Cancelled))[CalendarContract.Events.STATUS])
|
||||
.isEqualTo(CalendarContract.Events.STATUS_CONFIRMED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a one-off carries DTEND and no recurrence`() {
|
||||
val v = values(allDay())
|
||||
|
||||
@@ -162,4 +162,13 @@ class InstanceMapperTest {
|
||||
assertThat(event.isCrossedOut).isTrue()
|
||||
assertThat(event.isOutlined).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cancelled one-off from the events table gets a negated id`() {
|
||||
val inst = reader(instanceId = 7L, eventId = 7L, status = CalendarContract.Events.STATUS_CANCELED)
|
||||
.toCancelledOneOff()
|
||||
assertThat(inst?.instanceId).isEqualTo(-7L)
|
||||
assertThat(inst?.eventId).isEqualTo(7L)
|
||||
assertThat(inst?.isCrossedOut).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,14 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.showWeekNumbers.first()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `month start times default on and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
assertThat(prefs.monthShowStartTimes.first()).isTrue()
|
||||
prefs.setMonthShowStartTimes(false)
|
||||
assertThat(prefs.monthShowStartTimes.first()).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `autofocus event title defaults on and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
|
||||
@@ -212,4 +212,30 @@ class ReminderTimeTextTest {
|
||||
)
|
||||
assertThat(text).isEqualTo("Today")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed event from local midnight to midnight reads like an all-day one`() {
|
||||
val oneDay = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 0), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 13, 0, 0), berlin),
|
||||
today = LocalDate.of(2026, 6, 11),
|
||||
)
|
||||
assertThat(oneDay).isEqualTo("Tomorrow")
|
||||
val twoDays = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 0, 0), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 13, 0, 0), berlin),
|
||||
)
|
||||
assertThat(twoDays).startsWith("Today – ")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `midnight is read in the device zone`() {
|
||||
// Midnight in Berlin is 22:00 the day before in UTC, so it is timed there.
|
||||
val text = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 0), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 13, 0, 0), berlin),
|
||||
zone = ZoneOffset.UTC,
|
||||
)
|
||||
assertThat(text).contains("22:00")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.testEvent
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.plus
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
class EventDisplayOrderTest {
|
||||
|
||||
@Test
|
||||
fun `whole-day events order by title east and west of UTC`() {
|
||||
listOf("Europe/Berlin", "America/New_York").map(TimeZone::of).forEach { zone ->
|
||||
val day = LocalDate(2026, 11, 5)
|
||||
val allDay = testEvent(
|
||||
id = 1, title = "B all-day", isAllDay = true,
|
||||
start = Instant.parse("2026-11-05T00:00:00Z"), end = Instant.parse("2026-11-06T00:00:00Z"),
|
||||
)
|
||||
val timed = testEvent(
|
||||
id = 2, title = "A timed",
|
||||
start = day.atStartOfDayIn(zone), end = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone),
|
||||
)
|
||||
|
||||
assertThat(listOf(allDay, timed).sortedForDisplay(zone)).containsExactly(timed, allDay).inOrder()
|
||||
assertThat(listOf(timed, allDay).sortedForDisplay(zone)).containsExactly(timed, allDay).inOrder()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -574,9 +574,18 @@ class EventFormTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cancelled form cannot be saved as a single occurrence`() {
|
||||
assertThat(form().canSaveAsOccurrence()).isTrue()
|
||||
assertThat(form().copy(status = EventStatus.Tentative).canSaveAsOccurrence()).isTrue()
|
||||
assertThat(form().copy(status = EventStatus.Cancelled).canSaveAsOccurrence()).isFalse()
|
||||
fun `a cancelled form saves only as the whole series`() {
|
||||
assertThat(form().canSaveApartFromSeries()).isTrue()
|
||||
assertThat(form().copy(status = EventStatus.Tentative).canSaveApartFromSeries()).isTrue()
|
||||
assertThat(form().copy(status = EventStatus.Cancelled).canSaveApartFromSeries()).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a copy of a cancelled event starts confirmed, a tentative one stays tentative`() {
|
||||
val zone = TimeZone.of("Europe/Berlin")
|
||||
assertThat(testDetail(status = EventStatus.Cancelled).toDuplicateForm(canModify = true, zone).status)
|
||||
.isEqualTo(EventStatus.Confirmed)
|
||||
assertThat(testDetail(status = EventStatus.Tentative).toDuplicateForm(canModify = true, zone).status)
|
||||
.isEqualTo(EventStatus.Tentative)
|
||||
}
|
||||
}
|
||||
@@ -82,4 +82,39 @@ class EventInstanceSpanTest {
|
||||
val timed = instance(utc(2026, 7, 19, 12), utc(2026, 7, 19, 13), isAllDay = false)
|
||||
assertThat(timed.dateZone(newYork)).isEqualTo(newYork)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed event from local midnight to local midnight covers whole days`() {
|
||||
// Stored as 22:00Z, the shape ICSx5 gives a date-only feed event (#296).
|
||||
val oneDay = instance(utc(2026, 7, 18, 22), utc(2026, 7, 19, 22), isAllDay = false)
|
||||
val twoDays = instance(utc(2026, 7, 18, 22), utc(2026, 7, 20, 22), isAllDay = false)
|
||||
assertThat(oneDay.coversWholeDays(berlin)).isTrue()
|
||||
assertThat(twoDays.coversWholeDays(berlin)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local midnight is read in the device zone`() {
|
||||
val event = instance(utc(2026, 7, 18, 22), utc(2026, 7, 19, 22), isAllDay = false)
|
||||
assertThat(event.coversWholeDays(newYork)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed event off midnight at either end does not cover whole days`() {
|
||||
assertThat(instance(utc(2026, 7, 19), utc(2026, 7, 19, 23), isAllDay = false).coversWholeDays(TimeZone.UTC))
|
||||
.isFalse()
|
||||
assertThat(instance(utc(2026, 7, 19, 1), utc(2026, 7, 20), isAllDay = false).coversWholeDays(TimeZone.UTC))
|
||||
.isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero-length event at midnight does not cover whole days`() {
|
||||
assertThat(instance(utc(2026, 7, 19), utc(2026, 7, 19), isAllDay = false).coversWholeDays(TimeZone.UTC))
|
||||
.isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day event covers whole days in any zone`() {
|
||||
assertThat(allDayJul19().coversWholeDays(newYork)).isTrue()
|
||||
assertThat(allDayJul19().coversWholeDays(berlin)).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
@@ -195,11 +197,13 @@ class EventShiftTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day event ignores a resize`() {
|
||||
fun `a resized all-day event becomes timed`() {
|
||||
val allDay = form(isAllDay = true)
|
||||
val start = allDay.start.dateTime.toInstant(berlin)
|
||||
|
||||
assertThat(allDay.resizedTo(start, start, berlin)).isEqualTo(allDay)
|
||||
val timed = allDay.resizedTo(start, start + 1.hours, berlin)
|
||||
assertThat(timed.isAllDay).isFalse()
|
||||
assertThat(timed.end.dateTime).isEqualTo(allDay.start.dateTime.toInstant(berlin).plus(1.hours).toLocalDateTime(berlin))
|
||||
}
|
||||
|
||||
// 02:00 CEST to 02:00 CET on 2026-10-25: one real hour, one wall-clock value.
|
||||
|
||||
@@ -65,4 +65,14 @@ class AgendaTimeLabelTest {
|
||||
assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.Ends(e.end))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed event from midnight to midnight is all-day`() {
|
||||
val oneDay = event(at(2026, 7, 2), at(2026, 7, 3))
|
||||
val twoDays = event(at(2026, 7, 2), at(2026, 7, 4))
|
||||
|
||||
assertThat(labelOn(2026, 7, 2, oneDay)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
assertThat(labelOn(2026, 7, 2, twoDays)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
assertThat(labelOn(2026, 7, 3, twoDays)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,13 @@ class DragPieceBoundsTest {
|
||||
assertThat(bounds.height).isEqualTo(MIN_EVENT_FRACTION * hourPx - insetPx * 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a short piece at the end of the day rises as its block does`() {
|
||||
val bounds = dragPieceBounds(slice(startMin = 1438, spanMin = 1), hourPx, insetPx = 0f)
|
||||
assertThat(bounds.top + bounds.height).isWithin(0.01f).of(24 * hourPx)
|
||||
assertThat(bounds.height).isWithin(0.01f).of(MIN_EVENT_FRACTION * hourPx)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an inset taller than the piece leaves no height at all`() {
|
||||
val bounds = dragPieceBounds(slice(startMin = 0, spanMin = 60), hourPx = 4f, insetPx = insetPx)
|
||||
|
||||
@@ -78,6 +78,21 @@ class OutOfViewEdgesTest {
|
||||
assertThat(outOf(blip).above).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a short event at midnight is judged by where it rises to`() {
|
||||
// 23:58–23:59 draws from 23:34, so a viewport ending at 23:40 already shows it.
|
||||
val late = block(6, 23 * 60 + 58, 23 * 60 + 59)
|
||||
assertThat(outOfViewEvents(listOf(late), topMin = 1000f, bottomMin = 1420f).below).isNull()
|
||||
val target = revealScrollTarget(late, ViewportEdge.Bottom, hourPx = 60f, viewportPx = 10, marginPx = 0f, maxScroll = 1440)
|
||||
assertThat(target).isEqualTo(1440 - 26)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a short event carrying on past midnight is judged by its own start`() {
|
||||
val overnight = block(7, 23 * 60 + 58, 24 * 60).copy(continuesAfter = true)
|
||||
assertThat(outOfViewEvents(listOf(overnight), topMin = 1000f, bottomMin = 1420f).below).isEqualTo(overnight)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event above scrolls to its start less the margin`() {
|
||||
val target = revealScrollTarget(breakfast, ViewportEdge.Top, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** A short block near midnight keeps its whole floor rather than running off the day (#483). */
|
||||
class RenderedSpanTest {
|
||||
|
||||
private val floor = MIN_EVENT_FRACTION * 60f
|
||||
|
||||
@Test
|
||||
fun `a long block draws at its own times`() {
|
||||
assertThat(renderedSpan(540, 600, continuesAfter = false)).isEqualTo(RenderedSpan(540f, 600f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a short block in the day is floored downwards`() {
|
||||
val span = renderedSpan(20 * 60 + 58, 20 * 60 + 59, continuesAfter = false)
|
||||
assertThat(span).isEqualTo(RenderedSpan(1258f, 1258f + floor))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a short block at the end of the day rises to end at midnight`() {
|
||||
val span = renderedSpan(23 * 60 + 58, 23 * 60 + 59, continuesAfter = false)
|
||||
assertThat(span).isEqualTo(RenderedSpan(1440f - floor, 1440f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block ending at midnight rises too`() {
|
||||
val span = renderedSpan(23 * 60 + 59, 1440, continuesAfter = false)
|
||||
assertThat(span).isEqualTo(RenderedSpan(1440f - floor, 1440f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an instant event at the end of the day rises`() {
|
||||
val span = renderedSpan(1439, 1439, continuesAfter = false)
|
||||
assertThat(span.bottomMin).isEqualTo(1440f)
|
||||
assertThat(span.bottomMin - span.topMin).isEqualTo(floor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block that just fits stays put`() {
|
||||
val start = 1440 - 26
|
||||
assertThat(renderedSpan(start, start + 5, continuesAfter = false)).isEqualTo(RenderedSpan(start.toFloat(), 1440f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block carrying on past midnight keeps its start`() {
|
||||
val span = renderedSpan(23 * 60 + 58, 1440, continuesAfter = true)
|
||||
assertThat(span.topMin).isEqualTo(1438f)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.RegisterExtension
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
@@ -676,7 +678,7 @@ class RescheduleViewModelTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cancelled series offers no single-occurrence move`(
|
||||
fun `a cancelled series moves as a whole without asking`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
@@ -686,12 +688,11 @@ class RescheduleViewModelTest {
|
||||
|
||||
vm.move(oneHourLater())
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = false, seriesOnly = true))
|
||||
|
||||
vm.moveWithScope(RecurringWriteScope.ThisEvent)
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.scopePrompt.value).isNull()
|
||||
assertThat(fake.updatedEvents).hasSize(1)
|
||||
assertThat(fake.updatedOccurrences).isEmpty()
|
||||
assertThat(vm.scopePrompt.value).isNotNull()
|
||||
assertThat(fake.updatedFromOccurrences).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -707,8 +708,77 @@ class RescheduleViewModelTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.scopePrompt.value).isNull()
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedCancelledSeries)
|
||||
assertThat(vm.abandoned.value).isEqualTo(1)
|
||||
assertThat(fake.updatedEvents).isEmpty()
|
||||
assertThat(fake.updatedOccurrences).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day event dropped into the time grid becomes timed`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val dayStart = monday.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = {
|
||||
testDetail(
|
||||
instance = testEvent(
|
||||
id = 42L, title = "Offsite", isAllDay = true,
|
||||
start = Instant.fromEpochMilliseconds(dayStart),
|
||||
end = Instant.fromEpochMilliseconds(dayStart + 86_400_000L),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val start = Instant.fromEpochMilliseconds(beginMillis)
|
||||
|
||||
vm.move(
|
||||
MoveRequest(
|
||||
eventId = 42L,
|
||||
beginMillis = dayStart,
|
||||
endMillis = dayStart + 86_400_000L,
|
||||
target = MoveTarget.Span(start, Instant.fromEpochMilliseconds(endMillis)),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
val updated = fake.updatedEvents.single().third
|
||||
assertThat(updated.isAllDay).isFalse()
|
||||
assertThat(updated.start.dateTime).isEqualTo(LocalDateTime(monday, LocalTime(12, 0)))
|
||||
assertThat(updated.end.dateTime).isEqualTo(LocalDateTime(monday, LocalTime(13, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a recurring all-day event dropped into the grid early on its own day can still move the series`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val dayStart = monday.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = {
|
||||
testDetail(
|
||||
instance = testEvent(
|
||||
id = 42L, title = "Offsite", isAllDay = true,
|
||||
start = Instant.fromEpochMilliseconds(dayStart),
|
||||
end = Instant.fromEpochMilliseconds(dayStart + 86_400_000L),
|
||||
),
|
||||
rrule = "FREQ=WEEKLY;BYDAY=MO",
|
||||
)
|
||||
}
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val eight = LocalDateTime(monday, LocalTime(8, 0)).toInstant(TimeZone.currentSystemDefault())
|
||||
|
||||
vm.move(
|
||||
MoveRequest(
|
||||
eventId = 42L,
|
||||
beginMillis = dayStart,
|
||||
endMillis = dayStart + 86_400_000L,
|
||||
target = MoveTarget.Span(eight, eight + 1.hours),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = false))
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ class TimedBlockShapeTest {
|
||||
val block = layoutDay(listOf(ev), day1, zone).single()
|
||||
|
||||
assertThat(block.continuesBefore(day1, zone)).isFalse()
|
||||
assertThat(block.continuesAfter(day1, zone)).isFalse()
|
||||
assertThat(block.continuesAfter).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -46,11 +46,11 @@ class TimedBlockShapeTest {
|
||||
|
||||
val head = layoutDay(listOf(ev), day1, zone).single()
|
||||
assertThat(head.continuesBefore(day1, zone)).isFalse()
|
||||
assertThat(head.continuesAfter(day1, zone)).isTrue()
|
||||
assertThat(head.continuesAfter).isTrue()
|
||||
|
||||
val tail = layoutDay(listOf(ev), day2, zone).single()
|
||||
assertThat(tail.continuesBefore(day2, zone)).isTrue()
|
||||
assertThat(tail.continuesAfter(day2, zone)).isFalse()
|
||||
assertThat(tail.continuesAfter).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -59,7 +59,7 @@ class TimedBlockShapeTest {
|
||||
val middle = layoutDay(listOf(ev), day2, zone).single()
|
||||
|
||||
assertThat(middle.continuesBefore(day2, zone)).isTrue()
|
||||
assertThat(middle.continuesAfter(day2, zone)).isTrue()
|
||||
assertThat(middle.continuesAfter).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -67,7 +67,7 @@ class TimedBlockShapeTest {
|
||||
val ev = event(at(day1, 20), at(day2, 0))
|
||||
val block = layoutDay(listOf(ev), day1, zone).single()
|
||||
|
||||
assertThat(block.continuesAfter(day1, zone)).isFalse()
|
||||
assertThat(block.continuesAfter).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -45,6 +45,14 @@ class TimelineHoursTest {
|
||||
.isEqualTo(280)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the clearance under midnight is not counted as day`() {
|
||||
// 40px an hour plus 100px of clearance over a 600px viewport: 460px of scroll.
|
||||
assertThat(
|
||||
startHourScroll(maxValue = 460, viewportPx = 600, startHour = 7, hourPx = 40f, clearancePx = 100),
|
||||
).isEqualTo(280)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a layout still at the previous scale is waited out`() {
|
||||
// Still 56px an hour: 1344 - 600 = 744 of scroll.
|
||||
|
||||
@@ -40,6 +40,19 @@ class TimelineScaleTest {
|
||||
assertThat(h * 24).isGreaterThan(phoneViewport * 0.9f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fit-day leaves the FAB clearance under midnight`() {
|
||||
val viewport = 800.dp
|
||||
val h = TimelineScale.FitDay.hourHeight(dayFitHeight(viewport))
|
||||
assertThat(h * 24 + FabClearance).isAtMost(viewport)
|
||||
assertThat(h * 24 + FabClearance).isGreaterThan(viewport * 0.99f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the fitted height never goes negative`() {
|
||||
assertThat(dayFitHeight(40.dp)).isEqualTo(0.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fit-day clamps instead of shrinking hours past legibility`() {
|
||||
// A very short viewport (split screen, tiny device) would otherwise give
|
||||
|
||||
@@ -47,4 +47,40 @@ class MonthChipTimeTest {
|
||||
val time = monthChipTime(timed(9, 5), continuesLeft = true, zone, is24Hour = true, locale)
|
||||
assertThat(time).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switched off, no chip shows a time however wide`() {
|
||||
assertThat(monthChipShowsTime(enabled = false, availableWidth = 1000f, labelWidth = 10f)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the time shows only while the label fits beside it`() {
|
||||
assertThat(monthChipShowsTime(enabled = true, availableWidth = 80f, labelWidth = 80f)).isTrue()
|
||||
assertThat(monthChipShowsTime(enabled = true, availableWidth = 79f, labelWidth = 80f)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a short title has to fit whole`() {
|
||||
assertThat(monthTitleToKeep("Lunch")).isEqualTo("Lunch")
|
||||
assertThat(monthTitleToKeep("Appointments")).isEqualTo("Appointments")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a long title only has to keep its first twelve characters`() {
|
||||
assertThat(monthTitleToKeep("Quarterly planning review")).isEqualTo("Quarterly pl")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an emoji at the cut is kept whole`() {
|
||||
val title = "Birthday 🎂 party at home"
|
||||
val kept = monthTitleToKeep(title)
|
||||
assertThat(kept).isEqualTo("Birthday 🎂 p")
|
||||
assertThat(Character.isHighSurrogate(kept.last())).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an emoji as the twelfth character is not split`() {
|
||||
val kept = monthTitleToKeep("Team dinner🍕 tonight")
|
||||
assertThat(kept).isEqualTo("Team dinner🍕")
|
||||
}
|
||||
}
|
||||
@@ -321,4 +321,18 @@ class MonthLayoutTest {
|
||||
private fun declinedTimed(date: LocalDate) =
|
||||
timed(date, 7, 8, id = 4L, title = "Declined early")
|
||||
.copy(response = EventResponse.Declined)
|
||||
|
||||
@Test
|
||||
fun `a timed event from midnight to midnight is a bar, not a timed chip`() {
|
||||
val ev = timedEvent(
|
||||
LocalDate(2026, 6, 10), 0, 0, zone = zone, id = 3L,
|
||||
endDate = LocalDate(2026, 6, 11),
|
||||
)
|
||||
val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone)
|
||||
|
||||
assertThat(week.spans.map { it.event }).containsExactly(ev)
|
||||
assertThat(week.spans.single().startCol).isEqualTo(2)
|
||||
assertThat(week.spans.single().endCol).isEqualTo(2)
|
||||
assertThat(week.timedByDay.values.flatten()).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.StripDrag
|
||||
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.testEvent
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -106,6 +108,42 @@ class WeekLayoutTest {
|
||||
assertThat(blocks.all { it.lane == 0 && it.laneCount == 1 }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two short entries near midnight sit side by side`() {
|
||||
// Both lift to end at midnight, so on real times alone the later one would hide the earlier (#483).
|
||||
val a = event(at(wed, 23, 40), at(wed, 23, 41), id = 1L)
|
||||
val b = event(at(wed, 23, 55), at(wed, 23, 56), id = 2L)
|
||||
val blocks = layoutDay(listOf(a, b), wed, zone).sortedBy { it.lane }
|
||||
assertThat(blocks.map { it.event.instanceId }).containsExactly(1L, 2L).inOrder()
|
||||
assertThat(blocks.all { it.laneCount == 2 }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a short entry lifted into the event before it takes its own lane`() {
|
||||
val a = event(at(wed, 23), at(wed, 23, 45), id = 1L)
|
||||
val b = event(at(wed, 23, 58), at(wed, 23, 59), id = 2L)
|
||||
val blocks = layoutDay(listOf(a, b), wed, zone)
|
||||
assertThat(blocks.map { it.lane }).containsExactly(0, 1)
|
||||
assertThat(blocks.all { it.laneCount == 2 }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a short entry clear of the next one keeps a single lane`() {
|
||||
val a = event(at(wed, 9), at(wed, 9, 10), id = 1L)
|
||||
val b = event(at(wed, 9, 30), at(wed, 10), id = 2L)
|
||||
val blocks = layoutDay(listOf(a, b), wed, zone)
|
||||
assertThat(blocks.all { it.lane == 0 && it.laneCount == 1 }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block knows whether its event carries on past midnight`() {
|
||||
val overnight = event(at(wed, 23), at(wed.plusDays(1), 1))
|
||||
assertThat(layoutDay(listOf(overnight), wed, zone).single().continuesAfter).isTrue()
|
||||
assertThat(layoutDay(listOf(overnight), wed.plusDays(1), zone).single().continuesAfter).isFalse()
|
||||
val toMidnight = event(at(wed, 23), at(wed.plusDays(1), 0))
|
||||
assertThat(layoutDay(listOf(toMidnight), wed, zone).single().continuesAfter).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event spanning midnight is clipped to the day`() {
|
||||
// Starts the previous evening, ends 02:00 on wed.
|
||||
@@ -126,9 +164,9 @@ class WeekLayoutTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day events are excluded from the timed layout`() {
|
||||
fun `all-day events are kept out of the timed layout`() {
|
||||
val ev = event(at(wed, 0), at(wed.plusDays(1), 0), allDay = true)
|
||||
assertThat(layoutDay(listOf(ev), wed, zone)).isEmpty()
|
||||
assertThat(listOf(ev).splitWholeDays(zone)).isEqualTo(listOf(ev) to emptyList<EventInstance>())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -300,4 +338,30 @@ class WeekLayoutTest {
|
||||
val second = event(Instant.parse("2026-10-25T01:10:00Z"), Instant.parse("2026-10-25T01:40:00Z"))
|
||||
assertThat(layoutDay(listOf(second), day, brussels).single().endMin).isEqualTo(2 * 60 + 40)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed event from midnight to midnight stays out of the time grid`() {
|
||||
val ev = testEvent(start = at(wed, 0), end = at(wed.plus(1, DateTimeUnit.DAY), 0))
|
||||
|
||||
assertThat(listOf(ev).splitWholeDays(zone)).isEqualTo(listOf(ev) to emptyList<EventInstance>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bar held over another day makes room there`() {
|
||||
val held = event(at(mon, 0), at(mon.plusDays(1), 0), allDay = true, id = 1, title = "A")
|
||||
val there = event(at(wed, 0), at(wed.plusDays(1), 0), allDay = true, id = 2, title = "B")
|
||||
val state = WeekUiState.Success(
|
||||
weekStart = mon, today = mon, days = weekDays,
|
||||
allDaySpans = layoutAllDay(listOf(held, there), weekDays, zone), timedByDay = emptyMap(),
|
||||
)
|
||||
assertThat(state.allDaySpans.map { it.lane }).containsExactly(0, 0)
|
||||
|
||||
val preview = state.withStripPreview(StripDrag(held, dayShift = 2), zone).allDaySpans
|
||||
val moved = preview.single { it.event.instanceId == held.instanceId }
|
||||
|
||||
assertThat(moved.event).isEqualTo(held)
|
||||
assertThat(moved.startCol).isEqualTo(2)
|
||||
assertThat(preview.map { it.lane }).containsExactly(0, 1)
|
||||
assertThat(state.withStripPreview(StripDrag(held, dayShift = 2, overGrid = true), zone)).isSameInstanceAs(state)
|
||||
}
|
||||
}
|
||||
@@ -71,9 +71,16 @@ class CountdownTest {
|
||||
val over = event(now - 2.hours, now - 1.hours, id = 3)
|
||||
val later = event(now + 3.hours, id = 4)
|
||||
val running = event(now - 1.hours, now + 2.hours, id = 5)
|
||||
assertThat(pickNextEvent(listOf(allDay, declined, over, later), now)?.eventId).isEqualTo(4)
|
||||
assertThat(pickNextEvent(listOf(allDay, declined, over, later), now, zone)?.eventId).isEqualTo(4)
|
||||
// A running event ending before the next one starts comes first.
|
||||
assertThat(pickNextEvent(listOf(later, running), now)?.eventId).isEqualTo(5)
|
||||
assertThat(pickNextEvent(listOf(later, running), now, zone)?.eventId).isEqualTo(5)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next-event mode skips a timed event filling whole days`() {
|
||||
val wholeDay = event(Instant.parse("2026-10-22T00:00:00Z"), Instant.parse("2026-10-23T00:00:00Z"), id = 1)
|
||||
val later = event(now + 30.hours, id = 2)
|
||||
assertThat(pickNextEvent(listOf(wholeDay, later), now, zone)?.eventId).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
إصلاحات
|
||||
• يعرض عرض الشهر المقسّم أحداثًا أكثر: يظهر تاريخ اليوم المحدد مرة واحدة فقط في شريحة التصفية، وصارت رؤوس الأيام تشغل مساحة أقل.
|
||||
• لم تعد نقاط الأحداث في الشبكة المقسّمة تنزل للأسفل في الأيام التي تعرض „+N“.
|
||||
@@ -0,0 +1,13 @@
|
||||
الجديد
|
||||
• سبع أدوات جديدة للشاشة الرئيسية: إضافة سريعة، التاريخ، شريط الأسبوع، القادم، اليوم، العدّ التنازلي، والشهر مع اليوم.
|
||||
• لكل أداة إعداداتها الخاصة: الخلفية والمظهر والتقاويم.
|
||||
• مظهر جديد لأداتي جدول الأعمال والشهر.
|
||||
• اسحب أحداث اليوم الكامل إلى يوم آخر أو إلى الشبكة.
|
||||
• تمييز الأحداث الملغاة والمبدئية.
|
||||
• خيار لإخفاء أوقات البدء في عرض الشهر.
|
||||
|
||||
الإصلاحات
|
||||
• الأحداث القصيرة في آخر الليل تظهر كاملة.
|
||||
|
||||
التغييرات
|
||||
• إشعار واحد لكل حدث حتى مع عدة تذكيرات.
|
||||
@@ -0,0 +1,3 @@
|
||||
Выпраўлена
|
||||
• Падзелены выгляд месяца змяшчае больш падзей: дата абранага дня паказваецца толькі раз, у чыпе фільтра, а загалоўкі дзён займаюць менш месца.
|
||||
• Кропкі падзей у падзеленай сетцы больш не з'язджаюць уніз у днях з «+N».
|
||||
@@ -0,0 +1,13 @@
|
||||
Новае
|
||||
• Сем новых віджэтаў, у тым ліку хуткае даданне, паласа тыдня, адваротны адлік і месяц з днём.
|
||||
• У кожнага віджэта свае налады: фон, тэма і календары.
|
||||
• Новы выгляд віджэтаў спісу падзей і месяца.
|
||||
• Перацягвайце падзеі на ўвесь дзень на іншы дзень або ў сетку.
|
||||
• Адмененыя і папярэднія падзеі пазначаюцца.
|
||||
• Можна схаваць час пачатку ў выглядзе месяца.
|
||||
|
||||
Выпраўлена
|
||||
• Кароткія падзеі позна ўвечары бачныя цалкам.
|
||||
|
||||
Зменена
|
||||
• Адно апавяшчэнне на падзею.
|
||||
@@ -0,0 +1,3 @@
|
||||
Opraveno
|
||||
• Rozdělený měsíční pohled pojme více událostí: vybraný den ukazuje datum jen jednou, v čipu filtru, a záhlaví dnů zabírají méně místa.
|
||||
• Tečky událostí v rozdělené mřížce už ve dnech s „+N“ nesjíždějí dolů.
|
||||
@@ -0,0 +1,13 @@
|
||||
Novinky
|
||||
• Sedm nových widgetů, mimo jiné rychlé přidání, pruh týdne, odpočet a měsíc se dnem.
|
||||
• Každý widget má vlastní nastavení: pozadí, motiv a kalendáře.
|
||||
• Nový vzhled widgetů agendy a měsíce.
|
||||
• Celodenní události lze přetáhnout na jiný den nebo do mřížky.
|
||||
• Zrušené a předběžné události jsou označené.
|
||||
• Možnost skrýt časy začátku v zobrazení měsíce.
|
||||
|
||||
Opraveno
|
||||
• Krátké události pozdě večer jsou vidět celé.
|
||||
|
||||
Změněno
|
||||
• Jedno oznámení na událost.
|
||||
@@ -0,0 +1,3 @@
|
||||
Behoben
|
||||
• Die geteilte Monatsansicht zeigt mehr Termine: Ein gewählter Tag zeigt sein Datum nur noch einmal, im Filter-Chip, und die Tageskopfzeilen brauchen weniger Platz.
|
||||
• Die Termin-Punkte im geteilten Raster rutschen an Tagen mit „+N“ nicht mehr nach unten.
|
||||
@@ -0,0 +1,13 @@
|
||||
Neu
|
||||
• Sieben neue Widgets, u. a. Schnell hinzufügen, Wochenleiste, Countdown und Monat mit Tag.
|
||||
• Jedes Widget hat eigene Einstellungen: Hintergrund, Design und Kalender.
|
||||
• Neuer Look für Terminliste- und Monats-Widget.
|
||||
• Ganztägige Termine auf einen anderen Tag oder ins Raster ziehen.
|
||||
• Abgesagte und vorläufige Termine werden markiert.
|
||||
• Startzeiten in der Monatsansicht ausblendbar.
|
||||
|
||||
Behoben
|
||||
• Kurze Termine spät am Abend voll sichtbar.
|
||||
|
||||
Geändert
|
||||
• Eine Benachrichtigung pro Termin.
|
||||
@@ -0,0 +1,3 @@
|
||||
Fixed
|
||||
• The split month view fits more events: a selected day shows its date once, in the filter chip, and the day headers take less room.
|
||||
• Event dots in the split grid no longer drop down on days with a "+N".
|
||||
@@ -0,0 +1,13 @@
|
||||
New
|
||||
• Seven new widgets, including quick add, week strip, countdown and month with day.
|
||||
• Each widget has its own settings: background, theme and calendars.
|
||||
• New look for the agenda and month widgets.
|
||||
• Drag all-day events to another day or into the grid.
|
||||
• Cancelled and tentative events are marked.
|
||||
• Option to hide start times in the month view.
|
||||
|
||||
Fixed
|
||||
• Short events late at night are fully visible.
|
||||
|
||||
Changed
|
||||
• One notification per event.
|
||||
@@ -0,0 +1,3 @@
|
||||
Corregido
|
||||
• La vista mensual dividida muestra más eventos: el día seleccionado muestra su fecha una sola vez, en el chip del filtro, y las cabeceras de día ocupan menos espacio.
|
||||
• Los puntos de eventos de la cuadrícula dividida ya no bajan en los días con «+N».
|
||||
@@ -0,0 +1,13 @@
|
||||
Novedades
|
||||
• Siete widgets nuevos, como añadir rápido, franja semanal, cuenta atrás y mes con día.
|
||||
• Ajustes por widget: fondo, tema y calendarios.
|
||||
• Nuevo aspecto de los widgets de agenda y mes.
|
||||
• Arrastra eventos de día completo a otro día o a la cuadrícula.
|
||||
• Eventos cancelados y provisionales marcados.
|
||||
• Horas de inicio ocultables en la vista de mes.
|
||||
|
||||
Correcciones
|
||||
• Los eventos cortos a última hora se ven completos.
|
||||
|
||||
Cambios
|
||||
• Una sola notificación por evento.
|
||||
@@ -0,0 +1,3 @@
|
||||
Corrigé
|
||||
• La vue mensuelle partagée affiche plus d’événements : le jour sélectionné n’affiche plus sa date qu’une fois, dans la puce de filtre, et les en-têtes de jour prennent moins de place.
|
||||
• Les points d’événements de la grille partagée ne descendent plus les jours affichant « +N ».
|
||||
@@ -0,0 +1,13 @@
|
||||
Nouveautés
|
||||
• Sept nouveaux widgets, dont ajout rapide, semaine, compte à rebours et mois avec jour.
|
||||
• Réglages par widget : fond, thème, agendas.
|
||||
• Nouveau look des widgets agenda et mois.
|
||||
• Glissez les événements d’une journée vers un autre jour ou dans la grille.
|
||||
• Événements annulés et provisoires signalés.
|
||||
• Heures de début masquables dans la vue mois.
|
||||
|
||||
Corrections
|
||||
• Les événements courts en fin de soirée sont entièrement visibles.
|
||||
|
||||
Modifications
|
||||
• Une seule notification par événement.
|
||||
@@ -0,0 +1,3 @@
|
||||
Javítva
|
||||
• A megosztott havi nézet több eseményt mutat: a kiválasztott nap dátuma csak egyszer, a szűrőchipben jelenik meg, és a napfejlécek kevesebb helyet foglalnak.
|
||||
• A megosztott rács eseménypöttyei már nem csúsznak lejjebb a „+N” jelzésű napokon.
|
||||
@@ -0,0 +1,13 @@
|
||||
Újdonságok
|
||||
• Hét új widget, köztük gyors hozzáadás, heti sáv, visszaszámláló és hónap nappal.
|
||||
• Minden widgetnek saját beállításai vannak: háttér, téma és naptárak.
|
||||
• Új megjelenés a napirend és a havi widgetnek.
|
||||
• Az egész napos események áthúzhatók másik napra vagy a rácsba.
|
||||
• A lemondott és a feltételes események jelölve vannak.
|
||||
• A kezdési időpontok elrejthetők a havi nézetben.
|
||||
|
||||
Javítva
|
||||
• A késő esti rövid események teljesen láthatók.
|
||||
|
||||
Változások
|
||||
• Eseményenként egy értesítés.
|
||||
@@ -0,0 +1,3 @@
|
||||
Corretto
|
||||
• La vista mensile divisa mostra più eventi: il giorno selezionato mostra la data una sola volta, nel chip del filtro, e le intestazioni dei giorni occupano meno spazio.
|
||||
• I puntini degli eventi nella griglia divisa non scendono più nei giorni con «+N».
|
||||
@@ -0,0 +1,13 @@
|
||||
Novità
|
||||
• Sette nuovi widget, tra cui aggiunta rapida, settimana, conto alla rovescia e mese con giorno.
|
||||
• Impostazioni per widget: sfondo, tema, calendari.
|
||||
• Nuovo aspetto dei widget agenda e mese.
|
||||
• Trascina gli eventi di tutto il giorno su un altro giorno o nella griglia.
|
||||
• Eventi annullati e provvisori contrassegnati.
|
||||
• Orari di inizio nascondibili nella vista mese.
|
||||
|
||||
Correzioni
|
||||
• Gli eventi brevi a tarda sera sono interamente visibili.
|
||||
|
||||
Modifiche
|
||||
• Una sola notifica per evento.
|
||||
@@ -0,0 +1,3 @@
|
||||
修正
|
||||
• 分割月表示に表示できる予定が増えました。選択した日の日付はフィルターチップに一度だけ表示され、日付見出しも小さくなりました。
|
||||
• 「+N」が表示される日に、分割グリッドの予定ドットが下にずれなくなりました。
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user