Compare commits

..
Author SHA1 Message Date
+3 9ccc303a2a Release v2.24.0 (#487)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Renovate / renovate (push) Successful in 2m19s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 20m28s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m34s
2.24.0: the widget redesign and the fixes collected on release/2.24.0. Full list in CHANGELOG.md under 2.24.0.

- Seven new widgets (quick add, date, week strip, upcoming, today, countdown, month with day), each with its own settings; new look for the agenda and month widgets.
- Drag all-day events between days or into the grid; cancelled and tentative events are marked; month view setting to hide start times.
- Fixes: short events late at night, one notification per event, weekend missing from the week strip widget, split month view animations and back gesture.
- Weblate translations from #434, including the new Traditional Chinese (zh-TW), with its store listing and What's New.

Merges main's v2.23.1 in. #427 is moved out of the milestone until it can be reproduced.

Closes #434

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Co-authored-by: Sup99999 <alelmnour1o234@proton.me>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Benutzer <gabrielegobboch@protonmail.ch>
Co-authored-by: N Donkersloot <n@donkerslootjes.nl>
Co-authored-by: VinCap <vcapaldo@ik.me>
Co-authored-by: AventineC <AventineC@outlook.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: NattyNarwhal <fr.schuessele@gmx.de>
Co-authored-by: James Trivette <trivette@atlas.cz>
Co-authored-by: Dmitri Gabinski <dkgabi@gmail.com>
Co-authored-by: Tritra002 <tritratrulala002@outlook.com>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/487
2026-11-06 17:49:13 +01:00
Jean-Luc Makiolaandmakiolaj a5c4a73de1 Release v2.23.1 (#453)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Renovate / renovate (push) Successful in 2m2s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 17m11s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m37s
Follow-up to the #142 feedback in the split month view:

- With a day selected, the filter chip now stands in for the day's header, so the date isn't shown twice. Tapping the chip opens the Day view; its ✕ clears the filter.
- The day headers in the agenda pane below the grid use less vertical space.
- Event dots no longer drop on days that show a "+N".

Closes #142

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/453
2026-10-28 20:11:10 +01:00
212 changed files with 8807 additions and 1023 deletions

No files matched your search

+63
View File
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.24.0] — 2026-11-06
### Added
- **Six new home-screen widgets.** Quick add (1×1) opens a new event on today with the same defaults as the app's + button ([#435]). Date (1×1) shows today's weekday and day number on a rounded shape and opens today ([#376]). Week strip (4×1) shows this week's days with a dot in each event's colour and today filled ([#436]). Upcoming (2×2) shows today's date over your next events, as many as fit ([#437]). Today lists today's events; once they're over it says "Nothing left today" and shows tomorrow's first one or two ([#438]). Countdown counts down to your next timed event, or to an event or series you pick, like a holiday in 12 days ([#439]).
- **A month and day widget.** A compact month grid sits beside the events of one day. Tap a day in the grid to see its events, and tap the list to open that day in the app ([#441]).
- **Each widget has its own settings.** Placing a widget, or long-pressing it and choosing Edit, opens a settings screen with a live preview. Pick a solid, tinted or transparent background, a light or dark theme or the app's, and which calendars it shows. Widgets that list events also have their time range, text size and how past events look. Settings → Widgets lists the widgets on your home screen so you can change them from there. These options used to be shared by all widgets; widgets you already placed keep what you had set ([#292]).
- **Turn off start times in the month view.** Settings → Views → Month → Start times hides the time in front of event titles, so narrow fonts keep more of the title. With it on, the time now only shows when the whole title, or at least its first 12 characters, still fits beside it ([#476]).
- **Drag all-day events.** In the day, 3-day and week views, long-press an event in the all-day strip and move it sideways to another day, or down into the grid to turn it into a timed event ([#296]).
- **Traditional Chinese**, started as a community translation through [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/). It covers only a few screens so far, so the rest still shows in English until it fills out. Pick it under Settings → Language. Thanks to AventineC.
### Changed
- **The agenda and month widgets have a new look.** The agenda puts each day in its own card, with a round date pill beside the first event and today's pill filled. The month widget has rounded day cells, weekday pills, shaded days outside the month and a + for a new event. The agenda's refresh button is gone, since widgets now update on their own ([#71], [#90]).
- **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
@@ -1839,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
@@ -1888,4 +1929,26 @@ automatically, with zero telemetry and no internet permission.
[#306]: https://codeberg.org/jlmakiola/calendula/issues/306
[#178]: https://codeberg.org/jlmakiola/calendula/issues/178
[#116]: https://codeberg.org/jlmakiola/calendula/issues/116
[#388]: https://codeberg.org/jlmakiola/calendula/issues/388
[#413]: https://codeberg.org/jlmakiola/calendula/issues/413
[#71]: https://codeberg.org/jlmakiola/calendula/issues/71
[#90]: https://codeberg.org/jlmakiola/calendula/issues/90
[#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
+2 -2
View File
@@ -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 = 2230099
versionName = "2.23.0"
versionCode = 2240099
versionName = "2.24.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
+95
View File
@@ -352,6 +352,95 @@
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 (external sync; the app's own
writes redraw directly). The day boundary arrives as the app's own
@@ -361,6 +450,12 @@
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">
@@ -10,10 +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.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
@@ -44,6 +46,9 @@ 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()
@@ -58,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 {
syncWidgetRollover(this@CalendulaApp)
publishCalendulaWidgetPreviews(this@CalendulaApp)
}
}
@@ -30,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
@@ -278,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) {
@@ -593,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)
@@ -612,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)
}
@@ -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,
@@ -10,12 +10,16 @@ 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()
@@ -105,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`.
@@ -132,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)
@@ -194,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)
@@ -346,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),
)
}
@@ -146,6 +146,7 @@ class SettingsPrefs @Inject constructor(
val pastEventDisplay: Flow<PastEventDisplay> = prefs.flow(SettingsKeys.pastEventDisplay)
val dimCompletedEvents: Flow<Boolean> = prefs.flow(SettingsKeys.dimCompletedEvents)
val showWeekNumbers: Flow<Boolean> = prefs.flow(SettingsKeys.showWeekNumbers)
val monthShowStartTimes: Flow<Boolean> = prefs.flow(SettingsKeys.monthShowStartTimes)
val monthViewStyle: Flow<MonthViewStyle> = prefs.flow(SettingsKeys.monthViewStyle)
val timelineScale: Flow<TimelineScale> = prefs.flow(SettingsKeys.timelineScale)
val multiDayCount: Flow<Int> = prefs.flow(SettingsKeys.multiDayCount)
@@ -212,6 +213,7 @@ class SettingsPrefs @Inject constructor(
suspend fun setPastEventDisplay(mode: PastEventDisplay) = prefs.set(SettingsKeys.pastEventDisplay, mode)
suspend fun setDimCompletedEvents(enabled: Boolean) = prefs.set(SettingsKeys.dimCompletedEvents, enabled)
suspend fun setShowWeekNumbers(enabled: Boolean) = prefs.set(SettingsKeys.showWeekNumbers, enabled)
suspend fun setMonthShowStartTimes(enabled: Boolean) = prefs.set(SettingsKeys.monthShowStartTimes, enabled)
suspend fun setMonthViewStyle(style: MonthViewStyle) = prefs.set(SettingsKeys.monthViewStyle, style)
suspend fun setTimelineScale(scale: TimelineScale) = prefs.set(SettingsKeys.timelineScale, scale)
suspend fun setMultiDayCount(count: Int) = prefs.set(SettingsKeys.multiDayCount, count)
@@ -445,6 +447,9 @@ object SettingsKeys {
*/
val showWeekNumbers: Pref<Boolean> = booleanPref("show_week_numbers", false)
/** Whether Month chips may show the start time before the title when wide enough (#219, #476). */
val monthShowStartTimes: Pref<Boolean> = booleanPref("month_show_start_times", true)
/** How the Month view lays itself out (#38, #53). */
val monthViewStyle: Pref<MonthViewStyle> = enumPref("month_view_style", MonthViewStyle.Paged)
@@ -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.
@@ -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(
@@ -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))
}
@@ -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?,
@@ -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 }
@@ -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
@@ -432,7 +433,7 @@ private fun AgendaList(
modifier = modifier.nestedScroll(loadOnScrollUp),
state = listState,
// Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
contentPadding = PaddingValues(top = 8.dp, bottom = FabClearance),
) {
agendaDays(
days = days,
@@ -1,7 +1,8 @@
package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.domain.sortedForDisplay
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.domain.spanLastDay
@@ -18,7 +19,7 @@ import kotlin.time.Instant
* surface only formats the instants into its own locale/24h string.
*/
sealed interface AgendaTimeLabel {
/** An all-day event, or a whole in-between day of a multi-day span. */
/** An event filling whole days (#296), or a whole in-between day of a multi-day span. */
data object AllDay : AgendaTimeLabel
/** The first day of a multi-day timed event: when it begins. */
data class Starts(val start: Instant) : AgendaTimeLabel
@@ -30,7 +31,7 @@ sealed interface AgendaTimeLabel {
/** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */
fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel {
if (event.isAllDay) return AgendaTimeLabel.AllDay
if (event.coversWholeDays(zone)) return AgendaTimeLabel.AllDay
val firstDay = event.spanFirstDay(zone)
val lastDay = event.spanLastDay(zone)
return when {
@@ -87,7 +88,7 @@ fun groupAgendaDays(
return byDay.map { (date, dayEvents) ->
AgendaDay(
date = date,
events = dayEvents.sortedWith(eventDisplayComparator(zone)),
events = dayEvents.sortedForDisplay(zone),
)
}
}
@@ -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,
@@ -38,6 +38,9 @@ import de.jeanlucmakiola.calendula.R
/** The standard FAB's size, shared by the selection bar that unrolls from it (#141). */
val FabSize = 56.dp
/** Bottom inset that lets a scrolling view's last row or hour scroll clear of the FAB. */
val FabClearance = 96.dp
/** What the FAB is while an event is selected (#141): the toggle for its action bar. */
@Stable
class FabSelection(
@@ -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
}
@@ -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,
)
}
@@ -3,15 +3,12 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp
import de.jeanlucmakiola.floret.locale.currentLocale
@@ -45,33 +42,17 @@ fun inlineTimeLabel(time: String?, title: String, timeInk: Color): AnnotatedStri
append(title)
}
/**
* The characters of title that have to survive a time prefix for it to be worth
* its place — lowercase Latin of average advance, priced at the surface's own
* text style rather than guessed in dp.
*/
private const val TITLE_SAMPLE = "notepad"
/** The widest wall-clock time in either convention: two-digit hour, and a meridiem in 12-hour. */
private const val SAMPLE_HOUR = 12
private const val SAMPLE_MINUTE = 45
/**
* The narrowest run of [style] text that may carry a time in front of a title:
* wide enough for the widest time in the current convention plus [TITLE_SAMPLE].
*
* Measured rather than a device breakpoint, so it follows the font scale, the
* 12/24-hour setting, the locale's own time format and whatever else has
* already been taken off the width.
* The widest time the current convention writes, for pricing a time prefix by
* measurement: it follows the 12/24-hour setting and the locale's own format.
*/
@Composable
fun rememberInlineTimeWidth(style: TextStyle): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
fun rememberWidestTimeSample(): String {
val locale = currentLocale()
val sample = formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, LocalUse24HourFormat.current, locale)
return remember(sample, style, density, measurer) {
val label = inlineTimeLabel(sample, TITLE_SAMPLE, Color.Unspecified)
with(density) { measurer.measure(label, style).size.width.toDp() }
}
val is24Hour = LocalUse24HourFormat.current
return remember(is24Hour, locale) { formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, is24Hour, locale) }
}
@@ -56,7 +56,8 @@ private const val DIMMED_HOUR_ALPHA = 0.3f
* The timeline's hour gutter. Scrolls in sync with the day columns through the
* shared [scrollState], and while [dragController] holds a lifted block it dims
* the hour labels and floats a badge with the drag's current start time at the
* row the block would land on.
* row the block would land on. Its content ends in the same [FabClearance]
* the day columns do, or the two drift apart at the bottom of the scroll.
*/
@Composable
fun HourGutter(
@@ -86,7 +87,8 @@ fun HourGutter(
.width(GUTTER_WIDTH)
.padding(start = GUTTER_CONTENT_START_INSET)
.fillMaxHeight()
.verticalScroll(scrollState),
.verticalScroll(scrollState)
.padding(bottom = FabClearance),
) {
Column {
(0 until 24).forEach { h ->
@@ -70,27 +70,24 @@ data class OutOfViewEvents(val above: TimedBlock?, val below: TimedBlock?) {
/** Which edge of the viewport a hidden event sits past. */
enum class ViewportEdge { Top, Bottom }
/** Where [block]'s rendered bottom lands, in minutes — short blocks keep a floor. */
internal fun renderedEndMin(block: TimedBlock): Float =
maxOf(block.endMin.toFloat(), block.startMin + MIN_EVENT_FRACTION * 60f)
/**
* The blocks of one column lying out of a viewport spanning [topMin]..[bottomMin]
* (minutes of the day), nearest first. A block leaves this set as soon as any
* part of its rendered height enters the viewport.
* part of its [renderedSpan] enters the viewport.
*/
fun outOfViewEvents(
blocks: List<TimedBlock>,
topMin: Float,
bottomMin: Float,
): OutOfViewEvents {
val above = blocks
.filter { renderedEndMin(it) <= topMin }
.maxWithOrNull(compareBy<TimedBlock>({ renderedEndMin(it) }, { it.startMin }))
val below = blocks
.filter { it.startMin >= bottomMin }
.minWithOrNull(compareBy<TimedBlock>({ it.startMin }, { -renderedEndMin(it) }))
return OutOfViewEvents(above, below)
val spans = blocks.map { it to it.renderedSpan() }
val above = spans
.filter { (_, span) -> span.bottomMin <= topMin }
.maxWithOrNull(compareBy({ it.second.bottomMin }, { it.first.startMin }))
val below = spans
.filter { (_, span) -> span.topMin >= bottomMin }
.minWithOrNull(compareBy({ it.second.topMin }, { -it.second.bottomMin }))
return OutOfViewEvents(above?.first, below?.first)
}
/**
@@ -107,8 +104,9 @@ fun revealScrollTarget(
marginPx: Float,
maxScroll: Int,
): Int {
val startPx = block.startMin / 60f * hourPx
val endPx = renderedEndMin(block) / 60f * hourPx
val span = block.renderedSpan()
val startPx = span.topMin / 60f * hourPx
val endPx = span.bottomMin / 60f * hourPx
val target = when (edge) {
ViewportEdge.Top -> startPx - marginPx
ViewportEdge.Bottom -> minOf(endPx + marginPx - viewportPx, startPx - marginPx)
@@ -293,6 +291,7 @@ private fun EdgeBar(
modifier: Modifier = Modifier,
) {
block ?: return
val span = remember(block) { block.renderedSpan() }
val color = eventFill(block.event.color, dark, LocalSoftenColors.current)
val label = stringResource(
if (edge == ViewportEdge.Top) R.string.timeline_earlier_events else R.string.timeline_later_events,
@@ -303,8 +302,8 @@ private fun EdgeBar(
.height(EDGE_TOUCH_HEIGHT)
.graphicsLayer {
val gapPx = when (edge) {
ViewportEdge.Top -> scrollState.value - renderedEndMin(block) / 60f * hourPx
ViewportEdge.Bottom -> block.startMin / 60f * hourPx -
ViewportEdge.Top -> scrollState.value - span.bottomMin / 60f * hourPx
ViewportEdge.Bottom -> span.topMin / 60f * hourPx -
(scrollState.value + scrollState.viewportSize)
}
alpha = edgeBarAlpha(gapPx, fadeLeadPx)
@@ -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 {
@@ -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). */
@@ -629,6 +649,28 @@ class TimelineDragController {
/** The resize in progress, or null for a plain drag (#141). */
private var resize: ResizeSession? = null
/** The strip drag in progress; over the grid it drives [drag] through a timed stand-in. */
var stripDrag: StripDrag? by mutableStateOf(null)
private set
/** A strip drop held at its new days until the strip redraws the event there. */
var stripSettling: StripDrag? by mutableStateOf(null)
private set
/** Whether the strip now draws [stripSettling] at its new days itself. */
var stripSeated: Boolean by mutableStateOf(false)
private set
/** What [abandonedTicks] stood at when [stripSettling] was let go. */
var stripAbandonedMark: Int = 0
private set
/** The shown day the finger picked the strip bar up on. */
private var stripGrabIndex = 0
/** Whether a strip drag over the grid has cleared its top edge, and may now scroll up. */
private var stripScrollArmed = false
fun begin(
block: TimedBlock,
clipOffsetMin: Int,
@@ -667,6 +709,69 @@ class TimelineDragController {
begin(block, clipOffsetMin = 0, zone, titleLines = 1, pointerInRoot = pointerInRoot, blockInRoot = blockInRoot)
}
/**
* Lift [event] off the all-day strip. Over the time grid it is dragged as a
* block [draftMinutes] long, whose top sits a little above the finger.
*/
fun beginStrip(event: EventInstance, zone: TimeZone, pointerInRoot: Offset) {
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
if (geometry.columnWidthPx <= 0f || geometry.days.isEmpty()) return
deselect()
if (stripSettling?.event?.instanceId == event.instanceId) releaseStrip()
stripScrollArmed = false
val timed = event.copy(isAllDay = false, end = event.start + draftMinutes.minutes)
source = TimedBlock(timed, 0, draftMinutes, lane = 0, laneCount = 1)
clipOffsetMin = 0
dragZone = zone
dragDuration = draftMinutes.minutes
dragKept = null
titleLines = 1
settling = null
isDragging = true
liftedInstanceId = event.instanceId
grab = Offset(0f, geometry.hourPx * STRIP_GRAB_MINUTES / 60f)
pointer = pointerInRoot
pickupPointer = pointerInRoot
pointerMoved = false
originSlot = null
stripGrabIndex = dayIndexAt(pointerInRoot.x, grid.positionInRoot().x)
stripDrag = StripDrag(event)
}
/** The shown day under [x], clamped to the days on screen. */
private fun dayIndexAt(x: Float, originX: Float): Int =
column(((x - originX) / geometry.columnWidthPx).toInt().coerceIn(0, geometry.days.lastIndex))
/** End a strip drag, handing back where it landed — null when it went nowhere. */
fun finishStrip(): StripDrop? {
val held = stripDrag ?: return null
val landed = drag
val zone = dragZone
cancel()
if (held.overGrid && landed != null && zone != null && !landed.edgeOnly) {
settle(landed)
val start = TimelineDrop(landed.event, landed.date, landed.startMin).startInstant(zone)
return StripDrop.Timed(held.event, start, start + draftMinutes.minutes)
}
if (held.overGrid || held.dayShift == 0) return null
stripSettling = held
stripSeated = false
stripAbandonedMark = abandonedTicks?.value ?: 0
drops++
return StripDrop.Days(held.event, held.dayShift)
}
/** Let the held strip drop go, once the strip shows the event at its new days or the move failed. */
fun releaseStrip() {
stripSettling = null
stripSeated = false
}
/** Note that the strip draws the settling drop at its new days. */
fun seatStrip() {
if (stripSettling != null) stripSeated = true
}
fun move(pointerInRoot: Offset) {
pointer = pointerInRoot
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
@@ -675,6 +780,7 @@ class TimelineDragController {
fun cancel() {
resize = null
stripDrag = null
source = null
clipOffsetMin = 0
dragZone = null
@@ -755,12 +861,16 @@ class TimelineDragController {
/** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */
private fun settle(landed: TimelineDrag, end: Instant? = null) {
// Landed on midnight to midnight, the event moves to the all-day strip (#296).
val leavesGrid = !landed.event.isDraft && landed.eventSpanMin > 0 &&
landed.eventStartMin.mod(MINUTES_PER_DAY) == 0 &&
landed.eventSpanMin % MINUTES_PER_DAY == 0
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
// An edge piece is a hint for the finger, not a preview of the landing
// slot: the event belongs to a day this timeline has no column for, so
// [noteGrid] can never seat it and it would sit at the top of today for
// the whole settle timeout, long after the event moved off-screen.
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
settling = if (landed.edgeOnly || leavesGrid) landed.copy(pieces = emptyList()) else landed
liftedInstanceId = landed.event.instanceId
settledOrigin = landed.event.eventId to landed.event.start
settledAbandonedMark = abandonedTicks?.value ?: 0
@@ -795,7 +905,9 @@ class TimelineDragController {
val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY))
session.column = edgeIndex.takeIf { it >= 0 }
?.let { column(it) }
session.grabMin = (pointerResizeMinutes(session) ?: 0f) -
// The top handle sits on the drawn top, which a short block near midnight lifts (#483).
val lift = if (edge == ResizeEdge.Top) block.startMin - block.renderedSpan().topMin else 0f
session.grabMin = (pointerResizeMinutes(session) ?: 0f) + lift -
if (edge == ResizeEdge.Top) session.startMin else session.endMin
originSlot = null
recompute()
@@ -855,7 +967,9 @@ class TimelineDragController {
session.column = held
val dayOffset = (days[column(held)].toEpochDays() - session.day.toEpochDays()).toInt()
session.edgeDay = dayOffset
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
// Held to its column's midnight: the clearance under it is not the next day.
val minutes = ((pointer.y - origin.y) / hourPx * 60f).coerceAtMost(MINUTES_PER_DAY.toFloat())
return dayOffset * MINUTES_PER_DAY + minutes
}
private fun recomputeResize(session: ResizeSession) {
@@ -928,6 +1042,29 @@ class TimelineDragController {
recomputeResize(it)
return
}
stripDrag?.let {
recomputeStrip(it)
return
}
recomputeDrag()
}
/** Over the strip the bar moves by whole days; past its bottom edge it is a timed block. */
private fun recomputeStrip(held: StripDrag) {
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return
if (geometry.columnWidthPx <= 0f || geometry.days.isEmpty()) return
if (pointer.y >= viewport.boundsInRoot().top) {
stripDrag = held.copy(overGrid = true)
recomputeDrag()
} else {
drag = null
val shift = dayIndexAt(pointer.x, grid.positionInRoot().x) - stripGrabIndex
stripDrag = held.copy(dayShift = shift, overGrid = false)
}
}
private fun recomputeDrag() {
val block = source ?: return
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
val hourPx = geometry.hourPx
@@ -1013,11 +1150,18 @@ class TimelineDragController {
}
private fun edgeStep(): Float {
val strip = stripDrag
if (strip?.overGrid == false) return 0f
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return 0f
val edge = geometry.edgePx
if (edge <= 0f) return 0f
val bounds = viewport.boundsInRoot()
val fromTop = pointer.y - bounds.top
// Coming down out of the strip the finger starts inside the top edge.
if (strip != null && !stripScrollArmed) {
if (fromTop < edge) return 0f
stripScrollArmed = true
}
val fromBottom = bounds.bottom - pointer.y
return when {
fromTop < edge -> -(edge - fromTop) / edge * geometry.stepPx
@@ -1077,10 +1221,6 @@ fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int {
fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean =
event.start < day.atStartOfDayIn(zone)
/** True when the event runs past [day]'s midnight, so this block is cut there. */
fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
event.end > day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
/**
* The instant a drop asks for — the *event's* new start, so the tail of an
* overnight event drops back onto the day its own start belongs to. Read in the
@@ -1177,7 +1317,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
// Both live here rather than beside the controller: they read [drag], which
// changes every frame, and this composable is the one that is meant to.
DragSnapHaptics(controller.drag?.slot, controller.drops)
DragSnapHaptics(controller.drag?.slot ?: controller.stripDrag?.dayShift, controller.drops)
// Only once the finger has moved: a long press held near an edge must not
// scroll the drop target out from under a finger that only means to select.
LaunchedEffect(controller.isDragging && controller.pointerMoved) {
@@ -1201,6 +1341,15 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
controller.release()
}
// The same hold for a bar moved along the all-day strip.
LaunchedEffect(controller.stripSettling, moveInFlight, controller.stripSeated, abandoned) {
if (controller.stripSettling == null || moveInFlight) return@LaunchedEffect
if (abandoned == controller.stripAbandonedMark) {
delay(if (controller.stripSeated) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
}
controller.releaseStrip()
}
Box(
modifier = modifier
.fillMaxSize()
@@ -43,14 +43,21 @@ data class TimelineHours(
/**
* The scroll offset that puts [startHour] at the top of a timeline laid out
* with [maxValue] scroll range over a [viewportPx] viewport, or null while that
* with [maxValue] scroll range over a [viewportPx] viewport, [clearancePx] of
* which is the empty inset under midnight, or null while that
* layout is not yet at [hourPx] per hour — the scale changes a frame before the
* content grows to match it, and scrolling against the old layout would land on
* the wrong hour.
*/
internal fun startHourScroll(maxValue: Int, viewportPx: Int, startHour: Int, hourPx: Float): Int? {
internal fun startHourScroll(
maxValue: Int,
viewportPx: Int,
startHour: Int,
hourPx: Float,
clearancePx: Int = 0,
): Int? {
if (viewportPx <= 0 || maxValue < 0 || maxValue == Int.MAX_VALUE) return null
val laidOutHourPx = (maxValue + viewportPx) / 24f
val laidOutHourPx = (maxValue + viewportPx - clearancePx) / 24f
if (abs(laidOutHourPx - hourPx) > 1f) return null
return (startHour * laidOutHourPx).roundToInt().coerceIn(0, maxValue)
}
@@ -4,6 +4,8 @@ import androidx.annotation.StringRes
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
/**
* How tall one hour is drawn in the week and day timelines (#56).
@@ -59,8 +61,9 @@ sealed interface TimelineScale {
}
/**
* Hour height for this scale. [viewportHeight] is the visible height of the
* scrolling timeline and is only consulted by [TimelineScale.FitDay].
* Hour height for this scale. [viewportHeight] is the height the day is fitted
* to, the scrolling timeline's [dayFitHeight], and is not consulted by the
* fixed presets.
*
* The fit-day result is clamped: below [FIT_DAY_MIN] the 24 gutter labels stop
* being legible, and above [FIT_DAY_MAX] a short landscape day would stretch its
@@ -85,6 +88,13 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
.coerceAtLeast(fillHourHeight(viewportHeight))
}
/**
* The part of a [viewportHeight]-tall timeline the day is fitted to: the
* [FabClearance] under midnight, which lets the last hour scroll clear of the
* FAB (#483), is left out. Pass this, not the raw viewport, to [hourHeight].
*/
fun dayFitHeight(viewportHeight: Dp): Dp = (viewportHeight - FabClearance).coerceAtLeast(0.dp)
/**
* The minute of the day a tap [offsetY] px down a timeline column means, snapped
* to the hour it landed in.
@@ -105,6 +115,28 @@ fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
*/
const val MIN_EVENT_FRACTION = 26f / 60f
/** Where a timed block is drawn down its day, in minutes from midnight. */
data class RenderedSpan(val topMin: Float, val bottomMin: Float)
/**
* Where a block of [startMin]..[endMin] is drawn, at least [MIN_EVENT_FRACTION]
* of an hour tall. A short block near midnight rises until that floor ends at
* midnight instead of running off the day, unless it [continuesAfter] into the
* next one (#483).
*/
fun renderedSpan(startMin: Int, endMin: Int, continuesAfter: Boolean): RenderedSpan {
val floor = MIN_EVENT_FRACTION * 60f
val bottom = maxOf(endMin.toFloat(), startMin + floor)
return if (continuesAfter || bottom <= MINUTES_PER_DAY) {
RenderedSpan(startMin.toFloat(), bottom)
} else {
RenderedSpan(MINUTES_PER_DAY - floor, MINUTES_PER_DAY.toFloat())
}
}
/** This block's [renderedSpan]. */
fun TimedBlock.renderedSpan(): RenderedSpan = renderedSpan(startMin, endMin, continuesAfter)
/** Smallest hour height [TimelineScale.FitDay] will resolve to. */
val FIT_DAY_MIN = 24.dp
@@ -32,16 +32,21 @@ val LocalTimelineScroll = staticCompositionLocalOf<TimelineScrollMemory?> { null
/** A timeline's scroll, and where it opens before it can scroll there. */
@Stable
class TimelineScroll(val state: ScrollState, noonPending: Boolean, private val openingHour: Int?) {
class TimelineScroll(
val state: ScrollState,
noonPending: Boolean,
private val openingHour: Int?,
private val clearancePx: Float = 0f,
) {
/** Whether it is still to be put on noon, which it only can be once laid out. */
var noonPending by mutableStateOf(noonPending)
internal set
/** The top of what it shows, or will once open, over [content] pixels in a [viewport] pixels high. */
/** The top of what it shows, or will once open, over [content] pixels of day in a [viewport] pixels high. */
fun top(viewport: Float, content: Float): Float =
if (noonPending) {
(openingHour?.let { it * content / 24f } ?: (content - viewport) / 2f)
.coerceIn(0f, (content - viewport).coerceAtLeast(0f))
.coerceIn(0f, (content + clearancePx - viewport).coerceAtLeast(0f))
} else state.value.toFloat()
}
@@ -58,6 +63,7 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
val density = LocalDensity.current
val zoom = LocalTimelineZoom.current
val topPx = with(density) { timelineTop.toPx() }
val clearancePx = with(density) { FabClearance.roundToPx() }
val remembered = memory?.midnightY
val scrollState = rememberScrollState(
initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0,
@@ -67,6 +73,7 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
scrollState,
noonPending = remembered == null,
openingHour = zoom.hours.takeIf { it.enabled }?.startHour,
clearancePx = clearancePx.toFloat(),
)
}
val currentTop by rememberUpdatedState(topPx)
@@ -80,20 +87,20 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
val startHour = zoom.open()
if (startHour == null) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
scrollState.scrollTo(scrollState.maxValue / 2)
scrollState.scrollTo(((scrollState.maxValue - clearancePx) / 2).coerceAtLeast(0))
} else {
// Fitted to the scroll viewport itself: the layout resolves scales
// against the viewport plus the all-day strip, which would push the
// end hour below the screen.
val viewport = snapshotFlow { scrollState.viewportSize }.first { it > 0 }
val hourHeight = zoom.scale.hourHeight(with(density) { viewport.toDp() })
val hourHeight = zoom.scale.hourHeight(dayFitHeight(with(density) { viewport.toDp() }))
zoom.holdFit(hourHeight)
val hourPx = with(density) { hourHeight.toPx() }
val target = withTimeoutOrNull(FIT_LAYOUT_TIMEOUT_MILLIS) {
snapshotFlow {
startHourScroll(scrollState.maxValue, scrollState.viewportSize, startHour, hourPx)
startHourScroll(scrollState.maxValue, scrollState.viewportSize, startHour, hourPx, clearancePx)
}.filterNotNull().first()
} ?: (startHour * (scrollState.maxValue + scrollState.viewportSize) / 24)
} ?: (startHour * (scrollState.maxValue + scrollState.viewportSize - clearancePx) / 24)
.coerceIn(0, scrollState.maxValue.coerceAtLeast(0))
scrollState.scrollTo(target)
}
@@ -37,6 +37,7 @@ 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
@@ -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
@@ -892,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(
@@ -1314,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,
)
}
/**
@@ -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,
)
@@ -671,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. */
@@ -766,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
}
@@ -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
@@ -157,10 +163,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader
import de.jeanlucmakiola.calendula.ui.agenda.agendaDayLabel
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
import de.jeanlucmakiola.calendula.ui.agenda.agendaDayLabel
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
@@ -278,6 +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).
@@ -363,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 {
@@ -448,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
@@ -524,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,
@@ -543,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,
@@ -551,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,
@@ -561,7 +576,7 @@ fun MonthScreen(
}
}
}
MonthDragOverlay(chipDrag)
MonthDragOverlay(chipDrag, showStartTimes = showStartTimes == true)
}
}
}
@@ -574,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
@@ -640,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.
@@ -677,6 +693,7 @@ private fun ContinuousMonthContent(
listState: LazyListState,
dense: Boolean,
showWeekNumbers: Boolean,
showStartTimes: Boolean,
onRetry: () -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
@@ -693,6 +710,7 @@ private fun ContinuousMonthContent(
state = state,
listState = listState,
showWeekNumbers = showWeekNumbers,
showStartTimes = showStartTimes,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
@@ -701,6 +719,7 @@ private fun ContinuousMonthContent(
state = state,
listState = listState,
showWeekNumbers = showWeekNumbers,
showStartTimes = showStartTimes,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
@@ -873,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. */
@@ -897,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,
@@ -939,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)
@@ -968,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,
@@ -990,7 +1011,7 @@ private fun ContinuousMonthBlock(
weekStart: DayOfWeek,
today: LocalDate,
zone: TimeZone,
timeChipWidth: Dp,
timeFit: MonthChipTimeFit?,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
@@ -1011,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
@@ -1077,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
@@ -1095,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]
@@ -1106,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.
@@ -1198,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,
@@ -1207,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)
}
@@ -1278,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
@@ -1310,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
@@ -1335,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 = {
@@ -1377,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,
@@ -1397,6 +1432,7 @@ private fun SplitMonthCollapsed(
SplitMonthGrid(
state = state,
selected = selected,
selectionIndicatorVisible = selectionIndicatorVisible,
showWeekNumbers = showWeekNumbers,
onSelectDay = onSelectDay,
modifier = Modifier.height(splitGridHeight(state.weeks.size)),
@@ -1410,6 +1446,7 @@ private fun SplitMonthCollapsed(
Box(Modifier.weight(1f).fillMaxWidth().then(paneModifier)) {
SplitMonthAgendaPane(
state = state,
showToday = showToday,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
@@ -1423,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(),
@@ -1487,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,
@@ -1499,6 +1526,7 @@ private fun SplitMonthExpanded(
MonthGrid(
state = state,
showWeekNumbers = showWeekNumbers,
showStartTimes = showStartTimes,
onOpenDay = onPickDay,
onEventClick = onEventClick,
selected = selected,
@@ -1612,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,
@@ -1657,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 },
@@ -1685,6 +1715,7 @@ private fun SplitDayCell(
hidden: List<EventInstance>,
isToday: Boolean,
isSelected: Boolean,
selectionIndicatorVisible: Boolean,
inMonth: Boolean,
relativeToToday: Int?,
rippleProgress: () -> Float,
@@ -1714,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",
)
@@ -1726,12 +1757,19 @@ private fun SplitDayCell(
// built — and so the pill can be tagged for the morph on its own. Tagged as
// one piece with its contents inside, the dots would be nested shared
// elements within a shared element and travel twice.
// The cell keeps the click and selection semantics, so TalkBack reads the
// day number with them; the topmost layer only draws the ripple.
val interaction = remember { MutableInteractionSource() }
Box(
modifier = modifier
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.todayRipple(relativeToToday, rippleProgress)
.clip(CELL_SHAPE)
.selectable(selected = isSelected, onClick = onClick),
.selectable(
selected = isSelected,
interactionSource = interaction,
indication = null,
onClick = onClick,
),
contentAlignment = Alignment.TopCenter,
) {
Box(
@@ -1793,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),
)
}
}
@@ -1814,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,
) {
@@ -1849,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),
)
@@ -1859,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,
@@ -1882,6 +1942,7 @@ private fun SplitMonthAgendaPane(
dimmed = { event, day -> dimCutoff != null && event.isPastOn(day, dimCutoff) },
onOpenDay = onOpenDay,
onEventClick = onEventClick,
compact = true,
)
}
}
@@ -1889,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(
@@ -1899,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,
@@ -1910,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(),
@@ -1933,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,
@@ -2089,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,
@@ -2216,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
@@ -2307,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,
@@ -2384,7 +2464,8 @@ private fun MonthWeekRow(
continuesRight = false,
days = listOf(d),
time = chipTimes[ev.instanceId],
showTime = colW >= timeChipWidth,
timeFit = timeFit,
chipWidth = colW,
modifier = Modifier
.offset(
x = colW * col,
@@ -2482,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
@@ -2632,6 +2714,7 @@ private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier
Box(
modifier = modifier
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.morphElement(MonthMorphKey.WeekNumber(weekStart))
.clip(CELL_SHAPE)
.background(MaterialTheme.colorScheme.secondaryContainer)
.then(if (onOpenWeek != null) Modifier.clickable(onClickLabel = openLabel) {
@@ -2710,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(
@@ -2727,10 +2810,13 @@ private fun MonthBar(
days: List<LocalDate>? = null,
/** The chip's start time, or null where it has none — see `monthChipTime` (#219). */
time: String? = null,
/** Whether this chip has the width to draw [time]; a screen reader gets it either way. */
showTime: Boolean = false,
/** Decides whether [time] is drawn at [chipWidth]; null draws none. A screen reader gets it either way. */
timeFit: MonthChipTimeFit? = null,
/** The chip's full width, chrome included. */
chipWidth: Dp = 0.dp,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val showTime = time != null && timeFit != null && timeFit.showsTime(chipWidth, title)
val dimCutoff = LocalDimCutoff.current
// A bar is one piece, so it dims once the last day it covers has passed.
val dimmed = dimCutoff != null &&
@@ -31,6 +31,7 @@ import kotlinx.datetime.yearMonth
internal fun MonthStylePreview(
style: MonthViewStyle,
weekStart: DayOfWeek,
showStartTimes: Boolean,
height: Dp,
modifier: Modifier = Modifier,
) {
@@ -44,6 +45,7 @@ internal fun MonthStylePreview(
MonthViewStyle.Paged -> MonthGrid(
state = sample.month,
showWeekNumbers = false,
showStartTimes = showStartTimes,
onOpenDay = {},
onEventClick = {},
)
@@ -55,6 +57,7 @@ internal fun MonthStylePreview(
),
),
showWeekNumbers = false,
showStartTimes = showStartTimes,
onOpenDay = {},
onEventClick = {},
)
@@ -67,6 +70,7 @@ internal fun MonthStylePreview(
initialFirstVisibleItemIndex = weekIndexOf(today, weekStart) - 1,
),
showWeekNumbers = false,
showStartTimes = showStartTimes,
onOpenDay = {},
onEventClick = {},
)
@@ -83,6 +87,7 @@ internal fun MonthStylePreview(
events = sample.month.instancesByDay[today].orEmpty(),
zone = zone,
onOpenDay = {},
onClearSelection = {},
onEventClick = {},
onCreateEvent = {},
modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT),
@@ -13,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 ->
@@ -75,7 +75,7 @@ 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.calendula.domain.isCrossedOut
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.fadeThrough
@@ -94,6 +94,7 @@ import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
@@ -499,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,
@@ -609,7 +610,7 @@ private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())
}
val use24Hour = LocalUse24HourFormat.current
val timeText = if (event.isAllDay) {
val timeText = if (event.coversWholeDays(TimeZone.currentSystemDefault())) {
stringResource(R.string.event_detail_all_day)
} else {
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
@@ -33,6 +33,7 @@ import kotlinx.datetime.DayOfWeek
internal fun MonthViewStylePicker(
selected: MonthViewStyle,
weekStart: DayOfWeek,
showStartTimes: Boolean,
onSelect: (MonthViewStyle) -> Unit,
onDismiss: () -> Unit,
) {
@@ -50,6 +51,7 @@ internal fun MonthViewStylePicker(
MonthStylePreview(
style = shown,
weekStart = weekStart,
showStartTimes = showStartTimes,
height = PREVIEW_HEIGHT,
)
}
@@ -63,6 +63,7 @@ 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
@@ -258,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),
@@ -42,6 +42,8 @@ data class SettingsUiState(
val dimCompletedEvents: Boolean = false,
/** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */
val showWeekNumbers: Boolean = false,
/** Whether Month chips may show start times before their titles (#476). */
val monthShowStartTimes: Boolean = true,
/** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */
val todayButtonInToolbar: Boolean = false,
/** Whether events can be dragged to another slot to reschedule them (#68, #173). */
@@ -101,6 +101,7 @@ class SettingsViewModel @Inject constructor(
pastEventDisplay = this[SettingsKeys.pastEventDisplay],
dimCompletedEvents = this[SettingsKeys.dimCompletedEvents],
showWeekNumbers = this[SettingsKeys.showWeekNumbers],
monthShowStartTimes = this[SettingsKeys.monthShowStartTimes],
todayButtonInToolbar = this[SettingsKeys.todayButtonInToolbar],
dragToReschedule = this[SettingsKeys.dragToReschedule],
tapOpensEditor = this[SettingsKeys.tapOpensEditor],
@@ -292,6 +293,8 @@ class SettingsViewModel @Inject constructor(
fun setShowWeekNumbers(enabled: Boolean) = save { setShowWeekNumbers(enabled) }
fun setMonthShowStartTimes(enabled: Boolean) = save { setMonthShowStartTimes(enabled) }
fun setTodayButtonInToolbar(enabled: Boolean) = save { setTodayButtonInToolbar(enabled) }
fun setDragToReschedule(enabled: Boolean) = save { setDragToReschedule(enabled) }
@@ -160,6 +160,13 @@ internal fun ViewsScreen(
checked = state.showWeekNumbers,
onCheckedChange = viewModel::setShowWeekNumbers,
summary = stringResource(R.string.settings_week_numbers_summary),
position = Position.Middle,
)
GroupedSwitchRow(
title = stringResource(R.string.settings_month_start_times),
checked = state.monthShowStartTimes,
onCheckedChange = viewModel::setMonthShowStartTimes,
summary = stringResource(R.string.settings_month_start_times_summary),
position = Position.Bottom,
)
@@ -296,6 +303,7 @@ internal fun ViewsScreen(
selected = state.monthViewStyle,
// The preview is a real grid, so it uses the real week start too.
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
showStartTimes = state.monthShowStartTimes,
onSelect = viewModel::setMonthViewStyle,
onDismiss = { showMonthStyle = false },
)
@@ -324,6 +332,7 @@ internal fun ViewsScreen(
selected = state.weekStart,
// The preview is a real grid, so it uses the user's own month style.
monthStyle = state.monthViewStyle,
showStartTimes = state.monthShowStartTimes,
options = WEEK_START_OPTIONS,
onSelect = viewModel::setWeekStart,
onDismiss = { showWeekStart = false },
@@ -27,6 +27,7 @@ import de.jeanlucmakiola.floret.locale.currentLocale
internal fun WeekStartPicker(
selected: WeekStartPref,
monthStyle: MonthViewStyle,
showStartTimes: Boolean,
options: List<WeekStartPref>,
onSelect: (WeekStartPref) -> Unit,
onDismiss: () -> Unit,
@@ -46,6 +47,7 @@ internal fun WeekStartPicker(
MonthStylePreview(
style = monthStyle,
weekStart = day,
showStartTimes = showStartTimes,
height = PREVIEW_HEIGHT,
)
}
@@ -29,11 +29,10 @@ 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.config.WidgetKind
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.label
import de.jeanlucmakiola.calendula.widget.config.placedWidgets
import de.jeanlucmakiola.calendula.widget.config.themeLabel
@@ -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,16 +8,7 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
/** Pages the week pager spans — a little under four centuries. */
internal const val WEEK_PAGE_COUNT: Int = 20_000
/** Pages the multi-day pager spans — over three centuries at the widest page. */
internal const val DAYS_PAGE_COUNT: Int = 40_000
/** Pages the day pager spans — a little under four centuries. */
internal const val DAY_PAGE_COUNT: Int = 140_000
/** A fixed day every grid's pages are counted from, at its middle page. */
/** 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. */
@@ -24,13 +16,19 @@ private val WEEK_EPOCH = LocalDate(2000, 1, 3)
/**
* How a timeline pager cuts the calendar: runs of [dayCount] days, offset
* [phase] days from a fixed epoch, over [pageCount] pages centred on it. The
* multi-day view's phase (#193) stays put while paging and only changes when a
* jump moves another date to the first column.
* [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(val dayCount: Int, val phase: Int, val pageCount: Int = DAYS_PAGE_COUNT) {
data class DaysGrid(val dayCount: Int, val phase: Int) {
private val epochPage: Int get() = pageCount / 2
/** The page showing the epoch; page 0 is the one holding [CalendarSpan.FIRST_DAY]. */
private val epochPage: Int get() = -pagesFromEpoch(CalendarSpan.FIRST_DAY)
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 =
@@ -38,18 +36,16 @@ data class DaysGrid(val dayCount: Int, val phase: Int, val pageCount: Int = DAYS
/** The pager page showing [date]. */
fun pageFor(date: LocalDate): Int =
(epochPage + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount))
.coerceIn(0, pageCount - 1)
(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, pageCount = DAY_PAGE_COUNT)
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)),
pageCount = WEEK_PAGE_COUNT,
)
/** The grid of [dayCount]-day pages on which [date] is a page's first day. */
@@ -106,9 +106,16 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.runtime.State
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.unit.Density
import androidx.compose.ui.layout.layout
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
@@ -125,6 +132,9 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.dateZone
import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.domain.spanLastDay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
import de.jeanlucmakiola.calendula.ui.common.eventPaint
@@ -147,10 +157,11 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.StripDrag
import de.jeanlucmakiola.calendula.ui.common.StripDrop
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
import de.jeanlucmakiola.calendula.ui.common.ChipCuts
import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts
@@ -185,8 +196,10 @@ import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.renderedSpan
import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.dayFitHeight
import de.jeanlucmakiola.calendula.ui.common.FabClearance
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
@@ -198,6 +211,7 @@ import de.jeanlucmakiola.calendula.ui.common.hourGridCells
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
@@ -601,6 +615,21 @@ private fun WeekContent(
)
}
// Off the all-day strip: sideways moves days, into the grid makes it timed.
val onStripDrop: (StripDrop) -> Unit = { drop ->
move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = when (drop) {
is StripDrop.Days -> MoveTarget.ByDays(drop.days)
is StripDrop.Timed -> MoveTarget.Span(drop.start, drop.end)
},
),
)
}
// A drafted block (#153) opens the new-event form at its own instants: the
// end it is drawn to differs on a DST change (#387).
val createDraft: (EventInstance) -> Unit = { draft ->
@@ -661,6 +690,7 @@ private fun WeekContent(
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
onStripDrop = onStripDrop,
)
Box(modifier = modifier) {
@@ -736,7 +766,12 @@ private fun WeekPager(
val loaded = pageState as? WeekUiState.Success
// Until its days arrive a page shows their frame without events.
val empty = remember(pageSpan, today) { emptySpan(pageSpan, today) }
val stripHeight = loaded?.allDayStripHeight()
// A bar held over other days in the strip makes room there before it is dropped.
val held = (dragController.stripDrag ?: dragController.stripSettling)
?.takeIf { page == pagerState.currentPage }
val zone = LocalTimelineZone.current
val shown = remember(loaded, held, zone) { loaded?.withStripPreview(held, zone) }
val stripHeight = shown?.allDayStripHeight()
DisposableEffect(page, stripHeight) {
if (stripHeight != null) stripHeights[page] = stripHeight
onDispose { stripHeights.remove(page) }
@@ -744,8 +779,8 @@ private fun WeekPager(
ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) {
WeekPage(
// A cached page may predate midnight; today comes off the clock (#373).
state = remember(loaded, empty, today) {
loaded?.takeIf { it.today != today }?.copy(today = today) ?: loaded ?: empty
state = remember(shown, empty, today) {
shown?.takeIf { it.today != today }?.copy(today = today) ?: shown ?: empty
},
headerBias = kind.headerBias,
allDayHeight = allDayHeight,
@@ -757,6 +792,25 @@ private fun WeekPager(
}
}
/**
* [this] with the strip bar [held] sideways laid out at the days it is over, so
* the bars there move aside live. The bar keeps its own event, and with it its
* key and the gesture on it; one moved off the page keeps a lane it is not seen in.
*/
internal fun WeekUiState.Success.withStripPreview(held: StripDrag?, zone: TimeZone): WeekUiState.Success {
if (held == null || held.overGrid || held.dayShift == 0) return this
val id = held.event.instanceId
val original = allDaySpans.firstOrNull { it.event.instanceId == id } ?: return this
val events = allDaySpans.map { if (it.event.instanceId == id) it.event.shiftedDays(held.dayShift, zone) else it.event }
val spans = layoutAllDay(events, days, zone).map { if (it.event.instanceId == id) it.copy(event = original.event) else it }
return copy(allDaySpans = if (spans.any { it.event.instanceId == id }) spans else spans + original.copy(lane = 0))
}
private fun EventInstance.shiftedDays(days: Int, zone: TimeZone): EventInstance {
val dates = dateZone(zone)
return copy(start = start.plus(days, DateTimeUnit.DAY, dates), end = end.plus(days, DateTimeUnit.DAY, dates))
}
/** A page with no events yet, for one whose days are still loading. */
private fun emptySpan(span: DaySpan, today: LocalDate): WeekUiState.Success {
val days = (0 until span.count).map { span.start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) }
@@ -892,8 +946,9 @@ private fun WeekFrame(
),
) {
val zoom = LocalTimelineZoom.current
val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight
else maxHeight
val fitHeight = dayFitHeight(
if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight,
)
val hourHeight = zoom.scale.hourHeight(fitHeight)
HourGutter(
scrollState = actions.scrollState,
@@ -992,7 +1047,7 @@ private fun TimelinePageContent(
LocalChipGround provides MaterialTheme.colorScheme.surface,
) {
AllDayStrip(geometry = geometry, bars = bars, height = allDayHeight,
animatedHeight = animatedAllDayHeight, onEventClick = actions.onEventClick)
animatedHeight = animatedAllDayHeight, actions = actions, active = active)
}
}
Spacer(Modifier.height(TIMELINE_TOP_GAP))
@@ -1127,9 +1182,29 @@ private fun AllDayStrip(
bars: List<PlacedBar>,
height: Dp,
animatedHeight: (() -> Dp)? = null,
onEventClick: (EventInstance) -> Unit,
actions: TimelineActions,
active: Boolean,
) {
val dark = LocalFloretDarkTheme.current
val zone = LocalTimelineZone.current
val dragController = actions.dragController
// Only the page the finger is on draws the drag; the controller is shared.
val held = (dragController.stripDrag ?: dragController.stripSettling)?.takeIf { active }
val sideways = held?.takeIf { !it.overGrid }
val lifted = dragController.liftedInstanceId?.takeIf { active && dragController.stripDrag?.overGrid != false }
val settling = dragController.stripSettling?.takeIf { active }
// Seated once the re-read draws the event at the days it was dropped on.
LaunchedEffect(settling, bars) {
if (settling == null) return@LaunchedEffect
val target = settling.event.spanFirstDay(zone).plus(settling.dayShift, DateTimeUnit.DAY)
val landed = bars.any { bar ->
bar.event.instanceId != settling.event.instanceId &&
(bar.event.eventId == settling.event.eventId ||
(settling.event.title.isNotBlank() && bar.event.title == settling.event.title)) &&
bar.event.spanFirstDay(zone) == target
}
if (landed) dragController.seatStrip()
}
Row(
modifier = Modifier
@@ -1150,12 +1225,20 @@ private fun AllDayStrip(
content = {
bars.forEach { bar ->
key(bar.event.eventId, bar.event.start) {
val moving = sideways?.event?.instanceId == bar.event.instanceId
val lane = animatedStripLane(bar.toLane)
AllDayBar(
event = bar.event,
lastDay = bar.toRange.endInclusive,
dark = dark,
onClick = { onEventClick(bar.event) },
dragController = dragController,
lifted = moving,
ghosted = lifted == bar.event.instanceId,
onClick = { actions.onEventClick(bar.event) },
onDrop = actions.onStripDrop,
modifier = Modifier
.then(if (moving) Modifier.zIndex(1f) else Modifier)
.then(StripLane(lane))
.morphEvent(ViewMorphKey.event(bar.event, bar.keyDate))
.padding(horizontal = 1.dp, vertical = 1.dp),
)
@@ -1172,10 +1255,19 @@ private fun AllDayStrip(
val progress = geometry.progress()
val placed = measurables.mapIndexed { i, measurable ->
val bar = bars[i]
val extent = geometry.extent(bar.fromRange, bar.toRange, width, 0f)
val shift = sideways?.takeIf { it.event.instanceId == bar.event.instanceId }?.dayShift ?: 0
val extent = if (shift == 0) {
geometry.extent(bar.fromRange, bar.toRange, width, 0f)
} else {
// The whole event, not the part this page shows: it may come in from off screen.
val moved = bar.event.spanFirstDay(zone)..bar.event.spanLastDay(zone)
geometry.extent(moved.shiftedBy(shift), moved.shiftedBy(shift), width, 0f)
}
val left = extent.left.roundToInt()
val barWidth = (extent.right.roundToInt() - left).coerceAtLeast(0)
val y = (lerp(bar.fromLane.toFloat(), bar.toLane.toFloat(), progress) * rowPx).roundToInt()
// Standing still a bar tweens to a new lane, as bars make room for a dragged one.
val lane = (measurable.parentData as? StripLane)?.lane?.takeIf { bar.fromLane == bar.toLane }
val y = ((lane?.value ?: lerp(bar.fromLane.toFloat(), bar.toLane.toFloat(), progress)) * rowPx).roundToInt()
Triple(measurable.measure(Constraints.fixed(barWidth, rowPx.roundToInt())), left, y)
}
layout(constraints.maxWidth, constraints.maxHeight) {
@@ -1185,12 +1277,31 @@ private fun AllDayStrip(
}
}
private data class StripLane(val lane: State<Float>) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any = this@StripLane
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun animatedStripLane(lane: Int): State<Float> {
val spec: FiniteAnimationSpec<Float> =
if (rememberReduceMotion()) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
return animateFloatAsState(lane.toFloat(), spec, label = "strip-lane")
}
private fun ClosedRange<LocalDate>.shiftedBy(days: Int): ClosedRange<LocalDate> =
start.plus(days, DateTimeUnit.DAY)..endInclusive.plus(days, DateTimeUnit.DAY)
@Composable
private fun AllDayBar(
event: EventInstance,
lastDay: LocalDate,
dark: Boolean,
dragController: TimelineDragController,
lifted: Boolean,
ghosted: Boolean,
onClick: () -> Unit,
onDrop: (StripDrop) -> Unit,
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
@@ -1198,8 +1309,29 @@ private fun AllDayBar(
// A bar is one piece, so it dims once the last day it covers here has passed.
val dimmed = dimCutoff != null && event.isPastOn(lastDay, dimCutoff)
val paint = eventPaint(event, dark)
val zone = LocalTimelineZone.current
val draggable = eventDragAllowed(event)
// Long press lifts the bar: sideways moves its days, down into the grid makes it timed.
val dragModifier = rememberEventDragSource(
enabled = draggable,
key = event.instanceId,
onPickUp = { pointer, _, _ ->
dragController.beginStrip(event, zone, pointer)
if (dragController.stripDrag != null) PickUp.Drag else PickUp.None
},
onMove = dragController::move,
onDrop = { dragController.finishStrip()?.let(onDrop) },
onCancel = dragController::cancel,
onRelease = dragController::cancel,
)
// Ghosted while it hangs over the grid as a timed block, and until that drop lands.
val ghost = ghostAlpha(ghosted)
val raise = selectionLift(lifted)
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.alpha(ghost)
.selectionLift(raise, EventChipShape)
.then(dragModifier)
.eventSurface(paint, EventChipShape)
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
@@ -1254,7 +1386,9 @@ private fun Timeline(
else Modifier.stableMorphViewport(animatedAllDayHeight, fullViewportHeight),
),
) {
val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight
val fitHeight = dayFitHeight(
if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight,
)
val hourHeight = zoom.scale.hourHeight(fitHeight)
val totalHeight = hourHeight * 24
// The pinch sits above the scroll viewport: it has to outrank the
@@ -1287,9 +1421,11 @@ private fun Timeline(
.fillMaxSize()
.then(pinch)
.padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(TIMELINE_CORNER))
.verticalScroll(scrollState)
.onGloballyPositioned { if (active) dragController.geometry.viewport = it },
.onGloballyPositioned { if (active) dragController.geometry.viewport = it }
// Past midnight, outside the columns: nothing there takes a tap or a drop.
.padding(bottom = FabClearance),
) {
TimelineColumns(
geometry = geometry,
@@ -1325,6 +1461,7 @@ private fun Timeline(
visibleTop = visibleTop,
viewportHeight = visibleViewport,
actions = actions,
shape = columnShape(day == geometry.dates.first(), day == geometry.dates.last()),
modifier = Modifier.fillMaxHeight(),
)
}
@@ -1332,6 +1469,19 @@ private fun Timeline(
}
}
/**
* The outer columns round their bottom corner at midnight: the room left below
* for the buttons means the rounded window no longer does it there.
*/
private fun columnShape(first: Boolean, last: Boolean): Shape =
if (!first && !last) RectangleShape
else RoundedCornerShape(
bottomStart = if (first) TIMELINE_CORNER else 0.dp,
bottomEnd = if (last) TIMELINE_CORNER else 0.dp,
)
private val TIMELINE_CORNER = 16.dp
@Composable
private fun DayColumnCard(
blocks: List<TimedBlock>,
@@ -1343,6 +1493,7 @@ private fun DayColumnCard(
visibleTop: () -> Float,
viewportHeight: () -> Float,
actions: TimelineActions,
shape: Shape = RectangleShape,
modifier: Modifier = Modifier,
) {
val scrollState = actions.scrollState
@@ -1373,8 +1524,8 @@ private fun DayColumnCard(
val draftSurface = rememberDraftSurface(date, edges, hourPx, dragController, actions.onDrop)
Card(
// Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
shape = RectangleShape,
// rounded scroll viewport, apart from the outer ones' bottoms ([columnShape]).
shape = shape,
colors = CardDefaults.cardColors(
// With the grid on, the container colour moves onto the hour cells
// and the column behind them recedes to surface, so the gap between
@@ -1414,7 +1565,6 @@ private fun DayColumnCard(
// block or handle on top can claim the press first.
.then(draftSurface),
) {
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
// Keyed by event, so a block that changes time or lane is the *same*
// composable afterwards and tweens there. The ordinal disambiguates
// a column holding two occurrences of one series.
@@ -1422,9 +1572,17 @@ private fun DayColumnCard(
shown.forEach { block ->
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
key(block.event.eventId, ordinal) {
val top = hourHeight * (block.startMin / 60f)
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
// Where this block is cut at midnight, which decides both
// its shape and the half-gap it gives up to the hour cells.
val cuts = remember(block, date, zone) {
timedBlockCuts(
block.continuesBefore(date, zone),
block.continuesAfter,
)
}
val span = remember(block) { block.renderedSpan() }
val top = hourHeight * (span.topMin / 60f)
val height = hourHeight * ((span.bottomMin - span.topMin) / 60f)
val place = animatedBlockPlacement(
xFraction = block.lane.toFloat() / block.laneCount,
widthFraction = 1f / block.laneCount,
@@ -1432,14 +1590,6 @@ private fun DayColumnCard(
height = height,
landing = dragController.ghosts(block),
)
// Where this block is cut at midnight, which decides both
// its shape and the half-gap it gives up to the hour cells.
val cuts = remember(block, date, zone) {
timedBlockCuts(
block.continuesBefore(date, zone),
block.continuesAfter(date, zone),
)
}
val topInset = hourCellBlockInset(showHourGrid, cuts.top)
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
val blockHeight = (place.height - topInset - bottomInset)
@@ -1775,7 +1925,7 @@ private fun rememberDraftSurface(
block = block,
zone = zone,
pointerInRoot = pointer,
blockInRoot = node + Offset(0f, startMin / 60f * hour),
blockInRoot = node + Offset(0f, block.renderedSpan().topMin / 60f * hour),
)
true
},
@@ -1823,8 +1973,10 @@ private fun WeekLoading(dayCount: Int) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// Same scale resolution as the loaded timeline, so the skeleton's
// columns don't resize the moment the real week arrives.
val fitHeight = maxHeight + SKELETON_HEADER_HEIGHT + SKELETON_HEADER_PADDING * 2 -
headerHeight - TIMELINE_TOP_GAP
val fitHeight = dayFitHeight(
maxHeight + SKELETON_HEADER_HEIGHT + SKELETON_HEADER_PADDING * 2 -
headerHeight - TIMELINE_TOP_GAP,
)
val hourHeight = scale.hourHeight(fitHeight)
val totalHeight = hourHeight * 24
// The skeleton wears the loaded columns' own ground, so the arrival
@@ -1837,7 +1989,8 @@ private fun WeekLoading(dayCount: Int) {
modifier = Modifier
.fillMaxSize()
.padding(end = TIMELINE_CONTENT_END_INSET)
.verticalScroll(scrollState),
.verticalScroll(scrollState)
.padding(bottom = FabClearance),
) {
Spacer(Modifier.width(GUTTER_WIDTH))
repeat(dayCount) {
@@ -15,6 +15,8 @@ import kotlinx.datetime.LocalDate
* minimum tap-target height)
* @param lane 0-based column within [laneCount]
* @param laneCount number of columns the event's overlap-cluster needs
* @param continuesAfter whether the event runs past this day's midnight, so the
* block is cut there
*/
data class TimedBlock(
val event: EventInstance,
@@ -22,6 +24,7 @@ data class TimedBlock(
val endMin: Int,
val lane: Int,
val laneCount: Int,
val continuesAfter: Boolean = false,
)
/**
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.week
import de.jeanlucmakiola.calendula.ui.common.dayRange
import de.jeanlucmakiola.calendula.ui.common.renderedSpan
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -13,7 +14,9 @@ import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.domain.displayPriority
import de.jeanlucmakiola.calendula.domain.displayStart
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
@@ -206,8 +209,7 @@ abstract class PagedTimelineViewModel(
): WeekUiState {
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
val allDay = instances.filter { it.isAllDay }
val timed = instances.filterNot { it.isAllDay }
val (allDay, timed) = instances.splitWholeDays(zone)
return WeekUiState.Success(
weekStart = span.start,
today = today,
@@ -292,20 +294,21 @@ internal fun layoutAllDay(
days: List<LocalDate>,
zone: TimeZone,
): List<AllDaySpan> {
data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int)
data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int, val priority: Int)
val raw = events
.mapNotNull { ev ->
val covered = days.indices.filter { ev.coversDay(days[it], zone) }
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last())
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last(), ev.displayPriority(zone))
}
// Declined bars are packed after every other one, so on any day they
// cover they land in a lane below it (#230).
.sortedWith(
compareBy<Raw> { it.event.displayPriority(zone) }
compareBy<Raw> { it.priority }
.thenBy { it.startCol }
.thenBy { it.endCol }
.thenBy { it.event.start }
.thenBy { it.event.displayStart(zone) }
.thenBy { it.event.title }
.thenBy { it.event.instanceId },
)
@@ -426,12 +429,21 @@ internal fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean {
return coversDay(secondDay, zone)
}
/**
* Split events into those for the all-day strip and those for the time grid:
* all-day events and timed ones from midnight to midnight go to the strip (#296).
*/
internal fun List<EventInstance>.splitWholeDays(zone: TimeZone): Pair<List<EventInstance>, List<EventInstance>> =
partition { it.coversWholeDays(zone) }
/**
* Clip [events] to a single [day] and assign lanes so overlapping events render
* side-by-side. Lane count is computed per overlap-cluster (a maximal run of
* chained-overlapping events), matching the common phone week-view behaviour.
* Overlap is judged on each block's [renderedSpan], so a short block's floor
* (lifted off midnight, #483) never hides another block.
*
* All-day events are ignored here — they live in the all-day strip.
* Takes the timed half of [splitWholeDays]; the rest lives in the all-day strip.
*/
internal fun layoutDay(
events: List<EventInstance>,
@@ -441,55 +453,53 @@ internal fun layoutDay(
val dayStart = day.atStartOfDayIn(zone)
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
data class Raw(val event: EventInstance, val startMin: Int, val endMin: Int)
val raw = events.asSequence()
.filterNot { it.isAllDay }
.mapNotNull { ev ->
if (ev.start == ev.end) {
// Instant event: keep only if the point falls inside this day.
if (ev.start < dayStart || ev.start >= dayEnd) return@mapNotNull null
val m = wallClockMinutes(ev.start, day, zone)
Raw(ev, m, m)
TimedBlock(ev, m, m, lane = 0, laneCount = 1)
} else {
val s = maxOf(ev.start, dayStart)
val e = minOf(ev.end, dayEnd)
if (e <= s) return@mapNotNull null
val startMin = wallClockMinutes(s, day, zone)
val endMin = wallClockEndMinutes(startMin, s, e, day, zone).coerceAtMost(MINUTES_PER_DAY)
Raw(ev, startMin, endMin)
TimedBlock(ev, startMin, endMin, lane = 0, laneCount = 1, continuesAfter = ev.end > dayEnd)
}
}
.sortedWith(compareBy({ it.startMin }, { it.endMin }))
.map { it to it.renderedSpan() }
.sortedWith(compareBy({ it.second.topMin }, { it.second.bottomMin }))
.toList()
val result = ArrayList<TimedBlock>(raw.size)
var i = 0
while (i < raw.size) {
// Grow a cluster of chained-overlapping events.
var clusterEnd = raw[i].endMin
var clusterEnd = raw[i].second.bottomMin
var j = i + 1
while (j < raw.size && raw[j].startMin < clusterEnd) {
clusterEnd = maxOf(clusterEnd, raw[j].endMin)
while (j < raw.size && raw[j].second.topMin < clusterEnd) {
clusterEnd = maxOf(clusterEnd, raw[j].second.bottomMin)
j++
}
val cluster = raw.subList(i, j)
// Greedy first-fit column assignment (= max overlap depth in the cluster).
val laneEnd = ArrayList<Int>()
val laneEnd = ArrayList<Float>()
val lanes = IntArray(cluster.size)
cluster.forEachIndexed { k, r ->
var placed = laneEnd.indexOfFirst { it <= r.startMin }
cluster.forEachIndexed { k, (_, span) ->
var placed = laneEnd.indexOfFirst { it <= span.topMin }
if (placed == -1) {
laneEnd.add(r.endMin)
laneEnd.add(span.bottomMin)
placed = laneEnd.size - 1
} else {
laneEnd[placed] = r.endMin
laneEnd[placed] = span.bottomMin
}
lanes[k] = placed
}
val laneCount = laneEnd.size
cluster.forEachIndexed { k, r ->
result.add(TimedBlock(r.event, r.startMin, r.endMin, lanes[k], laneCount))
cluster.forEachIndexed { k, (block, _) ->
result.add(block.copy(lane = lanes[k], laneCount = laneCount))
}
i = j
}
@@ -61,6 +61,7 @@ internal fun WeekViewPreview(
onCreateAt = { _, _ -> },
onDrop = {},
onResize = {},
onStripDrop = {},
),
)
}
@@ -75,12 +76,12 @@ private fun sampleWeekState(
): WeekUiState.Success {
val start = if (dayCount == 7) today.startOfWeek(weekStart) else today
val days = (0 until dayCount).map { start.plus(it, DateTimeUnit.DAY) }
val events = sampleTimelineEvents(days, today, zone)
val (allDay, timed) = sampleTimelineEvents(days, today, zone).splitWholeDays(zone)
return WeekUiState.Success(
weekStart = start,
today = today,
days = days,
allDaySpans = layoutAllDay(events.filter { it.isAllDay }, days, zone),
timedByDay = days.associateWith { layoutDay(events, it, zone) },
allDaySpans = layoutAllDay(allDay, days, zone),
timedByDay = days.associateWith { layoutDay(timed, it, zone) },
)
}
@@ -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)
}
}
@@ -5,10 +5,12 @@ 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
@@ -50,6 +52,7 @@ 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(
@@ -84,5 +87,19 @@ internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) {
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)
@@ -5,6 +5,7 @@ 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
@@ -96,6 +97,12 @@ internal val SettingsPrefs.widgetSettings: Flow<WidgetSettings>
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
@@ -145,8 +152,9 @@ sealed interface MonthWidgetSource {
/** [loadAgendaWidgetData] with agenda widget [id]'s own options. */
internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed<AgendaWidgetData> {
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
val options = widgetState(id).agendaWidgetOptions(settings.agendaDefaults)
return Themed(loadAgendaWidgetData(settings, options), settings.themeMode)
val state = widgetState(id)
val options = state.agendaWidgetOptions(settings.agendaDefaults)
return Themed(loadAgendaWidgetData(settings, options, state.hiddenCalendars), settings.themeMode)
}
/**
@@ -157,16 +165,17 @@ internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed<AgendaWidget
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 weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
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 instances = ep.calendarRepository().instances(window).first().withoutCalendars(hiddenCalendars)
return AgendaWidgetData.Ready(
today = anchor,
days = groupAgendaDays(anchor, windowEnd, instances, zone),
@@ -181,7 +190,8 @@ internal suspend fun Context.loadAgendaWidgetData(
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)
/**
* The month the widget loads its window around while [index] is shown. Snapped
@@ -199,30 +209,57 @@ internal fun monthWindowCenter(index: Int): Int = Math.floorDiv(index, 3) * 3 +
private object MonthSourceCache {
private class Entry(val center: Int, val stamp: Long, val source: Themed<MonthWidgetSource.Ready>)
private val entries = ConcurrentHashMap<GlanceId, Entry>()
// Keyed by app-widget id, which a removed widget can still be named by.
private val entries = ConcurrentHashMap<Int, Entry>()
fun get(id: GlanceId, center: Int, stamp: Long): Themed<MonthWidgetSource>? =
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: GlanceId, center: Int, stamp: Long, source: Themed<MonthWidgetSource>) {
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)
}
}
/** [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */
internal suspend fun Context.loadCachedMonthSource(id: GlanceId, centerIndex: Int, stamp: Long): Themed<MonthWidgetSource> =
MonthSourceCache.get(id, centerIndex, stamp)
?: loadThemedMonthSource(centerIndex).also { MonthSourceCache.put(id, centerIndex, stamp, it) }
/** 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): Themed<MonthWidgetSource> {
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
return Themed(queryMonthSource(centerIndex, settings), settings.themeMode)
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): MonthWidgetSource {
private suspend fun Context.queryMonthSource(
centerIndex: Int,
settings: WidgetSettings,
hiddenCalendars: Set<Long>,
): MonthWidgetSource {
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
val zone = systemZone()
val ep = widgetEntryPoint()
@@ -237,9 +274,77 @@ private suspend fun Context.queryMonthSource(centerIndex: Int, settings: WidgetS
.toInstant(zone)
return MonthWidgetSource.Ready(
today = today(zone),
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
weekStart = settings.weekStart.resolveFirstDay(widgetLocale()),
soften = settings.soften,
instances = ep.calendarRepository().instances(from..to).first(),
instances = ep.calendarRepository().instances(from..to).first().withoutCalendars(hiddenCalendars),
months = months,
)
}
/** 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)
}
@@ -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()
@@ -6,22 +6,22 @@ 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.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.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
@@ -33,14 +33,25 @@ 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<*>> = listOf(
MonthWidgetReceiver::class.java,
AgendaWidgetReceiver::class.java,
)
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
@@ -57,39 +68,77 @@ internal fun syncWidgetRollover(context: Context) =
/**
* Reloads and redraws every placed widget, a running session included: the
* refresh stamp moves, and both widgets key their data load on it (#420).
* refresh stamp moves, and every widget keys its data load on it (#420).
*/
internal suspend fun redrawWidgets(context: Context) {
redrawAgendaWidgets(context)
redrawMonthWidgets(context)
}
private suspend fun redrawAgendaWidgets(context: Context) =
AgendaWidget().updatePlaced(context, AgendaWidgetReceiver::class.java, bumpRefreshStamp = true)
/** Redraws only the agenda widgets with no options of their own, which still follow the app's. */
private suspend fun redrawUnconfiguredAgendaWidgets(context: Context) {
val appContext = context.applicationContext
val ids = AppWidgetManager.getInstance(appContext)
?.getAppWidgetIds(ComponentName(appContext, AgendaWidgetReceiver::class.java)) ?: return
val glanceManager = GlanceAppWidgetManager(appContext)
ids.forEach { appWidgetId ->
val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
if (!appContext.widgetState(glanceId).hasAgendaOptions) AgendaWidget().updateWithRefresh(appContext, glanceId)
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 suspend fun redrawMonthWidgets(context: Context) =
MonthWidget().updatePlaced(context, MonthWidgetReceiver::class.java, bumpRefreshStamp = true)
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
internal fun isProviderEcho(): Boolean {
/** 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()
return last != 0L && SystemClock.elapsedRealtime() - last < PROVIDER_ECHO_WINDOW_MILLIS
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)
}
}
/**
@@ -97,7 +146,7 @@ internal fun isProviderEcho(): Boolean {
* 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
* agenda widgets that have none of their own (#292). Started once from
* widgets that have no content options of their own (#292). Started once from
* `CalendulaApp`.
*/
@Singleton
@@ -112,26 +161,28 @@ class WidgetRefreshCoordinator @Inject constructor(
/** 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 agenda = WidgetRefresher(scope) { redrawAgendaWidgets(context) }
val unconfiguredAgenda = WidgetRefresher(scope) { redrawUnconfiguredAgendaWidgets(context) }
val month = WidgetRefresher(scope) { redrawMonthWidgets(context) }
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())
agenda.request()
month.request()
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)) {
agenda.request()
month.request()
all.forEach { it.request() }
scope.launch(Dispatchers.IO) { publishCalendulaWidgetPreviews(context) }
}
watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) {
unconfiguredAgenda.request()
unconfigured.request()
}
}
private fun watch(changes: Flow<*>, onChange: () -> Unit) {
private fun <T> watch(changes: Flow<T>, onChange: (T) -> Unit) {
scope.launch(Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) {
changes.collect { onChange() }
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)
}
}
}
@@ -1,12 +1,17 @@
package de.jeanlucmakiola.calendula.widget
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.glance.LocalContext
import androidx.glance.text.TextDecoration
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
import de.jeanlucmakiola.floret.glance.WidgetSurface
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import de.jeanlucmakiola.floret.locale.AppLanguage
import de.jeanlucmakiola.floret.prefs.ThemeMode
/**
@@ -37,5 +42,22 @@ internal fun CalendulaWidgetRoot(appearance: WidgetAppearance, appTheme: ThemeMo
* Glance's counterpart to the app's `declinedDecoration`: a declined invitation
* reads the same on the home screen as it does inside the app (#180).
*/
fun glanceDeclinedDecoration(isDeclined: Boolean): TextDecoration =
if (isDeclined) TextDecoration.LineThrough else TextDecoration.None
fun glanceDeclinedDecoration(isCrossedOut: Boolean): TextDecoration =
if (isCrossedOut) TextDecoration.LineThrough else TextDecoration.None
/** Static, so a language change recomposes the whole widget rather than letting unchanged parts skip. */
private val LocalWidgetLanguage = staticCompositionLocalOf<String?> { null }
/**
* Draws [content] in the app's own language (#90): [LocalContext] and the
* default locale follow it, which below API 33 nothing else does outside an
* AppCompat activity. Read on every recomposition, so a redraw picks up a change.
*/
@Composable
internal fun InAppLanguage(content: @Composable () -> Unit) {
val base = LocalContext.current
val tag = AppLanguage.currentTag(base)
AppLanguage.applyDefault(base, tag)
val localized = remember(base, tag) { AppLanguage.localized(base, tag) }
CompositionLocalProvider(LocalContext provides localized, LocalWidgetLanguage provides tag, content = content)
}
@@ -0,0 +1,79 @@
package de.jeanlucmakiola.calendula.widget
import android.app.AlarmManager
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.net.toUri
import androidx.glance.appwidget.GlanceAppWidgetManager
import de.jeanlucmakiola.floret.android.goAsync
import de.jeanlucmakiola.floret.glance.updateWithRefresh
import kotlinx.coroutines.CancellationException
import kotlin.time.Instant
/**
* One alarm per placed widget, for the moment what it shows next changes: a
* countdown's label, the Today list's last event ending. Not a wake-up alarm:
* with the screen off nobody sees the widget, so the redraw waits for the
* device to wake. Exact where allowed (the app holds the exact alarm
* permission for reminders), else within a minute.
*/
internal object WidgetTicks {
private const val ACTION_TICK = "de.jeanlucmakiola.calendula.widget.TICK"
internal const val EXTRA_KIND = "de.jeanlucmakiola.calendula.widget.KIND"
fun schedule(context: Context, kind: WidgetKind, appWidgetId: Int, at: Instant) {
val alarms = context.getSystemService(AlarmManager::class.java) ?: return
val intent = pendingIntent(context, kind, appWidgetId)
val millis = at.toEpochMilliseconds()
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()) {
alarms.setExact(AlarmManager.RTC, millis, intent)
} else {
alarms.setWindow(AlarmManager.RTC, millis, 60_000L, intent)
}
}
fun cancel(context: Context, kind: WidgetKind, appWidgetId: Int) {
context.getSystemService(AlarmManager::class.java)?.cancel(pendingIntent(context, kind, appWidgetId))
}
private fun pendingIntent(context: Context, kind: WidgetKind, appWidgetId: Int): PendingIntent =
PendingIntent.getBroadcast(
context,
0,
Intent(context, WidgetTickReceiver::class.java)
.setAction(ACTION_TICK)
// Keys the PendingIntent per widget.
.setData("calendula://tick/${kind.name}/$appWidgetId".toUri())
.putExtra(EXTRA_KIND, kind.name)
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
/** Reloads the widget whose content just changed; the load arms its next tick. */
class WidgetTickReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
val kind = intent.getStringExtra(WidgetTicks.EXTRA_KIND)
?.let { name -> WidgetKind.entries.firstOrNull { it.name == name } }
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID || kind == null) return
val appContext = context.applicationContext
goAsync {
try {
val glanceId = GlanceAppWidgetManager(appContext).getGlanceIdBy(appWidgetId)
kind.widget().updateWithRefresh(appContext, glanceId)
} catch (e: CancellationException) {
throw e
} catch (_: IllegalArgumentException) {
// The widget is gone.
WidgetTicks.cancel(appContext, kind, appWidgetId)
} catch (_: Exception) {
// A failed redraw leaves the last frame; the next signal retries.
}
}
}
}
@@ -4,16 +4,18 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import de.jeanlucmakiola.floret.glance.FloretWidgetUpdateReceiver
import de.jeanlucmakiola.floret.locale.AppLanguage
import kotlin.time.Duration.Companion.minutes
/**
* Redraws both widgets when their data, day or language goes stale, and keeps
* Redraws the widgets when their data, day or language goes stale, and keeps
* the midnight rollover armed. `PROVIDER_CHANGED` covers changes made outside
* the app; the app's own writes go through [WidgetRefreshCoordinator]. A data
* change also re-arms, at most every 15 minutes: the inexact alarm can be
* dropped in a restricted standby bucket, and a calendar change is the one
* signal that keeps arriving. A `PROVIDER_CHANGED` right after one of the
* app's own changes only echoes it, so it doesn't redraw a second time.
* the app, and only redraws the widgets that draw events; the app's own writes
* go through [WidgetRefreshCoordinator]. A data change also re-arms, at most
* every 15 minutes: the inexact alarm can be dropped in a restricted standby
* bucket, and a calendar change is the one signal that keeps arriving. A
* `PROVIDER_CHANGED` right after one of the app's own changes waits out the
* echo window and redraws once ([claimProviderRedraw]).
*/
class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() {
override val rolloverAction = ACTION_WIDGET_ROLLOVER
@@ -21,9 +23,20 @@ class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() {
override val extraHandledActions = setOf(Intent.ACTION_PROVIDER_CHANGED)
override val extraRearmInterval = 15.minutes
/** This broadcast's action; a receiver instance handles exactly one. */
private var action: String? = null
override fun rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context)
override fun skipRedraw(action: String) = action == Intent.ACTION_PROVIDER_CHANGED && isProviderEcho()
override fun skipRedraw(action: String): Boolean {
this.action = action
return action == Intent.ACTION_PROVIDER_CHANGED && !claimProviderRedraw()
}
override suspend fun redraw(context: Context) = redrawWidgets(context)
override suspend fun redraw(context: Context) {
val dataChange = action == Intent.ACTION_PROVIDER_CHANGED
if (dataChange) awaitProviderEcho()
AppLanguage.applyDefault(context)
redrawWidgets(context, if (dataChange) EVENT_WIDGET_KINDS else WidgetKind.entries)
}
}
@@ -1,6 +1,12 @@
package de.jeanlucmakiola.calendula.widget.agenda
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.widget.InAppLanguage
import de.jeanlucmakiola.calendula.widget.relativeDayWord
import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import de.jeanlucmakiola.calendula.domain.isCrossedOut
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context
import androidx.compose.runtime.Composable
@@ -45,27 +51,24 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel
import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.widget.WidgetDimFillAlpha
import de.jeanlucmakiola.calendula.widget.widgetEventInk
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetCardColor
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
import de.jeanlucmakiola.calendula.widget.loadAgendaWidget
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
import de.jeanlucmakiola.floret.glance.themedColor
import de.jeanlucmakiola.floret.glance.wallpaperBacking
@@ -79,12 +82,8 @@ import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import java.util.Locale
/**
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
@@ -101,6 +100,11 @@ class AgendaWidget : GlanceAppWidget() {
// the RemoteViews past the binder transaction limit.
override val sizeMode = SizeMode.Single
override val previewSizeMode = previewSizeOf(WidgetKind.AGENDA)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.AGENDA)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val stamp = refreshStamp(context, id)
val widgetData = WidgetData(context.loadAgendaWidget(id), stamp)
@@ -108,11 +112,13 @@ class AgendaWidget : GlanceAppWidget() {
// A running session never re-runs this preamble, so every redraw
// moves the stamp and the data reloads here instead (#420).
val loaded = widgetData.current(currentRefreshStamp()) { context.loadAgendaWidget(id) }
AgendaWidgetContent(
data = loaded.content,
appearance = currentState<Preferences>().widgetAppearance,
appTheme = loaded.appTheme,
)
InAppLanguage {
AgendaWidgetContent(
data = loaded.content,
appearance = currentState<Preferences>().widgetAppearance,
appTheme = loaded.appTheme,
)
}
}
}
}
@@ -217,7 +223,7 @@ private fun AgendaRows(
// Each day is one card of grouped rows (#71), with its date pill
// beside the first row.
val positions = dayPositions(rows)
val cardColor = agendaCardColor()
val cardColor = widgetCardColor()
val item: @Composable (Int) -> Unit = { index ->
val row = rows[index]
val position = positions[index]
@@ -305,13 +311,6 @@ private fun AgendaHeader() {
}
}
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
@Composable
private fun agendaCardColor(): ColorProvider = when (LocalWidgetBackground.current) {
WidgetBackground.SOLID -> GlanceTheme.colors.surfaceVariant
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground
}
/** The month name ("November", with its year outside the current one) above a new month's first day. */
@Composable
private fun MonthLabel(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
@@ -397,7 +396,7 @@ private data class EventFill(val day: Color, val night: Color)
private fun AgendaRow.Event.fill(data: AgendaWidgetData.Ready): EventFill {
val dimmed = data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now)
fun tone(dark: Boolean) = eventFill(event.color, dark, data.soften).let {
if (dimmed) it.copy(alpha = EventDimAlpha) else it
if (dimmed) it.copy(alpha = WidgetDimFillAlpha) else it
}
return EventFill(day = tone(dark = false), night = tone(dark = true))
}
@@ -412,12 +411,10 @@ private fun EventRow(
) {
val context = androidx.glance.LocalContext.current
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
// Ink is picked against the opaque fill, then carries the dim along with it.
fun ink(fill: Color, alpha: Float) = eventInk(fill.copy(alpha = 1f), alpha * fill.alpha)
val titleInk = themedColor(day = ink(fill.day, 1f), night = ink(fill.night, 1f))
val titleInk = themedColor(day = widgetEventInk(fill.day, 1f), night = widgetEventInk(fill.night, 1f))
val timeInk = themedColor(
day = ink(fill.day, SECONDARY_INK_ALPHA),
night = ink(fill.night, SECONDARY_INK_ALPHA),
day = widgetEventInk(fill.day, SECONDARY_INK_ALPHA),
night = widgetEventInk(fill.night, SECONDARY_INK_ALPHA),
)
Column(
modifier = GlanceModifier
@@ -444,7 +441,7 @@ private fun EventRow(
color = titleInk,
fontSize = metrics.eventTitle,
fontWeight = FontWeight.Medium,
textDecoration = glanceDeclinedDecoration(event.isDeclined),
textDecoration = glanceDeclinedDecoration(event.isCrossedOut),
),
)
Text(
@@ -470,16 +467,10 @@ private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
}
}
private fun zone(): TimeZone = systemZone()
/** "Today · Wed, 17 Jun" — relative word for today/tomorrow, else the date. */
private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
val relative = when (date) {
today -> context.getString(R.string.agenda_header_today)
today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
else -> null
}
val locale = Locale.getDefault()
val relative = relativeDayWord(context, date, today)
val locale = widgetLocale()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
// Weekday + date in the locale's own field order (compact: no year), rather
// than a hardcoded day-month layout.
@@ -489,13 +480,13 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
private fun monthName(date: LocalDate, today: LocalDate): String = formatCalendarTitle(
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, 1),
locale = Locale.getDefault(),
locale = widgetLocale(),
currentYear = today.year,
skeleton = "LLLL",
)
private fun weekdayShort(date: LocalDate): String =
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, Locale.getDefault())
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, widgetLocale())
private fun eventTimeSummary(
context: Context,
@@ -503,20 +494,7 @@ private fun eventTimeSummary(
day: LocalDate,
is24Hour: Boolean,
): String {
val time = when (val label = agendaTimeLabel(event, day, zone())) {
AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day)
is AgendaTimeLabel.Starts ->
context.getString(R.string.agenda_span_starts, formatTime(label.start, is24Hour))
is AgendaTimeLabel.Ends ->
context.getString(R.string.agenda_span_ends, formatTime(label.end, is24Hour))
is AgendaTimeLabel.Range ->
"${formatTime(label.start, is24Hour)} – ${formatTime(label.end, is24Hour)}"
}
val time = widgetTimeLabel(context, event, day, is24Hour)
val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time
}
private fun formatTime(instant: Instant, is24Hour: Boolean): String {
val t = instant.toLocalDateTime(zone()).time
return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
}
@@ -1,54 +1,9 @@
package de.jeanlucmakiola.calendula.widget.agenda
import android.annotation.SuppressLint
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
import de.jeanlucmakiola.floret.glance.restoreState
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
/**
* Host-facing receiver for the agenda widget. Declared in the manifest with the
* `appwidget_info_agenda` provider metadata; delegates all rendering to
* [AgendaWidget].
*/
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
/** Host-facing receiver for the agenda widget; rendering is [AgendaWidget]'s. */
class AgendaWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
/** First agenda widget placed — start rolling "today" over at midnight (#228). */
override fun onEnabled(context: Context) {
super.onEnabled(context)
syncWidgetRollover(context)
}
/**
* Last agenda widget removed. [syncWidgetRollover] cancels only if
* no month widget is left either.
*/
override fun onDisabled(context: Context) {
super.onDisabled(context)
syncWidgetRollover(context)
}
/** A backup restore or device transfer renumbered the widgets: keep each one's settings (#292). */
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
restoreState(context, oldWidgetIds, newWidgetIds)
}
/**
* Self-heal on the system's own `updatePeriodMillis` wake-up — see
* [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
*/
@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,179 @@
package de.jeanlucmakiola.calendula.widget.config
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.domain.dateZone
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.PickerSearchField
import de.jeanlucmakiola.calendula.widget.countdown.CountdownMode
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedChoiceRow
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
/** What the countdown widget counts to (#439): the next event, or one picked from a searchable list. */
@Composable
internal fun CountdownOptionsSection(
options: CountdownOptions,
candidates: CountdownCandidates,
onChange: (CountdownOptions) -> Unit,
onLoadCandidates: () -> Unit,
) {
var picking by rememberSaveable { mutableStateOf(false) }
GroupedSectionHeader(stringResource(R.string.widget_config_countdown))
GroupedChoiceRow(
title = stringResource(R.string.widget_config_countdown),
options = CountdownMode.entries,
selected = options.mode,
label = {
stringResource(
when (it) {
CountdownMode.NEXT -> R.string.widget_config_countdown_next
CountdownMode.PINNED -> R.string.widget_config_countdown_pinned
},
)
},
onSelect = { mode ->
onChange(options.copy(mode = mode))
if (mode == CountdownMode.PINNED && options.eventId == null) picking = true
},
position = if (options.mode == CountdownMode.PINNED) Position.Top else Position.Alone,
)
when (options.mode) {
CountdownMode.NEXT -> GroupedHint(stringResource(R.string.widget_config_countdown_next_hint), verticalPadding = 8.dp)
CountdownMode.PINNED -> GroupedRow(
title = stringResource(R.string.widget_config_countdown_event),
summary = options.title ?: stringResource(R.string.widget_config_countdown_event_none),
position = Position.Bottom,
onClick = { picking = true },
)
}
if (picking) {
LaunchedEffect(Unit) { onLoadCandidates() }
CountdownEventPicker(
candidates = candidates,
selectedEventId = options.eventId,
onRetry = onLoadCandidates,
onSelect = { candidate ->
onChange(options.copy(mode = CountdownMode.PINNED, eventId = candidate.event.eventId, title = candidate.event.title))
picking = false
},
onDismiss = { picking = false },
)
}
}
@Composable
private fun CountdownEventPicker(
candidates: CountdownCandidates,
selectedEventId: Long?,
onRetry: () -> Unit,
onSelect: (CountdownCandidate) -> Unit,
onDismiss: () -> Unit,
) {
var query by rememberSaveable { mutableStateOf("") }
FullScreenPicker(
title = stringResource(R.string.widget_config_countdown_event),
onDismiss = onDismiss,
predictiveBack = true,
scrollable = false,
) {
PickerSearchField(
query = query,
onQueryChange = { query = it },
placeholder = stringResource(R.string.widget_config_countdown_search),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
when (candidates) {
CountdownCandidates.Idle, CountdownCandidates.Loading -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
CountdownCandidates.Failed -> Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
PickerMessage(stringResource(R.string.widget_config_countdown_load_failed))
FilledTonalButton(onClick = onRetry, modifier = Modifier.padding(top = 16.dp)) {
Text(stringResource(R.string.state_retry))
}
}
is CountdownCandidates.Ready -> {
val locale = currentLocale()
val matches = remember(candidates, query) {
val needle = query.trim()
if (needle.isEmpty()) candidates.events
else candidates.events.filter { it.event.title.contains(needle, ignoreCase = true) }
}
if (matches.isEmpty()) {
PickerMessage(
stringResource(R.string.widget_config_countdown_empty),
Modifier.fillMaxWidth().padding(24.dp),
)
} else {
val zone = remember { TimeZone.currentSystemDefault() }
val formatter = remember(locale) { localizedDateFormatter(locale, "EEEdMMMy") }
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
itemsIndexed(matches, key = { _, it -> it.event.eventId }) { index, candidate ->
val date = formatter.format(candidate.event.start.toLocalDateTime(candidate.event.dateZone(zone)).date.toJavaLocalDate())
GroupedRow(
title = candidate.event.title.ifBlank { stringResource(R.string.event_untitled) },
summary = if (candidate.repeats) stringResource(R.string.widget_config_countdown_repeats, date) else date,
position = positionOf(index, matches.size),
selected = candidate.event.eventId == selectedEventId,
minHeight = 56.dp,
leading = { CalendarColorChip(candidate.event.color) },
onClick = { onSelect(candidate) },
)
}
}
}
}
}
}
}
@Composable
private fun PickerMessage(text: String, modifier: Modifier = Modifier) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = modifier,
)
}
@@ -5,6 +5,7 @@ import android.content.ComponentName
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidgetManager
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetState
@@ -0,0 +1,60 @@
package de.jeanlucmakiola.calendula.widget.config
import androidx.compose.material3.Checkbox
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.positionOf
/** Which of the app's calendars one widget shows, grouped by account like the drawer's filter. */
@Composable
internal fun WidgetCalendarPicker(
calendars: List<CalendarSource>,
hidden: Set<Long>,
onSetShown: (Long, Boolean) -> Unit,
onDismiss: () -> Unit,
) {
FullScreenPicker(
title = stringResource(R.string.widget_config_calendars),
onDismiss = onDismiss,
predictiveBack = true,
) {
GroupedHint(stringResource(R.string.widget_config_calendars_hint), verticalPadding = 8.dp)
calendars.groupByAccount().forEach { group ->
GroupedSectionHeader(accountGroupTitle(group))
group.calendars.forEachIndexed { index, calendar ->
val shown = calendar.id !in hidden
GroupedRow(
title = calendar.displayName.orUnnamed(),
position = positionOf(index, group.calendars.size),
minHeight = 56.dp,
leading = { CalendarColorChip(calendar.color) },
trailing = { Checkbox(checked = shown, onCheckedChange = { onSetShown(calendar.id, it) }) },
onClick = { onSetShown(calendar.id, !shown) },
)
}
}
}
}
/** "All calendars", or how many of them the widget shows. */
@Composable
internal fun calendarsSummary(calendars: List<CalendarSource>, hidden: Set<Long>): String {
val shown = calendars.count { it.id !in hidden }
return if (shown == calendars.size) {
stringResource(R.string.widget_config_calendars_all)
} else {
pluralStringResource(R.plurals.widget_config_calendars_some, calendars.size, shown, calendars.size)
}
}
@@ -113,7 +113,10 @@ class WidgetConfigActivity : ComponentActivity() {
onSave = viewModel::save,
onRetry = viewModel::retry,
onAppearance = viewModel::setAppearance,
onAgendaOptions = viewModel::setAgendaOptions,
onContent = viewModel::setContent,
onCalendarShown = viewModel::setCalendarShown,
onCountdown = viewModel::setCountdown,
onLoadCountdownCandidates = viewModel::loadCountdownCandidates,
)
}
}
@@ -42,11 +42,21 @@ import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.settings.pastEventDisplayLabelRes
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.WidgetOption
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.WidgetTheme
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetContent
import de.jeanlucmakiola.calendula.widget.date.DateWidgetContent
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetContent
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetContent
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetContent
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetContent
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetContent
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.floret.components.GroupedChoiceRow
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
@@ -54,6 +64,7 @@ import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.glance.GlanceWidgetPreview
import de.jeanlucmakiola.floret.glance.WallpaperWindow
import de.jeanlucmakiola.floret.glance.WidgetBackground
@@ -69,7 +80,10 @@ internal fun WidgetConfigScreen(
onSave: () -> Unit,
onRetry: () -> Unit,
onAppearance: (WidgetAppearance) -> Unit,
onAgendaOptions: (AgendaWidgetOptions) -> Unit,
onContent: (AgendaWidgetOptions) -> Unit,
onCalendarShown: (Long, Boolean) -> Unit,
onCountdown: (CountdownOptions) -> Unit,
onLoadCountdownCandidates: () -> Unit,
) {
val ready = state as? WidgetConfigUiState.Ready
val surface = MaterialTheme.colorScheme.surface
@@ -143,6 +157,27 @@ internal fun WidgetConfigScreen(
AgendaWidgetContent(preview.data, state.appearance, state.appTheme, preview = true)
is WidgetPreviewData.Month ->
MonthWidgetContent(preview.source, preview.index, state.appearance, state.appTheme)
is WidgetPreviewData.MonthDay -> MonthDayWidgetContent(
preview.data,
preview.index,
selected = null,
appearance = state.appearance,
appTheme = state.appTheme,
preview = true,
)
is WidgetPreviewData.Countdown ->
CountdownWidgetContent(preview.data, state.appearance, state.appTheme)
is WidgetPreviewData.Days -> when (state.kind) {
WidgetKind.TODAY ->
TodayWidgetContent(preview.data, state.appearance, state.appTheme, preview = true)
WidgetKind.UPCOMING -> UpcomingWidgetContent(preview.data, state.appearance, state.appTheme)
else -> WeekStripWidgetContent(preview.data, state.appearance, state.appTheme)
}
is WidgetPreviewData.Today -> if (state.kind == WidgetKind.DATE) {
DateWidgetContent(preview.today, state.appearance, state.appTheme)
} else {
QuickAddWidgetContent(preview.today, state.appearance, state.appTheme)
}
}
}
}
@@ -150,7 +185,10 @@ internal fun WidgetConfigScreen(
ConfigOptions(
state = state,
onAppearance = onAppearance,
onAgendaOptions = onAgendaOptions,
onContent = onContent,
onCalendarShown = onCalendarShown,
onCountdown = onCountdown,
onLoadCountdownCandidates = onLoadCountdownCandidates,
modifier = Modifier
.weight(1f)
.background(surface),
@@ -173,17 +211,14 @@ private fun PreviewFailed() {
)
}
internal val WidgetKind.label: Int
get() = when (this) {
WidgetKind.AGENDA -> R.string.widget_agenda_label
WidgetKind.MONTH -> R.string.widget_month_label
}
@Composable
private fun ConfigOptions(
state: WidgetConfigUiState.Ready,
onAppearance: (WidgetAppearance) -> Unit,
onAgendaOptions: (AgendaWidgetOptions) -> Unit,
onContent: (AgendaWidgetOptions) -> Unit,
onCalendarShown: (Long, Boolean) -> Unit,
onCountdown: (CountdownOptions) -> Unit,
onLoadCountdownCandidates: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@@ -209,47 +244,80 @@ private fun ConfigOptions(
onSelect = { onAppearance(state.appearance.copy(theme = it)) },
position = Position.Bottom,
)
state.agenda?.let { options ->
AgendaOptions(options = options, state = state, onChange = onAgendaOptions)
if (state.kind == WidgetKind.COUNTDOWN) {
CountdownOptionsSection(
options = state.draft.countdown,
candidates = state.candidates,
onChange = onCountdown,
onLoadCandidates = onLoadCountdownCandidates,
)
}
if (state.kind.options.isNotEmpty()) {
ContentOptions(state = state, onChange = onContent, onCalendarShown = onCalendarShown)
}
}
}
@Composable
private fun AgendaOptions(
options: AgendaWidgetOptions,
private fun ContentOptions(
state: WidgetConfigUiState.Ready,
onChange: (AgendaWidgetOptions) -> Unit,
onCalendarShown: (Long, Boolean) -> Unit,
) {
val options = state.draft.content
var showRange by rememberSaveable { mutableStateOf(false) }
var showSize by rememberSaveable { mutableStateOf(false) }
var showPast by rememberSaveable { mutableStateOf(false) }
var showCalendars by rememberSaveable { mutableStateOf(false) }
// Rows in WidgetOption order, each shaped by where it lands among the kind's own.
val rows = WidgetOption.entries.filter { it in state.kind.options }
fun position(option: WidgetOption) = positionOf(rows.indexOf(option), rows.size)
GroupedSectionHeader(stringResource(R.string.widget_config_content))
GroupedRow(
title = stringResource(R.string.widget_config_range),
summary = agendaRangeLabel(options.range),
position = Position.Top,
onClick = { showRange = true },
)
GroupedRow(
title = stringResource(R.string.widget_config_text_size),
summary = widgetSizeLabel(options.textSize),
position = Position.Middle,
onClick = { showSize = true },
)
GroupedRow(
title = stringResource(R.string.settings_past_events),
summary = stringResource(pastEventDisplayLabelRes(options.pastDisplay)),
position = Position.Middle,
onClick = { showPast = true },
)
GroupedSwitchRow(
title = stringResource(R.string.settings_agenda_show_today),
checked = options.showToday,
onCheckedChange = { onChange(options.copy(showToday = it)) },
position = Position.Bottom,
)
rows.forEach { option ->
when (option) {
WidgetOption.RANGE -> GroupedRow(
title = stringResource(R.string.widget_config_range),
summary = agendaRangeLabel(options.range),
position = position(option),
onClick = { showRange = true },
)
WidgetOption.TEXT_SIZE -> GroupedRow(
title = stringResource(R.string.widget_config_text_size),
summary = widgetSizeLabel(options.textSize),
position = position(option),
onClick = { showSize = true },
)
WidgetOption.PAST_DISPLAY -> GroupedRow(
title = stringResource(R.string.settings_past_events),
summary = stringResource(pastEventDisplayLabelRes(options.pastDisplay)),
position = position(option),
onClick = { showPast = true },
)
WidgetOption.SHOW_TODAY -> GroupedSwitchRow(
title = stringResource(R.string.settings_agenda_show_today),
checked = options.showToday,
onCheckedChange = { onChange(options.copy(showToday = it)) },
position = position(option),
)
WidgetOption.CALENDARS -> GroupedRow(
title = stringResource(R.string.widget_config_calendars),
summary = calendarsSummary(state.calendars, state.draft.hiddenCalendars),
position = position(option),
onClick = { showCalendars = true },
)
}
}
if (showCalendars) {
WidgetCalendarPicker(
calendars = state.calendars,
hidden = state.draft.hiddenCalendars,
onSetShown = onCalendarShown,
onDismiss = { showCalendars = false },
)
}
if (showRange) {
AgendaRangePicker(
@@ -4,25 +4,46 @@ 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 androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
import de.jeanlucmakiola.calendula.widget.WidgetSettings
import de.jeanlucmakiola.calendula.widget.loadDays
import de.jeanlucmakiola.calendula.widget.weekOf
import de.jeanlucmakiola.calendula.widget.upcoming.loadUpcoming
import de.jeanlucmakiola.calendula.widget.today.loadTodayList
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.widget.countdown.CountdownData
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
import de.jeanlucmakiola.calendula.widget.countdown.countdownOptions
import de.jeanlucmakiola.calendula.widget.countdown.loadCountdown
import de.jeanlucmakiola.calendula.widget.countdown.putCountdownOptions
import de.jeanlucmakiola.calendula.widget.split.MonthDayData
import de.jeanlucmakiola.calendula.widget.split.loadMonthDay
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import de.jeanlucmakiola.calendula.widget.WidgetOption
import de.jeanlucmakiola.calendula.widget.hiddenCalendars
import de.jeanlucmakiola.calendula.widget.putHiddenCalendars
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
import de.jeanlucmakiola.calendula.widget.loadThemedMonthSource
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
import de.jeanlucmakiola.calendula.widget.monthIndexOf
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
import de.jeanlucmakiola.calendula.widget.agendaWidgetOptions
@@ -36,6 +57,7 @@ import de.jeanlucmakiola.calendula.widget.widgetState
import de.jeanlucmakiola.floret.glance.updateWithRefresh
import de.jeanlucmakiola.floret.prefs.ThemeMode
import javax.inject.Inject
import kotlinx.datetime.TimeZone
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
@@ -48,33 +70,40 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.datetime.DayOfWeek
import java.util.Locale
/** The widgets a config screen can edit, told apart by their receiver. */
enum class WidgetKind(val receiver: Class<*>) {
AGENDA(AgendaWidgetReceiver::class.java),
MONTH(MonthWidgetReceiver::class.java),
;
fun widget(): GlanceAppWidget = when (this) {
AGENDA -> AgendaWidget()
MONTH -> MonthWidget()
}
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 }
}
}
}
import kotlinx.datetime.LocalDate
/** What the preview draws, loaded with the draft's options. */
sealed interface WidgetPreviewData {
data class Agenda(val data: AgendaWidgetData) : WidgetPreviewData
data class Month(val source: MonthWidgetSource, val index: Int) : WidgetPreviewData
data class Today(val today: LocalDate) : WidgetPreviewData
data class Days(val data: DaysWidgetData) : WidgetPreviewData
data class Countdown(val data: CountdownData) : WidgetPreviewData
data class MonthDay(val data: MonthDayData, val index: Int) : WidgetPreviewData
}
/** The options being edited, applied to the preview as they change and written on save. */
data class WidgetDraft(
val appearance: WidgetAppearance,
/** The content options; stored only for a kind that [WidgetKind.hasContentOptions]. */
val content: AgendaWidgetOptions,
val hiddenCalendars: Set<Long>,
val countdown: CountdownOptions = CountdownOptions(),
)
/** One event or series the countdown can be pinned to, at its next occurrence. */
data class CountdownCandidate(val event: EventInstance, val repeats: Boolean)
/** The countdown's event list, loaded when its picker first opens. */
sealed interface CountdownCandidates {
data object Idle : CountdownCandidates
data object Loading : CountdownCandidates
data object Failed : CountdownCandidates
data class Ready(val events: List<CountdownCandidate>) : CountdownCandidates
}
private fun WidgetDraft.loadContext(settings: WidgetSettings) = WidgetLoadContext(settings, content, hiddenCalendars)
sealed interface WidgetConfigUiState {
data object Loading : WidgetConfigUiState
@@ -86,19 +115,22 @@ sealed interface WidgetConfigUiState {
data class Ready(
val kind: WidgetKind,
val appearance: WidgetAppearance,
/** Null for a widget with no content options. */
val agenda: AgendaWidgetOptions?,
val draft: WidgetDraft,
/** The calendars the app shows, for the widget's own calendar choice. */
val calendars: List<CalendarSource>,
val appTheme: ThemeMode,
val weekStart: DayOfWeek,
/** The widget's size on the home screen, which the preview is laid out at. */
val placedSize: DpSize,
/** Null until the first preview load lands. */
val preview: WidgetPreviewData?,
val candidates: CountdownCandidates = CountdownCandidates.Idle,
/** The first preview load failed, so there's nothing to show. */
val previewFailed: Boolean = false,
val saving: Boolean = false,
) : WidgetConfigUiState
) : WidgetConfigUiState {
val appearance: WidgetAppearance get() = draft.appearance
}
}
/** One-time outcomes of a save, for the screen that's showing when they land. */
@@ -113,6 +145,8 @@ enum class WidgetConfigEvent { SAVED, SAVE_FAILED }
class WidgetConfigViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val prefs: SettingsPrefs,
private val calendarPrefs: CalendarPrefs,
private val repository: CalendarRepository,
savedState: SavedStateHandle,
) : ViewModel() {
@@ -147,10 +181,15 @@ class WidgetConfigViewModel @Inject constructor(
val settings = prefs.widgetSettings.first()
WidgetConfigUiState.Ready(
kind = kind,
appearance = stored.widgetAppearance,
agenda = if (kind == WidgetKind.AGENDA) stored.agendaWidgetOptions(settings.agendaDefaults) else null,
draft = WidgetDraft(
appearance = stored.widgetAppearance,
content = stored.agendaWidgetOptions(settings.agendaDefaults),
hiddenCalendars = stored.hiddenCalendars,
countdown = stored.countdownOptions,
),
calendars = if (WidgetOption.CALENDARS in kind.options) shownCalendars() else emptyList(),
appTheme = settings.themeMode,
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
weekStart = settings.weekStart.resolveFirstDay(widgetLocale()),
placedSize = placedSize(kind),
preview = null,
)
@@ -171,16 +210,76 @@ class WidgetConfigViewModel @Inject constructor(
val width = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) ?: 0
val height = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) ?: 0
if (width > 0 && height > 0) return DpSize(width.dp, height.dp)
return when (kind) {
WidgetKind.AGENDA -> DpSize(260.dp, 280.dp)
WidgetKind.MONTH -> DpSize(320.dp, 340.dp)
return kind.defaultSize
}
/** The calendars the app itself shows; the ones hidden there never reach a widget anyway. */
private suspend fun shownCalendars(): List<CalendarSource> {
val hidden = calendarPrefs.hiddenCalendarIds.first()
return repository.calendars().first().filter { it.isVisibleInSystem && it.id !in hidden }
}
fun setAppearance(appearance: WidgetAppearance) = edit { it.copy(draft = it.draft.copy(appearance = appearance)) }
fun setContent(options: AgendaWidgetOptions) {
edit { it.copy(draft = it.draft.copy(content = options)) }
reloadPreview()
}
fun setCountdown(options: CountdownOptions) {
edit { it.copy(draft = it.draft.copy(countdown = options)) }
reloadPreview()
}
/** Loads the events the countdown can be pinned to, unless they're loaded or loading. */
fun loadCountdownCandidates() {
val ready = _state.value as? WidgetConfigUiState.Ready ?: return
if (ready.candidates is CountdownCandidates.Loading || ready.candidates is CountdownCandidates.Ready) return
edit { it.copy(candidates = CountdownCandidates.Loading) }
viewModelScope.launch {
val candidates = try {
val now = Clock.System.now()
val zone = TimeZone.currentSystemDefault()
// A year, a quarter at a time, keeping only each event's first
// occurrence so busy series don't hold the whole year in memory.
val first = LinkedHashMap<Long, EventInstance>()
val repeats = HashSet<Long>()
for (quarter in 0 until 4) {
val from = now + (quarter * 91).days
val to = if (quarter == 3) now + 365.days else now + ((quarter + 1) * 91).days
repository.instances(from..to).first()
.filter { !it.hasEnded(now, zone) }
.forEach { occurrence ->
val known = first[occurrence.eventId]
when {
known == null -> first[occurrence.eventId] = occurrence
known.start == occurrence.start -> Unit
else -> {
repeats += occurrence.eventId
if (occurrence.start < known.start) first[occurrence.eventId] = occurrence
}
}
}
}
CountdownCandidates.Ready(
first.values
.map { CountdownCandidate(it, repeats = it.eventId in repeats) }
.sortedBy { it.event.start },
)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
CountdownCandidates.Failed
}
edit { it.copy(candidates = candidates) }
}
}
fun setAppearance(appearance: WidgetAppearance) = edit { it.copy(appearance = appearance) }
fun setAgendaOptions(options: AgendaWidgetOptions) {
edit { it.copy(agenda = options) }
fun setCalendarShown(id: Long, shown: Boolean) {
edit {
val hidden = it.draft.hiddenCalendars
it.copy(draft = it.draft.copy(hiddenCalendars = if (shown) hidden - id else hidden + id))
}
reloadPreview()
}
@@ -205,14 +304,28 @@ class WidgetConfigViewModel @Inject constructor(
}
}
private suspend fun loadPreview(ready: WidgetConfigUiState.Ready): WidgetPreviewData = when (ready.kind) {
WidgetKind.AGENDA -> {
val settings = prefs.widgetSettings.first()
WidgetPreviewData.Agenda(context.loadAgendaWidgetData(settings, requireNotNull(ready.agenda)))
}
WidgetKind.MONTH -> {
val index = monthIndexOf(today(systemZone()))
WidgetPreviewData.Month(context.loadThemedMonthSource(monthWindowCenter(index)).content, index)
private suspend fun loadPreview(ready: WidgetConfigUiState.Ready): WidgetPreviewData {
val draft = ready.draft
val settings = prefs.widgetSettings.first()
return when (ready.kind) {
WidgetKind.AGENDA ->
WidgetPreviewData.Agenda(context.loadAgendaWidgetData(settings, draft.content, draft.hiddenCalendars))
WidgetKind.MONTH -> {
val index = monthIndexOf(today(systemZone()))
val source = context.loadThemedMonthSource(monthWindowCenter(index), draft.hiddenCalendars)
WidgetPreviewData.Month(source.content, index)
}
WidgetKind.QUICK_ADD, WidgetKind.DATE -> WidgetPreviewData.Today(today(systemZone()))
WidgetKind.UPCOMING -> WidgetPreviewData.Days(context.loadUpcoming(draft.loadContext(settings)).content)
WidgetKind.TODAY -> WidgetPreviewData.Days(context.loadTodayList(draft.loadContext(settings)).content)
WidgetKind.MONTH_DAY -> {
val index = monthIndexOf(today(systemZone()))
val source = context.loadThemedMonthSource(monthWindowCenter(index), draft.hiddenCalendars)
WidgetPreviewData.MonthDay(context.loadMonthDay(source, draft.content).content, index)
}
WidgetKind.COUNTDOWN ->
WidgetPreviewData.Countdown(context.loadCountdown(draft.loadContext(settings), draft.countdown, null).content)
WidgetKind.WEEK_STRIP -> WidgetPreviewData.Days(context.loadDays(draft.loadContext(settings), ::weekOf).content)
}
}
@@ -225,8 +338,10 @@ class WidgetConfigViewModel @Inject constructor(
try {
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
ready.kind.widget().updateWithRefresh(context, glanceId) { state ->
state.putAppearance(ready.appearance)
ready.agenda?.let { state.putAgendaOptions(it) }
state.putAppearance(ready.draft.appearance)
if (ready.kind.hasContentOptions) state.putAgendaOptions(ready.draft.content)
if (WidgetOption.CALENDARS in ready.kind.options) state.putHiddenCalendars(ready.draft.hiddenCalendars)
if (ready.kind == WidgetKind.COUNTDOWN) state.putCountdownOptions(ready.draft.countdown)
}
_events.send(WidgetConfigEvent.SAVED)
} catch (e: CancellationException) {
@@ -0,0 +1,99 @@
package de.jeanlucmakiola.calendula.widget.countdown
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.coversWholeDays
import de.jeanlucmakiola.calendula.domain.isCrossedOut
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.math.ceil
import kotlin.math.roundToInt
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/** What a countdown widget counts down to (#439). */
enum class CountdownMode { NEXT, PINNED }
data class CountdownOptions(
val mode: CountdownMode = CountdownMode.NEXT,
/** The pinned event or series; a series follows its next occurrence. */
val eventId: Long? = null,
/** The pinned event's title when it was chosen, for the config screen. */
val title: String? = null,
)
enum class CountdownUnit { NOW, MINUTES, HOURS, DAYS }
/** "in 25 min", "ends in 3 h", "in 12 days": [running] counts to the end instead of the start. */
data class CountdownLabel(val running: Boolean, val unit: CountdownUnit, val amount: Int)
private fun target(event: EventInstance, now: Instant): Instant = if (event.start <= now) event.end else event.start
/**
* The label for [event] at [now]. Under a minute reads "now", under an hour
* counts minutes (rounded up), under a day whole hours (rounded), and beyond
* that calendar days in [zone], so "in 1 day" is always tomorrow.
*/
fun countdownLabel(event: EventInstance, now: Instant, zone: TimeZone): CountdownLabel {
val running = event.start <= now
val target = target(event, now)
val delta = target - now
return when {
delta < 1.minutes -> CountdownLabel(running, CountdownUnit.NOW, 0)
delta < 1.hours -> CountdownLabel(running, CountdownUnit.MINUTES, ceil(delta / 1.minutes).toInt())
delta < 24.hours -> CountdownLabel(running, CountdownUnit.HOURS, (delta / 1.hours).roundToInt().coerceIn(1, 23))
else -> {
val days = now.toLocalDateTime(zone).date.daysUntil(target.toLocalDateTime(zone).date)
CountdownLabel(running, CountdownUnit.DAYS, days.coerceAtLeast(1))
}
}
}
/**
* When [countdownLabel] next reads differently, so the widget redraws then
* and not before: every minute in the last hour, at each half hour within the
* day, and at midnight or the 24-hour mark beyond that.
*/
fun nextLabelChange(event: EventInstance, now: Instant, zone: TimeZone): Instant {
val target = target(event, now)
val label = countdownLabel(event, now, zone)
val lowerBound: Duration = when (label.unit) {
CountdownUnit.NOW -> Duration.ZERO
CountdownUnit.MINUTES -> (label.amount - 1).minutes
CountdownUnit.HOURS -> maxOf((label.amount - 0.5).hours, 1.hours)
CountdownUnit.DAYS -> {
val midnight = now.toLocalDateTime(zone).date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
return minOf(midnight, target - 24.hours).coerceAtLeast(now + 1.minutes)
}
}
return (target - lowerBound).coerceAtLeast(now + 1.minutes)
}
/**
* The next-event mode's pick: timed and not filling whole days (#296), not
* declined or cancelled, not over; of those, the one whose countdown changes
* soonest — a running event's end, else a start.
*/
fun pickNextEvent(instances: List<EventInstance>, now: Instant, zone: TimeZone): EventInstance? =
instances
.filter { !it.coversWholeDays(zone) && !it.isCrossedOut && it.end > now }
.minByOrNull { target(it, now) }
/** A pinned event's occurrence to count to: the running or next one that is not cancelled (#388). */
fun pickPinnedOccurrence(occurrences: List<EventInstance>, now: Instant, zone: TimeZone): EventInstance? =
occurrences.filter { it.status != EventStatus.Cancelled && it.counted(zone).end > now }.minByOrNull { it.start }
/**
* The span a countdown counts over. An all-day event is stored on UTC
* midnights; it starts and ends at local midnight of those dates instead.
*/
fun EventInstance.counted(zone: TimeZone): EventInstance = if (!isAllDay) this else copy(
start = start.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone),
end = end.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone),
)
@@ -0,0 +1,142 @@
package de.jeanlucmakiola.calendula.widget.countdown
import android.content.Context
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import de.jeanlucmakiola.floret.glance.updateWithRefresh
import kotlinx.coroutines.CancellationException
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.widget.Themed
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
import de.jeanlucmakiola.calendula.widget.WidgetTicks
import de.jeanlucmakiola.calendula.widget.hasCalendarPermission
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
import de.jeanlucmakiola.calendula.widget.widgetState
import de.jeanlucmakiola.calendula.widget.withoutCalendars
import de.jeanlucmakiola.floret.prefs.toEnum
import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
private val ModeKey = stringPreferencesKey("cfg_countdown_mode")
private val EventKey = longPreferencesKey("cfg_countdown_event")
private val TitleKey = stringPreferencesKey("cfg_countdown_title")
internal val Preferences.countdownOptions: CountdownOptions
get() = CountdownOptions(this[ModeKey].toEnum(CountdownMode.NEXT), this[EventKey], this[TitleKey])
internal fun MutablePreferences.putCountdownOptions(options: CountdownOptions) {
this[ModeKey] = options.mode.name
if (options.eventId != null) this[EventKey] = options.eventId else remove(EventKey)
if (options.title != null) this[TitleKey] = options.title else remove(TitleKey)
}
/** How far ahead the next-event mode looks; past this it shows no event. */
private val NextEventHorizon = 30.days
/** How far ahead a pinned event's next occurrence is looked for. */
private val PinnedHorizon = 730.days
sealed interface CountdownData {
data object NeedsPermission : CountdownData
data class Ready(
val options: CountdownOptions,
/** The occurrence counted to; null when there is none to show. */
val event: EventInstance?,
val now: Instant,
val today: LocalDate,
val is24Hour: Boolean,
val soften: Boolean,
/** The placed widget, for opening its settings; null in a preview. */
val appWidgetId: Int?,
) : CountdownData
}
internal suspend fun Context.loadCountdown(
load: WidgetLoadContext,
options: CountdownOptions,
appWidgetId: Int?,
): Themed<CountdownData> {
val settings = load.settings
if (!hasCalendarPermission()) return Themed(CountdownData.NeedsPermission, settings.themeMode)
val now = Clock.System.now()
val repository = widgetEntryPoint().calendarRepository()
val event = when (options.mode) {
CountdownMode.NEXT ->
pickNextEvent(
repository.instances(now..now + NextEventHorizon).first().withoutCalendars(load.hiddenCalendars),
now,
systemZone(),
)
CountdownMode.PINNED -> options.eventId?.let { id ->
pickPinnedOccurrence(repository.eventOccurrences(id, now - 1.days..now + PinnedHorizon), now, systemZone())
}
}
return Themed(
CountdownData.Ready(
options = options,
event = event,
now = now,
today = today(systemZone()),
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
soften = settings.soften,
appWidgetId = appWidgetId,
),
settings.themeMode,
)
}
/** [loadCountdown] for placed widget [id], arming its redraw for when the label next changes. */
internal suspend fun Context.loadPlacedCountdown(id: GlanceId): Themed<CountdownData> {
val appWidgetId = GlanceAppWidgetManager(this).getAppWidgetId(id)
val loaded = loadCountdown(widgetLoadContext(id), widgetState(id).countdownOptions, appWidgetId)
val event = (loaded.content as? CountdownData.Ready)?.event
if (event == null) {
WidgetTicks.cancel(this, WidgetKind.COUNTDOWN, appWidgetId)
} else {
val zone = systemZone()
WidgetTicks.schedule(
this,
WidgetKind.COUNTDOWN,
appWidgetId,
nextLabelChange(event.counted(zone), (loaded.content as CountdownData.Ready).now, zone),
)
}
return loaded
}
/**
* Moves countdowns pinned to [oldEventId] on to [newEventId] after the app
* split that series, so they keep counting to the part that continues it.
*/
internal suspend fun repointCountdowns(context: Context, oldEventId: Long, newEventId: Long) {
val manager = GlanceAppWidgetManager(context)
manager.getGlanceIds(CountdownWidget::class.java).forEach { id ->
try {
var moved = false
updateAppWidgetState(context, id) { prefs ->
if (prefs[EventKey] == oldEventId) {
prefs[EventKey] = newEventId
moved = true
}
}
if (moved) CountdownWidget().updateWithRefresh(context, id)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// Gone mid-pass; nothing left to point anywhere.
}
}
}
@@ -0,0 +1,224 @@
package de.jeanlucmakiola.calendula.widget.countdown
import de.jeanlucmakiola.calendula.widget.InAppLanguage
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.res.Resources
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.action.Action
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition
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.coversWholeDays
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity
import de.jeanlucmakiola.calendula.widget.formatWidgetTime
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.floret.glance.wallpaperBacking
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.themedColor
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.minus
import kotlinx.datetime.toLocalDateTime
/**
* Countdown (#439): to the next timed event, or to a chosen event or series
* ("Vacation · in 12 days"). Redraws when its label changes, see
* [nextLabelChange] and [WidgetTicks]. Tapping opens the event.
*/
class CountdownWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override val sizeMode = SizeMode.Single
override val previewSizeMode = previewSizeOf(WidgetKind.COUNTDOWN)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.COUNTDOWN)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val widgetData = WidgetData(context.loadPlacedCountdown(id), refreshStamp(context, id))
provideContent {
val loaded = widgetData.current(currentRefreshStamp()) { context.loadPlacedCountdown(id) }
InAppLanguage {
CountdownWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
}
}
}
}
class CountdownWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = CountdownWidget()
}
@Composable
internal fun CountdownWidgetContent(data: CountdownData, appearance: WidgetAppearance, appTheme: ThemeMode) {
CalendulaWidgetRoot(appearance, appTheme) {
val context = LocalContext.current
when (data) {
CountdownData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
is CountdownData.Ready -> {
val event = data.event
when {
event != null -> EventCountdown(event, data)
data.options.mode == CountdownMode.PINNED && data.options.eventId == null ->
Message(context.getString(R.string.widget_countdown_choose), configAction(context, data.appWidgetId))
data.options.mode == CountdownMode.PINNED ->
Message(context.getString(R.string.widget_countdown_over, data.options.title.orEmpty()), configAction(context, data.appWidgetId))
else -> Message(
context.getString(R.string.widget_upcoming_empty),
actionStartActivity(MainActivity.openViewIntent(context, null)),
)
}
}
}
}
}
private fun configAction(context: Context, appWidgetId: Int?): Action? =
appWidgetId?.takeIf { it != AppWidgetManager.INVALID_APPWIDGET_ID }
?.let { actionStartActivity(WidgetConfigActivity.intent(context, it)) }
@Composable
private fun Message(text: String, action: Action?) {
val modifier = if (action != null) GlanceModifier.clickable(action) else GlanceModifier
WidgetMessage(text, modifier = modifier)
}
@Composable
private fun EventCountdown(event: EventInstance, data: CountdownData.Ready) {
val context = LocalContext.current
val label = countdownLabel(event.counted(systemZone()), data.now, systemZone())
val open = actionStartActivity(
MainActivity.openEventIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
source = CalendarView.Agenda,
),
)
// Over a wallpaper the text sits on its own card, as every other widget's rows do.
val card = GlanceModifier.wallpaperBacking(radius = 16.dp)
Box(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) {
Row(
modifier = card.fillMaxSize().clickable(open).padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = GlanceModifier
.width(4.dp)
.fillMaxHeight()
.cornerRadius(2.dp)
.background(
themedColor(
day = eventAccent(event.color, dark = false, data.soften),
night = eventAccent(event.color, dark = true, data.soften),
),
),
) {}
Spacer(GlanceModifier.width(10.dp))
Column(modifier = GlanceModifier.defaultWeight(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp, fontWeight = FontWeight.Medium),
)
Text(
text = countdownText(context.resources, label),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 22.sp, fontWeight = FontWeight.Bold),
)
Text(
text = whenText(context, event, data),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
)
}
}
}
}
internal fun countdownText(res: Resources, label: CountdownLabel): String = when (label.unit) {
CountdownUnit.NOW -> res.getString(if (label.running) R.string.widget_countdown_ending_now else R.string.widget_countdown_now)
CountdownUnit.MINUTES -> res.getQuantityString(
if (label.running) R.plurals.widget_countdown_ends_minutes else R.plurals.widget_countdown_in_minutes,
label.amount,
label.amount,
)
CountdownUnit.HOURS -> res.getQuantityString(
if (label.running) R.plurals.widget_countdown_ends_hours else R.plurals.widget_countdown_in_hours,
label.amount,
label.amount,
)
CountdownUnit.DAYS -> when {
label.amount == 1 && label.running -> res.getString(R.string.widget_countdown_ends_tomorrow)
label.amount == 1 -> res.getString(R.string.widget_countdown_tomorrow)
else -> res.getQuantityString(
if (label.running) R.plurals.widget_countdown_ends_days else R.plurals.widget_countdown_in_days,
label.amount,
label.amount,
)
}
}
/** When it starts, or ends once running: "Today · 14:00", "Fri · All day". */
private fun whenText(context: Context, event: EventInstance, data: CountdownData.Ready): String {
val counted = event.counted(systemZone())
val running = counted.start <= data.now
val at = if (running) counted.end else counted.start
val date = at.toLocalDateTime(systemZone()).date
val day = relativeDayLabel(context, date, data.today)
val wholeDays = event.coversWholeDays(systemZone())
return when {
wholeDays && running -> context.getString(
R.string.widget_countdown_until,
relativeDayLabel(context, date.minus(1, DateTimeUnit.DAY), data.today),
)
wholeDays -> "$day · ${context.getString(R.string.event_detail_all_day)}"
running -> context.getString(R.string.agenda_span_ends, "$day · ${formatWidgetTime(at, data.is24Hour)}")
else -> "$day · ${formatWidgetTime(at, data.is24Hour)}"
}
}
@@ -0,0 +1,126 @@
package de.jeanlucmakiola.calendula.widget.date
import de.jeanlucmakiola.calendula.widget.InAppLanguage
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.provideContent
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.height
import androidx.glance.semantics.contentDescription
import androidx.glance.semantics.semantics
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.loadToday
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.WidgetShape
import de.jeanlucmakiola.floret.glance.WidgetShapedBox
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.wallpaperBacking
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.datetime.LocalDate
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate
import java.time.format.TextStyle as JavaTextStyle
/** 1×1 date (#376): today's weekday above its number on an expressive shape; opens today. */
class DateWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override val sizeMode = SizeMode.Exact
override val previewSizeMode = previewSizeOf(WidgetKind.DATE)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.DATE)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val widgetData = WidgetData(context.loadToday(), refreshStamp(context, id))
provideContent {
val loaded = widgetData.current(currentRefreshStamp()) { context.loadToday() }
InAppLanguage {
DateWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
}
}
}
}
class DateWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = DateWidget()
}
private val WeekdayLine = 18.dp
@Composable
internal fun DateWidgetContent(today: LocalDate, appearance: WidgetAppearance, appTheme: ThemeMode) {
CalendulaWidgetRoot(appearance, appTheme) {
val context = LocalContext.current
val size = LocalSize.current
val fontScale = context.resources.configuration.fontScale
val side = (minOf(size.width, size.height - WeekdayLine * fontScale) - 8.dp).coerceIn(32.dp, 120.dp)
val label = localizedDateFormatter(widgetLocale(), "EEEEdMMMM").format(today.toJavaLocalDate())
val weekday = TextStyle(
color = GlanceTheme.colors.onSurface,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
)
Column(
modifier = GlanceModifier
.fillMaxSize()
.clickable(actionStartActivity(MainActivity.openDateIntent(context, today, CalendarView.Day)))
.semantics { contentDescription = label },
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
val name = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, widgetLocale())
val backing = GlanceModifier.wallpaperBacking(radius = 8.dp, horizontalPadding = 6.dp)
Text(text = name.uppercase(widgetLocale()), maxLines = 1, style = weekday, modifier = backing)
Spacer(GlanceModifier.height(2.dp))
WidgetShapedBox(shape = WidgetShape.COOKIE, color = GlanceTheme.colors.primary, size = side) {
Text(
text = today.day.toString(),
maxLines = 1,
style = TextStyle(
color = GlanceTheme.colors.onPrimary,
// Sized to the shape, so held against the font scale.
fontSize = (side.value * 0.45f / fontScale).sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
),
)
}
}
}
}
@@ -13,7 +13,7 @@ import androidx.compose.ui.graphics.Color
import de.jeanlucmakiola.calendula.MainActivity
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.CalendarView
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
@@ -129,7 +129,7 @@ private fun bar(context: Context, event: EventInstance, cols: Int, soften: Boole
views.setImageTint(context, R.id.bar_bg, mode.remoteColor(dayFill, nightFill))
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
// A span, like Glance's own decoration: paint flags would drop the text's defaults.
views.setTextViewText(R.id.bar_title, if (event.isDeclined) span(title, StrikethroughSpan()) else title)
views.setTextViewText(R.id.bar_title, if (event.isCrossedOut) span(title, StrikethroughSpan()) else title)
views.setTextColor(context, R.id.bar_title, mode.remoteColor(eventInk(dayFill), eventInk(nightFill)))
return views
}
@@ -147,9 +147,9 @@ private fun openDayIntent(context: Context, date: LocalDate): PendingIntent = Pe
)
private val STRIP_IDS = intArrayOf(R.id.strip_0, R.id.strip_1, R.id.strip_2, R.id.strip_3, R.id.strip_4, R.id.strip_5, R.id.strip_6)
/** The shade over a day outside the month: a dark cell needs more of it to read as dimmed. */
private val OUTSIDE_SHADE_DAY = Color.Black.copy(alpha = 0.18f)
private val OUTSIDE_SHADE_NIGHT = Color.Black.copy(alpha = 0.50f)
/** The shade over a day outside the month: a dark cell needs more of it to read as dimmed. Mirrored by `widget_preview_outside_shade`. */
private val OUTSIDE_SHADE_DAY = Color.Black.copy(alpha = 0.11f)
private val OUTSIDE_SHADE_NIGHT = Color.Black.copy(alpha = 0.30f)
private val CELL_DIM_IDS = intArrayOf(
R.id.cell_dim_0, R.id.cell_dim_1, R.id.cell_dim_2, R.id.cell_dim_3, R.id.cell_dim_4, R.id.cell_dim_5, R.id.cell_dim_6,
@@ -1,5 +1,10 @@
package de.jeanlucmakiola.calendula.widget.month
import de.jeanlucmakiola.calendula.widget.InAppLanguage
import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
@@ -43,7 +48,6 @@ import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition
import de.jeanlucmakiola.floret.glance.LocalWidgetThemeMode
import de.jeanlucmakiola.floret.glance.WidgetColorRole
@@ -67,6 +71,7 @@ import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.floret.glance.wallpaperBacking
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
import de.jeanlucmakiola.calendula.widget.firstDayOfMonthIndex
import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
import de.jeanlucmakiola.floret.glance.WidgetData
@@ -83,7 +88,6 @@ import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
/** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */
private val MONTH_INDEX_KEY = intPreferencesKey("month_index")
@@ -101,7 +105,7 @@ private fun gridChromeHeight(fontScale: Float): Dp =
private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone))
private fun yearMonthOf(index: Int): YearMonth =
YearMonth(index / 12, Month(index % 12 + 1))
firstDayOfMonthIndex(index).let { YearMonth(it.year, it.month) }
/**
* Month-grid widget: a 6×7 calendar with today highlighted, connected multi-day
@@ -136,6 +140,11 @@ class MonthWidget : GlanceAppWidget() {
*/
override val sizeMode = SizeMode.Exact
override val previewSizeMode = previewSizeOf(WidgetKind.MONTH)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.MONTH)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val shown = getAppWidgetState<Preferences>(context, id)[MONTH_INDEX_KEY]
?: currentMonthIndex(systemZone())
@@ -162,12 +171,14 @@ class MonthWidget : GlanceAppWidget() {
else -> index.coerceIn(source.months)
}
SideEffect { lastShown = shown }
MonthWidgetContent(
source = source,
index = shown,
appearance = currentState<Preferences>().widgetAppearance,
appTheme = loaded.appTheme,
)
InAppLanguage {
MonthWidgetContent(
source = source,
index = shown,
appearance = currentState<Preferences>().widgetAppearance,
appTheme = loaded.appTheme,
)
}
}
}
}
@@ -239,8 +250,6 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
is MonthWidgetSource.Ready -> {
val zone = systemZone()
val ym = yearMonthOf(index)
// Column width from the live widget size, minus our H padding.
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
val context = LocalContext.current
val fontScale = context.resources.configuration.fontScale
@@ -261,7 +270,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
// each carry their own fill, so a transparent widget stays
// readable without one blob over the wallpaper (#292).
Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
WeekdayHeader(weekStart = source.weekStart, colW = colW)
WeekdayHeader(weekStart = source.weekStart)
AndroidRemoteViews(
remoteViews = monthGridViews(context, layouts, ym.month, source.today, source.soften, colors, mode),
modifier = GlanceModifier.fillMaxWidth().defaultWeight(),
@@ -315,10 +324,10 @@ private fun MonthHeader(label: String, today: LocalDate) {
}
@Composable
private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) {
private fun WeekdayHeader(weekStart: DayOfWeek) {
Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 1.dp)) {
weekdayNarrowNames(weekStart).forEach { name ->
Box(modifier = GlanceModifier.width(colW).padding(horizontal = 1.dp)) {
Box(modifier = GlanceModifier.defaultWeight().padding(horizontal = 1.dp)) {
Text(
text = name,
style = TextStyle(
@@ -349,7 +358,7 @@ private fun monthCellRole(): WidgetColorRole = monthCellRole(LocalWidgetBackgrou
/** Narrow weekday initials starting at [weekStart], in the device locale.
* Computed outside the composable so the locale read stays observable-safe. */
private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
val locale = Locale.getDefault()
val locale = widgetLocale()
return (0 until 7).map { i ->
java.time.DayOfWeek.of((weekStart.ordinal + i) % 7 + 1)
.getDisplayName(JavaTextStyle.NARROW, locale)
@@ -375,13 +384,10 @@ private fun PermissionMessage() {
}
}
// Locale.getDefault() rather than currentLocale(): Glance has no
// LocalConfiguration, and the widget is re-rendered on a configuration change
// anyway (same convention as AgendaWidget).
private fun monthLabel(month: YearMonth, currentYear: Int): String =
formatCalendarTitle(
date = java.time.LocalDate.of(month.year, month.month.ordinal + 1, 1),
locale = Locale.getDefault(),
locale = widgetLocale(),
currentYear = currentYear,
skeleton = "LLLL",
)
@@ -1,56 +1,9 @@
package de.jeanlucmakiola.calendula.widget.month
import android.annotation.SuppressLint
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
import de.jeanlucmakiola.floret.glance.restoreState
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
/**
* Host-facing receiver for the month widget. Declared in the manifest with the
* `appwidget_info_month` provider metadata; delegates rendering to [MonthWidget].
*/
class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
/** Host-facing receiver for the month widget; rendering is [MonthWidget]'s. */
class MonthWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = MonthWidget()
/** First month widget placed — start rolling "today" over at midnight (#228). */
override fun onEnabled(context: Context) {
super.onEnabled(context)
syncWidgetRollover(context)
}
/**
* Last month widget removed. [syncWidgetRollover] cancels only if
* no agenda widget is left either.
*/
override fun onDisabled(context: Context) {
super.onDisabled(context)
syncWidgetRollover(context)
}
/** A backup restore or device transfer renumbered the widgets: keep each one's settings (#292). */
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
restoreState(context, oldWidgetIds, newWidgetIds)
}
/**
* The `updatePeriodMillis` backstop is the one wake-up the *system* still
* owns, so it doubles as the alarm's self-heal: anything that drops a
* pending alarm without a broadcast (force-stop, battery restriction, an OEM
* freeze) is repaired here rather than on the next app open. Re-arming
* closer to midnight also narrows the inexact delivery window.
*/
@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,95 @@
package de.jeanlucmakiola.calendula.widget.quickadd
import de.jeanlucmakiola.calendula.widget.InAppLanguage
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.datastore.preferences.core.Preferences
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.provideContent
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.fillMaxSize
import androidx.glance.state.PreferencesGlanceStateDefinition
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.loadToday
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.WidgetShape
import de.jeanlucmakiola.floret.glance.WidgetShapedIconButton
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.datetime.LocalDate
/**
* 1×1 quick add (#435): one shaped "+" that opens the new-event editor on
* today, with the same defaults as the app's own new-event button.
*/
class QuickAddWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
// Exact: the shape fills whatever square the launcher gives it.
override val sizeMode = SizeMode.Exact
override val previewSizeMode = previewSizeOf(WidgetKind.QUICK_ADD)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.QUICK_ADD)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val widgetData = WidgetData(context.loadToday(), refreshStamp(context, id))
provideContent {
// The rollover moves the stamp, so the editor opens on the new day.
val loaded = widgetData.current(currentRefreshStamp()) { context.loadToday() }
InAppLanguage {
QuickAddWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
}
}
}
}
class QuickAddWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = QuickAddWidget()
}
@Composable
internal fun QuickAddWidgetContent(today: LocalDate, appearance: WidgetAppearance, appTheme: ThemeMode) {
CalendulaWidgetRoot(appearance, appTheme) {
val context = LocalContext.current
val size = LocalSize.current
val action = actionStartActivity(MainActivity.openCreateIntent(context, today))
Box(
modifier = GlanceModifier.fillMaxSize().clickable(action),
contentAlignment = Alignment.Center,
) {
WidgetShapedIconButton(
shape = WidgetShape.SUNNY,
icon = ImageProvider(R.drawable.ic_widget_add),
contentDescription = context.getString(R.string.widget_new_event),
action = action,
color = GlanceTheme.colors.primary,
iconTint = GlanceTheme.colors.onPrimary,
size = (minOf(size.width, size.height) - 12.dp).coerceIn(36.dp, 120.dp),
)
}
}
}
@@ -0,0 +1,466 @@
package de.jeanlucmakiola.calendula.widget.split
import de.jeanlucmakiola.calendula.domain.isCrossedOut
import de.jeanlucmakiola.calendula.widget.InAppLanguage
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
import de.jeanlucmakiola.calendula.widget.Themed
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.WidgetEventRow
import de.jeanlucmakiola.calendula.widget.WidgetSettings
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
import de.jeanlucmakiola.calendula.widget.compactRowMetrics
import de.jeanlucmakiola.calendula.widget.firstDayOfMonthIndex
import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource
import de.jeanlucmakiola.calendula.widget.monthIndexOf
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
import de.jeanlucmakiola.calendula.widget.weekOf
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.calendula.widget.widgetCardColor
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import de.jeanlucmakiola.calendula.widget.widgetSettings
import de.jeanlucmakiola.calendula.widget.widgetState
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.toJavaLocalDate
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.WidgetHeader
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
import de.jeanlucmakiola.floret.glance.WidgetHeaderActionGap
import de.jeanlucmakiola.floret.glance.WidgetHeaderTextAction
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.wallpaperBacking
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.coroutines.flow.first
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
import kotlin.time.Clock
import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle
/** Per-widget state: the shown month (`year * 12 + monthOrdinal`) and the selected day; both follow today when absent. */
private val MONTH_INDEX_KEY = intPreferencesKey("split_month_index")
private val SELECTED_KEY = stringPreferencesKey("split_selected")
/** What the month + day widget draws: the month widget's window, plus what its list needs. */
sealed interface MonthDayData {
data object NeedsPermission : MonthDayData
data class Ready(
val source: MonthWidgetSource.Ready,
val options: AgendaWidgetOptions,
val is24Hour: Boolean,
val now: Instant,
) : MonthDayData
}
/** [loaded]'s month window with what the day list draws by. */
internal suspend fun Context.loadMonthDay(
loaded: Themed<MonthWidgetSource>,
options: AgendaWidgetOptions,
settings: WidgetSettings? = null,
): Themed<MonthDayData> {
val ready = loaded.content as? MonthWidgetSource.Ready ?: return Themed(MonthDayData.NeedsPermission, loaded.appTheme)
val settings = settings ?: widgetEntryPoint().settingsPrefs().widgetSettings.first()
return Themed(
MonthDayData.Ready(
source = ready,
options = options,
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
now = Clock.System.now(),
),
loaded.appTheme,
)
}
/**
* Month + day (#441): a compact month grid beside the selected day's events,
* like Samsung Calendar's. A day in the grid selects it, starting on today;
* the list and its events open the app. The plain month widget keeps
* tap-opens-the-day (#386).
*/
class MonthDayWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override val sizeMode = SizeMode.Exact
override val previewSizeMode = previewSizeOf(WidgetKind.MONTH_DAY)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.MONTH_DAY)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val stamp = refreshStamp(context, id)
val shown = context.widgetState(id)[MONTH_INDEX_KEY] ?: monthIndexOf(today(systemZone()))
val center = monthWindowCenter(shown)
val load: suspend (Int, Long) -> Themed<MonthDayData> = { c, s ->
val ctx = context.widgetLoadContext(id)
context.loadMonthDay(context.loadCachedMonthSource(id, c, s, ctx), ctx.options, ctx.settings)
}
val widgetData = WidgetData(load(center, stamp), center, stamp)
provideContent {
val index = currentState(MONTH_INDEX_KEY) ?: monthIndexOf(today(systemZone()))
val currentCenter = monthWindowCenter(index)
val currentStamp = currentRefreshStamp()
val loaded = widgetData.current(currentCenter, currentStamp) { load(currentCenter, currentStamp) }
val selected = currentState(SELECTED_KEY)?.let(LocalDate::parse)
InAppLanguage {
MonthDayWidgetContent(loaded.content, index, selected, currentState<Preferences>().widgetAppearance, loaded.appTheme)
}
}
}
}
class MonthDayWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = MonthDayWidget()
}
/** Selects the tapped day; the list redraws from what's loaded. */
class SelectDayAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val date = parameters[dateKey] ?: return
updateAppWidgetState(context, glanceId) { prefs ->
if (date == today(systemZone()).toString()) prefs.remove(SELECTED_KEY) else prefs[SELECTED_KEY] = date
}
MonthDayWidget().update(context.applicationContext, glanceId)
}
companion object {
val dateKey = ActionParameters.Key<String>("date")
}
}
/** Pages the grid by the `delta` parameter; landing on the current month follows the date again. */
class ShiftMonthDayAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val delta = parameters[deltaKey] ?: 0
val current = monthIndexOf(today(systemZone()))
// The selection moves with the page — to today on this month, else the
// 1st — so the list never shows a day outside the loaded window.
updateAppWidgetState(context, glanceId) { prefs ->
val next = (prefs[MONTH_INDEX_KEY] ?: current) + delta
if (next == current) {
prefs.remove(MONTH_INDEX_KEY)
prefs.remove(SELECTED_KEY)
} else {
prefs[MONTH_INDEX_KEY] = next
prefs[SELECTED_KEY] = firstDayOfMonthIndex(next).toString()
}
}
MonthDayWidget().update(context.applicationContext, glanceId)
}
companion object {
val deltaKey = ActionParameters.Key<Int>("delta")
}
}
/** Back to this month with today selected. */
class ResetMonthDayAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
updateAppWidgetState(context, glanceId) { prefs ->
prefs.remove(MONTH_INDEX_KEY)
prefs.remove(SELECTED_KEY)
}
MonthDayWidget().update(context.applicationContext, glanceId)
}
}
/** The grid's days: whole weeks from [weekStart] covering [index]'s month. */
internal fun monthGridDays(index: Int, weekStart: DayOfWeek): List<LocalDate> {
val first = firstDayOfMonthIndex(index)
val start = weekOf(first, weekStart).start
val last = first.plus(1, DateTimeUnit.MONTH).plus(-1, DateTimeUnit.DAY)
val weeks = (start.daysUntilInclusive(last) + 6) / 7
return List(weeks * 7) { start.plus(it, DateTimeUnit.DAY) }
}
private fun LocalDate.daysUntilInclusive(other: LocalDate): Int =
(other.toEpochDays() - toEpochDays()).toInt() + 1
private val GridGap = 8.dp
@Composable
internal fun MonthDayWidgetContent(
data: MonthDayData,
index: Int,
selected: LocalDate?,
appearance: WidgetAppearance,
appTheme: ThemeMode,
preview: Boolean = false,
) {
CalendulaWidgetRoot(appearance, appTheme) {
val context = LocalContext.current
when (data) {
MonthDayData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
is MonthDayData.Ready -> {
val source = data.source
val day = selected ?: source.today
val zone = systemZone()
val days = monthGridDays(index, source.weekStart)
// Declined and cancelled events are no plan for the day, as on the week strip (#388).
val planned = source.instances.filterNot { it.isCrossedOut }
val busy = groupAgendaDays(days.first(), days.last(), planned, zone).map { it.date }.toSet()
Column(modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 6.dp)) {
Header(index, source.today)
Spacer(GlanceModifier.height(4.dp))
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
val gridWidth = (LocalSize.current.width - 16.dp - GridGap) * 0.52f
Box(modifier = GlanceModifier.width(gridWidth).fillMaxHeight()) {
Grid(days, index, source, day, busy, gridWidth)
}
Spacer(GlanceModifier.width(GridGap))
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) {
DayList(day, data, preview)
}
}
}
}
}
}
}
@Composable
private fun Header(index: Int, today: LocalDate) {
val context = LocalContext.current
WidgetHeader(
title = formatCalendarTitle(
date = firstDayOfMonthIndex(index).toJavaLocalDate(),
locale = widgetLocale(),
currentYear = today.year,
skeleton = "LLLL",
),
titleAction = actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Month)),
modifier = GlanceModifier.padding(bottom = 2.dp),
titleInset = 4.dp,
) {
WidgetHeaderAction(
icon = ImageProvider(R.drawable.ic_widget_chevron_left),
contentDescription = context.getString(R.string.widget_prev_month),
action = actionRunCallback<ShiftMonthDayAction>(actionParametersOf(ShiftMonthDayAction.deltaKey to -1)),
style = WidgetActionStyle.TONAL,
)
WidgetHeaderActionGap()
WidgetHeaderTextAction(
text = today.day.toString(),
contentDescription = context.getString(R.string.widget_today),
action = actionRunCallback<ResetMonthDayAction>(),
style = WidgetActionStyle.TONAL,
)
WidgetHeaderActionGap()
WidgetHeaderAction(
icon = ImageProvider(R.drawable.ic_widget_chevron_right),
contentDescription = context.getString(R.string.widget_next_month),
action = actionRunCallback<ShiftMonthDayAction>(actionParametersOf(ShiftMonthDayAction.deltaKey to 1)),
style = WidgetActionStyle.TONAL,
)
WidgetHeaderActionGap()
WidgetHeaderAction(
icon = ImageProvider(R.drawable.ic_widget_add),
contentDescription = context.getString(R.string.widget_new_event),
action = actionStartActivity(MainActivity.openCreateIntent(context, today)),
)
}
}
/**
* The month as numbers: selected day filled, today in bold primary, days
* with events dotted, days around the month faded. Three views a day keeps
* a six-week month well inside Glance's view ids (#372).
*/
@Composable
private fun Grid(
days: List<LocalDate>,
index: Int,
source: MonthWidgetSource.Ready,
selected: LocalDate,
busy: Set<LocalDate>,
width: Dp,
) {
val locale = widgetLocale()
val month = firstDayOfMonthIndex(index).month.ordinal + 1
val backing = GlanceModifier.wallpaperBacking(radius = 14.dp)
Column(modifier = backing.fillMaxSize().padding(2.dp)) {
Row(modifier = GlanceModifier.fillMaxWidth()) {
for (i in 0 until 7) {
Text(
text = java.time.DayOfWeek.of((source.weekStart.ordinal + i) % 7 + 1).getDisplayName(JavaTextStyle.NARROW, locale),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp, textAlign = TextAlign.Center),
modifier = GlanceModifier.width((width - 4.dp) / 7),
)
}
}
days.chunked(7).forEach { week ->
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight(), verticalAlignment = Alignment.CenterVertically) {
week.forEach { date ->
DayCell(date, date.month.ordinal + 1 == month, date == source.today, date == selected, date in busy, (width - 4.dp) / 7)
}
}
}
}
}
@Composable
private fun DayCell(date: LocalDate, inMonth: Boolean, isToday: Boolean, isSelected: Boolean, hasEvents: Boolean, width: Dp) {
val ink = when {
isSelected -> GlanceTheme.colors.onPrimary
isToday -> GlanceTheme.colors.primary
inMonth -> GlanceTheme.colors.onSurface
else -> GlanceTheme.colors.outline
}
// Always set: the host reapplies onto reused views, so a dropped background would linger.
val fill = if (isSelected) GlanceTheme.colors.primary else ColorProvider(Color.Transparent)
Column(
modifier = GlanceModifier.cornerRadius(10.dp).background(fill)
.width(width)
.fillMaxHeight()
.clickable(actionRunCallback<SelectDayAction>(actionParametersOf(SelectDayAction.dateKey to date.toString()))),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = date.day.toString(),
maxLines = 1,
style = TextStyle(
color = ink,
fontSize = 11.sp,
fontWeight = if (isToday || isSelected) FontWeight.Bold else FontWeight.Normal,
textAlign = TextAlign.Center,
),
)
Text(
text = if (hasEvents) "•" else " ",
maxLines = 1,
style = TextStyle(color = if (isSelected) ink else GlanceTheme.colors.primary, fontSize = 7.sp, textAlign = TextAlign.Center),
)
}
}
@Composable
private fun DayList(day: LocalDate, data: MonthDayData.Ready, preview: Boolean) {
val context = LocalContext.current
val source = data.source
val zone = systemZone()
val events = groupAgendaDays(day, day, source.instances, zone).firstOrNull()?.events.orEmpty()
.let { all -> if (data.options.pastDisplay == PastEventDisplay.HIDE) all.filterNot { it.hasEnded(data.now) } else all }
val metrics = compactRowMetrics(data.options.textSize, context.resources.configuration.fontScale)
val openDay = actionStartActivity(MainActivity.openDateIntent(context, day, CalendarView.Month))
Column(modifier = GlanceModifier.fillMaxSize()) {
val date = localizedDateFormatter(widgetLocale(), "EEEdMMM").format(day.toJavaLocalDate())
val label = when (day) {
source.today, source.today.plus(1, DateTimeUnit.DAY) -> "${relativeDayLabel(context, day, source.today)} · $date"
else -> date
}
Text(
text = label,
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = metrics.time, fontWeight = FontWeight.Medium),
modifier = GlanceModifier
.padding(bottom = 4.dp)
.wallpaperBacking(radius = 8.dp)
.padding(horizontal = 6.dp, vertical = 2.dp)
.clickable(openDay),
)
if (events.isEmpty()) {
Box(
modifier = GlanceModifier.fillMaxSize().cornerRadius(14.dp).background(widgetCardColor()).clickable(openDay),
contentAlignment = Alignment.Center,
) {
Text(
text = context.getString(R.string.widget_day_empty),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.time, textAlign = TextAlign.Center),
modifier = GlanceModifier.padding(8.dp),
)
}
} else {
val row: @Composable (Int) -> Unit = { i ->
val event = events[i]
WidgetEventRow(
event = event,
secondary = widgetTimeLabel(context, event, day, data.is24Hour),
soften = source.soften,
metrics = metrics,
position = positionOf(i, events.size),
source = CalendarView.Month,
dimmed = data.options.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now),
)
}
if (preview) {
Column(modifier = GlanceModifier.fillMaxSize()) { repeat(minOf(events.size, 8)) { row(it) } }
} else {
LazyColumn(modifier = GlanceModifier.fillMaxSize()) { items(events.size) { row(it) } }
}
}
}
}
@@ -0,0 +1,269 @@
package de.jeanlucmakiola.calendula.widget.today
import de.jeanlucmakiola.calendula.widget.InAppLanguage
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.semantics.contentDescription
import androidx.glance.semantics.semantics
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
import de.jeanlucmakiola.calendula.widget.Themed
import de.jeanlucmakiola.calendula.widget.WidgetTicks
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.domain.localEnd
import androidx.glance.appwidget.GlanceAppWidgetManager
import de.jeanlucmakiola.calendula.widget.EventRowMetrics
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.WidgetEventRow
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
import de.jeanlucmakiola.calendula.widget.compactRowMetrics
import de.jeanlucmakiola.calendula.widget.loadDays
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.wallpaperBacking
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate
import java.time.format.TextStyle as JavaTextStyle
/**
* Today list (#438): only today's events beside today's date pill. Once
* they're over, or with none at all, it says so and shows tomorrow's first
* one or two events in a quieter style.
*/
class TodayWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
// Exact: whether one or two of tomorrow's events fit follows the height.
override val sizeMode = SizeMode.Exact
override val previewSizeMode = previewSizeOf(WidgetKind.TODAY)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.TODAY)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val widgetData = WidgetData(context.loadPlacedToday(id), refreshStamp(context, id))
provideContent {
val loaded = widgetData.current(currentRefreshStamp()) { context.loadPlacedToday(id) }
InAppLanguage {
TodayWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
}
}
}
}
class TodayWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = TodayWidget()
}
internal suspend fun Context.loadTodayList(load: WidgetLoadContext) = loadDays(load) { today, _ ->
today..today.plus(1, DateTimeUnit.DAY)
}
/** [loadTodayList] for placed widget [id], arming its redraw for when the next of today's events ends. */
private suspend fun Context.loadPlacedToday(id: GlanceId): Themed<DaysWidgetData> {
val loaded = loadTodayList(widgetLoadContext(id))
val appWidgetId = GlanceAppWidgetManager(this).getAppWidgetId(id)
val data = loaded.content as? DaysWidgetData.Ready
val zone = systemZone()
val nextEnd = data?.eventsOn(data.today)?.map { it.localEnd(zone) }?.filter { it > data.now }?.minOrNull()
if (nextEnd == null) {
WidgetTicks.cancel(this, WidgetKind.TODAY, appWidgetId)
} else {
WidgetTicks.schedule(this, WidgetKind.TODAY, appWidgetId, nextEnd)
}
return loaded
}
private val PillWidth = 44.dp
private val RowGap = 2.dp
/** What the list side shows: today's events, or the look ahead once none are left. */
internal sealed interface TodayRows {
data class Today(val events: List<EventInstance>) : TodayRows
data class NothingLeft(val tomorrow: List<EventInstance>) : TodayRows
}
internal fun todayRows(data: DaysWidgetData.Ready): TodayRows {
val today = data.eventsOn(data.today)
if (today.none { !it.hasEnded(data.now) }) {
return TodayRows.NothingLeft(data.eventsOn(data.today.plus(1, DateTimeUnit.DAY)))
}
val shown = if (data.options.pastDisplay == PastEventDisplay.HIDE) today.filterNot { it.hasEnded(data.now) } else today
return TodayRows.Today(shown)
}
/** The whole widget, shared with its config preview, which draws its list as a plain column. */
@Composable
internal fun TodayWidgetContent(
data: DaysWidgetData,
appearance: WidgetAppearance,
appTheme: ThemeMode,
preview: Boolean = false,
) {
CalendulaWidgetRoot(appearance, appTheme) {
val context = LocalContext.current
when (data) {
DaysWidgetData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
is DaysWidgetData.Ready -> Row(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) {
TodayPill(data.today)
Spacer(GlanceModifier.width(6.dp))
val metrics = compactRowMetrics(data.options.textSize, context.resources.configuration.fontScale)
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) {
when (val rows = todayRows(data)) {
is TodayRows.Today -> TodayList(rows.events, data, metrics, preview)
is TodayRows.NothingLeft -> NothingLeft(rows.tomorrow, data, metrics)
}
}
}
}
}
}
@Composable
private fun TodayList(events: List<EventInstance>, data: DaysWidgetData.Ready, metrics: EventRowMetrics, preview: Boolean) {
val context = LocalContext.current
val row: @Composable (Int) -> Unit = { index ->
val event = events[index]
WidgetEventRow(
event = event,
secondary = widgetTimeLabel(context, event, data.today, data.is24Hour),
soften = data.soften,
metrics = metrics,
position = positionOf(index, events.size),
source = CalendarView.Day,
dimmed = data.options.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now),
)
}
if (preview) {
val fits = ((LocalSize.current.height - 12.dp + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceAtLeast(1)
Column(modifier = GlanceModifier.fillMaxSize()) { repeat(minOf(events.size, fits)) { row(it) } }
} else {
LazyColumn(modifier = GlanceModifier.fillMaxSize()) { items(events.size) { row(it) } }
}
}
/** "Nothing left today", then as many of tomorrow's first two events as the height holds. */
@Composable
private fun NothingLeft(tomorrow: List<EventInstance>, data: DaysWidgetData.Ready, metrics: EventRowMetrics) {
val context = LocalContext.current
val fontScale = context.resources.configuration.fontScale
val lineHeight = 20.dp * fontScale
val room = LocalSize.current.height - 12.dp - lineHeight
val fits = ((room + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceIn(0, 2)
val ahead = tomorrow.take(fits)
val backing = GlanceModifier.wallpaperBacking(radius = 8.dp, horizontalPadding = 8.dp, filledHorizontalPadding = 4.dp)
Column(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = context.getString(R.string.widget_nothing_left_today),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.title, fontWeight = FontWeight.Medium),
modifier = backing.height(lineHeight),
)
val tomorrowDate = data.today.plus(1, DateTimeUnit.DAY)
val tomorrowLabel = relativeDayLabel(context, tomorrowDate, data.today)
ahead.forEachIndexed { index, event ->
WidgetEventRow(
event = event,
secondary = "$tomorrowLabel · ${widgetTimeLabel(context, event, tomorrowDate, data.is24Hour)}",
soften = data.soften,
metrics = metrics,
position = positionOf(index, ahead.size),
source = CalendarView.Day,
quiet = true,
)
}
}
}
/** Today's weekday and number, filled like the agenda's today pill; opens today. */
@Composable
private fun TodayPill(today: LocalDate) {
val context = LocalContext.current
val locale = widgetLocale()
Column(
modifier = GlanceModifier
.width(PillWidth)
.fillMaxHeight()
.cornerRadius(PillWidth / 2)
.background(GlanceTheme.colors.primary)
.clickable(actionStartActivity(MainActivity.openDateIntent(context, today, CalendarView.Day)))
.semantics { contentDescription = localizedDateFormatter(locale, "EEEEdMMMM").format(today.toJavaLocalDate()) },
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, locale),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onPrimary, fontSize = 11.sp, textAlign = TextAlign.Center),
)
Text(
text = today.day.toString(),
maxLines = 1,
style = TextStyle(
color = GlanceTheme.colors.onPrimary,
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
),
)
}
}
@@ -0,0 +1,185 @@
package de.jeanlucmakiola.calendula.widget.upcoming
import de.jeanlucmakiola.calendula.widget.InAppLanguage
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.provideContent
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition
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.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.WidgetEventRow
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
import de.jeanlucmakiola.calendula.widget.compactRowMetrics
import de.jeanlucmakiola.calendula.widget.loadDays
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.wallpaperBacking
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
import java.time.format.TextStyle as JavaTextStyle
/**
* 2×2 upcoming (#437): today's date on top, then the next events across the
* widget's range in compact rows, as many as its height holds. The date opens
* the agenda, an event opens itself.
*/
class UpcomingWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
// Exact: how many rows show follows the height.
override val sizeMode = SizeMode.Exact
override val previewSizeMode = previewSizeOf(WidgetKind.UPCOMING)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.UPCOMING)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val widgetData = WidgetData(context.loadUpcoming(context.widgetLoadContext(id)), refreshStamp(context, id))
provideContent {
val loaded = widgetData.current(currentRefreshStamp()) { context.loadUpcoming(context.widgetLoadContext(id)) }
InAppLanguage {
UpcomingWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
}
}
}
}
class UpcomingWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = UpcomingWidget()
}
internal suspend fun Context.loadUpcoming(load: WidgetLoadContext) = loadDays(load) { today, weekStart ->
today..today.plus(load.options.range.dayCount(today, weekStart) - 1, DateTimeUnit.DAY)
}
/** Most rows a tall widget lists; past this it is an agenda, which is its own widget. */
private const val MAX_ROWS = 8
private val RowGap = 2.dp
/** Every event still to come or running, each once, on the first day it shows. */
internal fun upcomingEvents(data: DaysWidgetData.Ready): List<Pair<LocalDate, EventInstance>> =
data.days
.flatMap { day -> day.events.map { day.date to it } }
.filterNot { (_, event) -> event.hasEnded(data.now) }
.distinctBy { (_, event) -> event.instanceId to event.start }
@Composable
internal fun UpcomingWidgetContent(data: DaysWidgetData, appearance: WidgetAppearance, appTheme: ThemeMode) {
CalendulaWidgetRoot(appearance, appTheme) {
val context = LocalContext.current
when (data) {
DaysWidgetData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
is DaysWidgetData.Ready -> {
val fontScale = context.resources.configuration.fontScale
val metrics = compactRowMetrics(data.options.textSize, fontScale)
val headerHeight = 44.dp * fontScale
val room = LocalSize.current.height - 16.dp - headerHeight
val fits = ((room + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceIn(1, MAX_ROWS)
val events = upcomingEvents(data).take(fits)
Column(modifier = GlanceModifier.fillMaxSize().padding(8.dp)) {
DateHeader(data.today, headerHeight)
if (events.isEmpty()) {
WidgetMessage(context.getString(R.string.widget_upcoming_empty))
} else {
events.forEachIndexed { index, (day, event) ->
val time = widgetTimeLabel(context, event, day, data.is24Hour)
WidgetEventRow(
event = event,
secondary = if (day == data.today) time else "${relativeDayLabel(context, day, data.today)} · $time",
soften = data.soften,
metrics = metrics,
position = positionOf(index, events.size),
source = CalendarView.Agenda,
)
}
}
}
}
}
}
}
/** Today's number beside its weekday and month; opens the agenda. */
@Composable
private fun DateHeader(today: LocalDate, height: Dp) {
val context = LocalContext.current
val locale = widgetLocale()
val backing = GlanceModifier.wallpaperBacking(radius = 12.dp, horizontalPadding = 8.dp, filledHorizontalPadding = 4.dp)
Box(modifier = GlanceModifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
Row(
modifier = backing.clickable(actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Agenda))),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = today.day.toString(),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 28.sp, fontWeight = FontWeight.Bold),
)
Spacer(GlanceModifier.width(8.dp))
Column {
Text(
text = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.FULL, locale),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 13.sp, fontWeight = FontWeight.Medium),
)
Text(
text = java.time.Month.of(today.month.ordinal + 1).getDisplayName(JavaTextStyle.FULL_STANDALONE, locale),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
)
}
}
}
}
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff. Show more