Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
+3 |
9ccc303a2a | ||
|
|
a5c4a73de1 | ||
|
|
dbd5ce6700 | ||
|
|
068a96b228 | ||
|
|
7c522c5903 | ||
|
|
6ef516fe4d | ||
|
|
9a7d65be6b | ||
|
|
c2aae1629f | ||
|
|
a697803e29 | ||
|
|
f4b6cb3acb | ||
|
|
c3f3eb83f2 | ||
|
|
5d10f773ca | ||
|
|
b2fcc55018 | ||
|
|
10414fcf05 | ||
|
|
49f604bdc2 | ||
|
|
56763f58da | ||
|
|
af34f014a7 | ||
|
|
3280191309 | ||
|
|
76cdc1c535 | ||
|
|
68c0a262fa | ||
|
|
39e13e86d2 | ||
|
|
1cc7f40f7e | ||
|
|
8a15325fdc | ||
|
|
7f977971ed | ||
|
|
632155c016 | ||
|
|
8834884d4e | ||
|
|
236372d740 | ||
|
|
678a526cb9 | ||
|
|
84fd51baf6 | ||
|
|
450b8b0107 | ||
|
|
8078fb4f10 | ||
|
|
46b88cc03e |
No files matched your search
+79
-1
@@ -7,8 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.24.0] — 2026-11-06
|
||||
|
||||
### Added
|
||||
- **Long press an event to select it.** In the day, 3-day and week views, a long press lifts the event and selects it. Keep your finger down and move to drag it straight away. A selected event has handles at its top and bottom: drag them to change when it starts or ends, in 15-minute steps and across midnight if you want. The bar at the top edits, duplicates, shares or deletes it, and tapping elsewhere or going back deselects it. Read-only events can be selected but not moved or resized ([#141]).
|
||||
- **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]).
|
||||
- **Tapping a day in the month widget opens that day,** wherever in its cell you tap ([#386]).
|
||||
- **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
|
||||
|
||||
### Added
|
||||
- **Long press an event to select it.** In the day, 3-day and week views, a long press lifts the event and selects it. Keep your finger down and move to drag it straight away. A selected event has handles at its top and bottom: drag them to change when it starts or ends, in 15-minute steps and across midnight if you want. The bar at the top edits, duplicates, shares or deletes it, and tapping elsewhere or going back deselects it. The bar shows the new times while you move or resize. Read-only events can be selected but not moved or resized ([#141]).
|
||||
- **Long press an empty slot to draft an event.** In the day, 3-day and week views, a long press on free time drops a block of your default event length there. Move it while still holding, or let go and drag it or its handles into place, then tap the tick to open a new event at that time. The ✕, a tap elsewhere or going back throws the draft away ([#153]).
|
||||
- **Tap a phone number in an event to call it.** Phone numbers in an event's title, location and description are now links. Tapping one opens your phone app with the number filled in, ready to call. A conference dial-in like "+1 646 558 8656,,850123#" brings its meeting ID along. Meeting IDs and PINs on their own, dates, times and amounts stay plain text. Long-pressing a field copies it, even when you press on a link ([#339]).
|
||||
- **Formatted event descriptions read properly.** Descriptions from Outlook, Exchange and Google Calendar are often stored as HTML, which used to show up as a wall of tags. The event view now shows them as formatted text: bold, italics, lists and links work, table rows sit on one line each, and the fonts and colours the sender picked are left out so the text matches the app. Search matches the text you see. The edit form still shows the original, so saving an event never changes its formatting ([#271]).
|
||||
@@ -16,12 +58,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Create an event from shared or selected text.** Share text to Calendula, or select text in another app and pick Create event from the selection menu, and the new-event form opens with it filled in. A shared subject becomes the title, otherwise the first line does, and the rest goes into the description. The event starts at the next full hour with your default length; Calendula doesn't try to read a date out of the text. If Calendula was opened just for this, saving or closing the form takes you back to the app you came from ([#306]).
|
||||
- **Tapping an event can open the edit form.** Settings → Views → Events → Tapping an event opens can now be set to Edit form instead of Details. Taps in the views and in search then go straight to the editor for events you can edit; read-only events, reminder notifications and widgets still open the details. The editor has Duplicate, Share and Delete in its menu, and going back with unsaved changes asks whether to save, discard or keep editing ([#178]).
|
||||
- **A black theme for AMOLED screens.** Settings → Appearance → Theme now has Black next to System, Light and Dark. It's the dark theme with pure black backgrounds, which saves battery on AMOLED screens and makes event colours stand out more. Day cells and the hour grid stay a shade above black so the grid lines still show, and sheets and menus still stand out from the page. It works with Material You colours too. The home-screen widgets aren't affected ([#181]).
|
||||
- **Donate through Liberapay.** Settings → Support development now offers Liberapay for recurring donations next to Ko-fi for one-off tips.
|
||||
- **More haptic feedback.** Swiping between days in the split month pane ticks, and dropping a moved or resized event gives a short buzz ([#116]).
|
||||
|
||||
### Changed
|
||||
- **The editor says Create for a new event.** New events, duplicates, shared text, imported .ics files and long-press drafts show Create instead of Save, so a duplicate no longer looks like you're editing the original ([#141]).
|
||||
|
||||
### Fixed
|
||||
- **Event colours follow the theme you picked in Calendula.** With the app set to Dark on a phone in light mode, or the other way round, events took their colours from the phone's setting, so they could come out too pale or too dark. They now match the app's theme. Starting the app with a theme set in Calendula also no longer flashes the phone's light or dark background first ([#181]).
|
||||
- **Events on the day the clocks change show at their real time.** In the day, 3-day and week views, events later on the day of a daylight saving change were drawn an hour off, so a 14:45 event showed at 15:45. They now sit at their own time. When the clocks go back, 02:00 to 03:00 happens twice: an event in that hour now stays in the right one when you move, resize or edit it, and the editor and event details add the zone, like CEST or CET, so you can tell the two apart ([#387]).
|
||||
- **The agenda always starts today.** Coming from the month or day view, the agenda used to start at the day you had open there, so "Next 30 days" could show the 1st to the 30th of the month. It now opens on today with your chosen range. Backing out of a day you opened from the agenda still returns you to where you were ([#367]).
|
||||
- **Multi-day events dim on the days already behind you.** With past events dimmed, an event running over several days stayed at full strength on its earlier days until it ended. Those days now dim in the agenda, month, week and day views ([#367]).
|
||||
- **Moving a recurring event to another calendar keeps the occurrences you changed.** Deleted occurrences used to come back after the move, and edited ones showed up twice. They now stay deleted or edited, and keep their colour.
|
||||
- **Sharing an .ics file to Calendula opens the import,** even when the email app adds a subject line as text.
|
||||
- **Rotating the phone no longer throws away an open import, duplicate or picker.**
|
||||
- **The repeat-until date picker opens on today.** Near midnight it used to open on yesterday or tomorrow.
|
||||
- **Tap ripples on week numbers, split-view day cells and the split-view handle stay inside their rounded shape** ([#413]).
|
||||
- **The export calendar picker previews the back gesture** like the other full-screen pickers.
|
||||
|
||||
## [2.22.5] — 2026-10-07
|
||||
|
||||
@@ -1826,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
|
||||
@@ -1874,3 +1928,27 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#181]: https://codeberg.org/jlmakiola/calendula/issues/181
|
||||
[#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
|
||||
[#229]: https://codeberg.org/jlmakiola/calendula/issues/229
|
||||
[#292]: https://codeberg.org/jlmakiola/calendula/issues/292
|
||||
[#372]: https://codeberg.org/jlmakiola/calendula/issues/372
|
||||
[#376]: https://codeberg.org/jlmakiola/calendula/issues/376
|
||||
[#386]: https://codeberg.org/jlmakiola/calendula/issues/386
|
||||
[#420]: https://codeberg.org/jlmakiola/calendula/issues/420
|
||||
[#435]: https://codeberg.org/jlmakiola/calendula/issues/435
|
||||
[#436]: https://codeberg.org/jlmakiola/calendula/issues/436
|
||||
[#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
|
||||
@@ -23,6 +23,8 @@ It works on top of the system calendar and has no network access.</p>
|
||||
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
|
||||
|
||||
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
|
||||
|
||||
<a href="https://liberapay.com/Jean-Luc_Makiola/donate"><img src="https://liberapay.com/assets/widgets/donate.svg" alt="Donate using Liberapay" height="56"></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
@@ -203,6 +205,13 @@ anywhere. Your events stay in Android's own calendar store.
|
||||
[Privacy policy](https://jeanlucmakiola.de/calendula/privacy), written in
|
||||
[`docs/PRIVACY.md`](docs/PRIVACY.md), which is what that page renders.
|
||||
|
||||
## Support
|
||||
|
||||
Calendula is free and has no ads. If you'd like to support its development,
|
||||
you can tip once on [Ko-fi](https://ko-fi.com/jeanlucmakiola) or donate
|
||||
regularly on [Liberapay](https://liberapay.com/Jean-Luc_Makiola/donate). Both
|
||||
are linked in the app under *Settings → Support development*.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE), Jean-Luc Makiola, 2026
|
||||
@@ -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 = 2230004
|
||||
versionName = "2.23.0-beta.4"
|
||||
versionCode = 2240099
|
||||
versionName = "2.24.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -181,6 +181,7 @@ dependencies {
|
||||
implementation("de.jeanlucmakiola.floret:core-android")
|
||||
implementation("de.jeanlucmakiola.floret:identity")
|
||||
implementation("de.jeanlucmakiola.floret:components")
|
||||
implementation("de.jeanlucmakiola.floret:glance")
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.os.Parcel
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.ExperimentalGlanceApi
|
||||
import androidx.glance.appwidget.compose
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* Renders the month widget for a packed month through Glance's own translator
|
||||
* (#372) and checks what one size costs on the wire: the host's binder buffer is
|
||||
* 1 MB for every widget update at once, and Exact sends one copy per size (#214).
|
||||
*/
|
||||
@OptIn(ExperimentalGlanceApi::class)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MonthWidgetPayloadTest {
|
||||
|
||||
@get:Rule
|
||||
val permissionRule: GrantPermissionRule =
|
||||
GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
|
||||
|
||||
private val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
private val context = instrumentation.targetContext
|
||||
private var calendarId = -1L
|
||||
|
||||
private fun Uri.asSyncAdapter(): Uri = buildUpon()
|
||||
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
|
||||
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT)
|
||||
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||
.build()
|
||||
|
||||
@Before
|
||||
fun packTheMonth() {
|
||||
val calendar = ContentValues().apply {
|
||||
put(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT)
|
||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||
put(CalendarContract.Calendars.NAME, ACCOUNT)
|
||||
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, ACCOUNT)
|
||||
put(CalendarContract.Calendars.CALENDAR_COLOR, 0xFF3F51B5.toInt())
|
||||
put(CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.CAL_ACCESS_OWNER)
|
||||
put(CalendarContract.Calendars.OWNER_ACCOUNT, ACCOUNT)
|
||||
put(CalendarContract.Calendars.VISIBLE, 1)
|
||||
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
|
||||
}
|
||||
val uri = context.contentResolver.insert(CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(), calendar)
|
||||
calendarId = ContentUris.parseId(requireNotNull(uri))
|
||||
|
||||
val zone = ZoneId.systemDefault()
|
||||
val first = LocalDate.now(zone).withDayOfMonth(1).minusDays(7)
|
||||
val events = (0 until 49).flatMap { offset ->
|
||||
val day = first.plusDays(offset.toLong())
|
||||
(0 until EVENTS_PER_DAY).map { i ->
|
||||
val start = day.atTime(8 + i, 0).atZone(zone).toInstant().toEpochMilli()
|
||||
ContentValues().apply {
|
||||
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||
put(CalendarContract.Events.TITLE, "Busy $i")
|
||||
put(CalendarContract.Events.DTSTART, start)
|
||||
put(CalendarContract.Events.DTEND, start + 30 * 60_000L)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, zone.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
context.contentResolver.bulkInsert(CalendarContract.Events.CONTENT_URI.asSyncAdapter(), events.toTypedArray())
|
||||
}
|
||||
|
||||
@After
|
||||
fun removeCalendar() {
|
||||
if (calendarId < 0) return
|
||||
context.contentResolver.delete(
|
||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId).asSyncAdapter(),
|
||||
null,
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPackedMonthRendersWithinItsPayload() = runBlocking {
|
||||
for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp), DpSize(600.dp, 1000.dp))) {
|
||||
val views = MonthWidget().compose(context, size = size)
|
||||
val bytes = Parcel.obtain().let { parcel ->
|
||||
try {
|
||||
views.writeToParcel(parcel, 0)
|
||||
parcel.dataSize()
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}
|
||||
var dayNumbers = 0
|
||||
var bars = 0
|
||||
instrumentation.runOnMainSync {
|
||||
val root = views.apply(context, FrameLayout(context))
|
||||
fun walk(view: View) {
|
||||
if (view.id in DAY_IDS && view is TextView && view.text.isNotEmpty()) dayNumbers += 1
|
||||
if (view.id == R.id.bar_title) bars += 1
|
||||
if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i))
|
||||
}
|
||||
walk(root)
|
||||
}
|
||||
Log.i(TAG, "size=$size bytes=$bytes days=$dayNumbers bars=$bars")
|
||||
// The grid itself rendered, not Glance's error layout: a number in every day of every week.
|
||||
assertThat(dayNumbers % 7).isEqualTo(0)
|
||||
assertThat(dayNumbers).isAtLeast(28)
|
||||
assertThat(bytes).isAtMost(MAX_BYTES_PER_SIZE)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "MonthWidgetPayload"
|
||||
const val ACCOUNT = "calendula-widget-payload-test"
|
||||
const val EVENTS_PER_DAY = 6
|
||||
|
||||
val DAY_IDS = setOf(R.id.day_0, R.id.day_1, R.id.day_2, R.id.day_3, R.id.day_4, R.id.day_5, R.id.day_6)
|
||||
|
||||
/** A quarter of the host's buffer, leaving room for a second size and every other widget. */
|
||||
const val MAX_BYTES_PER_SIZE = 256 * 1024
|
||||
}
|
||||
}
|
||||
@@ -152,7 +152,7 @@
|
||||
</intent-filter>
|
||||
|
||||
<!-- Text shared or selected in another app opens a prefilled new
|
||||
event (MainActivity.sharedTextExtrasOrNull, issue #306). -->
|
||||
event (MainActivity.sharedTextRequestOrNull, issue #306). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
@@ -318,6 +318,16 @@
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
||||
<activity
|
||||
android:name=".widget.config.WidgetConfigActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/Theme.Calendula.WidgetConfig">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<receiver
|
||||
android:name=".widget.agenda.AgendaWidgetReceiver"
|
||||
android:label="@string/widget_agenda_label"
|
||||
@@ -342,14 +352,110 @@
|
||||
android:resource="@xml/appwidget_info_month" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.quickadd.QuickAddWidgetReceiver"
|
||||
android:label="@string/widget_quick_add_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_quick_add" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.date.DateWidgetReceiver"
|
||||
android:label="@string/widget_date_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_date" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.week.WeekStripWidgetReceiver"
|
||||
android:label="@string/widget_week_strip_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_week_strip" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.upcoming.UpcomingWidgetReceiver"
|
||||
android:label="@string/widget_upcoming_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_upcoming" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.today.TodayWidgetReceiver"
|
||||
android:label="@string/widget_today_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_today" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.countdown.CountdownWidgetReceiver"
|
||||
android:label="@string/widget_countdown_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_countdown" />
|
||||
</receiver>
|
||||
|
||||
<!-- A countdown widget's redraw for when its label changes (#439). -->
|
||||
<receiver
|
||||
android:name=".widget.WidgetTickReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".widget.split.MonthDayWidgetReceiver"
|
||||
android:label="@string/widget_month_day_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_month_day" />
|
||||
</receiver>
|
||||
|
||||
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
||||
PROVIDER_CHANGED on any data change (our writes and external sync).
|
||||
The day boundary arrives as the app's own ROLLOVER alarm (#228), by
|
||||
explicit PendingIntent, so it needs no filter here; DATE_CHANGED is
|
||||
a free extra only, since Android 8+ withholds it from manifest
|
||||
receivers. The four below re-arm that alarm: TIME_SET /
|
||||
TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it.
|
||||
PROVIDER_CHANGED on any data change (external sync; the app's own
|
||||
writes redraw directly). The day boundary arrives as the app's own
|
||||
ROLLOVER alarm (#228), by explicit PendingIntent, so it needs no
|
||||
filter here; DATE_CHANGED is a free extra only, since Android 8+
|
||||
withholds it from manifest receivers. TIME_SET / TIMEZONE_CHANGED
|
||||
move the boundary, boot / package-replace wipe the alarm, and
|
||||
LOCALE_CHANGED redraws the labels in the new language.
|
||||
Exported: the system broadcasts arrive from outside the app. -->
|
||||
<!-- The app's own redraw request (language change); not exported so
|
||||
other apps can't trigger it. -->
|
||||
<receiver
|
||||
android:name=".widget.WidgetRedrawReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".widget.WidgetUpdateReceiver"
|
||||
android:exported="true">
|
||||
@@ -365,6 +471,7 @@
|
||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
<action android:name="android.intent.action.LOCALE_CHANGED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
|
||||
@@ -10,9 +10,12 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
||||
import de.jeanlucmakiola.calendula.widget.publishCalendulaWidgetPreviews
|
||||
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
|
||||
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -43,6 +46,11 @@ class CalendulaApp : Application() {
|
||||
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
||||
),
|
||||
)
|
||||
// Below API 33 only AppCompat activities get the app language; a process
|
||||
// woken for a widget formats in it too (#90).
|
||||
AppLanguage.applyDefault(this)
|
||||
// Before anything that writes, so the widgets see every change (#420).
|
||||
widgetEntryPoint().widgetRefreshCoordinator().start()
|
||||
reconcileAutoBackup()
|
||||
reconcileSpecialDates()
|
||||
reconcileCalendarVisibility()
|
||||
@@ -55,10 +63,12 @@ class CalendulaApp : Application() {
|
||||
* (#228). Idempotent, and it covers what no broadcast reaches — an alarm
|
||||
* dropped by a force-stop is armed again the next time the app is opened.
|
||||
* Off the main thread: a handful of binder calls on every process start.
|
||||
* The picker previews are republished here too, once per update.
|
||||
*/
|
||||
private fun reconcileWidgetRollover() {
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||
WidgetRolloverScheduler.sync(this@CalendulaApp)
|
||||
syncWidgetRollover(this@CalendulaApp)
|
||||
publishCalendulaWidgetPreviews(this@CalendulaApp)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
@@ -29,6 +30,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.isDark
|
||||
import de.jeanlucmakiola.floret.prefs.isPureBlack
|
||||
@@ -61,6 +63,8 @@ import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache
|
||||
import de.jeanlucmakiola.calendula.ui.theme.windowBackgroundRes
|
||||
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
|
||||
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
@@ -124,10 +128,17 @@ class MainActivity : AppCompatActivity() {
|
||||
cachedTheme?.windowBackgroundRes()?.let { window.setBackgroundDrawableResource(it) }
|
||||
systemBarsDark = (cachedTheme ?: ThemeMode.SYSTEM).isDark(systemDark)
|
||||
applyEdgeToEdge()
|
||||
val appearance = MutableStateFlow(
|
||||
Appearance(
|
||||
themeMode = cachedTheme ?: ThemeMode.SYSTEM,
|
||||
dynamicColor = WindowThemeCache.readDynamicColor(this),
|
||||
),
|
||||
)
|
||||
lifecycleScope.launch {
|
||||
settingsPrefs.themeMode.collect { mode ->
|
||||
WindowThemeCache.write(this@MainActivity, mode)
|
||||
window.setBackgroundDrawableResource(mode.windowBackgroundRes() ?: R.color.window_background)
|
||||
combine(settingsPrefs.themeMode, settingsPrefs.dynamicColor, ::Appearance).collect { stored ->
|
||||
WindowThemeCache.write(this@MainActivity, stored.themeMode, stored.dynamicColor ?: true)
|
||||
window.setBackgroundDrawableResource(stored.themeMode.windowBackgroundRes() ?: R.color.window_background)
|
||||
appearance.value = stored
|
||||
}
|
||||
}
|
||||
// Android hands the intent that started the task back to onCreate every
|
||||
@@ -143,6 +154,7 @@ class MainActivity : AppCompatActivity() {
|
||||
// is what carries one through a recreation — the create form only while
|
||||
// it's still open.
|
||||
val replaying = savedInstanceState != null || intent.isRelaunch()
|
||||
requests.replayed = savedInstanceState != null
|
||||
if (!replaying) {
|
||||
requests.detailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||
requests.editKey = intent.editEventKeyOrNull()
|
||||
@@ -161,7 +173,11 @@ class MainActivity : AppCompatActivity() {
|
||||
// and the Settings screen, so a theme change applies app-wide at once.
|
||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||
val settings by settingsViewModel.state.collectAsStateWithLifecycle()
|
||||
val darkTheme = settings.themeMode.isDark(isSystemInDarkTheme())
|
||||
// Seeded from the cache, so the first frame doesn't draw the default
|
||||
// theme and palette before the stored choices arrive.
|
||||
val shownAppearance by appearance.collectAsStateWithLifecycle()
|
||||
val themeMode = shownAppearance.themeMode
|
||||
val darkTheme = themeMode.isDark(isSystemInDarkTheme())
|
||||
// onCreate can only see the night resource qualifier, not the in-app
|
||||
// override — re-apply from the resolved theme so the bar icons follow
|
||||
// the app's light/dark choice.
|
||||
@@ -199,8 +215,8 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
CalendulaTheme(
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = settings.dynamicColor,
|
||||
pureBlack = settings.themeMode.isPureBlack,
|
||||
dynamicColor = shownAppearance.dynamicColor ?: settings.dynamicColor,
|
||||
pureBlack = themeMode.isPureBlack,
|
||||
typography = typography,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
@@ -263,6 +279,7 @@ class MainActivity : AppCompatActivity() {
|
||||
super.onResume()
|
||||
// Reaching a running UI means startup succeeded; reset the loop trail.
|
||||
CrashReporter.markHealthy(this)
|
||||
AppLanguage.syncMirror(this)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
@@ -273,6 +290,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
requests.replayed = false
|
||||
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requests.detailKey = it }
|
||||
intent.navRequestOrNull()?.let { requests.nav = it }
|
||||
intent.importUriOrNull()?.let { requests.importUri = it }
|
||||
@@ -281,6 +299,13 @@ class MainActivity : AppCompatActivity() {
|
||||
insertFormOpen = true
|
||||
}
|
||||
intent.editEventKeyOrNull()?.let { requests.editKey = it }
|
||||
// The user moved on into the app, so closing the create form no longer
|
||||
// hands control back to the app that started it.
|
||||
if (requests.detailKey != null || requests.nav != null || requests.importUri != null ||
|
||||
requests.editKey != null
|
||||
) {
|
||||
launchedForInsert = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -301,9 +326,10 @@ class MainActivity : AppCompatActivity() {
|
||||
private fun Intent.importUriOrNull(): Uri? {
|
||||
val uri = when (action) {
|
||||
Intent.ACTION_VIEW -> data
|
||||
// Plain text is a create (#306), never a file to import.
|
||||
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
.takeUnless { type == SHARED_TEXT_TYPE }
|
||||
// Shared text is a create (#306); a text/plain file is imported only
|
||||
// when it is calendar data, e.g. an .ics attachment labelled text/plain.
|
||||
Intent.ACTION_SEND -> sharedStreamOrNull()
|
||||
?.takeUnless { type == SHARED_TEXT_TYPE && !it.isCalendarFile() }
|
||||
else -> null
|
||||
} ?: return null
|
||||
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
|
||||
@@ -357,7 +383,7 @@ class MainActivity : AppCompatActivity() {
|
||||
*/
|
||||
private fun Intent.sharedTextRequestOrNull(): InsertRequest? {
|
||||
val shared = when (action) {
|
||||
Intent.ACTION_SEND -> if (type == SHARED_TEXT_TYPE) {
|
||||
Intent.ACTION_SEND -> if (type == SHARED_TEXT_TYPE && sharedStreamOrNull()?.isCalendarFile() != true) {
|
||||
sharedTextOrNull(
|
||||
subject = getCharSequenceExtra(Intent.EXTRA_SUBJECT)?.toString(),
|
||||
text = getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString(),
|
||||
@@ -378,6 +404,23 @@ class MainActivity : AppCompatActivity() {
|
||||
return InsertRequest(form, ImportSource.InsertOpenEnded)
|
||||
}
|
||||
|
||||
private fun Intent.sharedStreamOrNull(): Uri? =
|
||||
IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
|
||||
/** Whether a shared file is iCalendar/vCalendar data, by its resolved type or its name. */
|
||||
private fun Uri.isCalendarFile(): Boolean {
|
||||
val resolvedType = runCatching { contentResolver.getType(this) }.getOrNull()
|
||||
if (resolvedType in CALENDAR_TYPES) return true
|
||||
val name = when (scheme) {
|
||||
"content" -> runCatching {
|
||||
contentResolver.query(this, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
|
||||
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
|
||||
}.getOrNull()
|
||||
else -> lastPathSegment
|
||||
} ?: return false
|
||||
return CALENDAR_EXTENSIONS.any { name.endsWith(it, ignoreCase = true) }
|
||||
}
|
||||
|
||||
/** A Long extra's value, or null when the extra is absent. */
|
||||
private fun Intent.longExtraOrNull(key: String): Long? =
|
||||
if (hasExtra(key)) getLongExtra(key, 0L) else null
|
||||
@@ -504,6 +547,8 @@ class MainActivity : AppCompatActivity() {
|
||||
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
|
||||
private const val SHARED_TEXT_TYPE = "text/plain"
|
||||
private val CALENDAR_TYPES = setOf("text/calendar", "text/x-vcalendar", "application/ics")
|
||||
private val CALENDAR_EXTENSIONS = listOf(".ics", ".vcs")
|
||||
private const val KEY_INSERT_FORM_OPEN = "insert_form_open"
|
||||
private const val KEY_LAUNCHED_FOR_INSERT = "launched_for_insert"
|
||||
|
||||
@@ -550,7 +595,9 @@ class MainActivity : AppCompatActivity() {
|
||||
*/
|
||||
fun openDateIntent(context: Context, date: LocalDate, source: CalendarView): Intent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
data = "calendula://date/$date".toUri()
|
||||
// The source is part of the URI so widgets of different kinds
|
||||
// tapping the same date don't share one PendingIntent.
|
||||
data = "calendula://date/$date/${source.name}".toUri()
|
||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||
putExtra(EXTRA_SOURCE_VIEW, source.name)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
@@ -569,6 +616,7 @@ class MainActivity : AppCompatActivity() {
|
||||
endMillis: Long,
|
||||
source: CalendarView,
|
||||
): Intent = eventDetailIntent(context, eventId, beginMillis, endMillis).apply {
|
||||
data = "calendula://event/$eventId/$beginMillis/${source.name}".toUri()
|
||||
putExtra(EXTRA_SOURCE_VIEW, source.name)
|
||||
}
|
||||
|
||||
@@ -596,3 +644,6 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The theme choices the window needs before the settings screen's state loads. */
|
||||
private data class Appearance(val themeMode: ThemeMode, val dynamicColor: Boolean?)
|
||||
@@ -56,6 +56,10 @@ import javax.inject.Singleton
|
||||
interface CalendarDataSource {
|
||||
fun calendars(): List<CalendarSource>
|
||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||
|
||||
/** [instances] of event [eventId] alone, soonest first. */
|
||||
fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||
instances(beginMillis, endMillis).filter { it.eventId == eventId }
|
||||
/**
|
||||
* [allDayReminderTimeMinutes]: the hour all-day reminders fire at, needed to
|
||||
* decode their stored offsets back to whole-day lead times (see
|
||||
@@ -564,20 +568,60 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
|
||||
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
|
||||
// its own is still one of the series' occurrences.
|
||||
queryInstances(
|
||||
beginMillis,
|
||||
endMillis,
|
||||
"(${CalendarContract.Instances.EVENT_ID} = ? OR ${CalendarContract.Instances.ORIGINAL_ID} = ?)",
|
||||
arrayOf(eventId.toString(), eventId.toString()),
|
||||
)
|
||||
|
||||
private fun queryInstances(
|
||||
beginMillis: Long,
|
||||
endMillis: Long,
|
||||
extraSelection: String?,
|
||||
args: Array<String>?,
|
||||
): List<EventInstance> {
|
||||
ensureObserversRegistered()
|
||||
// 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 " +
|
||||
"${CalendarContract.Instances.ORIGINAL_SYNC_ID} IS NULL)) AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0"
|
||||
return resolver.query(
|
||||
instancesUri(beginMillis, endMillis),
|
||||
InstanceProjection.COLUMNS,
|
||||
// Hide cancelled occurrences: "delete only this event" writes a
|
||||
// cancelled exception for the one instance (#47). 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).
|
||||
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0",
|
||||
null,
|
||||
if (extraSelection == null) visible else "$visible AND $extraSelection",
|
||||
args,
|
||||
CalendarContract.Instances.BEGIN + " ASC",
|
||||
)?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList()
|
||||
}
|
||||
@@ -601,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,
|
||||
@@ -725,7 +770,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
),
|
||||
null,
|
||||
)?.use { c ->
|
||||
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
||||
c.mapRows { EventColorOption(key = getString(0).orEmpty(), argb = getInt(1)) }
|
||||
}
|
||||
?.filter { it.key.isNotEmpty() }
|
||||
?: emptyList()
|
||||
@@ -1116,7 +1161,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
?: throw WriteFailedException("read modified occurrence id=${ex.exceptionEventId}")
|
||||
val endMillis = ex.dtEndMillis ?: (ex.dtStartMillis + parseRfc2445DurationMillis(ex.duration))
|
||||
val form = detail.toEditForm(ex.dtStartMillis, endMillis, zone)
|
||||
.copy(calendarId = targetCalendarId)
|
||||
.copy(calendarId = targetCalendarId, colorKey = null)
|
||||
.toDetachedOccurrence()
|
||||
detached += insertEvent(form, allDayReminderTimeMinutes)
|
||||
}
|
||||
@@ -1691,11 +1736,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
null,
|
||||
)?.use { c -> c.mapRows { toReminder() } } ?: emptyList()
|
||||
|
||||
/** Iterate every row and map; skips nothing. */
|
||||
private inline fun <T> Cursor.mapAll(mapper: (Cursor) -> T): List<T> = buildList {
|
||||
while (moveToNext()) add(mapper(this@mapAll))
|
||||
}
|
||||
|
||||
/** A fresh globally-unique `UID_2445`, for an event that carries none of its own. */
|
||||
private fun mintUid(): String = "${UUID.randomUUID()}@calendula"
|
||||
|
||||
|
||||
@@ -10,14 +10,22 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlin.time.Instant
|
||||
|
||||
interface CalendarRepository {
|
||||
fun calendars(): Flow<List<CalendarSource>>
|
||||
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
||||
|
||||
/** One read of event [eventId]'s occurrences overlapping [range], soonest first, whatever calendar it is in. */
|
||||
suspend fun eventOccurrences(eventId: Long, range: ClosedRange<Instant>): List<EventInstance>
|
||||
|
||||
/** Re-query every open [calendars] / [instances] flow. */
|
||||
fun refresh()
|
||||
|
||||
/** Emits on the app's own writes and visibility changes; not on provider notifications or [refresh]. */
|
||||
val localChanges: Flow<Unit>
|
||||
|
||||
suspend fun eventDetail(eventId: Long): EventDetail
|
||||
|
||||
/**
|
||||
@@ -101,6 +109,12 @@ interface CalendarRepository {
|
||||
form: EventForm,
|
||||
): Long
|
||||
|
||||
/**
|
||||
* Every series split the app makes ([updateEventFromOccurrence]), so what
|
||||
* follows a series by id can move on to the part that continues it.
|
||||
*/
|
||||
val seriesSplits: Flow<SeriesSplit> get() = emptyFlow()
|
||||
|
||||
/**
|
||||
* Change a recurring event from [beginMillis] onwards (series split);
|
||||
* returns the new event's `Events._ID`.
|
||||
@@ -128,3 +142,6 @@ class NoSuchEventException(eventId: Long) :
|
||||
/** A ContentResolver write affected no rows or returned no URI. */
|
||||
class WriteFailedException(operation: String) :
|
||||
RuntimeException("Calendar write failed: $operation")
|
||||
|
||||
/** A "this and following" edit: [oldEventId] now ends where [newEventId] picks up. */
|
||||
data class SeriesSplit(val oldEventId: Long, val newEventId: Long)
|
||||
+23
-1
@@ -78,15 +78,28 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
ticks.tryEmit(Unit)
|
||||
}
|
||||
|
||||
private val writes = MutableSharedFlow<Unit>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
/** Run a provider write on [io], then re-query whether or not it threw. */
|
||||
private suspend fun <T> write(block: suspend () -> T): T = withContext(io) {
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
refresh()
|
||||
writes.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
override val localChanges: Flow<Unit> = merge(
|
||||
writes,
|
||||
prefs.pendingDisabledCalendarIds.distinctUntilChanged().drop(1).map {},
|
||||
prefs.hiddenCalendarIds.distinctUntilChanged().drop(1).map {},
|
||||
)
|
||||
|
||||
/**
|
||||
* Re-query signal for everything filtered by visibility: the provider's own
|
||||
* notifications, plus every change to the pending switch-off set.
|
||||
@@ -181,6 +194,11 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
cachedCalendars
|
||||
}
|
||||
|
||||
override suspend fun eventOccurrences(eventId: Long, range: ClosedRange<Instant>): List<EventInstance> =
|
||||
withContext(io) {
|
||||
dataSource.eventInstances(eventId, range.start.toEpochMillis(), range.endInclusive.toEpochMillis())
|
||||
}
|
||||
|
||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||
dataSource.eventDetail(eventId, allDayReminderTimeMinutes())
|
||||
?: throw NoSuchEventException(eventId)
|
||||
@@ -333,7 +351,11 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
dataSource.updateEventFromOccurrence(
|
||||
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
|
||||
)
|
||||
}
|
||||
}.also { newId -> if (newId != eventId) splits.tryEmit(SeriesSplit(eventId, newId)) }
|
||||
|
||||
private val splits = MutableSharedFlow<SeriesSplit>(extraBufferCapacity = 8)
|
||||
|
||||
override val seriesSplits: Flow<SeriesSplit> = splits
|
||||
|
||||
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = write {
|
||||
dataSource.deleteOccurrence(eventId, beginMillis)
|
||||
|
||||
@@ -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,6 +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())
|
||||
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() }
|
||||
@@ -179,6 +182,9 @@ internal fun buildEventUpdateValues(
|
||||
if (updated.accessLevel != original.accessLevel) {
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, updated.accessLevel.toProviderValue())
|
||||
}
|
||||
if (updated.status != original.status) {
|
||||
put(CalendarContract.Events.STATUS, updated.status.toProviderStatus())
|
||||
}
|
||||
if (updated.colorKey != original.colorKey || updated.color != original.color) {
|
||||
putAll(eventColorColumns(updated.colorKey, updated.color))
|
||||
}
|
||||
@@ -279,6 +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.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))
|
||||
@@ -354,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 })
|
||||
}
|
||||
@@ -858,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() }
|
||||
|
||||
@@ -38,9 +38,17 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
||||
color = color,
|
||||
location = getString(InstanceProjection.IDX_LOCATION),
|
||||
response = mapEventResponse(getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||
status = eventStatus(InstanceProjection.IDX_STATUS),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 —
|
||||
|
||||
@@ -62,6 +62,7 @@ internal object InstanceProjection {
|
||||
CalendarContract.Instances.CALENDAR_COLOR,
|
||||
CalendarContract.Instances.EVENT_LOCATION,
|
||||
CalendarContract.Instances.SELF_ATTENDEE_STATUS,
|
||||
CalendarContract.Instances.STATUS,
|
||||
)
|
||||
|
||||
const val IDX_INSTANCE_ID = 0
|
||||
@@ -75,6 +76,29 @@ internal object InstanceProjection {
|
||||
const val IDX_CALENDAR_COLOR = 8
|
||||
const val IDX_LOCATION = 9
|
||||
const val IDX_SELF_ATTENDEE_STATUS = 10
|
||||
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 {
|
||||
@@ -198,6 +222,7 @@ internal object SearchProjection {
|
||||
// Excerpted, not just filtered on: a hit has to show what it matched.
|
||||
CalendarContract.Events.DESCRIPTION,
|
||||
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||
CalendarContract.Events.STATUS,
|
||||
)
|
||||
|
||||
const val IDX_ID = 0
|
||||
@@ -214,6 +239,7 @@ internal object SearchProjection {
|
||||
const val IDX_RDATE = 11
|
||||
const val IDX_DESCRIPTION = 12
|
||||
const val IDX_SELF_ATTENDEE_STATUS = 13
|
||||
const val IDX_STATUS = 14
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,5 +35,6 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
||||
response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||
status = eventStatus(SearchProjection.IDX_STATUS),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.jeanlucmakiola.calendula.data.di
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
/** The process-wide `CoroutineScope` that app-lifetime singletons share their flows in. */
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class ApplicationScope
|
||||
@@ -24,6 +24,9 @@ import de.jeanlucmakiola.calendula.data.time.AndroidTodaySource
|
||||
import de.jeanlucmakiola.calendula.data.time.AndroidZoneSource
|
||||
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
@@ -85,4 +88,9 @@ object DataProvideModule {
|
||||
@Singleton
|
||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.calendulaDataStore
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@ApplicationScope
|
||||
fun provideApplicationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -154,8 +155,6 @@ class SettingsPrefs @Inject constructor(
|
||||
val dragToReschedule: Flow<Boolean> = prefs.flow(SettingsKeys.dragToReschedule)
|
||||
val tapOpensEditor: Flow<Boolean> = prefs.flow(SettingsKeys.tapOpensEditor)
|
||||
val agendaScreenRange: Flow<AgendaRange> = prefs.flow(SettingsKeys.agendaScreenRange)
|
||||
val agendaWidgetRange: Flow<AgendaRange> = prefs.flow(SettingsKeys.agendaWidgetRange)
|
||||
val widgetSize: Flow<WidgetSize> = prefs.flow(SettingsKeys.widgetSize)
|
||||
val agendaShowRangeBar: Flow<Boolean> = prefs.flow(SettingsKeys.agendaShowRangeBar)
|
||||
val agendaShowToday: Flow<Boolean> = prefs.flow(SettingsKeys.agendaShowToday)
|
||||
val defaultView: Flow<CalendarView> = prefs.flow(SettingsKeys.defaultView)
|
||||
@@ -214,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)
|
||||
@@ -231,8 +231,6 @@ class SettingsPrefs @Inject constructor(
|
||||
suspend fun setDragToReschedule(enabled: Boolean) = prefs.set(SettingsKeys.dragToReschedule, enabled)
|
||||
suspend fun setTapOpensEditor(enabled: Boolean) = prefs.set(SettingsKeys.tapOpensEditor, enabled)
|
||||
suspend fun setAgendaScreenRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaScreenRange, range)
|
||||
suspend fun setAgendaWidgetRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaWidgetRange, range)
|
||||
suspend fun setWidgetSize(size: WidgetSize) = prefs.set(SettingsKeys.widgetSize, size)
|
||||
suspend fun setAgendaShowRangeBar(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowRangeBar, enabled)
|
||||
suspend fun setAgendaShowToday(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowToday, enabled)
|
||||
suspend fun setDefaultView(view: CalendarView) = prefs.set(SettingsKeys.defaultView, view)
|
||||
@@ -449,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)
|
||||
|
||||
@@ -497,13 +498,16 @@ object SettingsKeys {
|
||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||
val agendaScreenRange: Pref<AgendaRange> = agendaRangePref("agenda_screen_range")
|
||||
|
||||
/** How far ahead the agenda **widget** shows events (v2.11). */
|
||||
/**
|
||||
* How far ahead the agenda **widget** shows events (v2.11). Read-only since
|
||||
* widgets keep their own options (#292): the default for a widget that has
|
||||
* none yet.
|
||||
*/
|
||||
val agendaWidgetRange: Pref<AgendaRange> = agendaRangePref("agenda_widget_range")
|
||||
|
||||
/**
|
||||
* The size step the agenda widget draws its text at (#51). [WidgetSize.SMALL]
|
||||
* reproduces its original metrics. The month widget takes no size setting —
|
||||
* it divides the width it is given by seven (#103).
|
||||
* reproduces its original metrics. Read-only like [agendaWidgetRange] (#292).
|
||||
*/
|
||||
val widgetSize: Pref<WidgetSize> = enumPref("widget_size", WidgetSize.SMALL)
|
||||
|
||||
@@ -511,8 +515,8 @@ object SettingsKeys {
|
||||
val agendaShowRangeBar: Pref<Boolean> = booleanPref("agenda_show_range_bar", true)
|
||||
|
||||
/**
|
||||
* Whether the agenda (screen and widget) always anchors today at the top with
|
||||
* a "nothing left today" placeholder (issue #35).
|
||||
* Whether the agenda screen always anchors today at the top with a "nothing
|
||||
* left today" placeholder (issue #35), and the default for a widget (#292).
|
||||
*/
|
||||
val agendaShowToday: Pref<Boolean> = booleanPref("agenda_show_today", true)
|
||||
|
||||
|
||||
+13
-4
@@ -38,6 +38,8 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
ACTION_DISMISS -> notifier.cancel(alert)
|
||||
|
||||
ACTION_SNOOZE -> {
|
||||
// Tapped on a stale view: the reminder that replaced it stays put.
|
||||
if (notifier.isSuperseded(alert)) return
|
||||
// Cancel now so the notification doesn't linger until the alarm;
|
||||
// the snooze delay read is the only async work.
|
||||
notifier.cancel(alert)
|
||||
@@ -50,6 +52,8 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
|
||||
ACTION_SHOW -> {
|
||||
goAsync {
|
||||
// A later reminder posted while this one was snoozed has taken its place.
|
||||
if (notifier.isSuperseded(alert)) return@goAsync
|
||||
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
|
||||
notifier.post(alert)
|
||||
}
|
||||
@@ -82,12 +86,14 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
/**
|
||||
* An explicit intent to this receiver carrying [alert] as extras. The
|
||||
* data URI duplicates no information but is what keeps two reminders'
|
||||
* `PendingIntent`s apart — `filterEquals` never compares extras.
|
||||
* `PendingIntent`s apart — `filterEquals` never compares extras. The
|
||||
* snooze alarm is the exception: one per occurrence, so a later snooze
|
||||
* replaces an earlier one and a newer reminder can cancel it.
|
||||
*/
|
||||
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
||||
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||
this.action = action
|
||||
data = "calendula://reminder/${alert.key}".toUri()
|
||||
data = "calendula://reminder/${identity(alert, action)}".toUri()
|
||||
putExtra(EXTRA_ALERT_KEY, alert.key)
|
||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||
@@ -101,7 +107,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
/**
|
||||
* A stable request code per (alert, action), so one notification's
|
||||
* PendingIntents stay distinct. The shift keeps the action slot intact;
|
||||
* the top bits it drops are separated by [intent]'s per-reminder URI.
|
||||
* the top bits it drops are separated by [intent]'s URI.
|
||||
*/
|
||||
fun requestCode(alert: ReminderAlert, action: String): Int {
|
||||
val actionOffset = when (action) {
|
||||
@@ -111,9 +117,12 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
ACTION_OPEN -> 4
|
||||
else -> 0
|
||||
}
|
||||
return (alert.key.toInt() shl 3) + actionOffset
|
||||
return (identity(alert, action).toInt() shl 3) + actionOffset
|
||||
}
|
||||
|
||||
private fun identity(alert: ReminderAlert, action: String): Long =
|
||||
if (action == ACTION_SHOW) alert.occurrenceKey else alert.key
|
||||
|
||||
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
|
||||
return ReminderAlert(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.occurrenceKey
|
||||
|
||||
/**
|
||||
* One reminder as the notification layer needs it: what to show, and the stable
|
||||
@@ -20,6 +21,13 @@ data class ReminderAlert(
|
||||
val isAllDay: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* The occurrence [ReminderAlert] belongs to. Keys the notification and the
|
||||
* snooze alarm, so a later reminder for the same occurrence replaces the earlier
|
||||
* one instead of stacking beside it (#463).
|
||||
*/
|
||||
val ReminderAlert.occurrenceKey: Long get() = occurrenceKey(eventId, beginMillis)
|
||||
|
||||
fun PlannedReminder.toAlert(): ReminderAlert = ReminderAlert(
|
||||
key = key,
|
||||
eventId = instance.eventId,
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
@@ -28,13 +29,13 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Posts one notification per due reminder on a dedicated channel. Tapping opens
|
||||
* Posts one notification per occurrence on a dedicated channel. Tapping opens
|
||||
* the event's detail screen.
|
||||
*
|
||||
* The tag is the reminder's stable key, so a scan that posts the same reminder
|
||||
* again — a catch-up pass overlapping the alarm that already fired — replaces
|
||||
* its notification silently ([NotificationCompat.Builder.setOnlyAlertOnce])
|
||||
* instead of stacking a second one.
|
||||
* The tag is the [occurrenceKey], so each reminder for an occurrence replaces
|
||||
* the one before it in place (#463). A different reminder alerts afresh; the
|
||||
* same one posted again — a catch-up pass overlapping the alarm that already
|
||||
* fired — replaces its notification silently.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderNotifier @Inject constructor(
|
||||
@@ -65,7 +66,7 @@ class ReminderNotifier @Inject constructor(
|
||||
suspend fun post(alert: ReminderAlert) {
|
||||
if (isSilenced(alert.calendarId)) return
|
||||
ensureChannel()
|
||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
val title = displayTitle(alert)
|
||||
val is24Hour = settingsPrefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
val zone = ZoneId.systemDefault()
|
||||
@@ -96,7 +97,8 @@ class ReminderNotifier @Inject constructor(
|
||||
.setCategory(NotificationCompat.CATEGORY_EVENT)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setOnlyAlertOnce(shownKey(alert) == alert.key)
|
||||
.addExtras(Bundle().apply { putLong(EXTRA_REMINDER_KEY, alert.key) })
|
||||
.setContentIntent(detailIntent(alert))
|
||||
.addAction(
|
||||
R.drawable.ic_notification_snooze,
|
||||
@@ -109,20 +111,58 @@ class ReminderNotifier @Inject constructor(
|
||||
actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
|
||||
)
|
||||
.build()
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
cancelLegacy(alert)
|
||||
try {
|
||||
NotificationManagerCompat.from(context)
|
||||
.notify(alert.key.toString(), NOTIFICATION_ID, notification)
|
||||
manager.notify(alert.occurrenceKey.toString(), NOTIFICATION_ID, notification)
|
||||
} catch (e: SecurityException) {
|
||||
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
||||
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
||||
/**
|
||||
* Remove [alert]'s notification (snooze re-shows it later; dismiss is final),
|
||||
* unless a later reminder has replaced it — an action tapped on a stale view
|
||||
* mustn't take the newer one with it.
|
||||
*/
|
||||
fun cancel(alert: ReminderAlert) {
|
||||
NotificationManagerCompat.from(context).cancel(alert.key.toString(), NOTIFICATION_ID)
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
if (shownKey(alert) == alert.key) manager.cancel(alert.occurrenceKey.toString(), NOTIFICATION_ID)
|
||||
// Posted before reminders shared a notification, under the reminder's own key.
|
||||
manager.cancel(alert.key.toString(), NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
/** Whether a different reminder for [alert]'s occurrence is on screen now. */
|
||||
fun isSuperseded(alert: ReminderAlert): Boolean = shownKey(alert).let { it != null && it != alert.key }
|
||||
|
||||
/** The key of the reminder showing for [alert]'s occurrence, if any. */
|
||||
private fun shownKey(alert: ReminderAlert): Long? {
|
||||
val tag = alert.occurrenceKey.toString()
|
||||
return NotificationManagerCompat.from(context).activeNotifications
|
||||
.firstOrNull { it.tag == tag && it.id == NOTIFICATION_ID }
|
||||
?.notification?.extras
|
||||
?.takeIf { it.containsKey(EXTRA_REMINDER_KEY) }
|
||||
?.getLong(EXTRA_REMINDER_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a reminder for the same occurrence posted before reminders shared a
|
||||
* notification. It carries no reminder key; its time and title identify it.
|
||||
*/
|
||||
private fun cancelLegacy(alert: ReminderAlert) {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
manager.activeNotifications
|
||||
.filter { it.id == NOTIFICATION_ID && it.notification.channelId == CHANNEL_ID }
|
||||
.filter { !it.notification.extras.containsKey(EXTRA_REMINDER_KEY) }
|
||||
.filter { it.notification.`when` == alert.beginMillis }
|
||||
.filter { it.notification.extras.getCharSequence(NotificationCompat.EXTRA_TITLE)?.toString() == displayTitle(alert) }
|
||||
.forEach { manager.cancel(it.tag, it.id) }
|
||||
}
|
||||
|
||||
private fun displayTitle(alert: ReminderAlert): String =
|
||||
alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
|
||||
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
@@ -157,8 +197,10 @@ class ReminderNotifier @Inject constructor(
|
||||
const val TAG = "ReminderNotifier"
|
||||
const val CHANNEL_ID = "reminders"
|
||||
|
||||
// One id, distinct tags: the tag (alert id) already keys the
|
||||
// One id, distinct tags: the tag (occurrence) already keys the
|
||||
// notification, so a fixed id keeps cancellation/replacement simple.
|
||||
const val NOTIFICATION_ID = 1
|
||||
|
||||
const val EXTRA_REMINDER_KEY = "de.jeanlucmakiola.calendula.reminder_key"
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ class ReminderScanner @Inject constructor(
|
||||
private val source: ReminderInstanceSource,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
private val notifier: ReminderNotifier,
|
||||
private val snoozes: ReminderSnoozeScheduler,
|
||||
private val alarms: ReminderAlarmScheduler,
|
||||
private val state: ReminderStatePrefs,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
@@ -104,7 +105,11 @@ class ReminderScanner @Inject constructor(
|
||||
)
|
||||
|
||||
if (notifier.canPost()) {
|
||||
schedule.due.forEach { notifier.post(it.toAlert()) }
|
||||
schedule.due.forEach {
|
||||
val alert = it.toAlert()
|
||||
snoozes.cancel(alert)
|
||||
notifier.post(alert)
|
||||
}
|
||||
}
|
||||
// Advance even when nothing could be posted, so muting notifications
|
||||
// doesn't build a backlog.
|
||||
|
||||
+12
@@ -33,4 +33,16 @@ class ReminderSnoozeScheduler @Inject constructor(
|
||||
)
|
||||
alarmManager.setWakeup(triggerAtMillis, pendingIntent)
|
||||
}
|
||||
|
||||
/** Drop a snooze still pending for [alert]'s occurrence, so it can't re-show over a newer reminder. */
|
||||
fun cancel(alert: ReminderAlert) {
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
ReminderActionReceiver.requestCode(alert, ReminderActionReceiver.ACTION_SHOW),
|
||||
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
|
||||
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE,
|
||||
) ?: return
|
||||
context.getSystemService<AlarmManager>()?.cancel(pendingIntent)
|
||||
pendingIntent.cancel()
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -3,12 +3,15 @@ package de.jeanlucmakiola.calendula.data.time
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.floret.android.broadcastTicks
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
@@ -27,10 +30,12 @@ interface TodaySource {
|
||||
/**
|
||||
* Re-reads the date on every minute boundary while collected, and at once on the
|
||||
* system's date, time and zone broadcasts, which can arrive late or not at all.
|
||||
* One receiver and tick loop is shared by every collector.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AndroidTodaySource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationScope scope: CoroutineScope,
|
||||
) : TodaySource {
|
||||
|
||||
override val today: Flow<LocalDate> = context
|
||||
@@ -42,6 +47,7 @@ class AndroidTodaySource @Inject constructor(
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.shareIn(scope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0), replay = 1)
|
||||
}
|
||||
|
||||
private const val MINUTE_MILLIS = 60_000L
|
||||
|
||||
@@ -3,10 +3,14 @@ package de.jeanlucmakiola.calendula.data.time
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.floret.android.broadcastTicks
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.datetime.TimeZone
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -23,14 +27,16 @@ interface ZoneSource {
|
||||
fun current(): TimeZone
|
||||
}
|
||||
|
||||
/** Follows the system's zone broadcast. */
|
||||
/** Follows the system's zone broadcast, one receiver shared by every collector. */
|
||||
class AndroidZoneSource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationScope scope: CoroutineScope,
|
||||
) : ZoneSource {
|
||||
|
||||
override val zone: Flow<TimeZone> = context.broadcastTicks(Intent.ACTION_TIMEZONE_CHANGED)
|
||||
.map { current() }
|
||||
.distinctUntilChanged()
|
||||
.shareIn(scope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0), replay = 1)
|
||||
|
||||
override fun current(): TimeZone = TimeZone.currentSystemDefault()
|
||||
}
|
||||
@@ -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 {
|
||||
isDeclined -> 3
|
||||
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
|
||||
@@ -42,6 +42,8 @@ data class EventForm(
|
||||
val reminders: List<Int> = emptyList(),
|
||||
val availability: Availability = Availability.Busy,
|
||||
val accessLevel: AccessLevel = AccessLevel.Default,
|
||||
/** Confirmed / Tentative / Cancelled (#388). */
|
||||
val status: EventStatus = EventStatus.Confirmed,
|
||||
/**
|
||||
* Bare RRULE value (`Events.RRULE` convention, no "RRULE:" prefix); null
|
||||
* means a one-off event. May hold rules the simple picker can't express —
|
||||
@@ -113,6 +115,7 @@ enum class EventFormField {
|
||||
Reminders,
|
||||
Recurrence,
|
||||
Availability,
|
||||
Status,
|
||||
Visibility,
|
||||
Color,
|
||||
Attendees,
|
||||
@@ -178,6 +181,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
||||
availability = availability,
|
||||
accessLevel = accessLevel,
|
||||
status = status,
|
||||
rrule = rrule.normalizedRrule(),
|
||||
// The provider fills EVENT_COLOR from the key, so [color] is the
|
||||
// swatch either way; a null colour means the event inherits its
|
||||
@@ -213,7 +217,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
* re-deriving with the same occurrence would mask an externally moved
|
||||
* event. Guests are covered (the form writes editable attendees), so an
|
||||
* external attendee change now also trips the conflict check. Still not
|
||||
* covered: status, the user's own response, reminder methods, the
|
||||
* covered: the user's own response, reminder methods, the
|
||||
* organizer/resource rows, and a recurring event's duration.
|
||||
*/
|
||||
data class EditSnapshot(
|
||||
@@ -243,6 +247,22 @@ internal fun allDayFormTimes(start: LocalDate, endExclusive: LocalDate?): Pair<F
|
||||
/** The bare RRULE value the form edits; blank means none. */
|
||||
internal fun String?.normalizedRrule(): String? = this?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }
|
||||
|
||||
/**
|
||||
* 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.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
|
||||
* pinned to another zone. False when there's nothing to disambiguate: an
|
||||
@@ -269,6 +289,7 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
||||
if (rrule != null) add(EventFormField.Recurrence)
|
||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||
if (status != EventStatus.Confirmed) add(EventFormField.Status)
|
||||
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
||||
if (colorKey != null || color != null) add(EventFormField.Color)
|
||||
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
|
||||
@@ -311,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))
|
||||
}
|
||||
@@ -131,6 +131,10 @@ private class HtmlRenderer(private val html: String) {
|
||||
text("<")
|
||||
return lt + 1
|
||||
}
|
||||
if (!closing && isAutolink(html.substring(nameStart, end))) {
|
||||
text(html.substring(lt, end + 1))
|
||||
return end + 1
|
||||
}
|
||||
var nameEnd = nameStart
|
||||
while (nameEnd < end && (html[nameEnd].isLetterOrDigit() || html[nameEnd] == ':')) nameEnd++
|
||||
val name = html.substring(nameStart, nameEnd).lowercase()
|
||||
@@ -149,6 +153,11 @@ private class HtmlRenderer(private val html: String) {
|
||||
return end + 1
|
||||
}
|
||||
|
||||
/** An angle-bracketed URL or address (`<https://…>`, `<a@b.c>`), kept as text. */
|
||||
private fun isAutolink(inner: String): Boolean =
|
||||
LINK_SCHEMES.any { inner.startsWith(it, ignoreCase = true) } ||
|
||||
('@' in inner && inner.none { it.isWhitespace() || it == '=' })
|
||||
|
||||
/** The `>` closing the tag at [lt], skipping any inside quoted attributes. */
|
||||
private fun tagEnd(lt: Int): Int {
|
||||
var quote: Char? = null
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
@@ -72,6 +73,8 @@ data class EventInstance(
|
||||
* (#180, #230).
|
||||
*/
|
||||
val response: EventResponse = EventResponse.Going,
|
||||
/** The event's own Confirmed / Tentative / Cancelled (`Events.STATUS`, #388). */
|
||||
val status: EventStatus = EventStatus.Confirmed,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -94,16 +97,33 @@ enum class EventResponse {
|
||||
Declined,
|
||||
}
|
||||
|
||||
/** Shorthand for the declined case, which most surfaces test on its own (#180). */
|
||||
val EventInstance.isDeclined: Boolean get() = response == EventResponse.Declined
|
||||
/**
|
||||
* Struck through on every surface and sorted last: an invitation you declined
|
||||
* (#180), or an event its organizer cancelled (#388). Neither plans reminders.
|
||||
*/
|
||||
val EventInstance.isCrossedOut: Boolean
|
||||
get() = response == EventResponse.Declined || status == EventStatus.Cancelled
|
||||
|
||||
/**
|
||||
* Drawn as an outline, still undecided: an invitation you have not answered
|
||||
* (#230), or a tentative event (#388).
|
||||
*/
|
||||
val EventInstance.isOutlined: Boolean
|
||||
get() = !isCrossedOut && (response == EventResponse.Invited || status == EventStatus.Tentative)
|
||||
|
||||
/**
|
||||
* Whether this event has finished relative to [now] — its end is at or before
|
||||
* the current instant. An in-progress event (already started but not yet ended)
|
||||
* is *not* considered ended. All-day events end at the exclusive next-midnight,
|
||||
* so they only count as ended once their day is fully over.
|
||||
* is *not* considered ended. All-day events end at the exclusive next-midnight
|
||||
* in [zone] rather than the UTC midnight they are stored on, so they only count
|
||||
* as ended once their local day is fully over.
|
||||
*/
|
||||
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
||||
fun EventInstance.hasEnded(now: Instant, zone: TimeZone = TimeZone.currentSystemDefault()): Boolean =
|
||||
localEnd(zone) <= now
|
||||
|
||||
/** [EventInstance.end], with an all-day event's UTC midnight moved to local midnight in [zone]. */
|
||||
fun EventInstance.localEnd(zone: TimeZone): Instant =
|
||||
if (isAllDay) end.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone) else end
|
||||
|
||||
/**
|
||||
* The zone this event's calendar dates live in: the device [zone] for timed
|
||||
@@ -130,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?,
|
||||
|
||||
@@ -66,7 +66,9 @@ data class SanitizedRrule(val rule: String?, val repaired: Boolean)
|
||||
* rule at all — gives up entirely.
|
||||
*/
|
||||
fun sanitizeRrule(raw: String?): SanitizedRrule {
|
||||
val rule = raw?.trim()?.removePrefix("RRULE:")?.trim().orEmpty()
|
||||
val rule = raw?.trim()
|
||||
?.let { if (it.startsWith("RRULE:", ignoreCase = true)) it.drop("RRULE:".length) else it }
|
||||
?.trim().orEmpty()
|
||||
if (rule.isEmpty()) return SanitizedRrule(rule = null, repaired = false)
|
||||
|
||||
var freqSeen = false
|
||||
@@ -84,8 +86,8 @@ fun sanitizeRrule(raw: String?): SanitizedRrule {
|
||||
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
|
||||
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
|
||||
// being dropped, so it falls through and marks the rule repaired.
|
||||
for (part in rruleParts(raw.orEmpty().trim())) {
|
||||
val key = part.key?.trim().orEmpty()
|
||||
for (part in rruleParts(rule)) {
|
||||
val key = part.key?.trim()?.uppercase().orEmpty()
|
||||
val value = part.value.trim()
|
||||
val kept = when {
|
||||
key.isEmpty() || value.isEmpty() -> null
|
||||
|
||||
@@ -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,6 +36,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||
description = description.orEmpty(),
|
||||
reminders = semanticReminderMinutes(),
|
||||
availability = availability,
|
||||
status = status.forNewEvent(),
|
||||
rrule = recurrenceRule.normalizedRrule(),
|
||||
)
|
||||
}
|
||||
@@ -32,19 +32,26 @@ data class PlannedReminder(
|
||||
val minutes: Int,
|
||||
val alarmMillis: Long,
|
||||
) {
|
||||
/** The occurrence this reminder belongs to; it shares one notification with its siblings (#463). */
|
||||
val occurrenceKey: Long = occurrenceKey(instance.eventId, instance.beginMillis)
|
||||
|
||||
/**
|
||||
* Stable identity, keying the notification tag and the snooze/dismiss
|
||||
* Stable identity of this one reminder, keying the snooze/dismiss
|
||||
* `PendingIntent`s. Survives reboot, re-scan and reinstall.
|
||||
*/
|
||||
val key: Long = key(instance.eventId, instance.beginMillis, minutes)
|
||||
val key: Long = mix64(occurrenceKey + minutes)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
fun key(eventId: Long, beginMillis: Long, minutes: Int): Long {
|
||||
var h = eventId * 1_000_003L
|
||||
h = (h xor beginMillis) * 31L
|
||||
return h + minutes
|
||||
}
|
||||
}
|
||||
/** Stable identity of one occurrence, shared by every reminder on it. */
|
||||
fun occurrenceKey(eventId: Long, beginMillis: Long): Long =
|
||||
mix64(mix64(eventId) + beginMillis)
|
||||
|
||||
/** SplitMix64's finaliser: spreads close inputs across all 64 bits. */
|
||||
private fun mix64(value: Long): Long {
|
||||
var z = value + -0x61c8864680b583ebL
|
||||
z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L
|
||||
z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L
|
||||
return z xor (z ushr 31)
|
||||
}
|
||||
|
||||
/** What one scan concluded: post these now, and wake up again at [nextAlarmMillis]. */
|
||||
@@ -146,7 +153,8 @@ private fun allDayAlarmMillis(
|
||||
*
|
||||
* Due means the fire instant falls in `(lastFiredMillis, nowMillis]`, so a scan
|
||||
* running twice cannot post twice while a late one still catches up. Reminders
|
||||
* whose event has ended are dropped ([isStillRelevant]). [nextAlarmMillis] is
|
||||
* whose event has ended are dropped ([isStillRelevant]), and of several due on
|
||||
* one occurrence only the latest is kept — they'd replace each other anyway (#463). [nextAlarmMillis] is
|
||||
* capped at [horizonMillis] so the lookahead window keeps rolling forward.
|
||||
*/
|
||||
fun scheduleReminders(
|
||||
@@ -158,7 +166,8 @@ fun scheduleReminders(
|
||||
val due = planned
|
||||
.filter { it.alarmMillis in (lastFiredMillis + 1)..nowMillis }
|
||||
.filter { it.instance.isStillRelevant(nowMillis) }
|
||||
.distinctBy { it.key }
|
||||
.groupBy { it.occurrenceKey }
|
||||
.values.map { siblings -> siblings.maxBy { it.alarmMillis } }
|
||||
.sortedWith(compareBy({ it.instance.beginMillis }, { it.key }))
|
||||
val nextPending = planned
|
||||
.filter { it.alarmMillis > nowMillis }
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.SeekableTransitionState
|
||||
import androidx.compose.animation.core.rememberTransition
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -34,6 +35,7 @@ import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberHeld
|
||||
import de.jeanlucmakiola.calendula.ui.common.screen
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewScreen
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
@@ -66,7 +68,7 @@ import de.jeanlucmakiola.calendula.ui.common.EventSelectionHost
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventActionFailureMessage
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberFabSelection
|
||||
import de.jeanlucmakiola.calendula.ui.common.canWriteCalendar
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGateOr
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||
@@ -243,20 +245,29 @@ fun CalendarHost(
|
||||
// An opened/received .ics file. [ImportScreen] parses it and either opens
|
||||
// the prefilled create form (one event → [importForm]) or its own bulk
|
||||
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
||||
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
||||
var importUri by rememberSaveable { mutableStateOf<android.net.Uri?>(null) }
|
||||
val importViewModel: EventEditViewModel = hiltViewModel(key = "import")
|
||||
// Saved as "open"; a recreation takes the form back from the ViewModel, which
|
||||
// still holds the user's edits.
|
||||
var importForm by rememberSaveable(
|
||||
stateSaver = Saver<EventForm?, Boolean>(
|
||||
save = { it != null },
|
||||
restore = { open -> if (open) importViewModel.currentForm() else null },
|
||||
),
|
||||
) { mutableStateOf<EventForm?>(null) }
|
||||
// Which channel filled [importForm]: an .ics file (prompt to apply the default
|
||||
// reminder) or an ACTION_INSERT intent (apply it automatically) — #49.
|
||||
var importFormSource by remember { mutableStateOf(ImportSource.File) }
|
||||
var importFormSource by rememberSaveable { mutableStateOf(ImportSource.File) }
|
||||
// Bumped per open, so even an equal prefill starts a fresh form.
|
||||
var importFormId by remember { mutableIntStateOf(0) }
|
||||
var importFormId by rememberSaveable { mutableIntStateOf(0) }
|
||||
// Whether [importForm] came from an external create request (#30, #306);
|
||||
// closing it then hands control back to whoever asked.
|
||||
var importFromRequest by rememberSaveable { mutableStateOf(false) }
|
||||
// A second external request that arrived while a prefilled form was open:
|
||||
// it waits behind that form's unsaved-changes prompt, then opens.
|
||||
var pendingInsert by remember { mutableStateOf<Pair<EventForm, ImportSource>?>(null) }
|
||||
val importViewModel: EventEditViewModel = hiltViewModel(key = "import")
|
||||
val createViewModel: EventEditViewModel = hiltViewModel(key = "create")
|
||||
val editViewModel: EventEditViewModel = hiltViewModel(key = "edit")
|
||||
fun openImportForm(form: EventForm, source: ImportSource, fromRequest: Boolean = false) {
|
||||
importFormSource = source
|
||||
importForm = form
|
||||
@@ -280,7 +291,7 @@ fun CalendarHost(
|
||||
// flow — picker + summary — even for a single-event file, because the intent
|
||||
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||
// keeps routing a single event straight into the prefilled create form.
|
||||
var importForceMany by remember { mutableStateOf(false) }
|
||||
var importForceMany by rememberSaveable { mutableStateOf(false) }
|
||||
// Where a restore came from, so closing the import puts it back: the import
|
||||
// overlays are declared under Backup & restore and the manager, so starting
|
||||
// one has to close them — without this, finishing a restore drops you on the
|
||||
@@ -291,9 +302,20 @@ fun CalendarHost(
|
||||
// what tells it a *re-import of the same file* is new work and not the run
|
||||
// it already finished; a rotation keeps the number and keeps the result.
|
||||
var importSession by rememberSaveable { mutableStateOf(0) }
|
||||
// A recreation re-reads the launch intent; when the import it carries came
|
||||
// back with the saved state, that replay is the same work, not a new one.
|
||||
fun isReplayOfRestoredImport(): Boolean {
|
||||
val replay = requests.replayed && (importUri != null || importForm != null)
|
||||
requests.replayed = false
|
||||
return replay
|
||||
}
|
||||
val requestedImportUri = requests.importUri
|
||||
LaunchedEffect(requestedImportUri) {
|
||||
if (requestedImportUri != null) {
|
||||
if (isReplayOfRestoredImport()) {
|
||||
requests.importUri = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
importUri = requestedImportUri
|
||||
importForceMany = false
|
||||
importSession++
|
||||
@@ -307,6 +329,10 @@ fun CalendarHost(
|
||||
val requestedInsert = requests.insert
|
||||
LaunchedEffect(requestedInsert) {
|
||||
if (requestedInsert != null) {
|
||||
if (isReplayOfRestoredImport()) {
|
||||
requests.insert = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (importForm == null) {
|
||||
openImportForm(requestedInsert.form, requestedInsert.source, fromRequest = true)
|
||||
} else {
|
||||
@@ -318,16 +344,22 @@ fun CalendarHost(
|
||||
|
||||
// Close every overlay that can sit over the calendar, so an externally
|
||||
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
|
||||
// top instead of underneath whatever the user had open.
|
||||
fun dismissCoveringOverlays() {
|
||||
// top instead of underneath whatever the user had open. The create form,
|
||||
// declared under the edit form, can stay for an edit to open over it.
|
||||
fun dismissCoveringOverlays(keepCreate: Boolean = false) {
|
||||
showSettings = false
|
||||
showSearch = false
|
||||
showCalendars = false
|
||||
showBackup = false
|
||||
detailKey = null
|
||||
editKey = null
|
||||
if (!keepCreate) createRequest = null
|
||||
importUri = null
|
||||
importForm = null
|
||||
importFromRequest = false
|
||||
if (importFromRequest) {
|
||||
importFromRequest = false
|
||||
requests.onExternalFormClosed()
|
||||
}
|
||||
pendingInsert = null
|
||||
importViewModel.reset()
|
||||
}
|
||||
@@ -340,7 +372,10 @@ fun CalendarHost(
|
||||
val requestedEditKey = requests.editKey
|
||||
LaunchedEffect(requestedEditKey) {
|
||||
if (requestedEditKey != null) {
|
||||
dismissCoveringOverlays()
|
||||
// An open edit form stays on screen, so it starts over here rather
|
||||
// than once it has slid away.
|
||||
if (editKey != null) editViewModel.reset()
|
||||
dismissCoveringOverlays(keepCreate = true)
|
||||
editKey = requestedEditKey
|
||||
requests.editKey = null
|
||||
}
|
||||
@@ -361,7 +396,6 @@ fun CalendarHost(
|
||||
// Drill the day view in over the widget's view: drop any overlay
|
||||
// that would cover it, so the open doesn't land under Settings/form.
|
||||
dismissCoveringOverlays()
|
||||
createRequest = null
|
||||
focus.navigateTo(LocalDate.parse(req.dateIso))
|
||||
// No widget source (an external date tap) roots over the default
|
||||
// home view, so backing out of the day returns home then exits.
|
||||
@@ -372,7 +406,6 @@ fun CalendarHost(
|
||||
// Root the stack in the widget's view, then open the occurrence
|
||||
// detail over it (same key shape as a tapped event / reminder).
|
||||
dismissCoveringOverlays()
|
||||
createRequest = null
|
||||
viewStack = viewBaseStack(defaultView, req.source)
|
||||
detailKey = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
|
||||
requests.nav = null
|
||||
@@ -383,7 +416,6 @@ fun CalendarHost(
|
||||
// the stack on the target (null → the default home) over the
|
||||
// default home — so backing out returns to the default, then exits.
|
||||
dismissCoveringOverlays()
|
||||
createRequest = null
|
||||
focus.navigateTo(currentDate())
|
||||
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
||||
requests.nav = null
|
||||
@@ -393,6 +425,7 @@ fun CalendarHost(
|
||||
// widget) must land on top of whatever is open — the form overlay
|
||||
// sits below Settings/calendars in the Box, so without this it
|
||||
// would open hidden underneath them.
|
||||
if (createRequest != null) createViewModel.reset()
|
||||
dismissCoveringOverlays()
|
||||
openCreate(req.dateIso ?: currentDate().toString())
|
||||
requests.nav = null
|
||||
@@ -414,9 +447,22 @@ fun CalendarHost(
|
||||
// tap time, so a tap before they've loaded still routes right.
|
||||
val tapScope = rememberCoroutineScope()
|
||||
val tapContext = LocalContext.current
|
||||
val requireWriteOr = rememberWriteGateOr()
|
||||
// A refused write upgrade is asked once; later taps just open the details.
|
||||
var tapWriteRefused by rememberSaveable { mutableStateOf(false) }
|
||||
// The tap waiting on the WRITE_CALENDAR prompt, saved so a rotation keeps it.
|
||||
var tapAwaitingWrite by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||
val editAwaitingTap = rememberWriteAction(
|
||||
onDenied = {
|
||||
tapAwaitingWrite?.let { key ->
|
||||
tapWriteRefused = true
|
||||
detailKey = key
|
||||
}
|
||||
tapAwaitingWrite = null
|
||||
},
|
||||
) {
|
||||
tapAwaitingWrite?.let { editKey = it }
|
||||
tapAwaitingWrite = null
|
||||
}
|
||||
val onEventClick: (EventInstance) -> Unit = { event ->
|
||||
tapScope.launch {
|
||||
val toEditor = viewModel.tapOpensEditor() && eventActions.canModify(event.calendarId)
|
||||
@@ -424,9 +470,9 @@ fun CalendarHost(
|
||||
!toEditor -> onOpenDetail(event)
|
||||
tapContext.canWriteCalendar() -> onEditEvent(event)
|
||||
tapWriteRefused -> onOpenDetail(event)
|
||||
else -> requireWriteOr({ onEditEvent(event) }) {
|
||||
tapWriteRefused = true
|
||||
onOpenDetail(event)
|
||||
else -> {
|
||||
tapAwaitingWrite = event.occurrenceKey()
|
||||
editAwaitingTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -674,24 +720,26 @@ fun CalendarHost(
|
||||
|
||||
// Event form (v1.2) — full-screen destination, slides over the calendar.
|
||||
val shownCreate = rememberHeld(createRequest)
|
||||
SlideOverlay(visible = createRequest != null) {
|
||||
SlideOverlay(visible = createRequest != null, onHidden = createViewModel::reset) {
|
||||
shownCreate?.let { create ->
|
||||
EventEditScreen(
|
||||
initialDateIso = create.dateIso,
|
||||
initialStartMinutes = create.startMinutes,
|
||||
initialDraft = create.draftMillis,
|
||||
onClose = { createRequest = null },
|
||||
onSaved = { createRequest = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
visible = createRequest != null,
|
||||
viewModel = hiltViewModel(key = "create"),
|
||||
)
|
||||
key(create) {
|
||||
EventEditScreen(
|
||||
initialDateIso = create.dateIso,
|
||||
initialStartMinutes = create.startMinutes,
|
||||
initialDraft = create.draftMillis,
|
||||
onClose = { createRequest = null },
|
||||
onSaved = { createRequest = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
visible = createRequest != null,
|
||||
viewModel = createViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Edit form (v1.3) — slides over the detail screen.
|
||||
val shownEditKey = rememberHeld(editKey)
|
||||
SlideOverlay(visible = editKey != null) {
|
||||
SlideOverlay(visible = editKey != null, onHidden = editViewModel::reset) {
|
||||
shownEditKey?.let { key ->
|
||||
EventEditScreen(
|
||||
initialDateIso = null,
|
||||
@@ -712,7 +760,7 @@ fun CalendarHost(
|
||||
if (detailKey == null) detailKey = key
|
||||
},
|
||||
visible = editKey != null,
|
||||
viewModel = hiltViewModel(key = "edit"),
|
||||
viewModel = editViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -830,33 +878,25 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
|
||||
* snapping back and sliding off.
|
||||
*/
|
||||
@Composable
|
||||
private fun SlideOverlay(visible: Boolean, content: @Composable AnimatedVisibilityScope.() -> Unit) {
|
||||
private fun SlideOverlay(
|
||||
visible: Boolean,
|
||||
onHidden: () -> Unit = {},
|
||||
content: @Composable AnimatedVisibilityScope.() -> Unit,
|
||||
) {
|
||||
val state = remember { MutableTransitionState(visible) }
|
||||
state.targetState = visible
|
||||
val hidden = state.isIdle && !state.currentState
|
||||
LaunchedEffect(hidden) {
|
||||
if (hidden) onHidden()
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
visibleState = state,
|
||||
enter = slideInHorizontally(rememberCalendarSlideSpec()) { it } + fadeIn(),
|
||||
exit = predictiveBackExit(),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [live], or after it goes null the last value it had, so an overlay keeps
|
||||
* drawing what it showed through its slide-out instead of flashing empty. Never
|
||||
* a placeholder: until [live] is first set this is null, so a destination never
|
||||
* loads a bogus key on its first frame. Not saved — after a recreation the
|
||||
* saveable [live] is the value again.
|
||||
*/
|
||||
@Composable
|
||||
private fun <T : Any> rememberHeld(live: T?): T? {
|
||||
val held = remember { HeldValue<T>() }
|
||||
if (live != null) held.value = live
|
||||
return live ?: held.value
|
||||
}
|
||||
|
||||
private class HeldValue<T : Any> {
|
||||
var value: T? = null
|
||||
}
|
||||
|
||||
/** The detail and edit overlays' key for one occurrence: eventId, begin, end. */
|
||||
private fun EventInstance.occurrenceKey(): LongArray =
|
||||
longArrayOf(eventId, start.toEpochMilliseconds(), end.toEpochMilliseconds())
|
||||
|
||||
@@ -36,6 +36,9 @@ class ExternalRequests(val onExternalFormClosed: () -> Unit = {}) {
|
||||
|
||||
/** An external "edit this event" (ACTION_EDIT), in the detail key's shape. */
|
||||
var editKey by mutableStateOf<LongArray?>(null)
|
||||
|
||||
/** Whether these came from re-reading the launch intent on a recreation. */
|
||||
var replayed = false
|
||||
}
|
||||
|
||||
/** A prefilled create form from an external launch, with the source it came from. */
|
||||
|
||||
@@ -32,7 +32,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.domain.isCrossedOut
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -138,7 +148,7 @@ internal fun AgendaEventRow(
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
GroupedRow(
|
||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = declinedTitle(title, event.isDeclined),
|
||||
title = declinedTitle(title, event.isCrossedOut),
|
||||
summary = AnnotatedString(agendaTimeSummary(event, day, zone)),
|
||||
position = position,
|
||||
minHeight = 64.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
|
||||
@@ -393,11 +394,8 @@ private fun AgendaList(
|
||||
val itemDates = agendaItemDates(days)
|
||||
val anchorRow = itemDates.indexOfFirst { it >= anchor }.coerceAtLeast(0)
|
||||
SideEffect { anchorIndex.intValue = anchorRow }
|
||||
// A new anchor opens the list on its row from the first frame, with past
|
||||
// days above. Keyed on the anchor the days arrived with, not the one just
|
||||
// requested: scrolling before the new rows compose leaves the list keyed to
|
||||
// a row that reappears further down, and it follows it there. The first run
|
||||
// may restore a remembered day instead.
|
||||
// A new anchor opens on its row from the first frame; keyed on the anchor
|
||||
// the days arrived with, not the one requested. The first run may restore.
|
||||
val pendingRestore = remember { arrayOf(initialTopDate) }
|
||||
DisposableEffect(anchor) {
|
||||
val target = pendingRestore[0]
|
||||
@@ -435,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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,15 +163,17 @@ class AgendaViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the window [PAST_CHUNK_DAYS] further back (#367). Ignored while the
|
||||
* previous extension is still loading, so a scroll that keeps asking only
|
||||
* moves one chunk at a time.
|
||||
* Extend the window further back (#367), doubling what's loaded (at least
|
||||
* [PAST_CHUNK_DAYS], at most [MAX_PAST_STEP_DAYS]) so a long scroll back
|
||||
* re-queries only a few times. Ignored while the previous extension is
|
||||
* still loading.
|
||||
*/
|
||||
fun loadEarlier() {
|
||||
val loaded = (state.value as? AgendaUiState.Success)?.windowStart ?: return
|
||||
val window = _window.value
|
||||
if (loaded != window.start) return
|
||||
_window.value = window.copy(pastDays = window.pastDays + PAST_CHUNK_DAYS)
|
||||
val step = window.pastDays.coerceIn(PAST_CHUNK_DAYS, MAX_PAST_STEP_DAYS)
|
||||
_window.value = window.copy(pastDays = window.pastDays + step)
|
||||
}
|
||||
|
||||
fun rememberTopDay(date: LocalDate) {
|
||||
@@ -233,6 +235,9 @@ class AgendaViewModel @Inject constructor(
|
||||
/** How many days each scroll back into the past loads (#367). */
|
||||
internal const val PAST_CHUNK_DAYS = 30
|
||||
|
||||
/** The most one scroll back adds, so an empty past grows the window by years, not exponentially. */
|
||||
internal const val MAX_PAST_STEP_DAYS = 365
|
||||
|
||||
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */
|
||||
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> =
|
||||
dayRange(anchor, anchor.plus(days, DateTimeUnit.DAY), zone)
|
||||
@@ -30,7 +30,9 @@ fun CalendarDatePickerDialog(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val state = rememberDatePickerState(
|
||||
initialSelectedDateMillis = initial.toEpochDays() * MILLIS_PER_DAY,
|
||||
initialSelectedDateMillis = initial.coerceIn(CalendarSpan.FIRST_DAY, CalendarSpan.LAST_DAY)
|
||||
.toEpochDays() * MILLIS_PER_DAY,
|
||||
yearRange = CalendarSpan.years,
|
||||
)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
|
||||
@@ -26,8 +26,6 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
@@ -40,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(
|
||||
@@ -82,8 +83,7 @@ fun CalendarFabColumn(
|
||||
)
|
||||
}
|
||||
// Held past the selection clearing, so the bar has something to fold away.
|
||||
val heldBar = remember { mutableStateOf<(@Composable () -> Unit)?>(null) }
|
||||
if (selection != null && heldBar.value !== selection.bar) heldBar.value = selection.bar
|
||||
val heldBar = rememberHeld(selection?.bar)
|
||||
// Held at the FAB's height so the taller bar can't shift the FAB.
|
||||
Row(
|
||||
modifier = Modifier.height(FabSize),
|
||||
@@ -101,7 +101,7 @@ fun CalendarFabColumn(
|
||||
shrinkTowards = Alignment.End,
|
||||
) + fadeOut(),
|
||||
) {
|
||||
heldBar.value?.invoke()
|
||||
heldBar?.invoke()
|
||||
}
|
||||
val label = stringResource(
|
||||
when {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* The dates every view can show and every date picker offers. One span for all
|
||||
* of them, so no view can hand another a date it has no page for (#457).
|
||||
*/
|
||||
object CalendarSpan {
|
||||
val FIRST_DAY: LocalDate = LocalDate(1600, 1, 1)
|
||||
val LAST_DAY: LocalDate = LocalDate(2399, 12, 31)
|
||||
|
||||
val years: IntRange get() = FIRST_DAY.year..LAST_DAY.year
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -100,33 +101,28 @@ suspend fun CalendarRepository.tryDeleteEvent(
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an action that writes to the calendar, asking for `WRITE_CALENDAR` first
|
||||
* where the install only holds read access (v1.0 installs). A grant continues
|
||||
* straight into the action that asked for it.
|
||||
* [action], which writes to the calendar, as a handler that asks for
|
||||
* `WRITE_CALENDAR` first where the install only holds read access (v1.0
|
||||
* installs). A grant continues into [action], also when it returns after a
|
||||
* rotation; a refusal runs [onDenied].
|
||||
*/
|
||||
@Composable
|
||||
fun rememberWriteGate(): (() -> Unit) -> Unit {
|
||||
val gate = rememberWriteGateOr()
|
||||
return { action -> gate(action) {} }
|
||||
}
|
||||
|
||||
/** [rememberWriteGate], running the fallback instead when the user refuses. */
|
||||
@Composable
|
||||
fun rememberWriteGateOr(): (action: () -> Unit, fallback: () -> Unit) -> Unit {
|
||||
fun rememberWriteAction(onDenied: () -> Unit = {}, action: () -> Unit): () -> Unit {
|
||||
val context = LocalContext.current
|
||||
var pending by remember { mutableStateOf<Pair<() -> Unit, () -> Unit>?>(null) }
|
||||
val currentAction by rememberUpdatedState(action)
|
||||
val currentDenied by rememberUpdatedState(onDenied)
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
pending?.let { (action, fallback) -> if (granted) action() else fallback() }
|
||||
pending = null
|
||||
if (granted) currentAction() else currentDenied()
|
||||
}
|
||||
return { action, fallback ->
|
||||
if (context.canWriteCalendar()) {
|
||||
action()
|
||||
} else {
|
||||
pending = action to fallback
|
||||
launcher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
return remember(context, launcher) {
|
||||
{
|
||||
if (context.canWriteCalendar()) {
|
||||
currentAction()
|
||||
} else {
|
||||
launcher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
@@ -26,7 +27,6 @@ import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -42,6 +42,7 @@ class EventActionsViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
private val icsExporter: IcsExporter,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
private val zoneSource: ZoneSource,
|
||||
) : ViewModel() {
|
||||
|
||||
/** Every calendar by id, for what a selected event's bar may offer. */
|
||||
@@ -64,7 +65,7 @@ class EventActionsViewModel @Inject constructor(
|
||||
suspend fun duplicateForm(event: EventInstance): EventForm? {
|
||||
val detail = load(event) ?: return null
|
||||
val canModify = calendars.value[event.calendarId]?.canModifyContents == true
|
||||
return detail.toDuplicateForm(canModify, TimeZone.currentSystemDefault())
|
||||
return detail.toDuplicateForm(canModify, zoneSource.current())
|
||||
}
|
||||
|
||||
/** A staged `.ics` for [event], or null — reported — when it can't be built. */
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Repeat
|
||||
import androidx.compose.material.icons.filled.TaskAlt
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
@@ -29,6 +30,7 @@ fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||
EventFormField.Availability -> R.string.event_edit_availability
|
||||
EventFormField.Status -> R.string.event_edit_status
|
||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||
EventFormField.Color -> R.string.event_edit_color
|
||||
EventFormField.Attendees -> R.string.event_edit_attendees
|
||||
@@ -41,6 +43,7 @@ fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||
EventFormField.Reminders -> Icons.Default.Notifications
|
||||
EventFormField.Recurrence -> Icons.Default.Repeat
|
||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||
EventFormField.Status -> Icons.Default.TaskAlt
|
||||
EventFormField.Visibility -> Icons.Default.Lock
|
||||
EventFormField.Color -> Icons.Default.Palette
|
||||
EventFormField.Attendees -> Icons.Default.People
|
||||
|
||||
@@ -91,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventResponse
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.domain.isCrossedOut
|
||||
import de.jeanlucmakiola.calendula.domain.isOutlined
|
||||
|
||||
/** Stroke an invitation you have not answered is outlined with. */
|
||||
val EVENT_OUTLINE_WIDTH = 1.dp
|
||||
@@ -60,13 +60,13 @@ fun timedBlockCuts(continuesBefore: Boolean, continuesAfter: Boolean): ChipCuts
|
||||
|
||||
/**
|
||||
* How one event's chip or block is painted, which turns on your answer to its
|
||||
* invitation (#180, #230).
|
||||
* invitation (#180, #230) and on the event's own status (#388).
|
||||
*
|
||||
* An invitation you have not answered is drawn as an outline: its calendar's
|
||||
* colour on the border and on the title, over the surface the chip sits on. So
|
||||
* it holds a chip's shape and a chip's weight without claiming the filled
|
||||
* container an event you are going to gets. A declined one keeps the fill and
|
||||
* its strike-through.
|
||||
* An invitation you have not answered, or a tentative event, is drawn as an
|
||||
* outline: its calendar's colour on the border and on the title, over the
|
||||
* surface the chip sits on. So it holds a chip's shape and a chip's weight
|
||||
* without claiming the filled container an event you are going to gets. A
|
||||
* declined or cancelled one keeps the fill and its strike-through.
|
||||
*/
|
||||
@Immutable
|
||||
data class EventPaint(
|
||||
@@ -87,7 +87,7 @@ data class EventPaint(
|
||||
@Composable
|
||||
fun eventPaint(event: EventInstance, dark: Boolean): EventPaint {
|
||||
val soften = LocalSoftenColors.current
|
||||
if (event.response == EventResponse.Invited) {
|
||||
if (event.isOutlined) {
|
||||
// Harmonised even when the setting is off. Raw mode exists so a filled
|
||||
// container matches what the sync source paints, and an outlined chip
|
||||
// has no container — what it has is coloured *text*, which needs the
|
||||
@@ -117,7 +117,7 @@ fun eventPaint(event: EventInstance, dark: Boolean): EventPaint {
|
||||
outline = null,
|
||||
titleInk = eventInk(fill, alpha = TITLE_INK_ALPHA),
|
||||
secondaryInk = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||
decoration = declinedDecoration(event.isDeclined),
|
||||
decoration = declinedDecoration(event.isCrossedOut),
|
||||
titleWeight = null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ fun rememberFabSelection(
|
||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val requireWrite = rememberWriteGate()
|
||||
val chooserTitle = stringResource(R.string.event_share_chooser_title)
|
||||
|
||||
// What the FAB has been told for this selection, over what the coverage says.
|
||||
@@ -43,10 +42,28 @@ fun rememberFabSelection(
|
||||
val currentEdit by rememberUpdatedState(onEdit)
|
||||
val currentDuplicate by rememberUpdatedState(onDuplicate)
|
||||
val currentDismissChip by rememberUpdatedState(onDismissChip)
|
||||
val currentWrite by rememberUpdatedState(requireWrite)
|
||||
// Each reads the selection at grant time, which may come after a rotation.
|
||||
val onCreate = rememberWriteAction { host.selection?.create?.invoke() }
|
||||
val onEditSelected = rememberWriteAction {
|
||||
host.selection?.takeIf { it.create == null }?.let { selected ->
|
||||
selected.dismiss()
|
||||
currentEdit(selected.event)
|
||||
}
|
||||
}
|
||||
val onDuplicateSelected = rememberWriteAction {
|
||||
val selected = host.selection?.takeIf { it.create == null } ?: return@rememberWriteAction
|
||||
scope.launch {
|
||||
val form = viewModel.duplicateForm(selected.event) ?: return@launch
|
||||
selected.dismiss()
|
||||
currentDuplicate(form)
|
||||
}
|
||||
}
|
||||
val onDeleteSelected = rememberWriteAction {
|
||||
host.selection?.takeIf { it.create == null }?.let { viewModel.requestDelete(it.event) }
|
||||
}
|
||||
|
||||
val selection = host.selection ?: return null
|
||||
selection.create?.let { create ->
|
||||
if (selection.create != null) {
|
||||
return remember(selection, expanded, chipShown) {
|
||||
FabSelection(
|
||||
expanded = expanded,
|
||||
@@ -56,7 +73,7 @@ fun rememberFabSelection(
|
||||
},
|
||||
bar = {
|
||||
DraftSelectionBar(
|
||||
onCreate = { currentWrite { create() } },
|
||||
onCreate = onCreate,
|
||||
onDiscard = selection.dismiss,
|
||||
)
|
||||
},
|
||||
@@ -78,28 +95,15 @@ fun rememberFabSelection(
|
||||
EventSelectionBar(
|
||||
canEdit = canEdit,
|
||||
canDelete = canDelete,
|
||||
onEdit = {
|
||||
currentWrite {
|
||||
selection.dismiss()
|
||||
currentEdit(event)
|
||||
}
|
||||
},
|
||||
onDuplicate = {
|
||||
currentWrite {
|
||||
scope.launch {
|
||||
val form = viewModel.duplicateForm(event) ?: return@launch
|
||||
selection.dismiss()
|
||||
currentDuplicate(form)
|
||||
}
|
||||
}
|
||||
},
|
||||
onEdit = onEditSelected,
|
||||
onDuplicate = onDuplicateSelected,
|
||||
onShare = {
|
||||
scope.launch {
|
||||
val uri = viewModel.shareUri(event) ?: return@launch
|
||||
if (!context.sendEventShare(uri, chooserTitle)) viewModel.reportShareFailed()
|
||||
}
|
||||
},
|
||||
onDelete = { currentWrite { viewModel.requestDelete(event) } },
|
||||
onDelete = onDeleteSelected,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
@@ -57,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(
|
||||
@@ -87,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 ->
|
||||
@@ -111,14 +112,13 @@ fun HourGutter(
|
||||
}
|
||||
}
|
||||
// Held while it fades, so it goes out where it was rather than blinking off.
|
||||
val badgeMin = remember { mutableStateOf<Int?>(null) }
|
||||
dragStartMin?.let { badgeMin.value = it }
|
||||
val badgeMin = rememberHeld(dragStartMin)
|
||||
val badgeIn by animateFloatAsState(
|
||||
targetValue = if (dragStartMin != null) 1f else 0f,
|
||||
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||
label = "gutterBadge",
|
||||
)
|
||||
badgeMin.value?.takeIf { badgeIn > 0f }?.let { startMin ->
|
||||
badgeMin?.takeIf { badgeIn > 0f }?.let { startMin ->
|
||||
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* One shared state per pager page — a week, a day, a month — so a page and the
|
||||
* screen's anchor read the same query, and the pages either side stay loaded
|
||||
* across a swipe.
|
||||
*
|
||||
* Every entry shares on its own job, cancelled when [keep] lets it go: a
|
||||
* `stateIn` on the ViewModel's scope would stay running, holding its last state,
|
||||
* until the ViewModel itself was cleared. Main thread only.
|
||||
* An entry queries only while something collects it, stopping [STOP_TIMEOUT_MILLIS]
|
||||
* after the last collector leaves, and starts again when one comes back. Letting
|
||||
* an entry go only forgets it: a page still holding it keeps a working state
|
||||
* however far the pager composes or prefetches, and however long the screen sat
|
||||
* stopped (#440, #454). A forgotten entry nobody collects holds no coroutine, so
|
||||
* paging through a year doesn't keep a year of queries around. Main thread only.
|
||||
*
|
||||
* @param keep whether a cached key is still worth holding once [get] asks for another.
|
||||
*/
|
||||
@@ -26,28 +30,54 @@ class PageStateCache<K, S>(
|
||||
private val keep: (cached: K, requested: K) -> Boolean,
|
||||
private val load: (K) -> Flow<S>,
|
||||
) {
|
||||
private class Entry<S>(val state: StateFlow<S>, val job: Job)
|
||||
|
||||
private val entries = HashMap<K, Entry<S>>()
|
||||
private val entries = HashMap<K, Entry>()
|
||||
|
||||
/** The number of entries held, for tests. */
|
||||
internal val size: Int get() = entries.size
|
||||
|
||||
fun get(key: K): StateFlow<S> {
|
||||
val iterator = entries.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val (cached, entry) = iterator.next()
|
||||
if (cached != key && !keep(cached, key)) {
|
||||
entry.job.cancel()
|
||||
iterator.remove()
|
||||
// One still collected stays, so the anchor asking for it again shares its query.
|
||||
entries.entries.removeAll { (cached, entry) ->
|
||||
cached != key && !keep(cached, key) && !entry.isCollected
|
||||
}
|
||||
return entries.getOrPut(key) { Entry(key) }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForInheritanceCoroutinesApi::class)
|
||||
private inner class Entry(private val key: K) : StateFlow<S> {
|
||||
private val state = MutableStateFlow(initial)
|
||||
private var collectors = 0
|
||||
private var loading: Job? = null
|
||||
private var stopping: Job? = null
|
||||
|
||||
val isCollected: Boolean get() = collectors > 0
|
||||
|
||||
override val value: S get() = state.value
|
||||
override val replayCache: List<S> get() = state.replayCache
|
||||
|
||||
override suspend fun collect(collector: FlowCollector<S>): Nothing {
|
||||
collectors++
|
||||
stopping?.cancel()
|
||||
stopping = null
|
||||
if (loading?.isActive != true) {
|
||||
loading = scope.launch { load(key).collect { state.value = it } }
|
||||
}
|
||||
try {
|
||||
state.collect(collector)
|
||||
} finally {
|
||||
if (--collectors == 0) {
|
||||
stopping = scope.launch {
|
||||
delay(STOP_TIMEOUT_MILLIS)
|
||||
loading?.cancel()
|
||||
loading = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries.getOrPut(key) {
|
||||
val job = SupervisorJob(scope.coroutineContext[Job])
|
||||
Entry(
|
||||
state = load(key).stateWhileSubscribed(scope + job, initial),
|
||||
job = job,
|
||||
)
|
||||
}.state
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Long enough to ride out a configuration change, as `stateWhileSubscribed` does. */
|
||||
const val STOP_TIMEOUT_MILLIS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ fun EventInstance.isPastOn(
|
||||
day: LocalDate,
|
||||
now: Instant,
|
||||
zone: TimeZone = TimeZone.currentSystemDefault(),
|
||||
): Boolean = hasEnded(now) || day < now.toLocalDateTime(zone).date
|
||||
): Boolean = hasEnded(now, zone) || day < now.toLocalDateTime(zone).date
|
||||
|
||||
/** Opacity applied to a completed/past event chip when it is dimmed. */
|
||||
const val EventDimAlpha = 0.4f
|
||||
@@ -41,19 +41,19 @@ const val EventDimAlpha = 0.4f
|
||||
* is still something the organiser expects you at. Null for everything else, so
|
||||
* it drops straight into a `Text`'s `textDecoration`.
|
||||
*/
|
||||
fun declinedDecoration(isDeclined: Boolean): TextDecoration? =
|
||||
if (isDeclined) TextDecoration.LineThrough else null
|
||||
fun declinedDecoration(isCrossedOut: Boolean): TextDecoration? =
|
||||
if (isCrossedOut) TextDecoration.LineThrough else null
|
||||
|
||||
/** [declinedDecoration] for the rows that take styled text rather than a `String`. */
|
||||
fun declinedTitle(title: String, isDeclined: Boolean): AnnotatedString = if (isDeclined) {
|
||||
fun declinedTitle(title: String, isCrossedOut: Boolean): AnnotatedString = if (isCrossedOut) {
|
||||
AnnotatedString(title, SpanStyle(textDecoration = TextDecoration.LineThrough))
|
||||
} else {
|
||||
AnnotatedString(title)
|
||||
}
|
||||
|
||||
/** [declinedTitle] over already-styled text, e.g. a search hit's marked runs. */
|
||||
fun declinedTitle(title: AnnotatedString, isDeclined: Boolean): AnnotatedString =
|
||||
if (!isDeclined) {
|
||||
fun declinedTitle(title: AnnotatedString, isCrossedOut: Boolean): AnnotatedString =
|
||||
if (!isCrossedOut) {
|
||||
title
|
||||
} else {
|
||||
buildAnnotatedString {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
/**
|
||||
* [live], or after it goes null the last value it had, so an overlay keeps
|
||||
* drawing what it showed through its slide-out instead of flashing empty. Never
|
||||
* a placeholder: until [live] is first set this is null, so a destination never
|
||||
* loads a bogus key on its first frame. Not saved — after a recreation the
|
||||
* saveable [live] is the value again.
|
||||
*/
|
||||
@Composable
|
||||
fun <T : Any> rememberHeld(live: T?): T? {
|
||||
val held = remember { HeldValue<T>() }
|
||||
if (live != null) held.value = live
|
||||
return live ?: held.value
|
||||
}
|
||||
|
||||
private class HeldValue<T : Any> {
|
||||
var value: T? = null
|
||||
}
|
||||
@@ -6,6 +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.canSaveApartFromSeries
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||
@@ -57,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
|
||||
}
|
||||
|
||||
@@ -114,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
|
||||
}
|
||||
|
||||
@@ -203,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
|
||||
@@ -227,15 +240,21 @@ class RescheduleViewModel @Inject constructor(
|
||||
busy = false
|
||||
return@launch
|
||||
}
|
||||
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) {
|
||||
_outcome.value = MoveOutcome.BlockedCancelledSeries
|
||||
_abandoned.value += 1
|
||||
busy = false
|
||||
} else {
|
||||
// Still busy: the scope dialog is now the thing in flight.
|
||||
pending = prepared
|
||||
_scopePrompt.value = MoveScopePrompt(
|
||||
occurrenceOnly = !prepared.canRealign,
|
||||
resize = request.target is MoveTarget.Span,
|
||||
resize = prepared.isResize,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -459,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
|
||||
}
|
||||
|
||||
@@ -494,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()
|
||||
}
|
||||
|
||||
@@ -101,9 +101,10 @@ fun TimeZonePickerDialog(
|
||||
onDismiss = onDismiss,
|
||||
scrollable = false,
|
||||
) {
|
||||
SearchField(
|
||||
PickerSearchField(
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
placeholder = stringResource(R.string.event_edit_timezone_search),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
|
||||
@@ -181,9 +182,10 @@ fun TimeZonePickerDialog(
|
||||
* which has a top bar to put it in) because the picker's bar is the title.
|
||||
*/
|
||||
@Composable
|
||||
private fun SearchField(
|
||||
internal fun PickerSearchField(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
@@ -206,7 +208,7 @@ private fun SearchField(
|
||||
InlineTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = stringResource(R.string.event_edit_timezone_search),
|
||||
placeholder = placeholder,
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Search,
|
||||
onImeAction = { keyboard?.hide() },
|
||||
|
||||
@@ -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). */
|
||||
@@ -626,25 +646,30 @@ class TimelineDragController {
|
||||
*/
|
||||
private var originSlot: Triple<LocalDate, Int, Int>? = null
|
||||
|
||||
/** The edge a resize is dragging, or null for a plain drag (#141). */
|
||||
private var resizeEdge: ResizeEdge? = null
|
||||
/** The resize in progress, or null for a plain drag (#141). */
|
||||
private var resize: ResizeSession? = null
|
||||
|
||||
/** The day the resized event starts on; its edges are minutes from that midnight. */
|
||||
private var resizeDay: LocalDate? = null
|
||||
private var resizeStartMin = 0
|
||||
private var resizeEndMin = 0
|
||||
/** The strip drag in progress; over the grid it drives [drag] through a timed stand-in. */
|
||||
var stripDrag: StripDrag? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/** How far the finger sits past the edge it grabbed, so the edge doesn't jump to it. */
|
||||
private var resizeGrabMin = 0f
|
||||
/** A strip drop held at its new days until the strip redraws the event there. */
|
||||
var stripSettling: StripDrag? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/** The column the resized edge is in, held until the finger is well into another. */
|
||||
private var resizeColumn: Int? = null
|
||||
/** Whether the strip now draws [stripSettling] at its new days itself. */
|
||||
var stripSeated: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/** The slot the resized edge sits on, held against a finger trembling between two. */
|
||||
private var resizeSlot: Int? = null
|
||||
/** What [abandonedTicks] stood at when [stripSettling] was let go. */
|
||||
var stripAbandonedMark: Int = 0
|
||||
private set
|
||||
|
||||
/** The day, counted from the event's start day, the finger is resizing in. */
|
||||
private var resizeEdgeDay = 0
|
||||
/** 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,
|
||||
@@ -684,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
|
||||
@@ -691,10 +779,8 @@ class TimelineDragController {
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
resizeEdge = null
|
||||
resizeDay = null
|
||||
resizeColumn = null
|
||||
resizeSlot = null
|
||||
resize = null
|
||||
stripDrag = null
|
||||
source = null
|
||||
clipOffsetMin = 0
|
||||
dragZone = null
|
||||
@@ -775,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
|
||||
@@ -800,11 +890,9 @@ class TimelineDragController {
|
||||
pointerInRoot: Offset,
|
||||
) {
|
||||
val span = wallClockSpan(block.event, zone)
|
||||
val session = ResizeSession(edge, span.day, span.startMin, span.endMin)
|
||||
source = block
|
||||
resizeEdge = edge
|
||||
resizeDay = span.day
|
||||
resizeStartMin = span.startMin
|
||||
resizeEndMin = span.endMin
|
||||
resize = session
|
||||
clipOffsetMin = 0
|
||||
this.titleLines = titleLines
|
||||
settling = null
|
||||
@@ -813,12 +901,14 @@ class TimelineDragController {
|
||||
liftedInstanceId = block.event.instanceId
|
||||
pointer = pointerInRoot
|
||||
// Measured from the edge's own column: the handle overhangs into the next.
|
||||
val edgeDay = if (edge == ResizeEdge.Top) 0 else (resizeEndMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
|
||||
val edgeDay = if (edge == ResizeEdge.Top) 0 else (session.endMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
|
||||
val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY))
|
||||
resizeColumn = edgeIndex.takeIf { it >= 0 }
|
||||
session.column = edgeIndex.takeIf { it >= 0 }
|
||||
?.let { column(it) }
|
||||
resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
|
||||
if (edge == ResizeEdge.Top) resizeStartMin else resizeEndMin
|
||||
// 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()
|
||||
originSlot = drag?.slot
|
||||
@@ -834,7 +924,7 @@ class TimelineDragController {
|
||||
fun preview(zone: TimeZone): EventInstance? {
|
||||
val (date, startMin, spanMin) = landingSlot?.takeIf { it != originSlot } ?: return null
|
||||
val event = source?.event ?: return null
|
||||
val edge = resizeEdge
|
||||
val edge = resize?.edge
|
||||
if (edge != null) {
|
||||
val drop = resizedDrop(event, edge, date, startMin, spanMin, zone)
|
||||
return event.copy(start = drop.start, end = drop.end)
|
||||
@@ -850,7 +940,7 @@ class TimelineDragController {
|
||||
fun finishResize(zone: TimeZone): TimelineResizeDrop? {
|
||||
val landed = drag
|
||||
val origin = originSlot
|
||||
val edge = resizeEdge
|
||||
val edge = resize?.edge
|
||||
cancel()
|
||||
if (landed == null || landed.slot == origin) return null
|
||||
val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
|
||||
@@ -862,9 +952,8 @@ class TimelineDragController {
|
||||
* Where the finger is, in minutes from the resized event's start day —
|
||||
* past 1440 in a later column, negative in an earlier one.
|
||||
*/
|
||||
private fun pointerResizeMinutes(): Float? {
|
||||
private fun pointerResizeMinutes(session: ResizeSession): Float? {
|
||||
val grid = geometry.grid?.takeIf { it.isAttached } ?: return null
|
||||
val day = resizeDay ?: return null
|
||||
val hourPx = geometry.hourPx
|
||||
val columnPx = geometry.columnWidthPx
|
||||
val days = geometry.days
|
||||
@@ -872,22 +961,25 @@ class TimelineDragController {
|
||||
val origin = grid.positionInRoot()
|
||||
val held = stickyColumn(
|
||||
position = (pointer.x - origin.x) / columnPx,
|
||||
held = resizeColumn,
|
||||
held = session.column,
|
||||
lastIndex = days.lastIndex,
|
||||
)
|
||||
resizeColumn = held
|
||||
val dayOffset = (days[column(held)].toEpochDays() - day.toEpochDays()).toInt()
|
||||
resizeEdgeDay = dayOffset
|
||||
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
|
||||
session.column = held
|
||||
val dayOffset = (days[column(held)].toEpochDays() - session.day.toEpochDays()).toInt()
|
||||
session.edgeDay = dayOffset
|
||||
// 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(edge: ResizeEdge) {
|
||||
private fun recomputeResize(session: ResizeSession) {
|
||||
val block = source ?: return
|
||||
val day = resizeDay ?: return
|
||||
val raw = pointerResizeMinutes() ?: return
|
||||
val slot = stickySnap(raw - resizeGrabMin, resizeSlot)
|
||||
resizeSlot = slot
|
||||
val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat(), resizeEdgeDay)
|
||||
val edge = session.edge
|
||||
val day = session.day
|
||||
val raw = pointerResizeMinutes(session) ?: return
|
||||
val slot = stickySnap(raw - session.grabMin, session.slot)
|
||||
session.slot = slot
|
||||
val (start, end) = resizedSpan(edge, session.startMin, session.endMin, slot.toFloat(), session.edgeDay)
|
||||
val days = geometry.days
|
||||
val origin = geometry.grid?.takeIf { it.isAttached }?.positionInRoot() ?: return
|
||||
// Which column the start day sits in, possibly off either end of the view.
|
||||
@@ -946,10 +1038,33 @@ class TimelineDragController {
|
||||
* no move events while auto-scroll walks the content underneath it.
|
||||
*/
|
||||
fun recompute() {
|
||||
resizeEdge?.let {
|
||||
resize?.let {
|
||||
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
|
||||
@@ -1035,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
|
||||
@@ -1049,6 +1171,27 @@ class TimelineDragController {
|
||||
}
|
||||
}
|
||||
|
||||
/** One resize gesture (#141): the edge it drags, and the edges as minutes from [day]'s midnight. */
|
||||
private class ResizeSession(
|
||||
val edge: ResizeEdge,
|
||||
/** The day the resized event starts on. */
|
||||
val day: LocalDate,
|
||||
val startMin: Int,
|
||||
val endMin: Int,
|
||||
) {
|
||||
/** How far the finger sits past the edge it grabbed, so the edge doesn't jump to it. */
|
||||
var grabMin = 0f
|
||||
|
||||
/** The column the resized edge is in, held until the finger is well into another. */
|
||||
var column: Int? = null
|
||||
|
||||
/** The slot the resized edge sits on, held against a finger trembling between two. */
|
||||
var slot: Int? = null
|
||||
|
||||
/** The day, counted from the event's start day, the finger is resizing in. */
|
||||
var edgeDay = 0
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberTimelineDragController(): TimelineDragController {
|
||||
val controller = remember { TimelineDragController() }
|
||||
@@ -1078,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
|
||||
@@ -1178,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) {
|
||||
@@ -1202,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)
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
|
||||
import de.jeanlucmakiola.calendula.ui.common.FieldCopier
|
||||
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
|
||||
@@ -169,14 +169,11 @@ fun EventDetailScreen(
|
||||
|
||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||
// upgrade in place and continues straight into the tapped action.
|
||||
val requireWrite = rememberWriteGate()
|
||||
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
|
||||
val onEditClick = { requireWrite(onEdit) }
|
||||
val onDeleteClick = rememberWriteAction { showDeleteDialog = true }
|
||||
val onEditClick = rememberWriteAction(action = onEdit)
|
||||
// Duplicate reads the loaded detail into a create form and hands it up; the
|
||||
// create still needs WRITE_CALENDAR even when the source is read-only.
|
||||
val onDuplicateClick = {
|
||||
requireWrite { viewModel.duplicateForm()?.let(onDuplicate) }
|
||||
}
|
||||
val onDuplicateClick = rememberWriteAction { viewModel.duplicateForm()?.let(onDuplicate) }
|
||||
|
||||
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
||||
val writeDeniedMessage = stringResource(R.string.event_delete_write_denied)
|
||||
|
||||
@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
@@ -31,7 +32,6 @@ import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -45,6 +45,7 @@ class EventDetailViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
private val icsExporter: IcsExporter,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
private val zoneSource: ZoneSource,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _target = MutableStateFlow<Target?>(null)
|
||||
@@ -141,7 +142,7 @@ class EventDetailViewModel @Inject constructor(
|
||||
*/
|
||||
fun duplicateForm(): EventForm? {
|
||||
val loaded = state.value as? EventDetailUiState.Success ?: return null
|
||||
return loaded.detail.toDuplicateForm(loaded.canModify, TimeZone.currentSystemDefault())
|
||||
return loaded.detail.toDuplicateForm(loaded.canModify, zoneSource.current())
|
||||
}
|
||||
|
||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||
|
||||
@@ -37,11 +37,12 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||
import de.jeanlucmakiola.floret.components.IconInfoCard
|
||||
import de.jeanlucmakiola.floret.components.SegmentedChoiceCard
|
||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import kotlinx.coroutines.launch
|
||||
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
|
||||
import de.jeanlucmakiola.calendula.ui.common.canWriteCalendar
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
@@ -119,6 +120,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
@@ -168,6 +170,7 @@ import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.withRepeatedHourZone
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
@@ -288,7 +291,6 @@ fun EventEditScreen(
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val requireWrite = rememberWriteGate()
|
||||
// Save keeps its own launcher: its result calls straight into the ViewModel,
|
||||
// so a save waiting on the permission dialog survives a rotation.
|
||||
val savePermissionLauncher = rememberLauncherForActivityResult(
|
||||
@@ -313,14 +315,13 @@ fun EventEditScreen(
|
||||
}
|
||||
}
|
||||
// The copy opens over this form; a form with nothing to lose steps aside.
|
||||
val onDuplicateClick: () -> Unit = {
|
||||
requireWrite {
|
||||
viewModel.duplicateForm()?.let { form ->
|
||||
onDuplicate?.invoke(form)
|
||||
if (!hasUnsavedChanges) close()
|
||||
}
|
||||
val onDuplicateClick = rememberWriteAction {
|
||||
viewModel.duplicateForm()?.let { form ->
|
||||
onDuplicate?.invoke(form)
|
||||
if (!hasUnsavedChanges) close()
|
||||
}
|
||||
}
|
||||
val onDeleteClick = rememberWriteAction { showDeleteDialog = true }
|
||||
|
||||
val saveFailedMessage = stringResource(R.string.event_edit_save_failed)
|
||||
val writeDeniedMessage = stringResource(R.string.event_edit_write_denied)
|
||||
@@ -386,7 +387,7 @@ fun EventEditScreen(
|
||||
canDuplicate = onDuplicate != null,
|
||||
onDuplicate = onDuplicateClick,
|
||||
onShare = onShareClick,
|
||||
onDelete = { requireWrite { showDeleteDialog = true } },
|
||||
onDelete = onDeleteClick,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
@@ -893,6 +894,15 @@ private fun EventEditContent(
|
||||
AvailabilityCard(availability = form.availability, onSelect = viewModel::setAvailability)
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = EventFormField.Status in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
StatusCard(
|
||||
status = form.status,
|
||||
offerCancelled = state.offerCancelled,
|
||||
onSelect = viewModel::setStatus,
|
||||
)
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = EventFormField.Visibility in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
IconInfoCard(
|
||||
@@ -1128,13 +1138,7 @@ private fun WhenCard(
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
// The rows above edit a pinned event in *its own* zone, which is the
|
||||
// time it was set at — but that says nothing about when it lands for
|
||||
// whoever is reading it. Spell the local equivalent out rather than
|
||||
// leaving the user to do the offset arithmetic. Absent unless the
|
||||
// event is pinned somewhere other than here.
|
||||
// Keyed rather than recomputed: the form recomposes on every title
|
||||
// keystroke, and this would otherwise re-parse the zone each time.
|
||||
// A pinned event's local equivalent, shown only when pinned elsewhere.
|
||||
val pinnedAway = remember(form.timezone, form.isAllDay, deviceZone) {
|
||||
form.isPinnedAwayFrom(deviceZone)
|
||||
}
|
||||
@@ -1321,33 +1325,33 @@ private fun RecurrenceCard(state: EventEditUiState, locale: Locale, onClick: (()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AvailabilityCard(availability: Availability, onSelect: (Availability) -> Unit) {
|
||||
IconInfoCard(
|
||||
SegmentedChoiceCard(
|
||||
icon = Icons.Default.EventAvailable,
|
||||
iconContentDescription = null,
|
||||
header = {
|
||||
Text(
|
||||
text = stringResource(R.string.event_edit_availability),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
SegmentedButton(
|
||||
selected = availability == Availability.Busy,
|
||||
onClick = { onSelect(Availability.Busy) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
|
||||
) { Text(stringResource(R.string.event_availability_busy)) }
|
||||
SegmentedButton(
|
||||
selected = availability == Availability.Free,
|
||||
onClick = { onSelect(Availability.Free) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
|
||||
) { Text(stringResource(R.string.event_availability_free)) }
|
||||
}
|
||||
}
|
||||
title = stringResource(R.string.event_edit_availability),
|
||||
options = listOf(
|
||||
Availability.Busy to stringResource(R.string.event_availability_busy),
|
||||
Availability.Free to stringResource(R.string.event_availability_free),
|
||||
),
|
||||
selected = availability,
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusCard(status: EventStatus, offerCancelled: Boolean, onSelect: (EventStatus) -> Unit) {
|
||||
SegmentedChoiceCard(
|
||||
icon = eventFormFieldIcon(EventFormField.Status),
|
||||
title = stringResource(R.string.event_edit_status),
|
||||
options = listOfNotNull(
|
||||
EventStatus.Confirmed to stringResource(R.string.event_status_confirmed),
|
||||
EventStatus.Tentative to stringResource(R.string.event_status_tentative),
|
||||
(EventStatus.Cancelled to stringResource(R.string.event_status_cancelled)).takeIf { offerCancelled },
|
||||
),
|
||||
selected = status,
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2421,9 +2425,8 @@ private fun accessLevelSummary(level: AccessLevel): Int = when (level) {
|
||||
private fun formatTimeRange(start: Instant, end: Instant, zone: TimeZone, locale: Locale): String {
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
return remember(start, end, zone, locale, use24Hour) {
|
||||
val timeFormat = timeOfDayFormatter(use24Hour, locale)
|
||||
fun format(at: Instant) = withRepeatedHourZone(
|
||||
timeFormat.format(at.toLocalDateTime(zone).time.toJavaLocalTime()),
|
||||
formatTimeOfDay(at, zone, use24Hour, locale),
|
||||
at,
|
||||
zone,
|
||||
locale,
|
||||
|
||||
@@ -48,6 +48,12 @@ data class EventEditUiState(
|
||||
* then drops "only this event" (an exception row can't carry a rule).
|
||||
*/
|
||||
val recurrenceChanged: Boolean = false,
|
||||
/**
|
||||
* Whether the Status field offers Cancelled: only for an event that already
|
||||
* is. Google reads a cancelled event as deleted, so picking it is no safe
|
||||
* way to strike an event through (#388).
|
||||
*/
|
||||
val offerCancelled: Boolean = false,
|
||||
/**
|
||||
* The event-colour palette the resolved target calendar publishes; empty
|
||||
* when it exposes none. Non-empty → the colour picker offers these swatches
|
||||
|
||||
@@ -14,8 +14,10 @@ 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.canSaveApartFromSeries
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
@@ -335,6 +337,7 @@ class EventEditViewModel @Inject constructor(
|
||||
// the scope dialog drops "only this event" after a rule change.
|
||||
recurrenceChanged = local.editTarget != null &&
|
||||
resolved.rrule != local.editTarget.original.rrule,
|
||||
offerCancelled = local.editTarget?.original?.status == EventStatus.Cancelled,
|
||||
colorPalette = palette,
|
||||
allowColorOnUnsupportedCalendars = external.allowColorOnUnsupported,
|
||||
)
|
||||
@@ -590,6 +593,9 @@ class EventEditViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** The form as the user has it now, or null when none is open. */
|
||||
fun currentForm(): EventForm? = _form.value
|
||||
|
||||
/** Forget the open form; the next [openNew]/[openForEdit] starts clean. */
|
||||
fun reset() {
|
||||
session++
|
||||
@@ -668,6 +674,7 @@ class EventEditViewModel @Inject constructor(
|
||||
applyDefaultDuration(id)
|
||||
}
|
||||
fun setAvailability(value: Availability) = edit { it.copy(availability = value) }
|
||||
fun setStatus(value: EventStatus) = edit { it.copy(status = value) }
|
||||
fun setAccessLevel(value: AccessLevel) = edit { it.copy(accessLevel = value) }
|
||||
|
||||
/** Pick a palette colour (round-trips via its key); [argb] is its swatch. */
|
||||
@@ -763,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
|
||||
}
|
||||
|
||||
@@ -90,8 +90,10 @@ fun ImportScreen(
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
// A single event isn't shown here — it opens the create form for review.
|
||||
// Read live: the state composed with may be the previous import's result,
|
||||
// which the load above has already replaced.
|
||||
LaunchedEffect(state) {
|
||||
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
|
||||
(viewModel.state.value as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
|
||||
}
|
||||
|
||||
// Hoisted target calendar so the always-visible top-bar Import action can
|
||||
|
||||
@@ -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
|
||||
@@ -11,8 +16,8 @@ import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
||||
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
|
||||
@@ -63,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
|
||||
@@ -78,6 +84,7 @@ import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.InputChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -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
|
||||
@@ -277,6 +283,8 @@ fun MonthScreen(
|
||||
val selectedDate by viewModel.selectedDate.collectAsStateWithLifecycle()
|
||||
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).
|
||||
@@ -362,7 +370,7 @@ fun MonthScreen(
|
||||
if (!scrolling) return@LaunchedEffect
|
||||
snapshotFlow {
|
||||
// Empty before the first layout pass — and reporting a default of 0
|
||||
// for it would claim January 1900 is on screen and drag the whole
|
||||
// for it would claim the span's first month is on screen and drag the whole
|
||||
// window back there.
|
||||
val visible = listState.layoutInfo.visibleItemsInfo
|
||||
when {
|
||||
@@ -447,7 +455,8 @@ fun MonthScreen(
|
||||
}
|
||||
}
|
||||
// Drawer jump-to-date.
|
||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||
val jumpToDate: (LocalDate) -> Unit = { date ->
|
||||
val target = coerceToMonthRange(date)
|
||||
if (scrolling) {
|
||||
val targetMonth = target.yearMonth
|
||||
pendingScrollMonth = targetMonth
|
||||
@@ -523,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,
|
||||
@@ -542,6 +554,8 @@ fun MonthScreen(
|
||||
weekStart = weekStart,
|
||||
monthState = viewModel::month,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
showStartTimes = showStartTimes == true,
|
||||
showToday = showToday,
|
||||
onPaneSwipeNext = { viewModel.stepSplitPane(1) },
|
||||
onPaneSwipePrev = { viewModel.stepSplitPane(-1) },
|
||||
onClearSelection = viewModel::clearSelection,
|
||||
@@ -550,8 +564,10 @@ fun MonthScreen(
|
||||
// month, pager and all — straight away, or a swipe
|
||||
// still settling would land and take the tap back.
|
||||
onSelectDay = { date ->
|
||||
viewModel.selectDate(date)
|
||||
jumpToMonth(date.yearMonth)
|
||||
if (isInMonthRange(date)) {
|
||||
viewModel.selectDate(date)
|
||||
jumpToMonth(date.yearMonth)
|
||||
}
|
||||
},
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
@@ -560,7 +576,7 @@ fun MonthScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
MonthDragOverlay(chipDrag)
|
||||
MonthDragOverlay(chipDrag, showStartTimes = showStartTimes == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -573,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
|
||||
@@ -639,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.
|
||||
@@ -676,6 +693,7 @@ private fun ContinuousMonthContent(
|
||||
listState: LazyListState,
|
||||
dense: Boolean,
|
||||
showWeekNumbers: Boolean,
|
||||
showStartTimes: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
@@ -692,6 +710,7 @@ private fun ContinuousMonthContent(
|
||||
state = state,
|
||||
listState = listState,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
showStartTimes = showStartTimes,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
@@ -700,6 +719,7 @@ private fun ContinuousMonthContent(
|
||||
state = state,
|
||||
listState = listState,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
showStartTimes = showStartTimes,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
@@ -841,7 +861,7 @@ internal fun monthBandHeight(rowHeight: Dp): Dp =
|
||||
|
||||
/** Whether any day in this week holds more than [lanes] lanes can seat. */
|
||||
private fun MonthWeek.overflowsAt(lanes: Int): Boolean =
|
||||
days.withIndex().any { (col, day) -> overflowEvents(col, day, lanes).isNotEmpty() }
|
||||
days.indices.any { col -> overflowEvents(col, lanes).isNotEmpty() }
|
||||
|
||||
/**
|
||||
* Row height in the continuous grid. The paged grid divides the viewport between
|
||||
@@ -872,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. */
|
||||
@@ -896,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,
|
||||
@@ -938,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)
|
||||
@@ -967,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,
|
||||
@@ -989,7 +1011,7 @@ private fun ContinuousMonthBlock(
|
||||
weekStart: DayOfWeek,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
timeChipWidth: Dp,
|
||||
timeFit: MonthChipTimeFit?,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
@@ -1010,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
|
||||
@@ -1076,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
|
||||
@@ -1094,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]
|
||||
@@ -1105,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.
|
||||
@@ -1197,6 +1220,8 @@ private fun SplitMonthContent(
|
||||
weekStart: DayOfWeek,
|
||||
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
|
||||
showWeekNumbers: Boolean,
|
||||
showStartTimes: Boolean,
|
||||
showToday: Boolean,
|
||||
onPaneSwipeNext: () -> Unit,
|
||||
onPaneSwipePrev: () -> Unit,
|
||||
onClearSelection: () -> Unit,
|
||||
@@ -1206,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)
|
||||
}
|
||||
@@ -1277,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
|
||||
@@ -1309,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
|
||||
@@ -1334,7 +1364,11 @@ 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,
|
||||
onSelectDay = onSelectDay,
|
||||
onPaneSwipeNext = {
|
||||
@@ -1376,7 +1410,9 @@ private fun SplitMonthCollapsed(
|
||||
state: MonthUiState.Success,
|
||||
paneReady: Boolean,
|
||||
selected: LocalDate?,
|
||||
selectionIndicatorVisible: Boolean,
|
||||
showWeekNumbers: Boolean,
|
||||
showToday: Boolean,
|
||||
expandDrag: Modifier,
|
||||
onSelectDay: (LocalDate) -> Unit,
|
||||
onPaneSwipeNext: () -> Unit,
|
||||
@@ -1396,6 +1432,7 @@ private fun SplitMonthCollapsed(
|
||||
SplitMonthGrid(
|
||||
state = state,
|
||||
selected = selected,
|
||||
selectionIndicatorVisible = selectionIndicatorVisible,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onSelectDay = onSelectDay,
|
||||
modifier = Modifier.height(splitGridHeight(state.weeks.size)),
|
||||
@@ -1409,6 +1446,7 @@ private fun SplitMonthCollapsed(
|
||||
Box(Modifier.weight(1f).fillMaxWidth().then(paneModifier)) {
|
||||
SplitMonthAgendaPane(
|
||||
state = state,
|
||||
showToday = showToday,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
@@ -1422,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(),
|
||||
@@ -1486,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,
|
||||
@@ -1498,6 +1526,7 @@ private fun SplitMonthExpanded(
|
||||
MonthGrid(
|
||||
state = state,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
showStartTimes = showStartTimes,
|
||||
onOpenDay = onPickDay,
|
||||
onEventClick = onEventClick,
|
||||
selected = selected,
|
||||
@@ -1529,18 +1558,30 @@ private fun SplitExpandHandle(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(SPLIT_HANDLE_ROW_HEIGHT)
|
||||
.clickable(onClick = onToggle)
|
||||
.semantics { contentDescription = label },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(width = SPLIT_HANDLE_WIDTH, height = SPLIT_HANDLE_HEIGHT)
|
||||
// Tagged too, so it slides from the pane's seam down to the foot
|
||||
// of the grid rather than blinking out at one and in at the other.
|
||||
.morphElement(MonthMorphKey.Handle)
|
||||
.background(MaterialTheme.colorScheme.outlineVariant, CircleShape),
|
||||
)
|
||||
.size(width = SPLIT_HANDLE_RIPPLE_WIDTH, height = SPLIT_HANDLE_ROW_HEIGHT)
|
||||
.clip(RoundedCornerShape(percent = 50))
|
||||
.clickable(
|
||||
interactionSource = null,
|
||||
indication = ripple(),
|
||||
onClick = onToggle,
|
||||
)
|
||||
.clearAndSetSemantics { },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(width = SPLIT_HANDLE_WIDTH, height = SPLIT_HANDLE_HEIGHT)
|
||||
// Tagged too, so it slides from the pane's seam down to the foot
|
||||
// of the grid rather than blinking out at one and in at the other.
|
||||
.morphElement(MonthMorphKey.Handle)
|
||||
.background(MaterialTheme.colorScheme.outlineVariant, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1573,6 +1614,7 @@ private val SPLIT_DOT_SIZE = 5.dp
|
||||
private val SPLIT_HANDLE_WIDTH = 32.dp
|
||||
private val SPLIT_HANDLE_HEIGHT = 4.dp
|
||||
private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp
|
||||
private val SPLIT_HANDLE_RIPPLE_WIDTH = SPLIT_HANDLE_WIDTH + 24.dp
|
||||
|
||||
/** The pane below the split grid fades in once the grid's morph has mostly landed. */
|
||||
private const val SPLIT_PANE_FADE_IN_DELAY_MILLIS = 300
|
||||
@@ -1598,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,
|
||||
@@ -1627,11 +1670,11 @@ internal fun SplitMonthGrid(
|
||||
val inMonth = day.month == month.month && day.year == month.year
|
||||
// Seated by lane rather than gathered by colour, so each dot
|
||||
// is the event the expanded grid draws in that same lane.
|
||||
val seated = week.laneEvents(col, day, SPLIT_DOT_LANES)
|
||||
val seated = week.laneEvents(col, SPLIT_DOT_LANES)
|
||||
SplitDayCell(
|
||||
date = day,
|
||||
events = seated,
|
||||
hidden = week.overflowEvents(col, day, SPLIT_DOT_LANES),
|
||||
hidden = week.overflowEvents(col, SPLIT_DOT_LANES),
|
||||
isToday = day == state.today,
|
||||
// A page marks only the days its own month owns. Paging
|
||||
// moves the selection before this month's replacement
|
||||
@@ -1643,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 },
|
||||
@@ -1671,6 +1715,7 @@ private fun SplitDayCell(
|
||||
hidden: List<EventInstance>,
|
||||
isToday: Boolean,
|
||||
isSelected: Boolean,
|
||||
selectionIndicatorVisible: Boolean,
|
||||
inMonth: Boolean,
|
||||
relativeToToday: Int?,
|
||||
rippleProgress: () -> Float,
|
||||
@@ -1700,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",
|
||||
)
|
||||
@@ -1712,11 +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)
|
||||
.selectable(selected = isSelected, onClick = onClick),
|
||||
.selectable(
|
||||
selected = isSelected,
|
||||
interactionSource = interaction,
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Box(
|
||||
@@ -1778,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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1799,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,
|
||||
) {
|
||||
@@ -1834,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),
|
||||
)
|
||||
@@ -1844,22 +1908,33 @@ private fun SplitDots(
|
||||
@Composable
|
||||
private fun SplitMonthAgendaPane(
|
||||
state: MonthUiState.Success,
|
||||
showToday: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateEvent: (LocalDate) -> Unit,
|
||||
) {
|
||||
val start = agendaStartForMonth(state.month, state.today)
|
||||
val days = state.instancesByDay.keys
|
||||
.filter { it >= start && it.year == state.month.year && it.month == state.month.month }
|
||||
.filter { state.instancesByDay[it].orEmpty().isNotEmpty() }
|
||||
.toMutableList()
|
||||
.apply { if (start !in this) add(start) }
|
||||
.sorted()
|
||||
val days = remember(state.month, state.today, state.instancesByDay, showToday) {
|
||||
splitAgendaDays(state.month, state.today, state.instancesByDay, showToday)
|
||||
}
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val listState = key(state.month) { rememberLazyListState() }
|
||||
LazyColumn(state = listState, contentPadding = PaddingValues(top = 8.dp, 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(4.dp)) }
|
||||
if (days.isEmpty()) {
|
||||
val start = agendaStartForMonth(state.month, state.today)
|
||||
item(key = "empty") {
|
||||
AgendaEmptyDayRow(
|
||||
text = stringResource(
|
||||
if (start == state.today) R.string.month_split_no_more_events else R.string.month_split_no_events_month,
|
||||
),
|
||||
onClick = { onCreateEvent(start) },
|
||||
)
|
||||
}
|
||||
}
|
||||
agendaDays(
|
||||
days = days.map { AgendaDay(it, state.instancesByDay[it].orEmpty()) },
|
||||
days = days,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
emptyText = R.string.month_split_no_events,
|
||||
@@ -1867,6 +1942,7 @@ private fun SplitMonthAgendaPane(
|
||||
dimmed = { event, day -> dimCutoff != null && event.isPastOn(day, dimCutoff) },
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
compact = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1874,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(
|
||||
@@ -1884,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,
|
||||
@@ -1895,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(),
|
||||
@@ -1918,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,
|
||||
@@ -2074,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,
|
||||
@@ -2098,8 +2192,8 @@ private fun MonthWeekRow(
|
||||
val ripple = rememberTodayRipple(active = todayCol != null)
|
||||
val overflowRow = rememberOverflowRowHeight()
|
||||
val laneCap = week.laneCapFor(rowHeight, overflowRow)
|
||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||
val shownLanes = laneCount.coerceAtMost(laneCap)
|
||||
// Once per row rather than per frame: a drag recomposes every frame.
|
||||
val seatings = remember(week, laneCap) { week.days.indices.map { week.seating(it, laneCap) } }
|
||||
val morphing = morphInFlight()
|
||||
// Every chip's start time for this row at once, and only when the row's own
|
||||
// inputs change: formatting is a parsed pattern per call, and the dim cutoff
|
||||
@@ -2201,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
|
||||
@@ -2283,7 +2377,7 @@ private fun MonthWeekRow(
|
||||
.then(if (morphing || dragging) Modifier else Modifier.clipToBounds()),
|
||||
) {
|
||||
// Spanning bars on their shared lanes.
|
||||
week.spans.filter { it.lane < shownLanes }.forEach { span ->
|
||||
week.spans.filter { it.lane < laneCap }.forEach { span ->
|
||||
val cols = span.endCol - span.startCol + 1
|
||||
MonthBar(
|
||||
event = span.event,
|
||||
@@ -2292,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,
|
||||
@@ -2355,19 +2450,13 @@ private fun MonthWeekRow(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Single-day timed pills + overflow, per column. Pills fill the
|
||||
// lane slots no bar occupies on THIS day (top-most first), so a
|
||||
// bar-free day isn't pushed down by a multi-day event that only
|
||||
// sits on other days of the week.
|
||||
// Single-day timed pills + overflow, per column, in the lanes
|
||||
// [seating] gave them.
|
||||
week.days.forEachIndexed { col, d ->
|
||||
val timed = week.timedByDay[d].orEmpty()
|
||||
val occupied = week.spans
|
||||
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
|
||||
.map { it.lane }
|
||||
.toSet()
|
||||
val freeSlots = (0 until laneCap).filter { it !in occupied }
|
||||
val pillsShown = timed.take(freeSlots.size)
|
||||
pillsShown.forEachIndexed { i, ev ->
|
||||
val seating = seatings[col]
|
||||
seating.seated.forEach { seat ->
|
||||
if (seat.bar) return@forEach
|
||||
val ev = seat.event
|
||||
MonthBar(
|
||||
event = ev,
|
||||
dark = dark,
|
||||
@@ -2375,11 +2464,12 @@ 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,
|
||||
y = EVENT_ROW_HEIGHT * freeSlots[i],
|
||||
y = EVENT_ROW_HEIGHT * seat.lane,
|
||||
)
|
||||
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
|
||||
.then(
|
||||
@@ -2395,18 +2485,10 @@ private fun MonthWeekRow(
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
||||
if (hidden > 0) {
|
||||
val hiddenEvents = buildList {
|
||||
week.spans
|
||||
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
|
||||
.forEach { add(it.event) }
|
||||
addAll(timed.drop(pillsShown.size))
|
||||
}
|
||||
if (seating.hidden.isNotEmpty()) {
|
||||
OverflowDots(
|
||||
events = hiddenEvents,
|
||||
events = seating.hidden,
|
||||
day = d,
|
||||
total = hidden,
|
||||
dark = dark,
|
||||
rowHeight = overflowRow,
|
||||
modifier = Modifier
|
||||
@@ -2481,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
|
||||
@@ -2630,11 +2713,13 @@ private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier
|
||||
val onOpenWeek = LocalOpenWeek.current
|
||||
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) {
|
||||
onOpenWeek(weekStart)
|
||||
} else Modifier)
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer, CELL_SHAPE)
|
||||
.semantics { contentDescription = "$label $weekNumber" },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -2708,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(
|
||||
@@ -2725,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 &&
|
||||
@@ -2786,7 +2874,6 @@ private fun MonthBar(
|
||||
private fun OverflowDots(
|
||||
events: List<EventInstance>,
|
||||
day: LocalDate,
|
||||
total: Int,
|
||||
dark: Boolean,
|
||||
rowHeight: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -2808,7 +2895,7 @@ private fun OverflowDots(
|
||||
.background(eventAccent(argb, dark, soften), CircleShape),
|
||||
)
|
||||
}
|
||||
val extra = total - dots.size
|
||||
val extra = events.size - dots.size
|
||||
if (extra > 0) {
|
||||
Text(
|
||||
text = "+$extra",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -39,33 +39,55 @@ data class MonthWeek(
|
||||
val countByDay: Map<LocalDate, Int>,
|
||||
)
|
||||
|
||||
/** One event seated in [lane] of a day; [bar] is a spanning bar rather than that day's own pill. */
|
||||
data class Seat(val lane: Int, val event: EventInstance, val bar: Boolean)
|
||||
|
||||
/**
|
||||
* The events occupying each lane of [day] — column [col] of this week — in lane
|
||||
* order, capped at [laneCap] lanes.
|
||||
* How one day of a week row is seated with [laneCap] lanes: what sits in each
|
||||
* lane, top-most first with empty lanes left out, and what had no lane left.
|
||||
*/
|
||||
data class DaySeating(val seated: List<Seat>, val hidden: List<EventInstance>)
|
||||
|
||||
/**
|
||||
* The seating of column [col] with [laneCap] lanes — the one every reader of a
|
||||
* row builds on: the bars and pills the paged grid draws, the split style's dots
|
||||
* ([laneEvents]), the hit test ([chipAt]) and the overflow ([overflowEvents]).
|
||||
*
|
||||
* This is the same seating [spans]/[timedByDay] get when the paged grid draws a
|
||||
* week: bars keep the lane the row layout gave them, and the day's timed events
|
||||
* fill whatever slots are left, top-most first. Reading it here means the split
|
||||
* style's dots and the paged style's bars describe a day in the *same order*, so
|
||||
* dot _i_ and lane _i_ are the same event and one can morph into the other (#53).
|
||||
* Bars keep the lane the row layout gave them, and the day's timed events fill
|
||||
* whatever slots are left, top-most first, so a bar-free day isn't pushed down by
|
||||
* a multi-day event that only sits on other days of the week. Overflow is the
|
||||
* exact complement, bars before pills.
|
||||
*/
|
||||
fun MonthWeek.seating(col: Int, laneCap: Int): DaySeating {
|
||||
val covering = spans.filter { col in it.startCol..it.endCol }
|
||||
val byLane = arrayOfNulls<Seat>(laneCap.coerceAtLeast(0))
|
||||
covering.forEach { span ->
|
||||
if (span.lane < laneCap) byLane[span.lane] = Seat(span.lane, span.event, bar = true)
|
||||
}
|
||||
val timed = timedByDay[days[col]].orEmpty()
|
||||
var pills = 0
|
||||
for (lane in byLane.indices) {
|
||||
if (byLane[lane] == null && pills < timed.size) byLane[lane] = Seat(lane, timed[pills++], bar = false)
|
||||
}
|
||||
return DaySeating(
|
||||
seated = byLane.filterNotNull(),
|
||||
hidden = covering.filter { it.lane >= laneCap }.map { it.event } + timed.drop(pills),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The events occupying each lane of column [col], in lane order, capped at
|
||||
* [laneCap] lanes — the same [seating] the paged grid draws a week with. Reading
|
||||
* it here means the split style's dots and the paged style's bars describe a day
|
||||
* in the *same order*, so dot _i_ and lane _i_ are the same event and one can
|
||||
* morph into the other (#53).
|
||||
*
|
||||
* Deriving the dots independently — by distinct colour, as they first were —
|
||||
* left dot _i_ standing for no particular event, and quietly merged two events
|
||||
* that shared a calendar into a single dot.
|
||||
*/
|
||||
fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
||||
val byLane = arrayOfNulls<EventInstance>(laneCap)
|
||||
spans.forEach { span ->
|
||||
if (span.lane < laneCap && col in span.startCol..span.endCol) {
|
||||
byLane[span.lane] = span.event
|
||||
}
|
||||
}
|
||||
val free = (0 until laneCap).filter { byLane[it] == null }
|
||||
timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event ->
|
||||
byLane[free[i]] = event
|
||||
}
|
||||
return byLane.filterNotNull()
|
||||
}
|
||||
fun MonthWeek.laneEvents(col: Int, laneCap: Int): List<EventInstance> =
|
||||
seating(col, laneCap).seated.map { it.event }
|
||||
|
||||
/**
|
||||
* The event drawn in lane [lane] of column [col], or null for an empty slot.
|
||||
@@ -74,14 +96,7 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
|
||||
*/
|
||||
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
||||
if (col !in days.indices || lane !in 0 until laneCap) return null
|
||||
spanAt(col, lane)?.let { return it.event }
|
||||
val occupied = spans
|
||||
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||
.map { it.lane }
|
||||
.toSet()
|
||||
val free = (0 until laneCap).filter { it !in occupied }
|
||||
val index = free.indexOf(lane).takeIf { it >= 0 } ?: return null
|
||||
return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index)
|
||||
return seating(col, laneCap).seated.firstOrNull { it.lane == lane }?.event
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,19 +113,11 @@ fun MonthWeek.spanAt(col: Int, lane: Int): MonthSpan? =
|
||||
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||
|
||||
/**
|
||||
* The events on [day] that [laneEvents] had no lane left for — its exact
|
||||
* The events in column [col] that [laneEvents] had no lane left for — its exact
|
||||
* complement, in the same bars-then-pills order. Returned as events rather than
|
||||
* a count because dimming is a per-event question (#79).
|
||||
*/
|
||||
fun MonthWeek.overflowEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
||||
val seatedLanes = spans.count { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||
return buildList {
|
||||
spans.forEach { span ->
|
||||
if (span.lane >= laneCap && col in span.startCol..span.endCol) add(span.event)
|
||||
}
|
||||
addAll(timedByDay[day].orEmpty().drop(laneCap - seatedLanes))
|
||||
}
|
||||
}
|
||||
fun MonthWeek.overflowEvents(col: Int, laneCap: Int): List<EventInstance> = seating(col, laneCap).hidden
|
||||
|
||||
/**
|
||||
* State for the continuous style (#38): a vertical stream of *self-contained*
|
||||
|
||||
@@ -13,11 +13,15 @@ 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
|
||||
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.isDeclined
|
||||
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||
import de.jeanlucmakiola.calendula.domain.isCrossedOut
|
||||
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
|
||||
@@ -81,6 +85,23 @@ class MonthViewModel @Inject constructor(
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
/** Whether the split agenda keeps a free today, as the Agenda view does (#35). */
|
||||
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
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(
|
||||
@@ -95,7 +116,7 @@ class MonthViewModel @Inject constructor(
|
||||
val today: StateFlow<LocalDate> =
|
||||
todaySource.today.stateFollowingDays(viewModelScope, todayDate, ::followToday)
|
||||
|
||||
private val _month = MutableStateFlow(todayDate.yearMonth)
|
||||
private val _month = MutableStateFlow(yearMonthForIndex(monthIndexOf(todayDate.yearMonth)))
|
||||
val month: StateFlow<YearMonth> = _month
|
||||
|
||||
/**
|
||||
@@ -149,7 +170,7 @@ class MonthViewModel @Inject constructor(
|
||||
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
|
||||
fun onPageSettled(page: Int) {
|
||||
if (monthIndexOf(_month.value) == page) return
|
||||
_month.value = yearMonthForIndex(page)
|
||||
setMonth(yearMonthForIndex(page))
|
||||
// Moving the grid to another month restores its full agenda.
|
||||
_selectedDate.value = null
|
||||
}
|
||||
@@ -298,9 +319,11 @@ class MonthViewModel @Inject constructor(
|
||||
* silently list a date the grid isn't pointing at.
|
||||
*/
|
||||
fun selectDate(date: LocalDate) {
|
||||
// A greyed-out day past either end of the grid has no month to follow to.
|
||||
if (!isInMonthRange(date)) return
|
||||
val target = date.yearMonth
|
||||
_selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date
|
||||
if (target != _month.value) _month.value = target
|
||||
if (target != _month.value) setMonth(target)
|
||||
}
|
||||
|
||||
fun clearSelection() { _selectedDate.value = null }
|
||||
@@ -309,16 +332,16 @@ class MonthViewModel @Inject constructor(
|
||||
fun stepSplitPane(days: Int) {
|
||||
val selected = _selectedDate.value
|
||||
if (selected == null) {
|
||||
_month.value = yearMonthForIndex(monthIndexOf(_month.value) + days)
|
||||
setMonth(yearMonthForIndex(monthIndexOf(_month.value) + days))
|
||||
} else {
|
||||
val next = selected.plus(days, DateTimeUnit.DAY)
|
||||
val next = coerceToMonthRange(selected.plus(days, DateTimeUnit.DAY))
|
||||
_selectedDate.value = next
|
||||
_month.value = next.yearMonth
|
||||
setMonth(next.yearMonth)
|
||||
}
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
_month.value = todayDate.yearMonth
|
||||
setMonth(todayDate.yearMonth)
|
||||
_selectedDate.value = null
|
||||
}
|
||||
|
||||
@@ -330,7 +353,7 @@ class MonthViewModel @Inject constructor(
|
||||
private fun followToday(old: LocalDate, new: LocalDate) {
|
||||
if (viewStyle.value.isScrolling) return
|
||||
if (_month.value != old.yearMonth) return
|
||||
_month.value = new.yearMonth
|
||||
setMonth(new.yearMonth)
|
||||
_selectedDate.value = null
|
||||
}
|
||||
|
||||
@@ -344,14 +367,24 @@ class MonthViewModel @Inject constructor(
|
||||
*/
|
||||
fun syncScrollMonth(ym: YearMonth) {
|
||||
if (_month.value == ym) return
|
||||
_month.value = ym
|
||||
setMonth(ym)
|
||||
_selectedDate.value = null
|
||||
}
|
||||
|
||||
/** Jump to the month containing [date] (drawer jump-to-date). */
|
||||
/**
|
||||
* Jump to the month containing [date] (drawer jump-to-date, or the focus
|
||||
* handed over by another view). The focus itself keeps a date outside the
|
||||
* grid's span, so the day and week views return to it unchanged.
|
||||
*/
|
||||
fun goToDate(date: LocalDate) {
|
||||
_month.value = date.yearMonth
|
||||
_selectedDate.value = date
|
||||
val target = coerceToMonthRange(date)
|
||||
setMonth(target.yearMonth)
|
||||
_selectedDate.value = target
|
||||
}
|
||||
|
||||
/** Every write to [_month] goes through here, so it can't leave the grid's span (#457). */
|
||||
private fun setMonth(ym: YearMonth) {
|
||||
_month.value = yearMonthForIndex(monthIndexOf(ym))
|
||||
}
|
||||
|
||||
private fun buildState(
|
||||
@@ -469,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(
|
||||
@@ -488,7 +521,7 @@ internal fun layoutCalendarWeek(
|
||||
// Declined last, so a day that overflows drops the events you said
|
||||
// no to before the ones you are actually going to (#230).
|
||||
singles.filter { it.coversDay(d, zone) }
|
||||
.sortedWith(compareBy<EventInstance> { it.isDeclined }.thenBy { it.start })
|
||||
.sortedWith(compareBy<EventInstance> { it.isCrossedOut }.thenBy { it.start })
|
||||
},
|
||||
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
||||
)
|
||||
@@ -509,7 +542,7 @@ internal fun instancesByDay(
|
||||
days.associateWith { day ->
|
||||
instances
|
||||
.filter { it.coversDay(day, zone) }
|
||||
.sortedWith(eventDisplayComparator(zone))
|
||||
.sortedForDisplay(zone)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -556,6 +589,25 @@ internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate
|
||||
LocalDate(month.year, month.month, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The days the split agenda lists for [month]: those from [agendaStartForMonth]
|
||||
* on that have events, plus a free today when [showToday] is set, as in the
|
||||
* Agenda view (#142).
|
||||
*/
|
||||
internal fun splitAgendaDays(
|
||||
month: YearMonth,
|
||||
today: LocalDate,
|
||||
instancesByDay: Map<LocalDate, List<EventInstance>>,
|
||||
showToday: Boolean,
|
||||
): List<AgendaDay> {
|
||||
val start = agendaStartForMonth(month, today)
|
||||
val days = instancesByDay.keys
|
||||
.filter { it >= start && it.yearMonth == month && instancesByDay.getValue(it).isNotEmpty() }
|
||||
.sorted()
|
||||
.map { AgendaDay(it, instancesByDay.getValue(it)) }
|
||||
return anchorTodayIfMissing(days, today, enabled = showToday && start == today)
|
||||
}
|
||||
|
||||
/**
|
||||
* The window to load for a visible range, or null to keep the current one.
|
||||
*
|
||||
@@ -578,24 +630,39 @@ internal fun nextLoadWindow(
|
||||
/**
|
||||
* The continuous grid addresses months by an absolute index, so the list has one
|
||||
* stable, gap-free coordinate space to scroll through and key its items by.
|
||||
* Index 0 is January 1900; the list runs to [continuousMonthCount].
|
||||
* Index 0 is the first month of [CalendarSpan]; the list runs to [continuousMonthCount].
|
||||
*
|
||||
* Unlike the week indexing this replaced, the coordinate space doesn't depend on
|
||||
* the week-start preference — changing it reflows the rows *inside* a month
|
||||
* block but never moves the block, so a scroll position stays put.
|
||||
*/
|
||||
private const val MONTH_INDEX_EPOCH_YEAR = 1900
|
||||
private const val MONTH_INDEX_END_YEAR = 2100
|
||||
private val MONTH_INDEX_EPOCH_YEAR = CalendarSpan.FIRST_DAY.year
|
||||
private val MONTH_INDEX_END_YEAR = CalendarSpan.LAST_DAY.year
|
||||
|
||||
/** The first and last day the month and Dense grids can show. */
|
||||
private val GRID_FIRST_DAY = LocalDate(MONTH_INDEX_EPOCH_YEAR, 1, 1)
|
||||
private val GRID_LAST_DAY = LocalDate(MONTH_INDEX_END_YEAR, 12, 31)
|
||||
|
||||
internal fun monthIndexOf(ym: YearMonth): Int =
|
||||
(ym.year - MONTH_INDEX_EPOCH_YEAR) * 12 + ym.month.ordinal
|
||||
|
||||
internal fun yearMonthForIndex(index: Int): YearMonth = YearMonth(
|
||||
MONTH_INDEX_EPOCH_YEAR + index / 12,
|
||||
Month.entries[index % 12],
|
||||
)
|
||||
/** The month at [index], held to the grid's span, so a stray index can't throw (#457). */
|
||||
internal fun yearMonthForIndex(index: Int): YearMonth {
|
||||
val held = clampMonthIndex(index)
|
||||
return YearMonth(MONTH_INDEX_EPOCH_YEAR + held / 12, Month.entries[held % 12])
|
||||
}
|
||||
|
||||
/** Total months the continuous grid scrolls through (1900 → 2100). */
|
||||
internal fun clampMonthIndex(index: Int): Int = index.coerceIn(0, continuousMonthCount() - 1)
|
||||
|
||||
/**
|
||||
* Hold [date] inside the span the month grid can show. Every view shares
|
||||
* [CalendarSpan], but another app can open Calendula on any date (#457).
|
||||
*/
|
||||
internal fun coerceToMonthRange(date: LocalDate): LocalDate = date.coerceIn(GRID_FIRST_DAY, GRID_LAST_DAY)
|
||||
|
||||
internal fun isInMonthRange(date: LocalDate): Boolean = date in GRID_FIRST_DAY..GRID_LAST_DAY
|
||||
|
||||
/** Total months the continuous grid scrolls through. */
|
||||
internal fun continuousMonthCount(): Int =
|
||||
(MONTH_INDEX_END_YEAR - MONTH_INDEX_EPOCH_YEAR + 1) * 12
|
||||
|
||||
@@ -611,25 +678,23 @@ internal fun monthIndexForItem(itemIndex: Int): Int = itemIndex / 2
|
||||
/**
|
||||
* Hold a load window inside the months that actually exist. The pad
|
||||
* [nextLoadWindow] adds can otherwise push an index past either end of the
|
||||
* 1900–2100 span, and [yearMonthForIndex] has no month to hand back for one.
|
||||
* span, and [yearMonthForIndex] has no month to hand back for one.
|
||||
*/
|
||||
internal fun clampMonthWindow(window: IntRange): IntRange {
|
||||
val last = continuousMonthCount() - 1
|
||||
return window.first.coerceIn(0, last)..window.last.coerceIn(0, last)
|
||||
}
|
||||
internal fun clampMonthWindow(window: IntRange): IntRange =
|
||||
clampMonthIndex(window.first)..clampMonthIndex(window.last)
|
||||
|
||||
/**
|
||||
* The Dense style addresses *weeks* by absolute index instead: it has no month
|
||||
* blocks to key by, and a row's identity has to survive the months around it
|
||||
* scrolling past. Index 0 is the first week of 1900 under the active week-start,
|
||||
* scrolling past. Index 0 is the span's first week under the active week-start,
|
||||
* so — like the month space — every index is non-negative and the LazyColumn's
|
||||
* item indices and week indices are the same number.
|
||||
*
|
||||
* Unlike month indices these *do* shift with the week-start preference, which is
|
||||
* why the list state is keyed on it.
|
||||
*/
|
||||
private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1)
|
||||
private val WEEK_INDEX_END = LocalDate(2100, 12, 31)
|
||||
private val WEEK_INDEX_EPOCH = GRID_FIRST_DAY
|
||||
private val WEEK_INDEX_END = GRID_LAST_DAY
|
||||
|
||||
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
|
||||
val base = WEEK_INDEX_EPOCH.startOfWeek(weekStart)
|
||||
@@ -639,7 +704,7 @@ internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
|
||||
internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate =
|
||||
WEEK_INDEX_EPOCH.startOfWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
|
||||
|
||||
/** Total weeks the Dense grid scrolls through (1900 → 2100). */
|
||||
/** Total weeks the Dense grid scrolls through. */
|
||||
internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
|
||||
weekIndexOf(WEEK_INDEX_END, weekStart) + 1
|
||||
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.search
|
||||
|
||||
import android.Manifest
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
@@ -59,7 +56,6 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -79,12 +75,12 @@ import de.jeanlucmakiola.calendula.domain.MatchSpan
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.SearchHit
|
||||
import de.jeanlucmakiola.calendula.domain.SearchMonth
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import de.jeanlucmakiola.calendula.domain.isCrossedOut
|
||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
@@ -98,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
|
||||
@@ -133,7 +130,6 @@ fun SearchScreen(
|
||||
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val context = LocalContext.current
|
||||
val inSelection = selection.isNotEmpty()
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -155,24 +151,8 @@ fun SearchScreen(
|
||||
keyboard?.show()
|
||||
}
|
||||
|
||||
// Same in-place WRITE_CALENDAR upgrade the detail screen does: a v1.0 install
|
||||
// holds only READ_CALENDAR, and granting continues into the held delete.
|
||||
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) pendingWrite?.invoke()
|
||||
pendingWrite = null
|
||||
}
|
||||
val requireWrite: (() -> Unit) -> Unit = { action ->
|
||||
val granted = context.hasPermission(Manifest.permission.WRITE_CALENDAR)
|
||||
if (granted) {
|
||||
action()
|
||||
} else {
|
||||
pendingWrite = action
|
||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
}
|
||||
}
|
||||
// A v1.0 install holds only READ_CALENDAR; granting continues into the held delete.
|
||||
val onDeleteClick = rememberWriteAction { showDeleteDialog = true }
|
||||
|
||||
// Back leaves selection mode before it leaves the screen, and without the
|
||||
// predictive-back scale-out — that would read as search closing and snapping
|
||||
@@ -193,7 +173,7 @@ fun SearchScreen(
|
||||
onBack = onBack,
|
||||
onCloseSelection = viewModel::clearSelection,
|
||||
onSelectAll = viewModel::selectAll,
|
||||
onDelete = { requireWrite { showDeleteDialog = true } },
|
||||
onDelete = onDeleteClick,
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
@@ -520,7 +500,7 @@ private fun SearchResultRow(
|
||||
// Faded like a past event anywhere else in the app — search reaches back
|
||||
// through the whole history.
|
||||
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = declinedTitle(marked(title, hit.titleSpans, highlight), event.isDeclined),
|
||||
title = declinedTitle(marked(title, hit.titleSpans, highlight), event.isCrossedOut),
|
||||
summary = searchSummary(hit, highlight),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
@@ -630,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)
|
||||
|
||||
@@ -7,6 +7,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventSearch
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.ui.common.deleteEvent
|
||||
import de.jeanlucmakiola.calendula.domain.SearchResults
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
@@ -196,15 +197,11 @@ class SearchViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun deleteOne(event: EventInstance, scope: RecurringWriteScope) {
|
||||
val begin = event.start.toEpochMilliseconds()
|
||||
when {
|
||||
!event.isRecurring -> repository.deleteEvent(event.eventId)
|
||||
scope == RecurringWriteScope.ThisEvent ->
|
||||
repository.deleteOccurrence(event.eventId, begin)
|
||||
scope == RecurringWriteScope.ThisAndFollowing ->
|
||||
repository.deleteEventFromOccurrence(event.eventId, begin)
|
||||
else -> repository.deleteEvent(event.eventId)
|
||||
}
|
||||
repository.deleteEvent(
|
||||
eventId = event.eventId,
|
||||
beginMillis = event.start.toEpochMilliseconds(),
|
||||
scope = if (event.isRecurring) scope else RecurringWriteScope.AllEvents,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun readOnlyCalendarIds(): Set<Long> =
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Autorenew
|
||||
import androidx.compose.material.icons.filled.Backup
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Cake
|
||||
@@ -23,6 +24,7 @@ import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Gavel
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.LocalCafe
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.PrivacyTip
|
||||
@@ -56,10 +58,12 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.licences.LicencesScreen
|
||||
import de.jeanlucmakiola.floret.components.AboutCard
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.LanguagePickerRow
|
||||
import de.jeanlucmakiola.calendula.widget.requestWidgetRedraw
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
@@ -152,7 +156,7 @@ fun SettingsScreen(
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Widgets)
|
||||
WidgetsScreen(onBack = { section = null }, visible = section == SettingsSection.Widgets)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.Licences,
|
||||
@@ -255,15 +259,16 @@ private fun SettingsHub(
|
||||
leading = { CategoryIcon(Icons.Default.Widgets, ChipAccent.Tertiary) },
|
||||
onClick = { onOpenSection(SettingsSection.Widgets) },
|
||||
)
|
||||
val context = LocalContext.current
|
||||
LanguagePickerRow(
|
||||
position = Position.Middle,
|
||||
title = stringResource(R.string.settings_language),
|
||||
autoLabel = stringResource(R.string.settings_language_auto),
|
||||
localesConfig = R.xml.locales_config,
|
||||
predictiveBack = true,
|
||||
onChange = { requestWidgetRedraw(context) },
|
||||
// Invite contributions right where users pick their language.
|
||||
header = {
|
||||
val context = LocalContext.current
|
||||
val translateUrl = stringResource(R.string.about_translate_url)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_translate),
|
||||
@@ -360,17 +365,46 @@ private fun AboutCard() {
|
||||
)
|
||||
}
|
||||
|
||||
/** "Support development", as the closing row of the About card's group. */
|
||||
/**
|
||||
* "Support development", as the closing row of the About card's group. Opens a
|
||||
* picker of the donation platforms.
|
||||
*/
|
||||
@Composable
|
||||
private fun SupportRow() {
|
||||
val context = LocalContext.current
|
||||
val url = stringResource(R.string.about_support_url)
|
||||
var showPicker by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_about_support),
|
||||
position = Position.Bottom,
|
||||
leading = { CategoryIcon(Icons.Default.Favorite, ChipAccent.Primary) },
|
||||
onClick = { openUrl(context, url) },
|
||||
onClick = { showPicker = true },
|
||||
)
|
||||
|
||||
if (showPicker) {
|
||||
val kofiUrl = stringResource(R.string.about_support_kofi_url)
|
||||
val liberapayUrl = stringResource(R.string.about_support_liberapay_url)
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.settings_about_support),
|
||||
onDismiss = { showPicker = false },
|
||||
predictiveBack = true,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_support_kofi),
|
||||
summary = stringResource(R.string.settings_support_kofi_hint),
|
||||
position = Position.Top,
|
||||
leading = { CategoryIcon(Icons.Default.LocalCafe, ChipAccent.Primary) },
|
||||
onClick = { openUrl(context, kofiUrl) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_support_liberapay),
|
||||
summary = stringResource(R.string.settings_support_liberapay_hint),
|
||||
position = Position.Bottom,
|
||||
leading = { CategoryIcon(Icons.Default.Autorenew, ChipAccent.Primary) },
|
||||
onClick = { openUrl(context, liberapayUrl) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,7 +16,6 @@ import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
|
||||
/**
|
||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||
@@ -43,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). */
|
||||
@@ -51,14 +52,10 @@ data class SettingsUiState(
|
||||
val tapOpensEditor: Boolean = false,
|
||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||
/** How far ahead the agenda widget shows events (v2.11). */
|
||||
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
|
||||
/** Whether the agenda (screen + widget) always anchors today at the top (#35). */
|
||||
/** Whether the agenda screen always anchors today at the top (#35); widgets have their own (#292). */
|
||||
val agendaShowToday: Boolean = true,
|
||||
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
||||
val agendaShowRangeBar: Boolean = true,
|
||||
/** The size step the agenda widget draws its text at (#51). */
|
||||
val widgetSize: WidgetSize = WidgetSize.SMALL,
|
||||
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
||||
val defaultView: CalendarView = CalendarView.Week,
|
||||
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
|
||||
|
||||
@@ -3,9 +3,6 @@ package de.jeanlucmakiola.calendula.ui.settings
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -42,20 +39,12 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SIZE_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -67,8 +56,6 @@ import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -114,14 +101,13 @@ 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],
|
||||
agendaScreenRange = this[SettingsKeys.agendaScreenRange],
|
||||
agendaWidgetRange = this[SettingsKeys.agendaWidgetRange],
|
||||
agendaShowToday = this[SettingsKeys.agendaShowToday],
|
||||
agendaShowRangeBar = this[SettingsKeys.agendaShowRangeBar],
|
||||
widgetSize = this[SettingsKeys.widgetSize],
|
||||
defaultView = this[SettingsKeys.defaultView],
|
||||
quickSwitchConfig = this[SettingsKeys.quickSwitchConfig],
|
||||
monthViewStyle = this[SettingsKeys.monthViewStyle],
|
||||
@@ -181,10 +167,6 @@ class SettingsViewModel @Inject constructor(
|
||||
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val fontImportFailed: SharedFlow<Unit> = _fontImportFailed
|
||||
|
||||
// Serialises widget redraws: concurrent updateAll calls coalesce around a
|
||||
// stale read and can leave the widget on an intermediate value.
|
||||
private val widgetRefreshMutex = Mutex()
|
||||
|
||||
/** Contact special-dates sub-page (issue #15). */
|
||||
val specialDatesState: StateFlow<SpecialDatesUiState> = prefs.data.map { stored ->
|
||||
val templates = stored.specialDatesTitleTemplates
|
||||
@@ -267,16 +249,7 @@ class SettingsViewModel @Inject constructor(
|
||||
|
||||
fun setDynamicColor(enabled: Boolean) = save { setDynamicColor(enabled) }
|
||||
|
||||
fun setSoftenColors(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setSoftenCalendarColors(enabled)
|
||||
// Both widgets paint event colours through the same softener.
|
||||
widgetRefreshMutex.withLock {
|
||||
AgendaWidget().updateAll(appContext)
|
||||
MonthWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
fun setSoftenColors(enabled: Boolean) = save { setSoftenCalendarColors(enabled) }
|
||||
|
||||
fun setFont(role: FontRole, token: String) = save {
|
||||
when (role) {
|
||||
@@ -304,68 +277,13 @@ class SettingsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setWeekStart(pref: WeekStartPref) {
|
||||
viewModelScope.launch {
|
||||
prefs.setWeekStart(pref)
|
||||
// Both widgets depend on the week start (month weekday header; the
|
||||
// agenda widget's "this week" range).
|
||||
widgetRefreshMutex.withLock {
|
||||
AgendaWidget().updateAll(appContext)
|
||||
MonthWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
fun setWeekStart(pref: WeekStartPref) = save { setWeekStart(pref) }
|
||||
|
||||
fun setAgendaScreenRange(range: AgendaRange) = save { setAgendaScreenRange(range) }
|
||||
|
||||
fun setAgendaWidgetRange(range: AgendaRange) {
|
||||
viewModelScope.launch {
|
||||
prefs.setAgendaWidgetRange(range)
|
||||
widgetRefreshMutex.withLock {
|
||||
// Push into each instance's Glance state and recompose:
|
||||
// updateAll alone won't re-run provideGlance's data preamble.
|
||||
val manager = GlanceAppWidgetManager(appContext)
|
||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[AGENDA_RANGE_KEY] = range.storageValue() }
|
||||
}
|
||||
AgendaWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAgendaShowRangeBar(enabled: Boolean) = save { setAgendaShowRangeBar(enabled) }
|
||||
|
||||
/**
|
||||
* Set the size the agenda widget draws its text at (#51), via the same
|
||||
* Glance-state path as [setAgendaWidgetRange]. Agenda-only: the month widget
|
||||
* sizes itself from the space it is given (#103).
|
||||
*/
|
||||
fun setWidgetSize(size: WidgetSize) {
|
||||
viewModelScope.launch {
|
||||
prefs.setWidgetSize(size)
|
||||
widgetRefreshMutex.withLock {
|
||||
val manager = GlanceAppWidgetManager(appContext)
|
||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[AGENDA_SIZE_KEY] = size.name }
|
||||
}
|
||||
AgendaWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAgendaShowToday(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setAgendaShowToday(enabled)
|
||||
// Same Glance-state path as setPastEventDisplay.
|
||||
widgetRefreshMutex.withLock {
|
||||
val manager = GlanceAppWidgetManager(appContext)
|
||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[AGENDA_SHOW_TODAY_STATE_KEY] = enabled }
|
||||
}
|
||||
AgendaWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
fun setAgendaShowToday(enabled: Boolean) = save { setAgendaShowToday(enabled) }
|
||||
|
||||
fun setTimeFormat(pref: TimeFormatPref) = save { setTimeFormat(pref) }
|
||||
|
||||
@@ -375,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) }
|
||||
@@ -396,19 +316,7 @@ class SettingsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||
viewModelScope.launch {
|
||||
prefs.setPastEventDisplay(mode)
|
||||
// Same Glance-state path as setAgendaWidgetRange.
|
||||
widgetRefreshMutex.withLock {
|
||||
val manager = GlanceAppWidgetManager(appContext)
|
||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[AGENDA_PAST_DISPLAY_KEY] = mode.name }
|
||||
}
|
||||
AgendaWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
fun setPastEventDisplay(mode: PastEventDisplay) = save { setPastEventDisplay(mode) }
|
||||
|
||||
fun setDimCompletedEvents(enabled: Boolean) = save { setDimCompletedEvents(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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -18,56 +20,46 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlin.math.roundToInt
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.calendula.widget.config.PlacedWidget
|
||||
import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import de.jeanlucmakiola.calendula.widget.config.backgroundLabel
|
||||
import de.jeanlucmakiola.calendula.widget.config.placedWidgets
|
||||
import de.jeanlucmakiola.calendula.widget.config.themeLabel
|
||||
|
||||
/**
|
||||
* Widgets & tiles (#69): the home-screen widgets' settings and the Quick
|
||||
* Settings tile shortcut. The agenda widget keeps its own range and text size,
|
||||
* separate from the Agenda screen's range in [ViewsScreen].
|
||||
* Widgets & tiles (#69): the placed home-screen widgets, each opening its own
|
||||
* config screen (#292), and the Quick Settings tile shortcut.
|
||||
*/
|
||||
@Composable
|
||||
internal fun WidgetsScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean = true,
|
||||
) {
|
||||
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
||||
var showWidgetSize by remember { mutableStateOf(false) }
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_widgets),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
GroupedHint(stringResource(R.string.settings_widgets_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_widget_range),
|
||||
summary = agendaRangeLabel(state.agendaWidgetRange),
|
||||
position = Position.Top,
|
||||
onClick = { showAgendaWidgetRange = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_widget_size),
|
||||
summary = widgetSizeLabel(state.widgetSize),
|
||||
position = Position.Bottom,
|
||||
onClick = { showWidgetSize = true },
|
||||
GroupedHint(
|
||||
stringResource(
|
||||
// The launcher's "Edit widget" action only exists from Android 12.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.string.settings_widgets_hint
|
||||
else R.string.settings_widgets_hint_legacy,
|
||||
),
|
||||
)
|
||||
GroupedSectionHeader(stringResource(R.string.settings_widgets_placed))
|
||||
PlacedWidgetRows()
|
||||
|
||||
// The add-tile prompt is API 33+; below that the tile is still
|
||||
// addable from the QS editor, so the row just doesn't appear.
|
||||
@@ -76,30 +68,44 @@ internal fun WidgetsScreen(
|
||||
QuickSettingsTileRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAgendaWidgetRange) {
|
||||
AgendaRangePicker(
|
||||
title = stringResource(R.string.settings_agenda_widget_range),
|
||||
description = stringResource(R.string.settings_agenda_widget_range_hint),
|
||||
selected = state.agendaWidgetRange,
|
||||
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
||||
onSelect = viewModel::setAgendaWidgetRange,
|
||||
onDismiss = { showAgendaWidgetRange = false },
|
||||
)
|
||||
@Composable
|
||||
private fun PlacedWidgetRows() {
|
||||
val context = LocalContext.current
|
||||
// Read on every resume, the first included, so a widget added, removed or
|
||||
// edited meanwhile shows up.
|
||||
var refresh by remember { mutableIntStateOf(0) }
|
||||
LifecycleResumeEffect(Unit) {
|
||||
refresh++
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
if (showWidgetSize) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_widget_size),
|
||||
header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) },
|
||||
predictiveBack = true,
|
||||
options = WidgetSize.entries,
|
||||
selected = state.widgetSize,
|
||||
label = { widgetSizeLabel(it) },
|
||||
// No live preview: the widget is Glance/RemoteViews, so a Compose
|
||||
// mock-up would be a second implementation free to drift.
|
||||
summary = { widgetSizeSummary(it) },
|
||||
onSelect = viewModel::setWidgetSize,
|
||||
onDismiss = { showWidgetSize = false },
|
||||
val widgets by produceState<List<PlacedWidget>?>(initialValue = null, refresh) {
|
||||
if (refresh > 0) value = withContext(Dispatchers.IO) { context.placedWidgets() }
|
||||
}
|
||||
val placed = widgets ?: return
|
||||
if (placed.isEmpty()) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_widgets_none),
|
||||
position = Position.Alone,
|
||||
dimmed = true,
|
||||
)
|
||||
return
|
||||
}
|
||||
val kindCounts = placed.groupingBy { it.kind }.eachCount()
|
||||
val kindSeen = mutableMapOf<WidgetKind, Int>()
|
||||
placed.forEachIndexed { index, widget ->
|
||||
val number = kindSeen.merge(widget.kind, 1, Int::plus)!!
|
||||
val label = stringResource(widget.kind.label)
|
||||
GroupedRow(
|
||||
title = if (kindCounts.getValue(widget.kind) > 1) stringResource(R.string.widget_numbered, label, number) else label,
|
||||
summary = stringResource(
|
||||
R.string.widget_appearance_summary,
|
||||
backgroundLabel(widget.appearance.background),
|
||||
themeLabel(widget.appearance.theme),
|
||||
),
|
||||
position = positionOf(index, placed.size),
|
||||
onClick = { context.startActivity(WidgetConfigActivity.intent(context, widget.appWidgetId)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -131,24 +137,3 @@ private fun requestAddQsTile(context: Context) {
|
||||
context.mainExecutor,
|
||||
) { /* result code unused — the system surfaces its own feedback */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* What a size step does, as a percentage of the smallest one's event text.
|
||||
* Derived from the widget's own [metricsFor] table rather than hardcoded.
|
||||
*/
|
||||
@Composable
|
||||
private fun widgetSizeSummary(size: WidgetSize): String {
|
||||
val base = metricsFor(WidgetSize.SMALL).eventTitle.value
|
||||
val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
|
||||
return stringResource(R.string.settings_widget_size_summary, percent)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
|
||||
when (size) {
|
||||
WidgetSize.SMALL -> R.string.settings_widget_size_small
|
||||
WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
|
||||
WidgetSize.LARGE -> R.string.settings_widget_size_large
|
||||
WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
|
||||
},
|
||||
)
|
||||
@@ -7,8 +7,8 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
|
||||
/**
|
||||
* A synchronous copy of the theme choice, for the window backdrop drawn before
|
||||
* the first Compose frame. The real setting lives in DataStore, which only
|
||||
* A synchronous copy of the theme and dynamic-colour choices, for the window
|
||||
* backdrop and the first Compose frame. The real setting lives in DataStore, which only
|
||||
* answers asynchronously — too late for onCreate, so a Light, Dark or Black
|
||||
* override flashed the night-qualifier backdrop on a cold start or a
|
||||
* recreation (#181). MainActivity mirrors every stored value here and reads it
|
||||
@@ -18,6 +18,7 @@ internal object WindowThemeCache {
|
||||
|
||||
private const val FILE = "window_theme"
|
||||
private const val KEY_THEME_MODE = "theme_mode"
|
||||
private const val KEY_DYNAMIC_COLOR = "dynamic_color"
|
||||
|
||||
/** The last mirrored theme choice, or null before the first mirror. */
|
||||
fun read(context: Context): ThemeMode? =
|
||||
@@ -25,10 +26,23 @@ internal object WindowThemeCache {
|
||||
.getString(KEY_THEME_MODE, null)
|
||||
?.let { name -> ThemeMode.entries.firstOrNull { it.name == name } }
|
||||
|
||||
fun write(context: Context, mode: ThemeMode) {
|
||||
/** The last mirrored dynamic-colour choice, or null before the first mirror. */
|
||||
fun readDynamicColor(context: Context): Boolean? {
|
||||
val prefs = context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
if (prefs.getString(KEY_THEME_MODE, null) == mode.name) return
|
||||
prefs.edit { putString(KEY_THEME_MODE, mode.name) }
|
||||
return if (prefs.contains(KEY_DYNAMIC_COLOR)) prefs.getBoolean(KEY_DYNAMIC_COLOR, true) else null
|
||||
}
|
||||
|
||||
fun write(context: Context, mode: ThemeMode, dynamicColor: Boolean) {
|
||||
val prefs = context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
if (prefs.getString(KEY_THEME_MODE, null) == mode.name &&
|
||||
prefs.contains(KEY_DYNAMIC_COLOR) && prefs.getBoolean(KEY_DYNAMIC_COLOR, true) == dynamicColor
|
||||
) {
|
||||
return
|
||||
}
|
||||
prefs.edit {
|
||||
putString(KEY_THEME_MODE, mode.name)
|
||||
putBoolean(KEY_DYNAMIC_COLOR, dynamicColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarSpan
|
||||
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -7,100 +8,58 @@ import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.plus
|
||||
|
||||
/** Pages the week pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
|
||||
internal const val WEEK_PAGE_COUNT: Int = 20_000
|
||||
|
||||
private const val EPOCH_PAGE = WEEK_PAGE_COUNT / 2
|
||||
|
||||
/** The week at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
|
||||
private val PAGE_EPOCH = LocalDate(2000, 1, 3)
|
||||
|
||||
/** First day of the week shown on pager [page], with weeks starting on [firstDay]. */
|
||||
internal fun weekStartForPage(page: Int, firstDay: DayOfWeek): LocalDate =
|
||||
PAGE_EPOCH.startOfWeek(firstDay).plus((page - EPOCH_PAGE) * 7, DateTimeUnit.DAY)
|
||||
|
||||
/** The pager page whose week contains [date], with weeks starting on [firstDay]. */
|
||||
internal fun weekPageFor(date: LocalDate, firstDay: DayOfWeek): Int {
|
||||
val days = PAGE_EPOCH.startOfWeek(firstDay).daysUntil(date.startOfWeek(firstDay))
|
||||
return (EPOCH_PAGE + days / 7).coerceIn(0, WEEK_PAGE_COUNT - 1)
|
||||
}
|
||||
|
||||
/** How a timeline pager cuts the calendar into pages of [dayCount] days. */
|
||||
sealed interface PageGrid {
|
||||
val dayCount: Int
|
||||
val pageCount: Int
|
||||
|
||||
/** First day shown on pager [page]. */
|
||||
fun startForPage(page: Int): LocalDate
|
||||
|
||||
/** The pager page showing [date]. */
|
||||
fun pageFor(date: LocalDate): Int
|
||||
}
|
||||
|
||||
/** Whole weeks, starting on [firstDay]. */
|
||||
data class WeekGrid(val firstDay: DayOfWeek) : PageGrid {
|
||||
override val dayCount: Int get() = 7
|
||||
override val pageCount: Int get() = WEEK_PAGE_COUNT
|
||||
override fun startForPage(page: Int): LocalDate = weekStartForPage(page, firstDay)
|
||||
override fun pageFor(date: LocalDate): Int = weekPageFor(date, firstDay)
|
||||
}
|
||||
|
||||
/** Pages the multi-day pager spans — over three centuries at the widest page. */
|
||||
internal const val DAYS_PAGE_COUNT: Int = 40_000
|
||||
|
||||
private const val DAYS_EPOCH_PAGE = DAYS_PAGE_COUNT / 2
|
||||
|
||||
/** A fixed day the multi-day pages are counted from. */
|
||||
/** A fixed day every grid's pages are counted from. */
|
||||
private val DAYS_EPOCH = LocalDate(2000, 1, 1)
|
||||
|
||||
/** The week the week pager's middle page shows; any date works, it only has to stay fixed. */
|
||||
private val WEEK_EPOCH = LocalDate(2000, 1, 3)
|
||||
|
||||
/**
|
||||
* Runs of [dayCount] days (#193), offset [phase] days from a fixed epoch so any
|
||||
* date can start a page. The phase stays put while paging and only changes when
|
||||
* How a timeline pager cuts the calendar: runs of [dayCount] days, offset
|
||||
* [phase] days from a fixed epoch, over as many pages as cover [CalendarSpan].
|
||||
* The multi-day view's phase (#193) stays put while paging and only changes when
|
||||
* a jump moves another date to the first column.
|
||||
*/
|
||||
data class DaysGrid(override val dayCount: Int, val phase: Int) : PageGrid {
|
||||
override val pageCount: Int get() = DAYS_PAGE_COUNT
|
||||
data class DaysGrid(val dayCount: Int, val phase: Int) {
|
||||
|
||||
override fun startForPage(page: Int): LocalDate =
|
||||
DAYS_EPOCH.plus(phase + (page - DAYS_EPOCH_PAGE) * dayCount, DateTimeUnit.DAY)
|
||||
/** The page showing the epoch; page 0 is the one holding [CalendarSpan.FIRST_DAY]. */
|
||||
private val epochPage: Int get() = -pagesFromEpoch(CalendarSpan.FIRST_DAY)
|
||||
|
||||
override fun pageFor(date: LocalDate): Int =
|
||||
(DAYS_EPOCH_PAGE + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount))
|
||||
.coerceIn(0, DAYS_PAGE_COUNT - 1)
|
||||
val pageCount: Int get() = epochPage + pagesFromEpoch(CalendarSpan.LAST_DAY) + 1
|
||||
|
||||
private fun pagesFromEpoch(date: LocalDate): Int =
|
||||
Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount)
|
||||
|
||||
/** First day shown on pager [page]. */
|
||||
fun startForPage(page: Int): LocalDate =
|
||||
DAYS_EPOCH.plus(phase + (page - epochPage) * dayCount, DateTimeUnit.DAY)
|
||||
|
||||
/** The pager page showing [date]. */
|
||||
fun pageFor(date: LocalDate): Int =
|
||||
(epochPage + pagesFromEpoch(date)).coerceIn(0, pageCount - 1)
|
||||
|
||||
companion object {
|
||||
/** Single days: every date starts a page of its own. */
|
||||
val days: DaysGrid = DaysGrid(dayCount = 1, phase = 0)
|
||||
|
||||
/** Whole weeks, starting on [firstDay]. */
|
||||
fun weeks(firstDay: DayOfWeek): DaysGrid = DaysGrid(
|
||||
dayCount = 7,
|
||||
phase = DAYS_EPOCH.daysUntil(WEEK_EPOCH.startOfWeek(firstDay)),
|
||||
)
|
||||
|
||||
/** The grid of [dayCount]-day pages on which [date] is a page's first day. */
|
||||
fun startingAt(date: LocalDate, dayCount: Int): DaysGrid =
|
||||
DaysGrid(dayCount, Math.floorMod(DAYS_EPOCH.daysUntil(date), dayCount))
|
||||
}
|
||||
}
|
||||
|
||||
/** Pages the day pager spans — a little under four centuries, centred on [DAY_EPOCH]. */
|
||||
internal const val DAY_PAGE_COUNT: Int = 140_000
|
||||
|
||||
private const val DAY_EPOCH_PAGE = DAY_PAGE_COUNT / 2
|
||||
|
||||
/** The day at [DAY_EPOCH_PAGE]; any date works, it only has to stay fixed. */
|
||||
private val DAY_EPOCH = LocalDate(2000, 1, 1)
|
||||
|
||||
/** Single days: every date starts a page of its own. */
|
||||
data object DayGrid : PageGrid {
|
||||
override val dayCount: Int get() = 1
|
||||
override val pageCount: Int get() = DAY_PAGE_COUNT
|
||||
|
||||
override fun startForPage(page: Int): LocalDate =
|
||||
DAY_EPOCH.plus(page - DAY_EPOCH_PAGE, DateTimeUnit.DAY)
|
||||
|
||||
override fun pageFor(date: LocalDate): Int =
|
||||
(DAY_EPOCH_PAGE + DAY_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the focused date goes when the pager settles on [page] (#184): kept if
|
||||
* the page still shows it, otherwise moved by the pages travelled, so it keeps
|
||||
* its place in the page — the same weekday a week on.
|
||||
*/
|
||||
internal fun PageGrid.focusOnPage(focus: LocalDate, page: Int): LocalDate {
|
||||
internal fun DaysGrid.focusOnPage(focus: LocalDate, page: Int): LocalDate {
|
||||
val start = startForPage(page)
|
||||
val end = start.plus(dayCount - 1, DateTimeUnit.DAY)
|
||||
if (focus in start..end) return focus
|
||||
|
||||
@@ -70,7 +70,7 @@ import de.jeanlucmakiola.calendula.ui.common.draftEvent
|
||||
import de.jeanlucmakiola.calendula.ui.common.draftStartMin
|
||||
import de.jeanlucmakiola.calendula.ui.common.isDraft
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdges
|
||||
import de.jeanlucmakiola.calendula.ui.common.wallClockInstant
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickUp
|
||||
@@ -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
|
||||
@@ -464,11 +478,12 @@ private fun PagedTimelineScreen(
|
||||
|
||||
// Straight to the pager: a tap mid-fling must still land, and the anchor
|
||||
// hasn't caught up with the fling yet to tell the difference. A date that
|
||||
// would open mid-page on this grid re-frames the pages around it instead.
|
||||
// would open mid-page on this grid, in a view that frames its pages from the
|
||||
// date (multi-day), re-frames the pages around it instead.
|
||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||
focus?.date = target
|
||||
val page = grid.pageFor(target)
|
||||
if (grid is DaysGrid && grid.startForPage(page) != target) {
|
||||
if (grid.startForPage(page) != target && viewModel.rule.value(target) != grid) {
|
||||
viewModel.jumpTo(target)
|
||||
} else {
|
||||
scope.launch { pagerState.jumpToPage(page, reduceMotion, pageSpec) }
|
||||
@@ -531,7 +546,7 @@ private fun WeekContent(
|
||||
state: WeekUiState,
|
||||
today: LocalDate,
|
||||
pagerState: PagerState,
|
||||
grid: PageGrid,
|
||||
grid: DaysGrid,
|
||||
kind: TimelineKind,
|
||||
morph: TimelineMorph?,
|
||||
handoffStrip: Dp?,
|
||||
@@ -600,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 ->
|
||||
@@ -637,10 +667,15 @@ private fun WeekContent(
|
||||
delay(SELECTION_ORPHAN_GRACE_MILLIS)
|
||||
dragController.deselect()
|
||||
}
|
||||
val requireWrite = rememberWriteGate()
|
||||
var pendingDraft by remember { mutableStateOf<EventInstance?>(null) }
|
||||
val createPendingDraft = rememberWriteAction {
|
||||
pendingDraft?.let(createDraft)
|
||||
pendingDraft = null
|
||||
}
|
||||
val openEvent: (EventInstance) -> Unit = { event ->
|
||||
if (event.isDraft) {
|
||||
requireWrite { createDraft(event) }
|
||||
pendingDraft = event
|
||||
createPendingDraft()
|
||||
} else {
|
||||
dragController.deselect()
|
||||
onEventClick(event)
|
||||
@@ -655,6 +690,7 @@ private fun WeekContent(
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
onStripDrop = onStripDrop,
|
||||
)
|
||||
|
||||
Box(modifier = modifier) {
|
||||
@@ -696,7 +732,7 @@ private fun WeekContent(
|
||||
@Composable
|
||||
private fun WeekPager(
|
||||
pagerState: PagerState,
|
||||
grid: PageGrid,
|
||||
grid: DaysGrid,
|
||||
kind: TimelineKind,
|
||||
span: (DaySpan) -> StateFlow<WeekUiState>,
|
||||
today: LocalDate,
|
||||
@@ -730,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) }
|
||||
@@ -738,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,
|
||||
@@ -751,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) }
|
||||
@@ -886,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,
|
||||
@@ -986,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))
|
||||
@@ -1121,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
|
||||
@@ -1144,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),
|
||||
)
|
||||
@@ -1166,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) {
|
||||
@@ -1179,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) }
|
||||
@@ -1192,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)
|
||||
@@ -1248,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
|
||||
@@ -1281,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,
|
||||
@@ -1319,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(),
|
||||
)
|
||||
}
|
||||
@@ -1326,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>,
|
||||
@@ -1337,6 +1493,7 @@ private fun DayColumnCard(
|
||||
visibleTop: () -> Float,
|
||||
viewportHeight: () -> Float,
|
||||
actions: TimelineActions,
|
||||
shape: Shape = RectangleShape,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scrollState = actions.scrollState
|
||||
@@ -1367,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
|
||||
@@ -1408,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.
|
||||
@@ -1416,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,
|
||||
@@ -1426,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)
|
||||
@@ -1769,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
|
||||
},
|
||||
@@ -1817,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
|
||||
@@ -1831,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
|
||||
@@ -62,10 +65,10 @@ private const val SPAN_CACHE_DAYS = 28
|
||||
data class DaySpan(val start: LocalDate, val count: Int)
|
||||
|
||||
/** How the pager cuts the calendar, and the page the anchor sits on. */
|
||||
data class PagerPosition(val grid: PageGrid, val page: Int)
|
||||
data class PagerPosition(val grid: DaysGrid, val page: Int)
|
||||
|
||||
/** Where [anchor] sits under [rule]. */
|
||||
fun positionOf(anchor: LocalDate, rule: (LocalDate) -> PageGrid): PagerPosition =
|
||||
fun positionOf(anchor: LocalDate, rule: (LocalDate) -> DaysGrid): PagerPosition =
|
||||
rule(anchor).let { PagerPosition(it, it.pageFor(anchor)) }
|
||||
|
||||
/**
|
||||
@@ -84,8 +87,8 @@ abstract class PagedTimelineViewModel(
|
||||
todaySource: TodaySource,
|
||||
zoneSource: ZoneSource,
|
||||
private val io: CoroutineDispatcher,
|
||||
gridRule: Flow<(LocalDate) -> PageGrid>,
|
||||
initialRule: (LocalDate) -> PageGrid,
|
||||
gridRule: Flow<(LocalDate) -> DaysGrid>,
|
||||
initialRule: (LocalDate) -> DaysGrid,
|
||||
) : ViewModel() {
|
||||
|
||||
/**
|
||||
@@ -121,7 +124,7 @@ abstract class PagedTimelineViewModel(
|
||||
* The grid for a given anchor, held eagerly so a screen can place its pager
|
||||
* on its very first frame rather than a frame after the anchor moved.
|
||||
*/
|
||||
val rule: StateFlow<(LocalDate) -> PageGrid> =
|
||||
val rule: StateFlow<(LocalDate) -> DaysGrid> =
|
||||
gridRule.stateIn(viewModelScope, SharingStarted.Eagerly, initialRule)
|
||||
|
||||
/** Today, kept current across midnight so a page left open doesn't mark yesterday (#373). */
|
||||
@@ -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,
|
||||
@@ -233,11 +235,11 @@ class WeekViewModel @Inject constructor(
|
||||
zoneSource = zoneSource,
|
||||
io = io,
|
||||
gridRule = settingsPrefs.weekStart.map { pref ->
|
||||
val grid = WeekGrid(pref.resolveFirstDay(Locale.getDefault()))
|
||||
val rule: (LocalDate) -> PageGrid = { grid }
|
||||
val grid = DaysGrid.weeks(pref.resolveFirstDay(Locale.getDefault()))
|
||||
val rule: (LocalDate) -> DaysGrid = { grid }
|
||||
rule
|
||||
},
|
||||
initialRule = { WeekGrid(DayOfWeek.MONDAY) },
|
||||
initialRule = { DaysGrid.weeks(DayOfWeek.MONDAY) },
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -278,8 +280,8 @@ class DayViewModel @Inject constructor(
|
||||
todaySource = todaySource,
|
||||
zoneSource = zoneSource,
|
||||
io = io,
|
||||
gridRule = flowOf { _: LocalDate -> DayGrid },
|
||||
initialRule = { DayGrid },
|
||||
gridRule = flowOf { _: LocalDate -> DaysGrid.days },
|
||||
initialRule = { DaysGrid.days },
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
|
||||
import de.jeanlucmakiola.floret.glance.restoreState
|
||||
|
||||
/**
|
||||
* What every widget receiver shares: the midnight rollover follows whether any
|
||||
* widget is placed (#228), a restore keeps each widget's settings (#292), and
|
||||
* the system's `updatePeriodMillis` wake-up reloads the data and re-arms the
|
||||
* alarm — anything that drops a pending alarm without a broadcast (force-stop,
|
||||
* battery restriction, an OEM freeze) is repaired there rather than on the
|
||||
* next app open.
|
||||
*/
|
||||
abstract class CalendulaWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
super.onEnabled(context)
|
||||
syncWidgetRollover(context)
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
super.onDisabled(context)
|
||||
syncWidgetRollover(context)
|
||||
}
|
||||
|
||||
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||
super.onDeleted(context, appWidgetIds)
|
||||
evictMonthSources(appWidgetIds)
|
||||
WidgetKind.entries.firstOrNull { it.receiver == this::class.java }?.let { kind ->
|
||||
appWidgetIds.forEach { WidgetTicks.cancel(context, kind, it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
|
||||
restoreState(context, oldWidgetIds, newWidgetIds)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingSuperCall")
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetIds: IntArray,
|
||||
) {
|
||||
// In place of super, which only recomposes and would take the goAsync handle.
|
||||
reloadOnUpdate(context, appWidgetIds)
|
||||
syncWidgetRollover(context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.appwidget.state.getAppWidgetState
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.toEnum
|
||||
|
||||
/** A widget's own light/dark choice (#292); [FOLLOW_APP] tracks the app's theme setting. */
|
||||
enum class WidgetTheme { FOLLOW_APP, LIGHT, DARK }
|
||||
|
||||
/** How a widget looks, set per placed widget in its config screen (#292). */
|
||||
data class WidgetAppearance(
|
||||
val background: WidgetBackground = WidgetBackground.TINTED,
|
||||
val theme: WidgetTheme = WidgetTheme.FOLLOW_APP,
|
||||
)
|
||||
|
||||
/** What the agenda widget lists, per placed widget. */
|
||||
data class AgendaWidgetOptions(
|
||||
val range: AgendaRange,
|
||||
val textSize: WidgetSize,
|
||||
val showToday: Boolean,
|
||||
val pastDisplay: PastEventDisplay,
|
||||
)
|
||||
|
||||
internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this) {
|
||||
WidgetTheme.LIGHT -> WidgetThemeMode.LIGHT
|
||||
WidgetTheme.DARK -> WidgetThemeMode.DARK
|
||||
WidgetTheme.FOLLOW_APP -> when (app) {
|
||||
ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM
|
||||
ThemeMode.LIGHT -> WidgetThemeMode.LIGHT
|
||||
ThemeMode.DARK, ThemeMode.BLACK -> WidgetThemeMode.DARK
|
||||
}
|
||||
}
|
||||
|
||||
private val BackgroundKey = stringPreferencesKey("cfg_background")
|
||||
private val ThemeKey = stringPreferencesKey("cfg_theme")
|
||||
private val AgendaRangeKey = stringPreferencesKey("cfg_agenda_range")
|
||||
private val TextSizeKey = stringPreferencesKey("cfg_text_size")
|
||||
private val ShowTodayKey = booleanPreferencesKey("cfg_show_today")
|
||||
private val PastDisplayKey = stringPreferencesKey("cfg_past_display")
|
||||
private val HiddenCalendarsKey = stringSetPreferencesKey("cfg_hidden_calendars")
|
||||
|
||||
internal val Preferences.widgetAppearance: WidgetAppearance
|
||||
get() = WidgetAppearance(
|
||||
background = this[BackgroundKey].toEnum(WidgetBackground.TINTED),
|
||||
theme = this[ThemeKey].toEnum(WidgetTheme.FOLLOW_APP),
|
||||
)
|
||||
|
||||
/**
|
||||
* This widget's agenda options. A widget placed before per-widget options
|
||||
* existed has none stored, and keeps following the app-wide values in
|
||||
* [defaults] until it is configured.
|
||||
*/
|
||||
internal fun Preferences.agendaWidgetOptions(defaults: AgendaWidgetOptions) = AgendaWidgetOptions(
|
||||
range = parseAgendaRange(this[AgendaRangeKey], defaults.range),
|
||||
textSize = this[TextSizeKey].toEnum(defaults.textSize),
|
||||
showToday = this[ShowTodayKey] ?: defaults.showToday,
|
||||
pastDisplay = this[PastDisplayKey].toEnum(defaults.pastDisplay),
|
||||
)
|
||||
|
||||
/** Whether this widget has agenda options of its own, rather than following the app's. */
|
||||
internal val Preferences.hasAgendaOptions: Boolean get() = this[AgendaRangeKey] != null
|
||||
|
||||
internal fun MutablePreferences.putAppearance(appearance: WidgetAppearance) {
|
||||
this[BackgroundKey] = appearance.background.name
|
||||
this[ThemeKey] = appearance.theme.name
|
||||
}
|
||||
|
||||
internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) {
|
||||
this[AgendaRangeKey] = options.range.storageValue()
|
||||
this[TextSizeKey] = options.textSize.name
|
||||
this[ShowTodayKey] = options.showToday
|
||||
this[PastDisplayKey] = options.pastDisplay.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendars this widget leaves out, on top of the ones hidden app-wide. Stored
|
||||
* as the excluded set, so a calendar added later shows up by default.
|
||||
*/
|
||||
internal val Preferences.hiddenCalendars: Set<Long>
|
||||
get() = this[HiddenCalendarsKey]?.mapNotNull { it.toLongOrNull() }?.toSet().orEmpty()
|
||||
|
||||
internal fun MutablePreferences.putHiddenCalendars(ids: Set<Long>) {
|
||||
this[HiddenCalendarsKey] = ids.map(Long::toString).toSet()
|
||||
}
|
||||
|
||||
internal fun List<EventInstance>.withoutCalendars(hidden: Set<Long>): List<EventInstance> =
|
||||
if (hidden.isEmpty()) this else filterNot { it.calendarId in hidden }
|
||||
|
||||
internal suspend fun Context.widgetState(id: GlanceId): Preferences =
|
||||
getAppWidgetState(this, PreferencesGlanceStateDefinition, id)
|
||||
@@ -4,7 +4,13 @@ import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
@@ -13,7 +19,12 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.get
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -25,6 +36,7 @@ import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -34,10 +46,63 @@ import kotlin.time.Instant
|
||||
* the whole window, and on slow devices a wide one held the widget on "loading"
|
||||
* (#363).
|
||||
*/
|
||||
private const val MONTH_WIDGET_PAD_MONTHS = 3
|
||||
internal const val MONTH_WIDGET_PAD_MONTHS = 3
|
||||
|
||||
/**
|
||||
* Every app setting the widgets draw with: what the loads read, and what
|
||||
* [WidgetRefreshCoordinator] watches. The agenda fields are only the defaults
|
||||
* for a widget that has no options of its own yet.
|
||||
*/
|
||||
internal data class WidgetSettings(
|
||||
val soften: Boolean,
|
||||
val weekStart: WeekStartPref,
|
||||
val timeFormat: TimeFormatPref,
|
||||
val themeMode: ThemeMode,
|
||||
val pastDisplay: PastEventDisplay,
|
||||
val agendaRange: AgendaRange,
|
||||
val widgetSize: WidgetSize,
|
||||
val showToday: Boolean,
|
||||
) {
|
||||
/** What every widget draws with; the agenda defaults aside. */
|
||||
val shared: SharedWidgetSettings get() = SharedWidgetSettings(soften, weekStart, timeFormat, themeMode)
|
||||
|
||||
val agendaDefaults: AgendaWidgetOptions
|
||||
get() = AgendaWidgetOptions(agendaRange, widgetSize, showToday, pastDisplay)
|
||||
}
|
||||
|
||||
/** The [WidgetSettings] a configured widget still takes from the app. */
|
||||
internal data class SharedWidgetSettings(
|
||||
val soften: Boolean,
|
||||
val weekStart: WeekStartPref,
|
||||
val timeFormat: TimeFormatPref,
|
||||
val themeMode: ThemeMode,
|
||||
)
|
||||
|
||||
/** A widget's loaded [content], with the app theme a [WidgetTheme.FOLLOW_APP] widget resolves against. */
|
||||
internal data class Themed<out T>(val content: T, val appTheme: ThemeMode)
|
||||
|
||||
internal val SettingsPrefs.widgetSettings: Flow<WidgetSettings>
|
||||
get() = data.map {
|
||||
WidgetSettings(
|
||||
soften = it[SettingsKeys.softenCalendarColors],
|
||||
weekStart = it[SettingsKeys.weekStart],
|
||||
timeFormat = it[SettingsKeys.timeFormat],
|
||||
themeMode = it[SettingsKeys.themeMode],
|
||||
pastDisplay = it[SettingsKeys.pastEventDisplay],
|
||||
agendaRange = it[SettingsKeys.agendaWidgetRange],
|
||||
widgetSize = it[SettingsKeys.widgetSize],
|
||||
showToday = it[SettingsKeys.agendaShowToday],
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
|
||||
|
||||
/**
|
||||
* The locale widget text is written in. Glance has no LocalConfiguration, and a
|
||||
* widget is redrawn on a locale change anyway (LOCALE_CHANGED), so a plain read is enough.
|
||||
*/
|
||||
internal fun widgetLocale(): Locale = Locale.getDefault()
|
||||
|
||||
internal fun today(zone: TimeZone): LocalDate =
|
||||
Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
@@ -51,131 +116,154 @@ sealed interface AgendaWidgetData {
|
||||
data object NeedsPermission : AgendaWidgetData
|
||||
data class Ready(
|
||||
val today: LocalDate,
|
||||
/**
|
||||
* Days with events across the **widest** selectable window. The displayed
|
||||
* range is sliced from this in the composition (read reactively from
|
||||
* Glance state), so a range change is pure recomposition rather than a
|
||||
* flaky widget session restart — the same approach the month widget uses.
|
||||
* Kept unfiltered: hiding/dimming finished events also happens reactively
|
||||
* in the composition, against [now], for the same reliable-update reason.
|
||||
*/
|
||||
/** Days with events across the configured range, unfiltered; hiding finished ones happens in the composition. */
|
||||
val days: List<AgendaDay>,
|
||||
/** Resolved clock convention for event time labels (the time-format pref). */
|
||||
val is24Hour: Boolean,
|
||||
/** Whether event colours are softened to pastels, or shown raw (#36). */
|
||||
val soften: Boolean,
|
||||
/** First day of the week, for the calendar-aligned "this week" range. */
|
||||
val weekStart: DayOfWeek,
|
||||
/** Saved range pref — the fallback when an instance has no Glance state yet. */
|
||||
val savedRange: AgendaRange,
|
||||
/** Saved past-event display pref — the fallback before Glance state is set. */
|
||||
val savedPastDisplay: PastEventDisplay,
|
||||
/**
|
||||
* Saved "always show today" pref (#35) — anchors today at the top with a
|
||||
* placeholder even when it has no events left. The fallback read reactively
|
||||
* from Glance state before an instance has its own state set.
|
||||
*/
|
||||
val savedShowToday: Boolean,
|
||||
/** Saved widget size step (#103) — the fallback before Glance state is set. */
|
||||
val savedWidgetSize: WidgetSize,
|
||||
/** Last day [days] covers; a wider range picked later needs a reload. */
|
||||
val loadedThrough: LocalDate,
|
||||
val pastDisplay: PastEventDisplay,
|
||||
/** "Always show today" (#35): anchor today at the top even with no events left. */
|
||||
val showToday: Boolean,
|
||||
val textSize: WidgetSize,
|
||||
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||
val now: Instant,
|
||||
) : AgendaWidgetData
|
||||
}
|
||||
|
||||
/**
|
||||
* Source data for the month widget: a wide window of instances plus the
|
||||
* week-start preference and today. The widget computes each displayed month's
|
||||
* grid from this in-memory list (via `layoutMonthWeeks`) as the user pages,
|
||||
* so month navigation is pure recomposition — no reload, no flaky widget
|
||||
* session restart.
|
||||
* Source data for the month widget: a window of instances plus the week-start
|
||||
* preference and today. The widget computes each displayed month's grid from
|
||||
* this in-memory list (via `layoutMonthWeeks`) as the user pages, so paging
|
||||
* inside the loaded window is pure recomposition.
|
||||
*/
|
||||
sealed interface MonthWidgetSource {
|
||||
data object NeedsPermission : MonthWidgetSource
|
||||
data class Ready(
|
||||
val today: LocalDate,
|
||||
val weekStart: DayOfWeek,
|
||||
val soften: Boolean,
|
||||
val instances: List<EventInstance>,
|
||||
/** Month indices (`year * 12 + monthOrdinal`) whose grids [instances] covers. */
|
||||
val months: IntRange,
|
||||
) : MonthWidgetSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-lived cache of the month window. Month navigation can re-run
|
||||
* `provideGlance`, and re-querying on every arrow tap is what made paging feel
|
||||
* sluggish — so we load once and reuse the same snapshot for every nearby month. Invalidated by
|
||||
* [invalidateMonthWidgetCache] when calendar data changes (the freshness
|
||||
* receiver), and automatically when the day rolls over (the `today` guard).
|
||||
*/
|
||||
internal object MonthWidgetCache {
|
||||
@Volatile
|
||||
var data: MonthWidgetSource.Ready? = null
|
||||
}
|
||||
|
||||
internal fun invalidateMonthWidgetCache() {
|
||||
MonthWidgetCache.data = null
|
||||
/** [loadAgendaWidgetData] with agenda widget [id]'s own options. */
|
||||
internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed<AgendaWidgetData> {
|
||||
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||
val state = widgetState(id)
|
||||
val options = state.agendaWidgetOptions(settings.agendaDefaults)
|
||||
return Themed(loadAgendaWidgetData(settings, options, state.hiddenCalendars), settings.themeMode)
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot read of the upcoming agenda for the widget. Reuses the app's
|
||||
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's
|
||||
* [first]-emitted snapshot already has hidden calendars filtered out.
|
||||
*
|
||||
* Loads [range] (the widget's own, or the saved pref when null) rather than the
|
||||
* widest selectable window (#363).
|
||||
*/
|
||||
internal suspend fun Context.loadAgendaWidgetData(range: AgendaRange? = null): AgendaWidgetData {
|
||||
internal suspend fun Context.loadAgendaWidgetData(
|
||||
settings: WidgetSettings,
|
||||
options: AgendaWidgetOptions,
|
||||
hiddenCalendars: Set<Long>,
|
||||
): AgendaWidgetData {
|
||||
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
||||
val zone = systemZone()
|
||||
val anchor = today(zone)
|
||||
val ep = widgetEntryPoint()
|
||||
val prefs = ep.settingsPrefs()
|
||||
val savedRange = prefs.agendaWidgetRange.first()
|
||||
val savedPastDisplay = prefs.pastEventDisplay.first()
|
||||
val showToday = prefs.agendaShowToday.first()
|
||||
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||
// The displayed range is still sliced in the composition from Glance state;
|
||||
// a wider one picked later reloads there (see loadedThrough).
|
||||
val dayCount = (range ?: savedRange).dayCount(anchor, weekStart)
|
||||
val weekStart = settings.weekStart.resolveFirstDay(widgetLocale())
|
||||
val dayCount = options.range.dayCount(anchor, weekStart)
|
||||
val window = agendaRange(anchor, dayCount - 1, zone)
|
||||
val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY)
|
||||
val instances = ep.calendarRepository().instances(window).first()
|
||||
val is24Hour = prefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
||||
val soften = prefs.softenCalendarColors.first()
|
||||
val instances = ep.calendarRepository().instances(window).first().withoutCalendars(hiddenCalendars)
|
||||
return AgendaWidgetData.Ready(
|
||||
today = anchor,
|
||||
days = groupAgendaDays(anchor, windowEnd, instances, zone),
|
||||
is24Hour = is24Hour,
|
||||
soften = soften,
|
||||
weekStart = weekStart,
|
||||
savedRange = savedRange,
|
||||
savedPastDisplay = savedPastDisplay,
|
||||
savedShowToday = showToday,
|
||||
savedWidgetSize = prefs.widgetSize.first(),
|
||||
loadedThrough = windowEnd,
|
||||
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
|
||||
soften = settings.soften,
|
||||
pastDisplay = options.pastDisplay,
|
||||
showToday = options.showToday,
|
||||
textSize = options.textSize,
|
||||
now = Clock.System.now(),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.ordinal
|
||||
|
||||
private fun firstDayOfMonthIndex(index: Int): LocalDate = LocalDate(index / 12, index % 12 + 1, 1)
|
||||
internal fun firstDayOfMonthIndex(index: Int): LocalDate =
|
||||
LocalDate(Math.floorDiv(index, 12), Math.floorMod(index, 12) + 1, 1)
|
||||
|
||||
/** Read backing the month widget's grid for [centerIndex] and the months around it. */
|
||||
internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int? = null): MonthWidgetSource {
|
||||
/**
|
||||
* The month the widget loads its window around while [index] is shown. Snapped
|
||||
* to steps of three, so paging only reloads every third month, and the window
|
||||
* always reaches at least two months past [index] either way.
|
||||
*/
|
||||
internal fun monthWindowCenter(index: Int): Int = Math.floorDiv(index, 3) * 3 + 1
|
||||
|
||||
/**
|
||||
* The last window each month widget loaded, under its refresh stamp, so a new
|
||||
* session for the same widget (a tap after the last one timed out) draws
|
||||
* without a query. Every redraw moves the stamp, so an entry is never served
|
||||
* stale.
|
||||
*/
|
||||
private object MonthSourceCache {
|
||||
private class Entry(val center: Int, val stamp: Long, val source: Themed<MonthWidgetSource.Ready>)
|
||||
|
||||
// Keyed by app-widget id, which a removed widget can still be named by.
|
||||
private val entries = ConcurrentHashMap<Int, Entry>()
|
||||
|
||||
fun get(id: Int, center: Int, stamp: Long): Themed<MonthWidgetSource>? =
|
||||
entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.content.today == today(systemZone()) }
|
||||
?.source
|
||||
|
||||
fun put(id: Int, center: Int, stamp: Long, source: Themed<MonthWidgetSource>) {
|
||||
val ready = source.content as? MonthWidgetSource.Ready ?: return
|
||||
entries[id] = Entry(center, stamp, Themed(ready, source.appTheme))
|
||||
}
|
||||
|
||||
fun remove(id: Int) {
|
||||
entries.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops the cached windows of removed widgets [appWidgetIds]. */
|
||||
internal fun evictMonthSources(appWidgetIds: IntArray) = appWidgetIds.forEach(MonthSourceCache::remove)
|
||||
|
||||
/**
|
||||
* [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds.
|
||||
* A [load] already read for this pass saves reading the settings and widget state again.
|
||||
*/
|
||||
internal suspend fun Context.loadCachedMonthSource(
|
||||
id: GlanceId,
|
||||
centerIndex: Int,
|
||||
stamp: Long,
|
||||
load: WidgetLoadContext? = null,
|
||||
): Themed<MonthWidgetSource> {
|
||||
val key = GlanceAppWidgetManager(this).getAppWidgetId(id)
|
||||
return MonthSourceCache.get(key, centerIndex, stamp)
|
||||
?: loadThemedMonthSource(centerIndex, load?.hiddenCalendars ?: widgetState(id).hiddenCalendars, load?.settings)
|
||||
.also { MonthSourceCache.put(key, centerIndex, stamp, it) }
|
||||
}
|
||||
|
||||
/** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */
|
||||
internal suspend fun Context.loadThemedMonthSource(
|
||||
centerIndex: Int,
|
||||
hiddenCalendars: Set<Long>,
|
||||
settings: WidgetSettings? = null,
|
||||
): Themed<MonthWidgetSource> {
|
||||
val resolved = settings ?: widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||
return Themed(queryMonthSource(centerIndex, resolved, hiddenCalendars), resolved.themeMode)
|
||||
}
|
||||
|
||||
private suspend fun Context.queryMonthSource(
|
||||
centerIndex: Int,
|
||||
settings: WidgetSettings,
|
||||
hiddenCalendars: Set<Long>,
|
||||
): MonthWidgetSource {
|
||||
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
|
||||
val zone = systemZone()
|
||||
val anchor = today(zone)
|
||||
val center = centerIndex ?: monthIndexOf(anchor)
|
||||
// Reuse the cached window unless the day changed (then it's stale for "today").
|
||||
MonthWidgetCache.data?.let { if (it.today == anchor && center in it.months) return it }
|
||||
val ep = widgetEntryPoint()
|
||||
val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||
val months = (center - MONTH_WIDGET_PAD_MONTHS)..(center + MONTH_WIDGET_PAD_MONTHS)
|
||||
val months = (centerIndex - MONTH_WIDGET_PAD_MONTHS)..(centerIndex + MONTH_WIDGET_PAD_MONTHS)
|
||||
// Padded by the grid's spill into the neighbouring months.
|
||||
val from = firstDayOfMonthIndex(months.first)
|
||||
.minus(1, DateTimeUnit.WEEK)
|
||||
@@ -184,11 +272,79 @@ internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int? = null): Mo
|
||||
.plus(2, DateTimeUnit.WEEK)
|
||||
.atTime(23, 59, 59)
|
||||
.toInstant(zone)
|
||||
val instances = ep.calendarRepository().instances(from..to).first()
|
||||
return MonthWidgetSource.Ready(
|
||||
today = anchor,
|
||||
weekStart = weekStart,
|
||||
instances = instances,
|
||||
today = today(zone),
|
||||
weekStart = settings.weekStart.resolveFirstDay(widgetLocale()),
|
||||
soften = settings.soften,
|
||||
instances = ep.calendarRepository().instances(from..to).first().withoutCalendars(hiddenCalendars),
|
||||
months = months,
|
||||
).also { MonthWidgetCache.data = it }
|
||||
)
|
||||
}
|
||||
|
||||
/** Today and the app theme, for a widget that draws no events. */
|
||||
internal suspend fun Context.loadToday(): Themed<LocalDate> =
|
||||
Themed(today(systemZone()), widgetEntryPoint().settingsPrefs().widgetSettings.first().themeMode)
|
||||
|
||||
/** What a widget drawing a run of days loads: those days' events, grouped like the agenda. */
|
||||
sealed interface DaysWidgetData {
|
||||
data object NeedsPermission : DaysWidgetData
|
||||
data class Ready(
|
||||
val today: LocalDate,
|
||||
val firstDay: LocalDate,
|
||||
val lastDay: LocalDate,
|
||||
/** Only the days with events, in order. */
|
||||
val days: List<AgendaDay>,
|
||||
val weekStart: DayOfWeek,
|
||||
val is24Hour: Boolean,
|
||||
val soften: Boolean,
|
||||
val options: AgendaWidgetOptions,
|
||||
val now: Instant,
|
||||
) : DaysWidgetData {
|
||||
fun eventsOn(date: LocalDate): List<EventInstance> = days.firstOrNull { it.date == date }?.events.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/** Widget [id]'s stored options and calendars, with the app's values for what it has none of. */
|
||||
internal class WidgetLoadContext(val settings: WidgetSettings, val options: AgendaWidgetOptions, val hiddenCalendars: Set<Long>)
|
||||
|
||||
internal suspend fun Context.widgetLoadContext(id: GlanceId): WidgetLoadContext {
|
||||
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||
val state = widgetState(id)
|
||||
return WidgetLoadContext(settings, state.agendaWidgetOptions(settings.agendaDefaults), state.hiddenCalendars)
|
||||
}
|
||||
|
||||
/** The events on the days [days] picks from today and the week start, for [load]'s widget. */
|
||||
internal suspend fun Context.loadDays(
|
||||
load: WidgetLoadContext,
|
||||
days: (today: LocalDate, weekStart: DayOfWeek) -> ClosedRange<LocalDate>,
|
||||
): Themed<DaysWidgetData> {
|
||||
val settings = load.settings
|
||||
if (!hasCalendarPermission()) return Themed(DaysWidgetData.NeedsPermission, settings.themeMode)
|
||||
val zone = systemZone()
|
||||
val anchor = today(zone)
|
||||
val weekStart = settings.weekStart.resolveFirstDay(widgetLocale())
|
||||
val range = days(anchor, weekStart)
|
||||
val window = range.start.atStartOfDayIn(zone)..range.endInclusive.atTime(23, 59, 59).toInstant(zone)
|
||||
val instances = widgetEntryPoint().calendarRepository().instances(window).first().withoutCalendars(load.hiddenCalendars)
|
||||
return Themed(
|
||||
DaysWidgetData.Ready(
|
||||
today = anchor,
|
||||
firstDay = range.start,
|
||||
lastDay = range.endInclusive,
|
||||
days = groupAgendaDays(range.start, range.endInclusive, instances, zone),
|
||||
weekStart = weekStart,
|
||||
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
|
||||
soften = settings.soften,
|
||||
options = load.options,
|
||||
now = Clock.System.now(),
|
||||
),
|
||||
settings.themeMode,
|
||||
)
|
||||
}
|
||||
|
||||
/** The week [today] falls in, from [weekStart]. */
|
||||
internal fun weekOf(today: LocalDate, weekStart: DayOfWeek): ClosedRange<LocalDate> {
|
||||
val back = (today.dayOfWeek.ordinal - weekStart.ordinal + 7) % 7
|
||||
val first = today.minus(back, DateTimeUnit.DAY)
|
||||
return first..first.plus(6, DateTimeUnit.DAY)
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
interface WidgetEntryPoint {
|
||||
fun calendarRepository(): CalendarRepository
|
||||
fun settingsPrefs(): SettingsPrefs
|
||||
fun widgetRefreshCoordinator(): WidgetRefreshCoordinator
|
||||
}
|
||||
|
||||
internal fun Context.widgetEntryPoint(): WidgetEntryPoint =
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
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.isCrossedOut
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
|
||||
import de.jeanlucmakiola.floret.glance.themedColor
|
||||
|
||||
/** A past event's fill when dimmed: fainter than the app's whole-row dim, so its text can stay readable (#90). */
|
||||
internal const val WidgetDimFillAlpha = 0.3f
|
||||
|
||||
/** A dimmed event's ink, held above its fill. */
|
||||
private const val WidgetDimInkAlpha = 0.65f
|
||||
|
||||
/** Ink for an event on [fill], picked against the opaque tone and faded when the fill is dimmed. */
|
||||
internal fun widgetEventInk(fill: Color, alpha: Float): Color =
|
||||
eventInk(fill.copy(alpha = 1f), if (fill.alpha < 1f) alpha * WidgetDimInkAlpha else alpha)
|
||||
|
||||
/** Text and row sizes of a compact event list, from a widget's [WidgetSize]. */
|
||||
internal data class EventRowMetrics(val title: TextUnit, val time: TextUnit, val rowHeight: Dp, val message: TextUnit)
|
||||
|
||||
/** The agenda widget's sizes with tighter rows, resolved against the system font scale. */
|
||||
internal fun compactRowMetrics(size: WidgetSize, fontScale: Float): EventRowMetrics {
|
||||
val agenda = metricsFor(size)
|
||||
return EventRowMetrics(
|
||||
title = agenda.eventTitle,
|
||||
time = agenda.eventTime,
|
||||
rowHeight = agenda.contentH * fontScale + agenda.rowVPad,
|
||||
message = agenda.message,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One event as a two-line row in its calendar's fill, part of a grouped card
|
||||
* at [position]; opens the occurrence. [quiet] draws it in the card colour
|
||||
* instead, for events shown only as a look ahead.
|
||||
*/
|
||||
@Composable
|
||||
internal fun WidgetEventRow(
|
||||
event: EventInstance,
|
||||
secondary: String,
|
||||
soften: Boolean,
|
||||
metrics: EventRowMetrics,
|
||||
position: Position,
|
||||
source: CalendarView,
|
||||
dimmed: Boolean = false,
|
||||
quiet: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
fun tone(dark: Boolean): Color = eventFill(event.color, dark, soften).let { if (dimmed) it.copy(alpha = WidgetDimFillAlpha) else it }
|
||||
val day = tone(dark = false)
|
||||
val night = tone(dark = true)
|
||||
val card = widgetCardColor()
|
||||
WidgetGroupedRow(position = position, color = if (quiet) card else themedColor(day = day, night = night)) {
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.height(metrics.rowHeight)
|
||||
.padding(horizontal = 12.dp)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openEventIntent(
|
||||
context = context,
|
||||
eventId = event.eventId,
|
||||
beginMillis = event.start.toEpochMilliseconds(),
|
||||
endMillis = event.end.toEpochMilliseconds(),
|
||||
source = source,
|
||||
),
|
||||
),
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val titleInk = if (quiet) {
|
||||
GlanceTheme.colors.onSurface
|
||||
} else {
|
||||
themedColor(day = widgetEventInk(day, 1f), night = widgetEventInk(night, 1f))
|
||||
}
|
||||
val timeInk = if (quiet) {
|
||||
GlanceTheme.colors.onSurfaceVariant
|
||||
} else {
|
||||
themedColor(day = widgetEventInk(day, SECONDARY_INK_ALPHA), night = widgetEventInk(night, SECONDARY_INK_ALPHA))
|
||||
}
|
||||
Text(
|
||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = titleInk,
|
||||
fontSize = metrics.title,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textDecoration = glanceDeclinedDecoration(event.isCrossedOut),
|
||||
),
|
||||
)
|
||||
Text(text = secondary, maxLines = 1, style = TextStyle(color = timeInk, fontSize = metrics.time))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidget
|
||||
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.date.DateWidget
|
||||
import de.jeanlucmakiola.calendula.widget.date.DateWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidget
|
||||
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidget
|
||||
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.today.TodayWidget
|
||||
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidget
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidget
|
||||
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetReceiver
|
||||
|
||||
/** A content option a widget's config screen offers, besides background and theme. */
|
||||
enum class WidgetOption { RANGE, TEXT_SIZE, PAST_DISPLAY, SHOW_TODAY, CALENDARS }
|
||||
|
||||
/**
|
||||
* Every widget the app ships, told apart by their receiver: what redraws,
|
||||
* the rollover and the config screen go through.
|
||||
*/
|
||||
enum class WidgetKind(
|
||||
val receiver: Class<*>,
|
||||
val label: Int,
|
||||
val options: Set<WidgetOption>,
|
||||
/** The size a config preview draws at before the launcher reports one. */
|
||||
val defaultSize: DpSize,
|
||||
) {
|
||||
AGENDA(
|
||||
AgendaWidgetReceiver::class.java,
|
||||
R.string.widget_agenda_label,
|
||||
setOf(WidgetOption.RANGE, WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.SHOW_TODAY, WidgetOption.CALENDARS),
|
||||
DpSize(260.dp, 280.dp),
|
||||
),
|
||||
MONTH(
|
||||
MonthWidgetReceiver::class.java,
|
||||
R.string.widget_month_label,
|
||||
setOf(WidgetOption.CALENDARS),
|
||||
DpSize(320.dp, 340.dp),
|
||||
),
|
||||
QUICK_ADD(
|
||||
QuickAddWidgetReceiver::class.java,
|
||||
R.string.widget_quick_add_label,
|
||||
emptySet(),
|
||||
DpSize(76.dp, 76.dp),
|
||||
),
|
||||
DATE(
|
||||
DateWidgetReceiver::class.java,
|
||||
R.string.widget_date_label,
|
||||
emptySet(),
|
||||
DpSize(76.dp, 76.dp),
|
||||
),
|
||||
WEEK_STRIP(
|
||||
WeekStripWidgetReceiver::class.java,
|
||||
R.string.widget_week_strip_label,
|
||||
setOf(WidgetOption.CALENDARS),
|
||||
DpSize(300.dp, 90.dp),
|
||||
),
|
||||
UPCOMING(
|
||||
UpcomingWidgetReceiver::class.java,
|
||||
R.string.widget_upcoming_label,
|
||||
setOf(WidgetOption.RANGE, WidgetOption.TEXT_SIZE, WidgetOption.CALENDARS),
|
||||
DpSize(180.dp, 180.dp),
|
||||
),
|
||||
TODAY(
|
||||
TodayWidgetReceiver::class.java,
|
||||
R.string.widget_today_label,
|
||||
setOf(WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.CALENDARS),
|
||||
DpSize(300.dp, 100.dp),
|
||||
),
|
||||
COUNTDOWN(
|
||||
CountdownWidgetReceiver::class.java,
|
||||
R.string.widget_countdown_label,
|
||||
setOf(WidgetOption.CALENDARS),
|
||||
DpSize(200.dp, 100.dp),
|
||||
),
|
||||
MONTH_DAY(
|
||||
MonthDayWidgetReceiver::class.java,
|
||||
R.string.widget_month_day_label,
|
||||
setOf(WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.CALENDARS),
|
||||
DpSize(320.dp, 200.dp),
|
||||
),
|
||||
;
|
||||
|
||||
/** Whether a calendar change can change what it draws; Date and Quick add draw no events. */
|
||||
val drawsEvents: Boolean get() = this != QUICK_ADD && this != DATE
|
||||
|
||||
fun widget(): GlanceAppWidget = when (this) {
|
||||
AGENDA -> AgendaWidget()
|
||||
MONTH -> MonthWidget()
|
||||
QUICK_ADD -> QuickAddWidget()
|
||||
DATE -> DateWidget()
|
||||
WEEK_STRIP -> WeekStripWidget()
|
||||
UPCOMING -> UpcomingWidget()
|
||||
TODAY -> TodayWidget()
|
||||
COUNTDOWN -> CountdownWidget()
|
||||
MONTH_DAY -> MonthDayWidget()
|
||||
}
|
||||
|
||||
/** Whether this kind stores [AgendaWidgetOptions] of its own. */
|
||||
val hasContentOptions: Boolean
|
||||
get() = options.any { it != WidgetOption.CALENDARS }
|
||||
|
||||
companion object {
|
||||
fun of(context: Context, appWidgetId: Int): WidgetKind? {
|
||||
val provider = AppWidgetManager.getInstance(context)?.getAppWidgetInfo(appWidgetId)?.provider ?: return null
|
||||
return entries.firstOrNull { it.receiver.name == provider.className }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toJavaDayOfWeek
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
|
||||
@Composable
|
||||
internal fun widgetCardColor(): ColorProvider = when (LocalWidgetBackground.current) {
|
||||
WidgetBackground.SOLID -> GlanceTheme.colors.surfaceVariant
|
||||
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground
|
||||
}
|
||||
|
||||
/** An event's time on [day]: "All day", "Starts 14:00", "Ends 9:00" or "14:00 – 15:30". */
|
||||
internal fun widgetTimeLabel(context: Context, event: EventInstance, day: LocalDate, is24Hour: Boolean): String =
|
||||
when (val label = agendaTimeLabel(event, day, systemZone())) {
|
||||
AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day)
|
||||
is AgendaTimeLabel.Starts -> context.getString(R.string.agenda_span_starts, formatWidgetTime(label.start, is24Hour))
|
||||
is AgendaTimeLabel.Ends -> context.getString(R.string.agenda_span_ends, formatWidgetTime(label.end, is24Hour))
|
||||
is AgendaTimeLabel.Range -> "${formatWidgetTime(label.start, is24Hour)} – ${formatWidgetTime(label.end, is24Hour)}"
|
||||
}
|
||||
|
||||
internal fun formatWidgetTime(instant: Instant, is24Hour: Boolean): String {
|
||||
val t = instant.toLocalDateTime(systemZone()).time
|
||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, widgetLocale())
|
||||
}
|
||||
|
||||
/** "Today" or "Tomorrow" for those two days, else null. */
|
||||
internal fun relativeDayWord(context: Context, date: LocalDate, today: LocalDate): String? = when (date) {
|
||||
today -> context.getString(R.string.agenda_header_today)
|
||||
today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** "Today", "Tomorrow", or the short weekday within the coming week, else the short date. */
|
||||
internal fun relativeDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
|
||||
val locale = widgetLocale()
|
||||
return relativeDayWord(context, date, today) ?: when {
|
||||
date < today.plus(7, DateTimeUnit.DAY) && date > today ->
|
||||
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, locale)
|
||||
else -> localizedDateFormatter(locale, "dMMM").format(date.toJavaLocalDate())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.CountdownData
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.date.DateWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.date.DateWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.split.MonthDayData
|
||||
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetReceiver
|
||||
import de.jeanlucmakiola.floret.glance.publishWidgetPreviews
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* Republishes the generated picker previews (Android 15+) when anything they
|
||||
* draw with has changed: the app version, language, today's date, or the
|
||||
* theme, week start, time format and colour softening.
|
||||
*/
|
||||
internal suspend fun publishCalendulaWidgetPreviews(context: Context) {
|
||||
val settings = context.widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||
val updated = context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime
|
||||
val is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
publishWidgetPreviews(
|
||||
context,
|
||||
version = listOf(
|
||||
updated,
|
||||
widgetLocale().toLanguageTag(),
|
||||
today(systemZone()),
|
||||
settings.themeMode,
|
||||
settings.weekStart,
|
||||
is24Hour,
|
||||
settings.soften,
|
||||
).joinToString("-"),
|
||||
receivers = listOf(
|
||||
AgendaWidgetReceiver::class,
|
||||
MonthWidgetReceiver::class,
|
||||
QuickAddWidgetReceiver::class,
|
||||
DateWidgetReceiver::class,
|
||||
WeekStripWidgetReceiver::class,
|
||||
UpcomingWidgetReceiver::class,
|
||||
TodayWidgetReceiver::class,
|
||||
CountdownWidgetReceiver::class,
|
||||
MonthDayWidgetReceiver::class,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The size [kind]'s picker preview is laid out at. */
|
||||
internal fun previewSizeOf(kind: WidgetKind) = SizeMode.Responsive(setOf(kind.defaultSize))
|
||||
|
||||
/** [kind]'s picker preview: the real widget, drawn with sample events around today's morning. */
|
||||
internal suspend fun GlanceAppWidget.provideSamplePreview(context: Context, kind: WidgetKind) {
|
||||
val settings = context.widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||
val zone = systemZone()
|
||||
val today = today(zone)
|
||||
val now = today.atTime(8, 0).toInstant(zone)
|
||||
val instances = context.sampleInstances(today, zone)
|
||||
val weekStart = settings.weekStart.resolveFirstDay(widgetLocale())
|
||||
val is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
val options = settings.agendaDefaults
|
||||
val appearance = WidgetAppearance()
|
||||
val index = monthIndexOf(today)
|
||||
val month = MonthWidgetSource.Ready(today, weekStart, settings.soften, instances, index..index)
|
||||
fun days(range: ClosedRange<LocalDate>) = DaysWidgetData.Ready(
|
||||
today = today,
|
||||
firstDay = range.start,
|
||||
lastDay = range.endInclusive,
|
||||
days = groupAgendaDays(range.start, range.endInclusive, instances, zone),
|
||||
weekStart = weekStart,
|
||||
is24Hour = is24Hour,
|
||||
soften = settings.soften,
|
||||
options = options,
|
||||
now = now,
|
||||
)
|
||||
val theme = settings.themeMode
|
||||
provideContent {
|
||||
when (kind) {
|
||||
WidgetKind.AGENDA -> AgendaWidgetContent(
|
||||
AgendaWidgetData.Ready(
|
||||
today = today,
|
||||
days = groupAgendaDays(today, today.plus(6, DateTimeUnit.DAY), instances, zone),
|
||||
is24Hour = is24Hour,
|
||||
soften = settings.soften,
|
||||
pastDisplay = options.pastDisplay,
|
||||
showToday = options.showToday,
|
||||
textSize = options.textSize,
|
||||
now = now,
|
||||
),
|
||||
appearance,
|
||||
theme,
|
||||
preview = true,
|
||||
)
|
||||
WidgetKind.MONTH -> MonthWidgetContent(month, index, appearance, theme)
|
||||
WidgetKind.MONTH_DAY -> MonthDayWidgetContent(
|
||||
MonthDayData.Ready(month, options, is24Hour, now),
|
||||
index,
|
||||
selected = null,
|
||||
appearance = appearance,
|
||||
appTheme = theme,
|
||||
preview = true,
|
||||
)
|
||||
WidgetKind.QUICK_ADD -> QuickAddWidgetContent(today, appearance, theme)
|
||||
WidgetKind.DATE -> DateWidgetContent(today, appearance, theme)
|
||||
WidgetKind.WEEK_STRIP -> WeekStripWidgetContent(days(weekOf(today, weekStart)), appearance, theme)
|
||||
WidgetKind.UPCOMING -> UpcomingWidgetContent(days(today..today.plus(6, DateTimeUnit.DAY)), appearance, theme)
|
||||
WidgetKind.TODAY -> TodayWidgetContent(days(today..today.plus(1, DateTimeUnit.DAY)), appearance, theme, preview = true)
|
||||
WidgetKind.COUNTDOWN -> CountdownWidgetContent(
|
||||
CountdownData.Ready(
|
||||
options = CountdownOptions(),
|
||||
event = instances.first { !it.isAllDay && it.start > now },
|
||||
now = now,
|
||||
today = today,
|
||||
is24Hour = is24Hour,
|
||||
soften = settings.soften,
|
||||
appWidgetId = null,
|
||||
),
|
||||
appearance,
|
||||
theme,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A plausible few weeks: a busy today, a trip tomorrow, and a scatter of days with something on. */
|
||||
private fun Context.sampleInstances(today: LocalDate, zone: TimeZone): List<EventInstance> {
|
||||
var id = 0L
|
||||
fun timed(day: Int, at: LocalTime, length: kotlin.time.Duration, title: Int, color: Int): EventInstance {
|
||||
val start = today.plus(day, DateTimeUnit.DAY).atTime(at).toInstant(zone)
|
||||
id++
|
||||
return EventInstance(id, id, 1, getString(title), start, start + length, false, color, null)
|
||||
}
|
||||
fun allDay(day: Int, title: Int, color: Int): EventInstance {
|
||||
val date = today.plus(day, DateTimeUnit.DAY)
|
||||
id++
|
||||
return EventInstance(
|
||||
id, id, 1, getString(title),
|
||||
date.atStartOfDayIn(TimeZone.UTC), date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(TimeZone.UTC),
|
||||
true, color, null,
|
||||
)
|
||||
}
|
||||
return listOf(
|
||||
timed(0, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue),
|
||||
timed(0, LocalTime(12, 30), 1.hours, R.string.settings_past_events_sample_midday, Green),
|
||||
timed(0, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple),
|
||||
allDay(1, R.string.widget_preview_sample_trip, Orange),
|
||||
timed(2, LocalTime(10, 0), 1.hours, R.string.widget_preview_sample_dentist, Red),
|
||||
timed(-6, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue),
|
||||
timed(-2, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple),
|
||||
timed(5, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple),
|
||||
timed(8, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue),
|
||||
timed(12, LocalTime(12, 30), 1.hours, R.string.settings_past_events_sample_midday, Green),
|
||||
).sortedBy { it.start }
|
||||
}
|
||||
|
||||
private const val Blue = 0xFF039BE5.toInt()
|
||||
private const val Green = 0xFF33B679.toInt()
|
||||
private const val Purple = 0xFF8E24AA.toInt()
|
||||
private const val Orange = 0xFFF4511E.toInt()
|
||||
private const val Red = 0xFFD50000.toInt()
|
||||
@@ -0,0 +1,188 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.repointCountdowns
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRefresher
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||
import de.jeanlucmakiola.floret.glance.updatePlaced
|
||||
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Every widget receiver; the set [WidgetRollover] checks for placed instances. */
|
||||
internal val WIDGET_RECEIVER_CLASSES: List<Class<*>> = WidgetKind.entries.map { it.receiver }
|
||||
|
||||
/** The app's own midnight wake-up (#228). */
|
||||
internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
|
||||
|
||||
/** Redraws every widget, for app changes they draw with that no other signal reaches, like its language (#90). */
|
||||
internal const val ACTION_WIDGET_REDRAW = "de.jeanlucmakiola.calendula.widget.REDRAW"
|
||||
|
||||
/**
|
||||
* Asks [WidgetRedrawReceiver] to redraw every widget. A broadcast rather than a
|
||||
* coroutine, so it outlives the activity a language change recreates.
|
||||
*/
|
||||
fun requestWidgetRedraw(context: Context) {
|
||||
context.sendBroadcast(Intent(context, WidgetRedrawReceiver::class.java).setAction(ACTION_WIDGET_REDRAW))
|
||||
}
|
||||
|
||||
/** The widgets a calendar change can affect. */
|
||||
internal val EVENT_WIDGET_KINDS: List<WidgetKind> = WidgetKind.entries.filter { it.drawsEvents }
|
||||
|
||||
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
|
||||
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
|
||||
|
||||
internal fun widgetRolloverPendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
ROLLOVER_REQUEST_CODE,
|
||||
Intent(context, WidgetUpdateReceiver::class.java).setAction(ACTION_WIDGET_ROLLOVER),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
/** Arm the next midnight rollover, or cancel it when no widget is placed. Idempotent. */
|
||||
internal fun syncWidgetRollover(context: Context) =
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
|
||||
/**
|
||||
* Reloads and redraws every placed widget, a running session included: the
|
||||
* refresh stamp moves, and every widget keys its data load on it (#420).
|
||||
*/
|
||||
internal suspend fun redrawWidgets(context: Context, kinds: List<WidgetKind> = WidgetKind.entries) {
|
||||
// One kind failing, say a widget removed mid-pass, mustn't stop the others
|
||||
// or escape the broadcast's coroutine.
|
||||
kinds.forEach { kind ->
|
||||
try {
|
||||
redrawWidgets(context, kind)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Redrawing $kind widgets failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "WidgetRedraw"
|
||||
|
||||
internal suspend fun redrawWidgets(context: Context, kind: WidgetKind) =
|
||||
kind.widget().updatePlaced(context, kind.receiver, bumpRefreshStamp = true)
|
||||
|
||||
/** Redraws only the widgets with no content options of their own, which still follow the app's. */
|
||||
private suspend fun redrawUnconfiguredWidgets(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
val manager = AppWidgetManager.getInstance(appContext) ?: return
|
||||
val glanceManager = GlanceAppWidgetManager(appContext)
|
||||
WidgetKind.entries.filter { it.hasContentOptions }.forEach { kind ->
|
||||
manager.getAppWidgetIds(ComponentName(appContext, kind.receiver)).forEach { appWidgetId ->
|
||||
try {
|
||||
val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
|
||||
if (!appContext.widgetState(glanceId).hasAgendaOptions) kind.widget().updateWithRefresh(appContext, glanceId)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Redrawing widget $appWidgetId failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** When the app last saw a calendar change of its own, for skipping the `PROVIDER_CHANGED` that echoes it. */
|
||||
private val lastLocalChange = AtomicLong(0L)
|
||||
|
||||
private const val PROVIDER_ECHO_WINDOW_MILLIS = 5_000L
|
||||
|
||||
/** Set while a `PROVIDER_CHANGED` redraw waits out the echo window, so a burst of them redraws once. */
|
||||
private val echoRedrawQueued = AtomicBoolean(false)
|
||||
|
||||
private fun providerEchoRemainingMillis(): Long {
|
||||
val last = lastLocalChange.get()
|
||||
if (last == 0L) return 0L
|
||||
return PROVIDER_ECHO_WINDOW_MILLIS - (SystemClock.elapsedRealtime() - last)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `PROVIDER_CHANGED` should redraw. Within the echo window of one of
|
||||
* the app's own changes it may only echo that change, or be a sync landing
|
||||
* right after it, so it isn't dropped: the first one redraws once the window
|
||||
* has passed ([awaitProviderEcho]) and the rest of the burst ride along.
|
||||
*/
|
||||
internal fun claimProviderRedraw(): Boolean =
|
||||
providerEchoRemainingMillis() <= 0L || echoRedrawQueued.compareAndSet(false, true)
|
||||
|
||||
/** Waits out the echo window, if one is open, before a `PROVIDER_CHANGED` redraw. */
|
||||
internal suspend fun awaitProviderEcho() {
|
||||
try {
|
||||
val wait = providerEchoRemainingMillis()
|
||||
if (wait > 0L) delay(wait)
|
||||
} finally {
|
||||
echoRedrawQueued.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraws the widgets on the app's own calendar writes, visibility changes and
|
||||
* the settings they draw with, so those don't wait for `PROVIDER_CHANGED`,
|
||||
* which arrives seconds late or not at all (#420, #229). Changes from outside
|
||||
* the app are [WidgetUpdateReceiver]'s. The app's agenda options only redraw
|
||||
* widgets that have no content options of their own (#292). Started once from
|
||||
* `CalendulaApp`.
|
||||
*/
|
||||
@Singleton
|
||||
class WidgetRefreshCoordinator @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val repository: CalendarRepository,
|
||||
private val prefs: SettingsPrefs,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
) {
|
||||
private val started = AtomicBoolean(false)
|
||||
|
||||
/** Subscribes before returning, so a change made right after is never taken for the starting value. */
|
||||
fun start() {
|
||||
if (!started.compareAndSet(false, true)) return
|
||||
val refreshers = WidgetKind.entries.associateWith { kind -> WidgetRefresher(scope) { redrawWidgets(context, kind) } }
|
||||
val all = refreshers.values
|
||||
val unconfigured = WidgetRefresher(scope) { redrawUnconfiguredWidgets(context) }
|
||||
watch(repository.localChanges) {
|
||||
lastLocalChange.set(SystemClock.elapsedRealtime())
|
||||
EVENT_WIDGET_KINDS.forEach { refreshers.getValue(it).request() }
|
||||
}
|
||||
watch(repository.seriesSplits) { split ->
|
||||
scope.launch(Dispatchers.IO) { repointCountdowns(context, split.oldEventId, split.newEventId) }
|
||||
}
|
||||
watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) {
|
||||
all.forEach { it.request() }
|
||||
scope.launch(Dispatchers.IO) { publishCalendulaWidgetPreviews(context) }
|
||||
}
|
||||
watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) {
|
||||
unconfigured.request()
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> watch(changes: Flow<T>, onChange: (T) -> Unit) {
|
||||
scope.launch(Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) {
|
||||
changes.collect { onChange(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import de.jeanlucmakiola.floret.android.goAsync
|
||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||
|
||||
/**
|
||||
* Takes [requestWidgetRedraw]'s in-app redraw. Not exported, unlike
|
||||
* [WidgetUpdateReceiver], so no other app can make every widget reload.
|
||||
*/
|
||||
class WidgetRedrawReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != ACTION_WIDGET_REDRAW) return
|
||||
val appContext = context.applicationContext
|
||||
goAsync {
|
||||
AppLanguage.applyDefault(appContext)
|
||||
redrawWidgets(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 292 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user