Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f81bb9ea5 | ||
|
|
a08ad15c4a | ||
|
|
c9cc40ff60 | ||
|
|
343a8904ea | ||
|
|
075940d29e | ||
|
|
670cdb226a | ||
|
|
a8da8bc7a3 | ||
|
|
cfcba283f1 | ||
|
|
85d4dc535c | ||
|
|
edf80fe8b8 | ||
|
|
0ecd6de27b | ||
|
|
610409012f | ||
|
|
7e45656479 | ||
|
|
94b04c1365 | ||
|
|
d1557825c6 | ||
|
|
d43a741c93 | ||
|
|
6da0648b77 | ||
|
|
3b48987ab7 | ||
|
|
04a677dc10 | ||
|
|
46ca2db63a | ||
|
|
a87ebc33b6 | ||
|
|
3471447ab6 | ||
|
|
b206e0e6a5 | ||
|
|
35f5dc1666 | ||
|
|
225901ceee | ||
|
|
77e103c66c |
No files matched your search
@@ -33,7 +33,7 @@ jobs:
|
||||
# directly drops the docker-in-docker requirement entirely.
|
||||
# Full tag pinned; Renovate's github-actions manager keeps it bumped.
|
||||
container:
|
||||
image: ghcr.io/renovatebot/renovate:44.237.3
|
||||
image: ghcr.io/renovatebot/renovate:43.232.0
|
||||
steps:
|
||||
- name: Run Renovate
|
||||
run: renovate
|
||||
|
||||
@@ -7,72 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.24.2] — 2026-11-13
|
||||
|
||||
### Fixed
|
||||
- **Quick moves no longer get lost.** Dropping an event while an earlier move was still saving used to snap it back without a word. Drops now wait in line and are saved in order, and a moved event stays where you dropped it until the calendar shows it there. If an event changed before a drop could be saved, or the line is full, a message says so instead ([#494]).
|
||||
- **Undo after a run of moves** keeps the first move's Undo, and each failed move gets its own message rather than replacing the last one ([#494]).
|
||||
|
||||
### Changed
|
||||
- **The day view's title names the month**, like the week and 3-day views. Weekday and date already sit in the column header below it ([#501]).
|
||||
- Library updates (kotlinx-datetime, AppCompat).
|
||||
|
||||
## [2.24.1] — 2026-11-12
|
||||
|
||||
### Added
|
||||
- **A classic style for the agenda widget.** Its settings now have a Style option: Cards keeps the new look, Classic brings back the 2.23 layout with a line per day and a colour stripe beside each event, under the current header. Dates in another year show the year. Classic has an Invert text colour switch, so its text stays readable on light and dark wallpapers ([#489]).
|
||||
- **Widget opacity.** Every widget with a Solid or Tinted background has an Opacity slider from 20 to 100%. Below 50%, the card-style widgets back their content like a Transparent widget so it stays readable ([#489]).
|
||||
|
||||
### Fixed
|
||||
- **Week numbers match your region.** With weeks starting on Sunday, the month and week views showed every week number one too low. Rows are now numbered by your region's rules, so ISO regions such as Germany are unchanged ([#493]).
|
||||
- **The month widget shows events at every height.** At some sizes a week row had room for a little more than the date but not a full line of events, and drew none. It now fits a slimmer line there, and a row with a single line shows "+N" on days with more events than fit ([#491]).
|
||||
- **Dimmed past events in dark widgets are readable again.** They used to turn pastel with white text on top ([#475]).
|
||||
- **A widget set to Light or Dark matches the system colours.** With Material You, the + button and today's date could look different from a widget that follows the app ([#475]).
|
||||
|
||||
### Changed
|
||||
- **Fewer permissions.** Calendula no longer asks for network state, wake lock or foreground service access, which a library added without the app needing them ([#448]).
|
||||
- Translation updates from [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||
|
||||
## [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
|
||||
@@ -1905,7 +1839,6 @@ 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
|
||||
@@ -1955,33 +1888,4 @@ 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
|
||||
[#448]: https://codeberg.org/jlmakiola/calendula/issues/448
|
||||
[#475]: https://codeberg.org/jlmakiola/calendula/issues/475
|
||||
[#489]: https://codeberg.org/jlmakiola/calendula/issues/489
|
||||
[#491]: https://codeberg.org/jlmakiola/calendula/issues/491
|
||||
[#493]: https://codeberg.org/jlmakiola/calendula/issues/493
|
||||
[#494]: https://codeberg.org/jlmakiola/calendula/issues/494
|
||||
[#501]: https://codeberg.org/jlmakiola/calendula/issues/501
|
||||
@@ -7,4 +7,4 @@ source "https://rubygems.org"
|
||||
# Pinned exactly; Renovate's bundler manager keeps it bumped. No Gemfile.lock is
|
||||
# committed on purpose: this resolves an uploader's transitive deps, not the
|
||||
# app's, and none of it affects the reproducible release build.
|
||||
gem "fastlane", "2.241.1"
|
||||
gem "fastlane", "2.237.0"
|
||||
@@ -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 = 2240299
|
||||
versionName = "2.24.2"
|
||||
versionCode = 2230099
|
||||
versionName = "2.23.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -182,7 +182,6 @@ dependencies {
|
||||
implementation("de.jeanlucmakiola.floret:identity")
|
||||
implementation("de.jeanlucmakiola.floret:components")
|
||||
implementation("de.jeanlucmakiola.floret:glance")
|
||||
implementation("de.jeanlucmakiola.floret:ring")
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
|
||||
@@ -41,16 +41,6 @@
|
||||
-->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<!-- WorkManager declares this for network constraints, which the app doesn't use (#448). -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
|
||||
|
||||
<!-- Full-screen reminder alerts and their ring (#171), opt-in per calendar. -->
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||
returns null and the calendar manager's per-account "manage" button can't
|
||||
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
||||
@@ -327,20 +317,6 @@
|
||||
android:name=".data.reminders.ReminderActionReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- The full-screen reminder alert (#171), in a task of its own. -->
|
||||
<activity
|
||||
android:name=".ui.reminders.ReminderAlertActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTask"
|
||||
android:taskAffinity="" />
|
||||
|
||||
<!-- Rings a "Ring until dismissed" reminder (#171). -->
|
||||
<service
|
||||
android:name=".data.reminders.ReminderRingService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="systemExempted" />
|
||||
|
||||
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
||||
<activity
|
||||
android:name=".widget.config.WidgetConfigActivity"
|
||||
@@ -450,7 +426,7 @@
|
||||
|
||||
<!-- A countdown widget's redraw for when its label changes (#439). -->
|
||||
<receiver
|
||||
android:name=".widget.WidgetTickReceiver"
|
||||
android:name=".widget.countdown.CountdownTickReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
@@ -474,12 +450,6 @@
|
||||
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">
|
||||
|
||||
@@ -15,7 +15,6 @@ 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
|
||||
@@ -46,9 +45,6 @@ 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()
|
||||
|
||||
@@ -30,7 +30,6 @@ 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
|
||||
@@ -279,7 +278,6 @@ 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) {
|
||||
@@ -595,9 +593,7 @@ class MainActivity : AppCompatActivity() {
|
||||
*/
|
||||
fun openDateIntent(context: Context, date: LocalDate, source: CalendarView): Intent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
// 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()
|
||||
data = "calendula://date/$date".toUri()
|
||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||
putExtra(EXTRA_SOURCE_VIEW, source.name)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
@@ -616,7 +612,6 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -567,41 +567,11 @@ 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 instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||
queryInstances(beginMillis, endMillis, null, null)
|
||||
|
||||
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()),
|
||||
)
|
||||
queryInstances(beginMillis, endMillis, "${CalendarContract.Instances.EVENT_ID} = ?", arrayOf(eventId.toString()))
|
||||
|
||||
private fun queryInstances(
|
||||
beginMillis: Long,
|
||||
@@ -610,12 +580,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
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.
|
||||
// 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).
|
||||
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.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0"
|
||||
return resolver.query(
|
||||
instancesUri(beginMillis, endMillis),
|
||||
@@ -645,8 +617,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
.toTypedArray()
|
||||
val selection = "($match) AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||
"${CalendarContract.Events.ORIGINAL_ID} IS NULL AND " +
|
||||
"${CalendarContract.Events.ORIGINAL_SYNC_ID} IS NULL"
|
||||
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
|
||||
return resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
SearchProjection.COLUMNS,
|
||||
|
||||
@@ -10,7 +10,6 @@ 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 {
|
||||
@@ -109,12 +108,6 @@ 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`.
|
||||
@@ -142,6 +135,3 @@ 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)
|
||||
+1
-5
@@ -351,11 +351,7 @@ 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,7 +9,6 @@ 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
|
||||
@@ -133,8 +132,6 @@ 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() }
|
||||
@@ -182,9 +179,6 @@ 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))
|
||||
}
|
||||
@@ -285,7 +279,6 @@ 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))
|
||||
@@ -361,8 +354,7 @@ internal fun buildMovedMasterValues(
|
||||
}
|
||||
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
|
||||
snapshot.status?.takeIf { it != CalendarContract.Events.STATUS_CANCELED }
|
||||
?.let { put(CalendarContract.Events.STATUS, it) }
|
||||
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
|
||||
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
|
||||
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
|
||||
}
|
||||
@@ -866,7 +858,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.forNewEvent().toProviderStatus())
|
||||
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
|
||||
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
@@ -38,17 +38,9 @@ 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,7 +62,6 @@ 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
|
||||
@@ -76,29 +75,6 @@ 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 {
|
||||
@@ -222,7 +198,6 @@ 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
|
||||
@@ -239,7 +214,6 @@ 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,6 +35,5 @@ 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),
|
||||
)
|
||||
}
|
||||
@@ -81,7 +81,7 @@ class CalendarPrefs @Inject constructor(
|
||||
}
|
||||
|
||||
/** A comma-joined id set; non-numeric tokens are dropped, and an empty set removes the key. */
|
||||
internal fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
|
||||
private fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
|
||||
{ stored -> stored.orEmpty().split(',').mapNotNull { it.trim().toLongOrNull() }.toSet() },
|
||||
{ ids -> if (ids.isEmpty()) null else ids.sorted().joinToString(",") },
|
||||
)
|
||||
@@ -5,9 +5,6 @@ import androidx.datastore.preferences.core.Preferences
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.parseAlertStyles
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.toStoredAlertStyles
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
@@ -149,7 +146,6 @@ 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)
|
||||
@@ -169,9 +165,6 @@ class SettingsPrefs @Inject constructor(
|
||||
val defaultEventDurationMinutes: Flow<Int> = prefs.flow(SettingsKeys.defaultEventDurationMinutes)
|
||||
val perCalendarEventDuration: Flow<Map<Long, Int>> = prefs.flow(SettingsKeys.perCalendarEventDuration)
|
||||
val remindersEnabled: Flow<Boolean> = prefs.flow(SettingsKeys.remindersEnabled)
|
||||
val mutedReminderCalendarIds: Flow<Set<Long>> = prefs.flow(SettingsKeys.mutedReminderCalendarIds)
|
||||
val reminderAlertStyles: Flow<Map<Long, ReminderAlertStyle>> = prefs.flow(SettingsKeys.reminderAlertStyles)
|
||||
val notificationCategoryHintDismissed: Flow<Boolean> = prefs.flow(SettingsKeys.notificationCategoryHintDismissed)
|
||||
val allowColorOnUnsupportedCalendars: Flow<Boolean> =
|
||||
prefs.flow(SettingsKeys.allowColorOnUnsupportedCalendars)
|
||||
val defaultReminderMinutes: Flow<List<Int>> = prefs.flow(SettingsKeys.defaultReminderMinutes)
|
||||
@@ -219,7 +212,6 @@ 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)
|
||||
@@ -279,16 +271,6 @@ class SettingsPrefs @Inject constructor(
|
||||
|
||||
suspend fun setRemindersEnabled(enabled: Boolean) = prefs.set(SettingsKeys.remindersEnabled, enabled)
|
||||
|
||||
suspend fun dismissNotificationCategoryHint() = prefs.set(SettingsKeys.notificationCategoryHintDismissed, true)
|
||||
|
||||
suspend fun setCalendarRemindersEnabled(calendarId: Long, enabled: Boolean) {
|
||||
prefs.update(SettingsKeys.mutedReminderCalendarIds) { if (enabled) it - calendarId else it + calendarId }
|
||||
}
|
||||
|
||||
suspend fun setCalendarAlertStyle(calendarId: Long, style: ReminderAlertStyle) {
|
||||
prefs.update(SettingsKeys.reminderAlertStyles) { it + (calendarId to style) }
|
||||
}
|
||||
|
||||
suspend fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) =
|
||||
prefs.set(SettingsKeys.allowColorOnUnsupportedCalendars, enabled)
|
||||
|
||||
@@ -463,9 +445,6 @@ 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)
|
||||
|
||||
@@ -610,22 +589,6 @@ object SettingsKeys {
|
||||
*/
|
||||
val remindersEnabled: Pref<Boolean> = booleanPref("reminders_enabled", true)
|
||||
|
||||
/**
|
||||
* Calendars whose reminders Calendula doesn't post (#272), independent of
|
||||
* `Calendars.VISIBLE`: the events still show, only the notifications stop.
|
||||
*/
|
||||
val mutedReminderCalendarIds: Pref<Set<Long>> = idSetPref("muted_reminder_calendar_ids")
|
||||
|
||||
/** Per-calendar alert styles (#171); a calendar absent from the map uses plain notifications. */
|
||||
val reminderAlertStyles: Pref<Map<Long, ReminderAlertStyle>> =
|
||||
nullableStringPref("reminder_alert_styles").map(::parseAlertStyles) { it.toStoredAlertStyles() }
|
||||
|
||||
/**
|
||||
* Whether the one-time hint that some skins (Samsung) start new notification
|
||||
* categories switched off has been dismissed (#145).
|
||||
*/
|
||||
val notificationCategoryHintDismissed: Pref<Boolean> = booleanPref("notification_category_hint_dismissed", false)
|
||||
|
||||
/**
|
||||
* Whether to offer a custom event colour on calendars that publish no colour
|
||||
* palette; synced ones without a palette (some CalDAV) may drop or overwrite
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* What the full-screen alerts (#171) share within the process: the reminder
|
||||
* [ReminderRingService] is ringing for, and the reminders just dismissed or
|
||||
* snoozed, so an alert screen showing one closes whichever way it was handled.
|
||||
*/
|
||||
@Singleton
|
||||
class FullScreenAlerts @Inject constructor() {
|
||||
|
||||
/** Written only by [ReminderRingService], on the main thread, for as long as it rings. */
|
||||
@Volatile
|
||||
var ringing: ReminderAlert? = null
|
||||
internal set
|
||||
|
||||
/** Rings handed to [ReminderRingService] that have not reached it yet, with how many starts each. */
|
||||
private val pending = ConcurrentHashMap<Long, Pair<ReminderAlert, Int>>()
|
||||
|
||||
/** Set when the service is stopped from outside but its ring should go back to the shade. */
|
||||
@Volatile
|
||||
internal var handBackOnDestroy = false
|
||||
|
||||
internal fun addPending(alert: ReminderAlert) {
|
||||
pending.compute(alert.key) { _, entry -> alert to ((entry?.second ?: 0) + 1) }
|
||||
}
|
||||
|
||||
internal fun removePending(alert: ReminderAlert) {
|
||||
pending.computeIfPresent(alert.key) { _, entry -> if (entry.second <= 1) null else entry.first to entry.second - 1 }
|
||||
}
|
||||
|
||||
internal fun isPending(alert: ReminderAlert): Boolean = pending.containsKey(alert.key)
|
||||
|
||||
internal val anyPending: Boolean get() = pending.isNotEmpty()
|
||||
|
||||
/**
|
||||
* Whether a ring going or on its way [match]es, so a stop is worth sending.
|
||||
* Pending first: the service sets [ringing] before it lets a start go.
|
||||
*/
|
||||
fun mayRing(match: RingMatch): Boolean =
|
||||
pending.values.any { match.matches(it.first) } || ringing?.let(match::matches) == true
|
||||
|
||||
private val handledKeys = MutableSharedFlow<Long>(
|
||||
extraBufferCapacity = 8,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
/** The [ReminderAlert.key]s of reminders as they are dismissed, snoozed or handed back. */
|
||||
val handled: SharedFlow<Long> = handledKeys.asSharedFlow()
|
||||
|
||||
fun markHandled(alert: ReminderAlert) {
|
||||
handledKeys.tryEmit(alert.key)
|
||||
}
|
||||
}
|
||||
|
||||
/** What becomes of a ring once its settings are re-read. */
|
||||
enum class RingVerdict { Ring, HandBack, Drop }
|
||||
|
||||
/** Which ring a stop is for. */
|
||||
sealed interface RingMatch {
|
||||
fun matches(alert: ReminderAlert): Boolean
|
||||
|
||||
data class Key(val key: Long) : RingMatch {
|
||||
override fun matches(alert: ReminderAlert) = alert.key == key
|
||||
}
|
||||
|
||||
data class Occurrence(val occurrenceKey: Long) : RingMatch {
|
||||
override fun matches(alert: ReminderAlert) = alert.occurrenceKey == occurrenceKey
|
||||
}
|
||||
|
||||
data class Calendar(val calendarId: Long) : RingMatch {
|
||||
override fun matches(alert: ReminderAlert) = alert.calendarId == calendarId
|
||||
}
|
||||
|
||||
data object Any : RingMatch {
|
||||
override fun matches(alert: ReminderAlert) = true
|
||||
}
|
||||
}
|
||||
+15
-33
@@ -13,8 +13,8 @@ import javax.inject.Inject
|
||||
/**
|
||||
* Handles the "Snooze" and "Dismiss" actions on a reminder notification, plus
|
||||
* the internal re-show when a snooze elapses. All three are app-internal
|
||||
* intents (notification action buttons, the full-screen alert's buttons and
|
||||
* our own [ReminderSnoozeScheduler] alarm), so the receiver is not exported.
|
||||
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
||||
* alarm), so the receiver is not exported.
|
||||
*
|
||||
* - **Dismiss** just cancels the notification — the scan's watermark has moved
|
||||
* past this reminder, so nothing re-posts it.
|
||||
@@ -38,8 +38,6 @@ 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)
|
||||
@@ -52,8 +50,6 @@ 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)
|
||||
}
|
||||
@@ -74,9 +70,6 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
*/
|
||||
const val ACTION_OPEN = "de.jeanlucmakiola.calendula.reminders.OPEN"
|
||||
|
||||
/** Not handled here either: the slot of the full-screen alert's own intent (#171). */
|
||||
const val ACTION_ALERT_SCREEN = "de.jeanlucmakiola.calendula.reminders.ALERT_SCREEN"
|
||||
|
||||
private const val EXTRA_ALERT_KEY = "alert_key"
|
||||
private const val EXTRA_EVENT_ID = "event_id"
|
||||
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||
@@ -89,33 +82,26 @@ 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. The
|
||||
* snooze alarm is the exception: one per occurrence, so a later snooze
|
||||
* replaces an earlier one and a newer reminder can cancel it.
|
||||
* `PendingIntent`s apart — `filterEquals` never compares extras.
|
||||
*/
|
||||
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
||||
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||
this.action = action
|
||||
data = "calendula://reminder/${identity(alert, action)}".toUri()
|
||||
putAlert(alert)
|
||||
data = "calendula://reminder/${alert.key}".toUri()
|
||||
putExtra(EXTRA_ALERT_KEY, alert.key)
|
||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||
putExtra(EXTRA_END, alert.endMillis)
|
||||
putExtra(EXTRA_TITLE, alert.title)
|
||||
putExtra(EXTRA_LOCATION, alert.location)
|
||||
putExtra(EXTRA_ALL_DAY, alert.isAllDay)
|
||||
}
|
||||
|
||||
/** Carries [alert] as extras, for [alertFrom] to read back. */
|
||||
fun Intent.putAlert(alert: ReminderAlert): Intent = apply {
|
||||
putExtra(EXTRA_ALERT_KEY, alert.key)
|
||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||
putExtra(EXTRA_END, alert.endMillis)
|
||||
putExtra(EXTRA_TITLE, alert.title)
|
||||
putExtra(EXTRA_LOCATION, alert.location)
|
||||
putExtra(EXTRA_ALL_DAY, alert.isAllDay)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 URI.
|
||||
* the top bits it drops are separated by [intent]'s per-reminder URI.
|
||||
*/
|
||||
fun requestCode(alert: ReminderAlert, action: String): Int {
|
||||
val actionOffset = when (action) {
|
||||
@@ -123,16 +109,12 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
ACTION_DISMISS -> 2
|
||||
ACTION_SHOW -> 3
|
||||
ACTION_OPEN -> 4
|
||||
ACTION_ALERT_SCREEN -> 5
|
||||
else -> 0
|
||||
}
|
||||
return (identity(alert, action).toInt() shl 3) + actionOffset
|
||||
return (alert.key.toInt() shl 3) + actionOffset
|
||||
}
|
||||
|
||||
private fun identity(alert: ReminderAlert, action: String): Long =
|
||||
if (action == ACTION_SHOW) alert.occurrenceKey else alert.key
|
||||
|
||||
fun alertFrom(intent: Intent): ReminderAlert? {
|
||||
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
|
||||
return ReminderAlert(
|
||||
key = intent.getLongExtra(EXTRA_ALERT_KEY, 0L),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -21,13 +20,6 @@ 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,
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||
|
||||
/** The single channel every reminder posted on before they moved per calendar (#145). */
|
||||
const val LEGACY_REMINDER_CHANNEL_ID = "reminders"
|
||||
|
||||
/** The silent channel ringing full-screen alerts post on (#171); outside the per-calendar namespace. */
|
||||
const val RING_CHANNEL_ID = "reminders_ring"
|
||||
|
||||
private const val CHANNEL_PREFIX = "reminders.calendar."
|
||||
private const val GROUP_PREFIX = "reminders.account."
|
||||
private const val LOCAL_GROUP_ID = GROUP_PREFIX + "local"
|
||||
|
||||
/** The notification channel [calendarId]'s reminders post on. */
|
||||
fun reminderChannelId(calendarId: Long): String = CHANNEL_PREFIX + calendarId
|
||||
|
||||
/** Whether [channelId] is one of the per-calendar reminder channels. */
|
||||
fun isReminderChannelId(channelId: String): Boolean = channelId.startsWith(CHANNEL_PREFIX)
|
||||
|
||||
/** Whether [groupId] is one of the per-account reminder channel groups. */
|
||||
fun isReminderGroupId(groupId: String): Boolean = groupId.startsWith(GROUP_PREFIX)
|
||||
|
||||
/**
|
||||
* One account's channel group, labelled as the calendar manager labels it. The
|
||||
* app's own device calendars share a single group ([isLocal]); [ambiguous]
|
||||
* marks a [label] another group shows too, so it should name the source app.
|
||||
*/
|
||||
data class ReminderChannelGroupSpec(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val accountType: String,
|
||||
val isLocal: Boolean,
|
||||
val ambiguous: Boolean,
|
||||
)
|
||||
|
||||
data class ReminderChannelSpec(
|
||||
val id: String,
|
||||
val calendarId: Long,
|
||||
val name: String,
|
||||
/** Null only for a channel created before its calendar could be looked up. */
|
||||
val groupId: String?,
|
||||
/**
|
||||
* Whether a sync creates it up front. A calendar that syncs no events never
|
||||
* fires, so its channel only appears once something posts on it.
|
||||
*/
|
||||
val upFront: Boolean = true,
|
||||
)
|
||||
|
||||
/** What a channel already looks like in the system, as far as the plan compares it. */
|
||||
data class ExistingReminderChannel(val id: String, val name: String, val description: String?, val groupId: String?)
|
||||
|
||||
data class ReminderChannelPlan(
|
||||
val groups: List<ReminderChannelGroupSpec>,
|
||||
val channels: List<ReminderChannelSpec>,
|
||||
/**
|
||||
* The [channels] a sync writes: missing up-front ones, and existing ones
|
||||
* whose name or (localized) description changed, or that still lack a group.
|
||||
*/
|
||||
val channelsToWrite: List<ReminderChannelSpec>,
|
||||
/** The [groups] holding a channel that exists or is about to be written. */
|
||||
val groupsInUse: List<ReminderChannelGroupSpec>,
|
||||
val staleChannelIds: List<String>,
|
||||
val staleGroupIds: List<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* What the reminder channels should look like for [calendars]: one channel per
|
||||
* calendar inside a group per account, which of them need writing, and the
|
||||
* reminder channels and groups in [existing] / [existingGroupIds] no calendar
|
||||
* claims any more. Ids outside the reminder namespace (the backup channel, the
|
||||
* legacy channel) are never touched.
|
||||
*/
|
||||
fun planReminderChannels(
|
||||
calendars: List<CalendarSource>,
|
||||
existing: Collection<ExistingReminderChannel>,
|
||||
existingGroupIds: Collection<String>,
|
||||
unnamed: String,
|
||||
description: String,
|
||||
): ReminderChannelPlan {
|
||||
val (local, synced) = calendars.partition { it.isLocal }
|
||||
val groups = buildList {
|
||||
if (local.isNotEmpty()) {
|
||||
add(ReminderChannelGroupSpec(LOCAL_GROUP_ID, "", local.first().accountType, isLocal = true, ambiguous = false))
|
||||
}
|
||||
synced.groupByAccount().forEach { group ->
|
||||
add(
|
||||
ReminderChannelGroupSpec(
|
||||
id = accountGroupId(group.key.type, group.key.name),
|
||||
label = group.label,
|
||||
accountType = group.accountType,
|
||||
isLocal = false,
|
||||
ambiguous = group.ambiguous,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
val channels = calendars.map { calendar ->
|
||||
ReminderChannelSpec(
|
||||
id = reminderChannelId(calendar.id),
|
||||
calendarId = calendar.id,
|
||||
name = calendar.displayName.ifBlank { unnamed },
|
||||
groupId = if (calendar.isLocal) LOCAL_GROUP_ID else accountGroupId(calendar.accountType, calendar.accountName),
|
||||
upFront = calendar.isLocal || calendar.syncsEvents,
|
||||
)
|
||||
}
|
||||
val existingById = existing.associateBy { it.id }
|
||||
val wantedChannels = channels.mapTo(HashSet()) { it.id }
|
||||
val wantedGroups = groups.mapTo(HashSet()) { it.id }
|
||||
val toWrite = channels.filter { spec ->
|
||||
val current = existingById[spec.id]
|
||||
if (current == null) {
|
||||
spec.upFront
|
||||
} else {
|
||||
current.name != spec.name || current.description != description ||
|
||||
(current.groupId == null && spec.groupId != null)
|
||||
}
|
||||
}
|
||||
val usedGroupIds = channels
|
||||
.filter { it.id in existingById || it in toWrite }
|
||||
.mapNotNullTo(HashSet()) { it.groupId }
|
||||
return ReminderChannelPlan(
|
||||
groups = groups,
|
||||
channels = channels,
|
||||
channelsToWrite = toWrite,
|
||||
groupsInUse = groups.filter { it.id in usedGroupIds },
|
||||
staleChannelIds = existingById.keys.filter { isReminderChannelId(it) && it !in wantedChannels },
|
||||
staleGroupIds = existingGroupIds.filter { isReminderGroupId(it) && it !in wantedGroups },
|
||||
)
|
||||
}
|
||||
|
||||
private fun accountGroupId(type: String, name: String): String = "$GROUP_PREFIX$type/$name"
|
||||
|
||||
/** A channel as far as its blocked state goes: switched off itself, and the group it sits in. */
|
||||
data class ReminderChannelState(val blocked: Boolean, val groupId: String?)
|
||||
|
||||
/** Reminder notifications the system blocks, while Calendula would post them. */
|
||||
data class ReminderChannelStatus(
|
||||
val appBlocked: Boolean = false,
|
||||
val blockedCalendarIds: Set<Long> = emptySet(),
|
||||
/** The blocked ones whose whole account group is off, which only the app's own screen can undo. */
|
||||
val groupBlockedCalendarIds: Set<Long> = emptySet(),
|
||||
/** The shared ring channel (#171) is switched off, so ringing calendars only sound once. */
|
||||
val ringBlocked: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* What the system blocks of [calendarIds]' reminders: all of them while the
|
||||
* app's notifications are off, otherwise those whose channel, or the group it
|
||||
* sits in, is switched off. A calendar without a channel yet isn't blocked.
|
||||
*/
|
||||
fun reminderChannelStatus(
|
||||
calendarIds: Collection<Long>,
|
||||
appEnabled: Boolean,
|
||||
channels: Map<String, ReminderChannelState>,
|
||||
blockedGroupIds: Set<String>,
|
||||
): ReminderChannelStatus {
|
||||
val groupBlocked = calendarIds.filterTo(HashSet()) { channels[reminderChannelId(it)]?.groupId in blockedGroupIds }
|
||||
val blocked = if (!appEnabled) {
|
||||
calendarIds.toSet()
|
||||
} else {
|
||||
calendarIds.filterTo(HashSet()) { channels[reminderChannelId(it)]?.blocked == true } + groupBlocked
|
||||
}
|
||||
return ReminderChannelStatus(
|
||||
appBlocked = !appEnabled,
|
||||
blockedCalendarIds = blocked,
|
||||
groupBlockedCalendarIds = groupBlocked,
|
||||
ringBlocked = appEnabled && channels[RING_CHANNEL_ID]?.blocked == true,
|
||||
)
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationChannelGroup
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.ui.common.sourceAppLabel
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The reminder notification channels: one per calendar, grouped by account
|
||||
* (#145), created up front so each calendar can be muted or given its own sound
|
||||
* in the system settings before it ever fires.
|
||||
*
|
||||
* A channel the user has touched keeps their settings; [sync] only creates the
|
||||
* missing ones, renames changed ones, and deletes those whose calendar is gone.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderChannels @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
) {
|
||||
private val manager: NotificationManager
|
||||
get() = context.getSystemService(NotificationManager::class.java)
|
||||
|
||||
/** What the last [sync] saw, so an unchanged provider costs no channel writes. */
|
||||
@Volatile
|
||||
private var lastSync: Any? = null
|
||||
|
||||
/**
|
||||
* Bring the channels in line with the provider's calendars. An empty
|
||||
* snapshot changes nothing: that is a provider hiccup far more often than
|
||||
* every calendar gone at once.
|
||||
*/
|
||||
@Synchronized
|
||||
fun sync() {
|
||||
val calendars = calendarDataSource.calendars()
|
||||
if (calendars.isEmpty()) return
|
||||
val manager = manager
|
||||
val existing = manager.notificationChannels
|
||||
val existingIds = existing.mapTo(HashSet()) { it.id }
|
||||
if (syncKey(calendars, existingIds) == lastSync && LEGACY_REMINDER_CHANNEL_ID !in existingIds) return
|
||||
val groups = manager.notificationChannelGroups
|
||||
val plan = plan(calendars, existing, groups.map { it.id })
|
||||
plan.staleChannelIds.forEach(manager::deleteNotificationChannel)
|
||||
plan.staleGroupIds.forEach(manager::deleteNotificationChannelGroup)
|
||||
val groupNames = groups.associate { it.id to it.name?.toString() }
|
||||
write(
|
||||
groups = plan.groupsInUse.filter { groupNames[it.id] != groupLabel(it) },
|
||||
channels = plan.channelsToWrite,
|
||||
existing = existing,
|
||||
)
|
||||
if (RING_CHANNEL_ID in existingIds) ensureRingChannel()
|
||||
val ids = existingIds - plan.staleChannelIds.toSet() + plan.channelsToWrite.map { it.id }
|
||||
lastSync = syncKey(calendars, if (retireLegacyChannel()) ids - LEGACY_REMINDER_CHANNEL_ID else ids)
|
||||
}
|
||||
|
||||
private fun syncKey(calendars: List<CalendarSource>, channelIds: Set<String>): Any = listOf(
|
||||
calendars.map { listOf(it.id, it.displayName, it.accountName, it.accountType, it.isLocal, it.syncsEvents) },
|
||||
Locale.getDefault(),
|
||||
channelIds,
|
||||
)
|
||||
|
||||
/**
|
||||
* The channel to post [calendarId]'s reminder on, created first if a sync
|
||||
* has not got to it yet. Null when the calendar no longer exists. Should the
|
||||
* calendar lookup fail or come back empty, the channel is still created,
|
||||
* plainly named and ungrouped, for the next sync to name and file — a
|
||||
* reminder is never lost to its channel. A ringing [style] posts on the
|
||||
* shared ring channel instead, once the calendar is known to exist.
|
||||
*/
|
||||
@Synchronized
|
||||
fun channelFor(calendarId: Long, style: ReminderAlertStyle = ReminderAlertStyle.Notification): String? {
|
||||
val calendarChannel = calendarChannelFor(calendarId) ?: return null
|
||||
return if (style == ReminderAlertStyle.FullScreenRing) ringChannel() else calendarChannel
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel a ringing full-screen alert (#171) posts on, shared by every
|
||||
* calendar. Silent: the ring service owns the sound and vibration, so the
|
||||
* channel adds neither. High importance keeps the heads-up a denied
|
||||
* full-screen intent degrades to.
|
||||
*/
|
||||
private fun ringChannel(): String {
|
||||
ensureRingChannel()
|
||||
return RING_CHANNEL_ID
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the ring channel, or renames it after a language change. Called
|
||||
* as soon as a calendar is set to ring, so the channel can be checked in
|
||||
* system settings (and switched on, where a skin starts it off) before the
|
||||
* first ring.
|
||||
*/
|
||||
@Synchronized
|
||||
fun ensureRingChannel() {
|
||||
val manager = manager
|
||||
val name = context.getString(R.string.reminder_ring_channel_name)
|
||||
val description = context.getString(R.string.reminder_ring_channel_description)
|
||||
val current = manager.getNotificationChannel(RING_CHANNEL_ID)
|
||||
if (current != null && current.name?.toString() == name && current.description == description) return
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
RING_CHANNEL_ID,
|
||||
name,
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
this.description = description
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a ring may sound for [calendarId]: neither its own channel, its
|
||||
* account's group nor the ring channel is blocked, since the service plays
|
||||
* the sound itself.
|
||||
*/
|
||||
fun canRing(calendarId: Long): Boolean {
|
||||
val status = status(listOf(calendarId))
|
||||
return !status.appBlocked && calendarId !in status.blockedCalendarIds && !status.ringBlocked
|
||||
}
|
||||
|
||||
private fun calendarChannelFor(calendarId: Long): String? {
|
||||
val id = reminderChannelId(calendarId)
|
||||
val manager = manager
|
||||
if (manager.getNotificationChannel(id) != null) return id
|
||||
val existing = manager.notificationChannels
|
||||
val calendars = try {
|
||||
calendarDataSource.calendars()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not look up calendar $calendarId for its channel", e)
|
||||
emptyList()
|
||||
}
|
||||
val fallback = ReminderChannelSpec(id, calendarId, context.getString(R.string.reminder_channel_name), null)
|
||||
if (calendars.isEmpty()) {
|
||||
write(emptyList(), listOf(fallback), existing)
|
||||
return id
|
||||
}
|
||||
val plan = plan(calendars, existing, emptyList())
|
||||
val spec = plan.channels.firstOrNull { it.calendarId == calendarId } ?: return null
|
||||
try {
|
||||
val knownGroups = manager.notificationChannelGroups.mapTo(HashSet()) { it.id }
|
||||
write(
|
||||
groups = plan.groups.filter { it.id == spec.groupId && it.id !in knownGroups },
|
||||
channels = listOf(spec),
|
||||
existing = existing,
|
||||
)
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "Could not file the channel of calendar $calendarId", e)
|
||||
write(emptyList(), listOf(fallback), existing)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
/** What the system currently lets through for [calendarIds]' reminders (#145). */
|
||||
fun status(calendarIds: Collection<Long>): ReminderChannelStatus {
|
||||
val manager = manager
|
||||
val appEnabled = manager.areNotificationsEnabled()
|
||||
val channels = manager.notificationChannels.associate {
|
||||
it.id to ReminderChannelState(it.importance == NotificationManager.IMPORTANCE_NONE, it.group)
|
||||
}
|
||||
val blockedGroups = manager.notificationChannelGroups.filter { it.isBlocked }.mapTo(HashSet()) { it.id }
|
||||
return reminderChannelStatus(calendarIds, appEnabled, channels, blockedGroups)
|
||||
}
|
||||
|
||||
private fun plan(
|
||||
calendars: List<CalendarSource>,
|
||||
existing: List<NotificationChannel>,
|
||||
existingGroupIds: List<String>,
|
||||
): ReminderChannelPlan = planReminderChannels(
|
||||
calendars = calendars,
|
||||
existing = existing.map { ExistingReminderChannel(it.id, it.name.toString(), it.description, it.group) },
|
||||
existingGroupIds = existingGroupIds,
|
||||
unnamed = context.getString(R.string.calendar_unnamed),
|
||||
description = context.getString(R.string.reminder_channel_description),
|
||||
)
|
||||
|
||||
private fun write(
|
||||
groups: List<ReminderChannelGroupSpec>,
|
||||
channels: List<ReminderChannelSpec>,
|
||||
existing: List<NotificationChannel>,
|
||||
) {
|
||||
val manager = manager
|
||||
if (groups.isNotEmpty()) {
|
||||
manager.createNotificationChannelGroups(groups.map { NotificationChannelGroup(it.id, groupLabel(it)) })
|
||||
}
|
||||
if (channels.isEmpty()) return
|
||||
val byId = existing.associateBy { it.id }
|
||||
val description = context.getString(R.string.reminder_channel_description)
|
||||
val template = byId[LEGACY_REMINDER_CHANNEL_ID]
|
||||
// Importance only applies on creation; an existing channel keeps its own.
|
||||
manager.createNotificationChannels(
|
||||
channels.map { spec ->
|
||||
val current = byId[spec.id]
|
||||
NotificationChannel(
|
||||
spec.id,
|
||||
spec.name,
|
||||
current?.importance ?: template?.importance ?: NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
this.description = description
|
||||
spec.groupId?.let { group = it }
|
||||
if (current == null && template != null) inheritFrom(template)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry the old single channel's sound, vibration, lights and badge over to
|
||||
* a new per-calendar one (the system resets lock-screen visibility itself), so an upgrade keeps how reminders sounded.
|
||||
*/
|
||||
private fun NotificationChannel.inheritFrom(template: NotificationChannel) {
|
||||
setSound(template.sound, template.audioAttributes)
|
||||
// The pattern setter also flips vibration on or off, so it goes first.
|
||||
vibrationPattern = template.vibrationPattern
|
||||
enableVibration(template.shouldVibrate())
|
||||
enableLights(template.shouldShowLights())
|
||||
lightColor = template.lightColor
|
||||
setShowBadge(template.canShowBadge())
|
||||
// Only honoured with Do Not Disturb access, so usually left to the user.
|
||||
setBypassDnd(template.canBypassDnd())
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the pre-#145 channel once the per-calendar ones carry its settings,
|
||||
* but not while a reminder posted on it is still on screen — deleting the
|
||||
* channel would take that notification with it. Returns whether it is gone.
|
||||
*/
|
||||
private fun retireLegacyChannel(): Boolean {
|
||||
val manager = manager
|
||||
if (manager.getNotificationChannel(LEGACY_REMINDER_CHANNEL_ID) == null) return true
|
||||
val showing = runCatching {
|
||||
manager.activeNotifications.any { it.notification.channelId == LEGACY_REMINDER_CHANNEL_ID }
|
||||
}.getOrElse { e ->
|
||||
Log.w(TAG, "Could not read active notifications", e)
|
||||
true
|
||||
}
|
||||
if (!showing) manager.deleteNotificationChannel(LEGACY_REMINDER_CHANNEL_ID)
|
||||
return !showing
|
||||
}
|
||||
|
||||
private fun groupLabel(group: ReminderChannelGroupSpec): String {
|
||||
if (group.isLocal) return context.getString(R.string.calendars_local_header)
|
||||
val name = group.label.ifBlank { context.getString(R.string.calendar_unnamed) }
|
||||
if (!group.ambiguous) return name
|
||||
return context.getString(R.string.calendars_account_from_source, name, sourceAppLabel(context, group.accountType))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ReminderChannels"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
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
|
||||
@@ -17,11 +17,7 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.alertStyleFor
|
||||
import de.jeanlucmakiola.calendula.ui.reminders.ReminderAlertActivity
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import de.jeanlucmakiola.floret.ring.canUseFullScreenIntent
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.isoDayNumber
|
||||
import java.time.DayOfWeek
|
||||
@@ -32,14 +28,13 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Posts one notification per occurrence on its calendar's channel
|
||||
* ([ReminderChannels], #145), or as a full-screen alert where the calendar
|
||||
* asks for one (#171). Tapping opens the event's detail screen.
|
||||
* Posts one notification per due reminder on a dedicated channel. Tapping opens
|
||||
* the event's detail screen.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderNotifier @Inject constructor(
|
||||
@@ -47,8 +42,6 @@ class ReminderNotifier @Inject constructor(
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val calendarPrefs: CalendarPrefs,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
private val channels: ReminderChannels,
|
||||
private val alerts: FullScreenAlerts,
|
||||
) {
|
||||
|
||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||
@@ -59,96 +52,20 @@ class ReminderNotifier @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* The single choke point for "this calendar posts no reminders": switched
|
||||
* off (also covering a snooze armed before the switch-off, and a read-only
|
||||
* install whose switch lives in [CalendarPrefs]), or its notifications
|
||||
* turned off in Calendula's own settings (#272).
|
||||
* The single choke point for "this calendar is switched off", covering the
|
||||
* two paths that reach [post] around the scan's own filter: a snooze armed
|
||||
* before the switch-off, and a read-only install whose switch lives in
|
||||
* [CalendarPrefs].
|
||||
*/
|
||||
private suspend fun isSilenced(calendarId: Long): Boolean =
|
||||
calendarId in settingsPrefs.mutedReminderCalendarIds.first() ||
|
||||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
||||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
||||
calendarDataSource.isCalendarVisible(calendarId) == false
|
||||
|
||||
/**
|
||||
* Post [alert] in its calendar's alert style (#171), unless the calendar is
|
||||
* silenced or gone. [ring] false posts a ringing style as a full-screen
|
||||
* alert that sounds once instead; [quiet] posts a plain notification that
|
||||
* makes no sound. Both are how a ring hands a reminder back to the shade.
|
||||
*/
|
||||
suspend fun post(alert: ReminderAlert, quiet: Boolean = false, ring: Boolean = true) {
|
||||
/** Post [alert], unless its calendar is switched off. */
|
||||
suspend fun post(alert: ReminderAlert) {
|
||||
if (isSilenced(alert.calendarId)) return
|
||||
var style = when {
|
||||
quiet -> ReminderAlertStyle.Notification
|
||||
else -> effectiveStyle(alert).let {
|
||||
if (it == ReminderAlertStyle.FullScreenRing && !ring) ReminderAlertStyle.FullScreen else it
|
||||
}
|
||||
}
|
||||
// Rung already and handed back to the shade: a catch-up post leaves it there.
|
||||
if (style == ReminderAlertStyle.FullScreenRing && (isShowing(alert) || alerts.isPending(alert))) return
|
||||
val channelId = channelFor(alert, style)
|
||||
// The ring channel could not be made: still full-screen, sounding once.
|
||||
?: if (style == ReminderAlertStyle.FullScreenRing) {
|
||||
style = ReminderAlertStyle.FullScreen
|
||||
channelFor(alert, style) ?: return
|
||||
} else {
|
||||
return
|
||||
}
|
||||
val title = displayTitle(alert)
|
||||
val text = contentText(alert)
|
||||
if (style == ReminderAlertStyle.FullScreenRing) {
|
||||
if (ReminderRingService.start(context, alerts, alert, build(alert, channelId, title, text, style, quiet = false))) {
|
||||
// An earlier reminder for this occurrence gives way to the ring.
|
||||
NotificationManagerCompat.from(context).cancel(alert.occurrenceKey.toString(), NOTIFICATION_ID)
|
||||
cancelLegacy(alert)
|
||||
return
|
||||
}
|
||||
// Android refused the ring: still full-screen, sounding once on the calendar's channel.
|
||||
stopRing(RingMatch.Occurrence(alert.occurrenceKey))
|
||||
val fallback = channelFor(alert, ReminderAlertStyle.FullScreen) ?: return
|
||||
notify(alert, build(alert, fallback, title, text, ReminderAlertStyle.FullScreen, quiet = false))
|
||||
return
|
||||
}
|
||||
// A fresh reminder replaces a ring for its occurrence from before the style changed.
|
||||
if (ring) stopRing(RingMatch.Occurrence(alert.occurrenceKey))
|
||||
notify(alert, build(alert, channelId, title, text, style, quiet))
|
||||
}
|
||||
|
||||
/** Whether a ring for [alert] should go on, now that settings may have changed since it started. */
|
||||
suspend fun ringVerdict(alert: ReminderAlert): RingVerdict = when {
|
||||
!settingsPrefs.remindersEnabled.first() || !canPost() || isSilenced(alert.calendarId) ||
|
||||
calendarDataSource.isCalendarVisible(alert.calendarId) == null -> RingVerdict.Drop
|
||||
effectiveStyle(alert) != ReminderAlertStyle.FullScreenRing -> RingVerdict.HandBack
|
||||
else -> RingVerdict.Ring
|
||||
}
|
||||
|
||||
/** Whether a live ring for [alert] may go on: its notification can still be seen, and its calendar still notifies. */
|
||||
suspend fun canStillRing(alert: ReminderAlert): Boolean =
|
||||
canPost() && channels.canRing(alert.calendarId) && !isSilenced(alert.calendarId)
|
||||
|
||||
/** The calendar's style, short of ringing where the user blocked its calendar, group or the ring channel. */
|
||||
private suspend fun effectiveStyle(alert: ReminderAlert): ReminderAlertStyle {
|
||||
val chosen = settingsPrefs.reminderAlertStyles.first().alertStyleFor(alert.calendarId)
|
||||
return if (chosen == ReminderAlertStyle.FullScreenRing && !channels.canRing(alert.calendarId)) {
|
||||
ReminderAlertStyle.FullScreen
|
||||
} else {
|
||||
chosen
|
||||
}
|
||||
}
|
||||
|
||||
private fun channelFor(alert: ReminderAlert, style: ReminderAlertStyle): String? = try {
|
||||
channels.channelFor(alert.calendarId, style).also {
|
||||
if (it == null) Log.i(TAG, "Dropped the reminder of event ${alert.eventId}: calendar ${alert.calendarId} is gone")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "No channel for the reminder of event ${alert.eventId}", e)
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun contentText(alert: ReminderAlert): String =
|
||||
listOfNotNull(timeText(alert), alert.location).joinToString(" · ")
|
||||
|
||||
/** When [alert]'s occurrence happens, as its notification words it. */
|
||||
suspend fun timeText(alert: ReminderAlert): String {
|
||||
ensureChannel()
|
||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
val is24Hour = settingsPrefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
val zone = ZoneId.systemDefault()
|
||||
@@ -156,7 +73,7 @@ class ReminderNotifier @Inject constructor(
|
||||
// resolveFirstDay yields a kotlinx.datetime day; bridge it to java.time by
|
||||
// its shared ISO number (1..7) for the date math in reminderTimeText.
|
||||
val firstDayOfWeek = DayOfWeek.of(settingsPrefs.weekStart.first().resolveFirstDay(locale).isoDayNumber)
|
||||
return reminderTimeText(
|
||||
val time = reminderTimeText(
|
||||
beginMillis = alert.beginMillis,
|
||||
endMillis = alert.endMillis,
|
||||
isAllDay = alert.isAllDay,
|
||||
@@ -169,35 +86,18 @@ class ReminderNotifier @Inject constructor(
|
||||
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
|
||||
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The reminder's notification in [style]. A ringing one belongs to
|
||||
* [ReminderRingService]: ongoing and opening the alert screen. A full-screen
|
||||
* one adds the alert screen as a full-screen intent where Android allows
|
||||
* it, degrading to a heads-up where it does not, and is dismissed by a
|
||||
* swipe, so its screen closes too.
|
||||
*/
|
||||
private fun build(
|
||||
alert: ReminderAlert,
|
||||
channelId: String,
|
||||
title: String,
|
||||
text: String,
|
||||
style: ReminderAlertStyle,
|
||||
quiet: Boolean,
|
||||
): Notification {
|
||||
val ringing = style == ReminderAlertStyle.FullScreenRing
|
||||
val fullScreen = style.isFullScreen
|
||||
val builder = NotificationCompat.Builder(context, channelId)
|
||||
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setWhen(alert.beginMillis)
|
||||
.setShowWhen(false)
|
||||
.setCategory(if (ringing) NotificationCompat.CATEGORY_ALARM else NotificationCompat.CATEGORY_EVENT)
|
||||
.setPriority(if (ringing) NotificationCompat.PRIORITY_MAX else NotificationCompat.PRIORITY_HIGH)
|
||||
.addExtras(Bundle().apply { putLong(EXTRA_REMINDER_KEY, alert.key) })
|
||||
.setContentIntent(if (ringing) alertScreenIntent(alert) else detailIntent(alert))
|
||||
.setCategory(NotificationCompat.CATEGORY_EVENT)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(detailIntent(alert))
|
||||
.addAction(
|
||||
R.drawable.ic_notification_snooze,
|
||||
context.getString(R.string.reminder_action_snooze),
|
||||
@@ -208,85 +108,21 @@ class ReminderNotifier @Inject constructor(
|
||||
context.getString(R.string.reminder_action_dismiss),
|
||||
actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
|
||||
)
|
||||
if (fullScreen) builder.setDeleteIntent(actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS))
|
||||
if (ringing) {
|
||||
builder.setOngoing(true).setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
} else {
|
||||
builder.setAutoCancel(true).setOnlyAlertOnce(shownKey(alert) == alert.key)
|
||||
}
|
||||
if (quiet) builder.setSilent(true)
|
||||
if (fullScreen && context.canUseFullScreenIntent()) builder.setFullScreenIntent(alertScreenIntent(alert), true)
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun notify(alert: ReminderAlert, notification: Notification) {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
cancelLegacy(alert)
|
||||
.build()
|
||||
try {
|
||||
manager.notify(alert.occurrenceKey.toString(), NOTIFICATION_ID, notification)
|
||||
NotificationManagerCompat.from(context)
|
||||
.notify(alert.key.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 [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.
|
||||
*/
|
||||
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
||||
fun cancel(alert: ReminderAlert) {
|
||||
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)
|
||||
stopRing(RingMatch.Key(alert.key))
|
||||
alerts.markHandled(alert)
|
||||
NotificationManagerCompat.from(context).cancel(alert.key.toString(), NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
/** Stop the ring if it is for a reminder [match]es; [handBack] keeps it in the shade, quietly. */
|
||||
fun stopRing(match: RingMatch, handBack: Boolean = false) {
|
||||
if (alerts.mayRing(match)) ReminderRingService.stop(context, alerts, match, handBack)
|
||||
}
|
||||
|
||||
/** Whether a different reminder for [alert]'s occurrence is on screen now. */
|
||||
fun isSuperseded(alert: ReminderAlert): Boolean {
|
||||
val ringing = alerts.ringing?.takeIf { it.occurrenceKey == alert.occurrenceKey }
|
||||
if (ringing != null) return ringing.key != alert.key
|
||||
return shownKey(alert).let { it != null && it != alert.key }
|
||||
}
|
||||
|
||||
/** Whether [alert] itself is still on screen, as a notification or ringing. */
|
||||
fun isShowing(alert: ReminderAlert): Boolean =
|
||||
alerts.ringing?.key == alert.key || shownKey(alert) == 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 == LEGACY_REMINDER_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,
|
||||
@@ -295,13 +131,6 @@ class ReminderNotifier @Inject constructor(
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private fun alertScreenIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
ReminderActionReceiver.requestCode(alert, ReminderActionReceiver.ACTION_ALERT_SCREEN),
|
||||
ReminderAlertActivity.intent(context, alert),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
// Shares the per-(alert, action) request-code scheme with the buttons.
|
||||
@@ -312,13 +141,24 @@ class ReminderNotifier @Inject constructor(
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
/** Channel creation is idempotent; re-running refreshes the localized name. */
|
||||
private fun ensureChannel() {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.reminder_channel_name),
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = context.getString(R.string.reminder_channel_description)
|
||||
}
|
||||
NotificationManagerCompat.from(context).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ReminderNotifier"
|
||||
const val CHANNEL_ID = "reminders"
|
||||
|
||||
// One id, distinct tags: the tag (occurrence) already keys the
|
||||
// One id, distinct tags: the tag (alert id) 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"
|
||||
}
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.RingtoneManager
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.IntentCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderActionReceiver.Companion.putAlert
|
||||
import de.jeanlucmakiola.floret.ring.RingVibrator
|
||||
import de.jeanlucmakiola.floret.ring.RingWakeLock
|
||||
import de.jeanlucmakiola.floret.ring.TonePlayer
|
||||
import de.jeanlucmakiola.floret.ring.isInCall
|
||||
import de.jeanlucmakiola.floret.ring.startRingForeground
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Rings a reminder whose calendar asks for "Ring until dismissed" (#171): the
|
||||
* alarm sound looped with vibration, for as long as the reminder is neither
|
||||
* dismissed nor snoozed, up to [RING_TIMEOUT_MILLIS]. Then it goes quiet and
|
||||
* hands the reminder back to the shade as an ordinary notification.
|
||||
*
|
||||
* One reminder rings at a time. A new one takes over, and the one it replaces
|
||||
* goes back to the shade, silently, unless it was for the same occurrence.
|
||||
* Starts and stops both arrive as commands, so they are handled on the main
|
||||
* thread in the order they were sent.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ReminderRingService : Service() {
|
||||
|
||||
@Inject lateinit var alerts: FullScreenAlerts
|
||||
@Inject lateinit var notifier: ReminderNotifier
|
||||
|
||||
@Inject @ApplicationScope
|
||||
lateinit var appScope: CoroutineScope
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
private val tones by lazy { TonePlayer(this) }
|
||||
private val vibrator by lazy { RingVibrator(this) }
|
||||
private val wakeLock by lazy { RingWakeLock(this, WAKE_LOCK_TAG, RING_TIMEOUT_MILLIS) }
|
||||
|
||||
private var ringing: Job? = null
|
||||
private var timeout: Job? = null
|
||||
private var notificationId = RING_NOTIFICATION_ID
|
||||
private var lastStartId: Int? = null
|
||||
|
||||
/** The start the system would redeliver, so a takeover can mark it done. */
|
||||
private var ringStartId: Int? = null
|
||||
|
||||
/** Whether the ring's players were ever built, so a stop-only service builds none. */
|
||||
private var rang = false
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
lastStartId = startId
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
stop(intent)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
val alert = intent?.let(ReminderActionReceiver::alertFrom)
|
||||
if (intent == null || alert == null) {
|
||||
stopIfIdle()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
try {
|
||||
return startRing(intent, alert, startId)
|
||||
} finally {
|
||||
// Only now, once alerts.ringing says whether it rings, so a stop sent meanwhile is never skipped.
|
||||
if (flags and START_FLAG_REDELIVERY == 0) alerts.removePending(alert)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRing(intent: Intent, alert: ReminderAlert, startId: Int): Int {
|
||||
val notification = IntentCompat.getParcelableExtra(intent, EXTRA_NOTIFICATION, Notification::class.java)
|
||||
if (notification == null) {
|
||||
stopIfIdle()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
val previous = alerts.ringing
|
||||
// A different reminder posts under the other id, so it is a new
|
||||
// notification whose full-screen intent opens; an update would not.
|
||||
val replacing = previous != null && previous.key != alert.key
|
||||
val shown = notificationId
|
||||
if (replacing) notificationId = if (shown == RING_NOTIFICATION_ID) OTHER_RING_NOTIFICATION_ID else RING_NOTIFICATION_ID
|
||||
val foreground = runCatching { startRingForeground(notificationId, notification) }
|
||||
if (foreground.isFailure) {
|
||||
// A restart after a process death may be refused a foreground start:
|
||||
// the reminder still shows full-screen, sounding once.
|
||||
Log.w(TAG, "Could not ring for event ${alert.eventId}", foreground.exceptionOrNull())
|
||||
notificationId = shown
|
||||
handBack(alert, quiet = false)
|
||||
stopIfIdle()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
if (replacing) NotificationManagerCompat.from(this).cancel(shown)
|
||||
// A duplicate start for this very ring leaves the original in charge of redelivery.
|
||||
if (previous?.key == alert.key) return START_NOT_STICKY
|
||||
// Only the newest start is redelivered after a process death; the older
|
||||
// ones are done, so a handled reminder never rings again.
|
||||
ringStartId?.let { stopSelfResult(it) }
|
||||
ringStartId = startId
|
||||
alerts.ringing = alert
|
||||
if (previous != null) {
|
||||
// Its screen closes either way; another occurrence goes back to the shade.
|
||||
if (previous.occurrenceKey != alert.occurrenceKey) handBack(previous) else alerts.markHandled(previous)
|
||||
}
|
||||
val since = intent.getLongExtra(EXTRA_SINCE, System.currentTimeMillis())
|
||||
ring(alert, remaining = since + RING_TIMEOUT_MILLIS - System.currentTimeMillis())
|
||||
return START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (rang) releaseRing()
|
||||
// Stopped from outside: its screen must not stay up for a ring that is gone.
|
||||
alerts.ringing?.let { if (alerts.handBackOnDestroy) handBack(it) else alerts.markHandled(it) }
|
||||
alerts.handBackOnDestroy = false
|
||||
alerts.ringing = null
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/** Rings for what is left of the ring's time, so a ring restarted after a process death does not begin again. */
|
||||
private fun ring(alert: ReminderAlert, remaining: Long) {
|
||||
releaseRing()
|
||||
rang = true
|
||||
wakeLock.acquire()
|
||||
timeout = scope.launch {
|
||||
delay(remaining.coerceAtLeast(0L))
|
||||
giveUp()
|
||||
}
|
||||
ringing = scope.launch {
|
||||
// Settings changed while this ring was on its way apply before it makes a sound.
|
||||
val verdict = try {
|
||||
withContext(Dispatchers.IO) { notifier.ringVerdict(alert) }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not re-check the ring for event ${alert.eventId}", e)
|
||||
RingVerdict.Ring
|
||||
}
|
||||
if (verdict != RingVerdict.Ring) {
|
||||
// Never heard yet, so it alerts once in its new style.
|
||||
if (verdict == RingVerdict.HandBack) handBack(alert, quiet = false)
|
||||
end()
|
||||
return@launch
|
||||
}
|
||||
sound(alert)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tone and vibration, paused for a phone call and back after it, until the
|
||||
* user hides the ring's notification, which would leave no way to stop it.
|
||||
*/
|
||||
private suspend fun sound(alert: ReminderAlert) {
|
||||
var sounding = false
|
||||
var tick = 0
|
||||
while (true) {
|
||||
if (tick++ % VISIBILITY_CHECK_TICKS == 0 && !stillRings(alert)) {
|
||||
handBack(alert)
|
||||
end()
|
||||
return
|
||||
}
|
||||
val inCall = isInCall()
|
||||
if (inCall && sounding) {
|
||||
tones.stop()
|
||||
vibrator.stop()
|
||||
sounding = false
|
||||
} else if (!inCall && !sounding) {
|
||||
vibrator.start()
|
||||
tones.play(scope, ringSources())
|
||||
sounding = true
|
||||
}
|
||||
delay(CALL_CHECK_MILLIS)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun stillRings(alert: ReminderAlert): Boolean = try {
|
||||
withContext(Dispatchers.IO) { notifier.canStillRing(alert) }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not re-check the ring for event ${alert.eventId}", e)
|
||||
true
|
||||
}
|
||||
|
||||
private fun stop(intent: Intent) {
|
||||
val current = alerts.ringing
|
||||
if (current != null && matchFrom(intent).matches(current)) {
|
||||
if (intent.getBooleanExtra(EXTRA_HAND_BACK, false)) handBack(current)
|
||||
end()
|
||||
} else {
|
||||
stopIfIdle()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rung long enough: back to the shade quietly. */
|
||||
private fun giveUp() {
|
||||
timeout = null
|
||||
alerts.ringing?.let(::handBack)
|
||||
end()
|
||||
}
|
||||
|
||||
/** Ends the ring, closes its screen, and stops the service unless a newer command is on its way. */
|
||||
private fun end() {
|
||||
alerts.ringing?.let(alerts::markHandled)
|
||||
releaseRing()
|
||||
alerts.ringing = null
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopIfIdle()
|
||||
}
|
||||
|
||||
/** Declines while a newer command is still on its way. */
|
||||
private fun stopIfIdle() {
|
||||
if (alerts.ringing == null) lastStartId?.let { stopSelfResult(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* [alert] back in the shade, in the app's scope so a service stopped
|
||||
* meanwhile cannot cancel it, and its alert screen closed. A newer reminder
|
||||
* for the occurrence that took its place meanwhile is left alone.
|
||||
*/
|
||||
private fun handBack(alert: ReminderAlert, quiet: Boolean = true) {
|
||||
alerts.markHandled(alert)
|
||||
appScope.launch {
|
||||
try {
|
||||
if (!notifier.isSuperseded(alert)) notifier.post(alert, quiet = quiet, ring = false)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not hand the reminder of event ${alert.eventId} back", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseRing() {
|
||||
ringing?.cancel()
|
||||
ringing = null
|
||||
timeout?.cancel()
|
||||
timeout = null
|
||||
tones.stop()
|
||||
vibrator.stop()
|
||||
wakeLock.release()
|
||||
}
|
||||
|
||||
/** The device's alarm sound; "None", or none opening, leaves the vibration. */
|
||||
private fun ringSources() = listOfNotNull(
|
||||
RingtoneManager.getActualDefaultRingtoneUri(this, RingtoneManager.TYPE_ALARM),
|
||||
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM),
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ReminderRingService"
|
||||
private const val ACTION_RING = "de.jeanlucmakiola.calendula.reminders.RING"
|
||||
private const val ACTION_STOP = "de.jeanlucmakiola.calendula.reminders.RING_STOP"
|
||||
private const val EXTRA_NOTIFICATION = "notification"
|
||||
private const val EXTRA_SINCE = "since"
|
||||
private const val CALL_CHECK_MILLIS = 1_000L
|
||||
|
||||
/** How many call checks between re-reading whether the ring's notification can still be seen. */
|
||||
private const val VISIBILITY_CHECK_TICKS = 5
|
||||
private const val EXTRA_MATCH_KIND = "match_kind"
|
||||
private const val EXTRA_MATCH_VALUE = "match_value"
|
||||
private const val EXTRA_HAND_BACK = "hand_back"
|
||||
private const val WAKE_LOCK_TAG = "calendula:reminder-ring"
|
||||
|
||||
/** Clear of the reminders' tagged id 1 and the backup notice's 2. */
|
||||
private const val RING_NOTIFICATION_ID = 171
|
||||
private const val OTHER_RING_NOTIFICATION_ID = 172
|
||||
|
||||
/** How long a reminder rings unanswered before it goes quiet. */
|
||||
const val RING_TIMEOUT_MILLIS = 10L * 60 * 1000
|
||||
|
||||
/**
|
||||
* Rings [alert] under [notification]. False when Android refused to
|
||||
* start the service from where the reminder fired.
|
||||
*/
|
||||
fun start(context: Context, alerts: FullScreenAlerts, alert: ReminderAlert, notification: Notification): Boolean {
|
||||
val intent = Intent(context, ReminderRingService::class.java)
|
||||
.setAction(ACTION_RING)
|
||||
.putAlert(alert)
|
||||
.putExtra(EXTRA_NOTIFICATION, notification)
|
||||
.putExtra(EXTRA_SINCE, System.currentTimeMillis())
|
||||
alerts.addPending(alert)
|
||||
return try {
|
||||
RingWakeLock.handOff(context, WAKE_LOCK_TAG)
|
||||
context.startForegroundService(intent)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not start the ring for event ${alert.eventId}", e)
|
||||
alerts.removePending(alert)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the ring if it is for a reminder [match]es, in order with the
|
||||
* starts sent before it. [handBack] puts it back in the shade quietly.
|
||||
* A command rather than `stopService`, which would also drop a start
|
||||
* still on its way; sent while the ring's foreground service keeps the
|
||||
* app allowed to.
|
||||
*/
|
||||
fun stop(context: Context, alerts: FullScreenAlerts, match: RingMatch, handBack: Boolean = false) {
|
||||
val (kind, value) = when (match) {
|
||||
is RingMatch.Key -> 0 to match.key
|
||||
is RingMatch.Occurrence -> 1 to match.occurrenceKey
|
||||
is RingMatch.Calendar -> 2 to match.calendarId
|
||||
RingMatch.Any -> 3 to 0L
|
||||
}
|
||||
val intent = Intent(context, ReminderRingService::class.java)
|
||||
.setAction(ACTION_STOP)
|
||||
.putExtra(EXTRA_MATCH_KIND, kind)
|
||||
.putExtra(EXTRA_MATCH_VALUE, value)
|
||||
.putExtra(EXTRA_HAND_BACK, handBack)
|
||||
try {
|
||||
context.startService(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not stop the ring", e)
|
||||
// Refused from the background: nothing must ring on regardless,
|
||||
// unless a start is still on its way, which this would crash.
|
||||
if (!alerts.anyPending) {
|
||||
alerts.handBackOnDestroy = handBack
|
||||
context.stopService(Intent(context, ReminderRingService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchFrom(intent: Intent): RingMatch {
|
||||
val value = intent.getLongExtra(EXTRA_MATCH_VALUE, 0L)
|
||||
return when (intent.getIntExtra(EXTRA_MATCH_KIND, 3)) {
|
||||
0 -> RingMatch.Key(value)
|
||||
1 -> RingMatch.Occurrence(value)
|
||||
2 -> RingMatch.Calendar(value)
|
||||
else -> RingMatch.Any
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,6 @@ class ReminderScanner @Inject constructor(
|
||||
private val source: ReminderInstanceSource,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
private val notifier: ReminderNotifier,
|
||||
private val channels: ReminderChannels,
|
||||
private val snoozes: ReminderSnoozeScheduler,
|
||||
private val alarms: ReminderAlarmScheduler,
|
||||
private val state: ReminderStatePrefs,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
@@ -86,14 +84,10 @@ class ReminderScanner @Inject constructor(
|
||||
return
|
||||
}
|
||||
|
||||
syncChannels()
|
||||
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
|
||||
// Reach into the past too: an all-day "at time of event" encodes to a
|
||||
// negative offset, and a catch-up pass needs the occurrences it missed.
|
||||
// Calendars muted in-app (#272) plan nothing, so they arm no wake-ups.
|
||||
val muted = settingsPrefs.mutedReminderCalendarIds.first()
|
||||
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
|
||||
.filter { it.calendarId !in muted }
|
||||
val planned = planReminders(
|
||||
instances = occurrences,
|
||||
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId })
|
||||
@@ -110,11 +104,7 @@ class ReminderScanner @Inject constructor(
|
||||
)
|
||||
|
||||
if (notifier.canPost()) {
|
||||
schedule.due.forEach {
|
||||
val alert = it.toAlert()
|
||||
snoozes.cancel(alert)
|
||||
notifier.post(alert)
|
||||
}
|
||||
schedule.due.forEach { notifier.post(it.toAlert()) }
|
||||
}
|
||||
// Advance even when nothing could be posted, so muting notifications
|
||||
// doesn't build a backlog.
|
||||
@@ -122,19 +112,6 @@ class ReminderScanner @Inject constructor(
|
||||
alarms.scheduleScan(schedule.nextAlarmMillis)
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider change re-scans, so a calendar added, renamed or removed keeps
|
||||
* its channel in step here. Skipped while reminders are off, when the
|
||||
* channels have nothing to do. A failure never costs the reminders.
|
||||
*/
|
||||
private fun syncChannels() {
|
||||
try {
|
||||
channels.sync()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Reminder channel sync failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasReadCalendar(): Boolean = context.hasPermission(Manifest.permission.READ_CALENDAR)
|
||||
|
||||
/**
|
||||
|
||||
-12
@@ -33,16 +33,4 @@ 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,6 +1,5 @@
|
||||
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
|
||||
@@ -12,8 +11,6 @@ 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
|
||||
@@ -35,8 +32,7 @@ import kotlin.time.Instant as KInstant
|
||||
* - 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). A timed event
|
||||
* from local midnight to local midnight reads like one too, in [zone] (#296).
|
||||
* in UTC and the end day is the last *covered* day (issue #308).
|
||||
*/
|
||||
fun reminderTimeText(
|
||||
beginMillis: Long,
|
||||
@@ -51,17 +47,11 @@ fun reminderTimeText(
|
||||
tomorrowLabel: String,
|
||||
yesterdayLabel: String,
|
||||
): String {
|
||||
val wholeDays = !isAllDay && runsMidnightToMidnight(
|
||||
KInstant.fromEpochMilliseconds(beginMillis),
|
||||
KInstant.fromEpochMilliseconds(endMillis),
|
||||
zone.toKotlinTimeZone(),
|
||||
)
|
||||
if (isAllDay || wholeDays) {
|
||||
val dayZone = if (isAllDay) ZoneOffset.UTC else zone
|
||||
if (isAllDay) {
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
// (atZone().toLocalDate() instead of LocalDate.ofInstant — API 34+)
|
||||
val firstDay = Instant.ofEpochMilli(beginMillis).atZone(dayZone).toLocalDate()
|
||||
val lastDay = Instant.ofEpochMilli(endMillis).atZone(dayZone).toLocalDate()
|
||||
val firstDay = Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
.minusDays(1)
|
||||
.coerceAtLeast(firstDay)
|
||||
val start = relativeDayPrefix(
|
||||
|
||||
@@ -1,35 +1,18 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Lower values appear first in lists and get the upper visible lanes in grids. */
|
||||
fun EventInstance.displayPriority(zone: TimeZone): Int = when {
|
||||
isCrossedOut -> 3
|
||||
isDeclined -> 3
|
||||
spansMultipleDays(zone) -> 0
|
||||
coversWholeDays(zone) -> 1
|
||||
isAllDay -> 1
|
||||
else -> 2
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
/** 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 }
|
||||
@@ -42,8 +42,6 @@ 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 —
|
||||
@@ -115,7 +113,6 @@ enum class EventFormField {
|
||||
Reminders,
|
||||
Recurrence,
|
||||
Availability,
|
||||
Status,
|
||||
Visibility,
|
||||
Color,
|
||||
Attendees,
|
||||
@@ -181,7 +178,6 @@ 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
|
||||
@@ -217,7 +213,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: the user's own response, reminder methods, the
|
||||
* covered: status, the user's own response, reminder methods, the
|
||||
* organizer/resource rows, and a recurring event's duration.
|
||||
*/
|
||||
data class EditSnapshot(
|
||||
@@ -247,22 +243,6 @@ 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
|
||||
@@ -289,7 +269,6 @@ 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)
|
||||
@@ -332,5 +311,4 @@ fun EventDetail.toDuplicateForm(canModify: Boolean, zone: TimeZone): EventForm =
|
||||
).copy(
|
||||
rrule = null,
|
||||
calendarId = instance.calendarId.takeIf { canModify },
|
||||
status = status.forNewEvent(),
|
||||
)
|
||||
@@ -173,11 +173,8 @@ 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. An
|
||||
* all-day event becomes timed: one dragged out of the all-day strip.
|
||||
*/
|
||||
/** The form with new start and end, each in the form's own zone — a resize. All-day is untouched. */
|
||||
fun EventForm.resizedTo(newStart: Instant, newEnd: Instant, deviceZone: TimeZone): EventForm {
|
||||
val timed = copy(isAllDay = false)
|
||||
return timed.withTimes(newStart, newEnd, timed.resolvedZone(deviceZone))
|
||||
if (isAllDay) return this
|
||||
return withTimes(newStart, newEnd, resolvedZone(deviceZone))
|
||||
}
|
||||
@@ -2,7 +2,6 @@ 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
|
||||
@@ -73,8 +72,6 @@ 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,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -97,33 +94,16 @@ enum class 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)
|
||||
/** Shorthand for the declined case, which most surfaces test on its own (#180). */
|
||||
val EventInstance.isDeclined: Boolean get() = response == EventResponse.Declined
|
||||
|
||||
/**
|
||||
* 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
|
||||
* in [zone] rather than the UTC midnight they are stored on, so they only count
|
||||
* as ended once their local day is fully over.
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
||||
|
||||
/**
|
||||
* The zone this event's calendar dates live in: the device [zone] for timed
|
||||
@@ -150,20 +130,6 @@ 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,7 +2,6 @@ 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
|
||||
@@ -36,7 +35,6 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||
description = description.orEmpty(),
|
||||
reminders = semanticReminderMinutes(),
|
||||
availability = availability,
|
||||
status = status.forNewEvent(),
|
||||
rrule = recurrenceRule.normalizedRrule(),
|
||||
)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.domain.reminders
|
||||
|
||||
/** How a calendar's reminders get the user's attention (#171). */
|
||||
enum class ReminderAlertStyle(val storageValue: String) {
|
||||
/** An ordinary notification on the calendar's channel. */
|
||||
Notification("notification"),
|
||||
|
||||
/** A full-screen alert that sounds the calendar's notification sound once and stays until handled. */
|
||||
FullScreen("full_screen"),
|
||||
|
||||
/** A full-screen alert that rings and vibrates like an alarm until handled. */
|
||||
FullScreenRing("full_screen_ring"),
|
||||
;
|
||||
|
||||
val isFullScreen: Boolean get() = this != Notification
|
||||
|
||||
companion object {
|
||||
fun fromStorage(value: String): ReminderAlertStyle? = entries.firstOrNull { it.storageValue == value }
|
||||
}
|
||||
}
|
||||
|
||||
/** [calendarId]'s alert style; a calendar without one uses plain notifications. */
|
||||
fun Map<Long, ReminderAlertStyle>.alertStyleFor(calendarId: Long): ReminderAlertStyle =
|
||||
this[calendarId] ?: ReminderAlertStyle.Notification
|
||||
|
||||
/**
|
||||
* Parse `id=style` entries joined by `;`. Malformed entries and unknown styles
|
||||
* are dropped, so a garbled value degrades to plain notifications.
|
||||
*/
|
||||
fun parseAlertStyles(stored: String?): Map<Long, ReminderAlertStyle> =
|
||||
stored?.split(";").orEmpty().mapNotNull { entry ->
|
||||
val parts = entry.split("=")
|
||||
if (parts.size != 2) return@mapNotNull null
|
||||
val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null
|
||||
val style = ReminderAlertStyle.fromStorage(parts[1].trim()) ?: return@mapNotNull null
|
||||
calendarId to style
|
||||
}.toMap()
|
||||
|
||||
/** The stored form of [parseAlertStyles]; plain-notification entries are left out. */
|
||||
fun Map<Long, ReminderAlertStyle>.toStoredAlertStyles(): String =
|
||||
entries.filter { it.value != ReminderAlertStyle.Notification }
|
||||
.sortedBy { it.key }
|
||||
.joinToString(";") { (id, style) -> "$id=${style.storageValue}" }
|
||||
@@ -32,26 +32,19 @@ 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 of this one reminder, keying the snooze/dismiss
|
||||
* Stable identity, keying the notification tag and the snooze/dismiss
|
||||
* `PendingIntent`s. Survives reboot, re-scan and reinstall.
|
||||
*/
|
||||
val key: Long = mix64(occurrenceKey + minutes)
|
||||
}
|
||||
val key: Long = key(instance.eventId, instance.beginMillis, 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)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What one scan concluded: post these now, and wake up again at [nextAlarmMillis]. */
|
||||
@@ -153,8 +146,7 @@ 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]), and of several due on
|
||||
* one occurrence only the latest is kept — they'd replace each other anyway (#463). [nextAlarmMillis] is
|
||||
* whose event has ended are dropped ([isStillRelevant]). [nextAlarmMillis] is
|
||||
* capped at [horizonMillis] so the lookahead window keeps rolling forward.
|
||||
*/
|
||||
fun scheduleReminders(
|
||||
@@ -166,8 +158,7 @@ fun scheduleReminders(
|
||||
val due = planned
|
||||
.filter { it.alarmMillis in (lastFiredMillis + 1)..nowMillis }
|
||||
.filter { it.instance.isStillRelevant(nowMillis) }
|
||||
.groupBy { it.occurrenceKey }
|
||||
.values.map { siblings -> siblings.maxBy { it.alarmMillis } }
|
||||
.distinctBy { it.key }
|
||||
.sortedWith(compareBy({ it.instance.beginMillis }, { it.key }))
|
||||
val nextPending = planned
|
||||
.filter { it.alarmMillis > nowMillis }
|
||||
|
||||
@@ -485,8 +485,9 @@ fun CalendarHost(
|
||||
movableCalendarIds = movableCalendarIds,
|
||||
dragEnabled = dragToReschedule,
|
||||
move = { reschedule.move(it) },
|
||||
drops = reschedule.drops,
|
||||
inFlight = reschedule.inFlight,
|
||||
undoStarted = reschedule.undoStarted,
|
||||
abandoned = reschedule.abandoned,
|
||||
edit = onEditEvent,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.isCrossedOut
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
||||
@@ -59,16 +59,11 @@ 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,
|
||||
@@ -84,12 +79,7 @@ internal fun AgendaDayHeader(
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
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,
|
||||
),
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -148,7 +138,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.isCrossedOut),
|
||||
title = declinedTitle(title, event.isDeclined),
|
||||
summary = AnnotatedString(agendaTimeSummary(event, day, zone)),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
@@ -230,11 +220,10 @@ 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, compact = compact)
|
||||
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
||||
}
|
||||
if (day.events.isEmpty()) {
|
||||
item(key = "placeholder-${day.date}") {
|
||||
@@ -262,6 +251,6 @@ internal fun LazyListScope.agendaDays(
|
||||
)
|
||||
}
|
||||
}
|
||||
item(key = "gap-${day.date}") { Spacer(Modifier.height(if (compact) 4.dp else 8.dp)) }
|
||||
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
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
|
||||
@@ -433,7 +432,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 = FabClearance),
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||
) {
|
||||
agendaDays(
|
||||
days = days,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
||||
import de.jeanlucmakiola.calendula.domain.sortedForDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
||||
@@ -19,7 +18,7 @@ import kotlin.time.Instant
|
||||
* surface only formats the instants into its own locale/24h string.
|
||||
*/
|
||||
sealed interface AgendaTimeLabel {
|
||||
/** An event filling whole days (#296), or a whole in-between day of a multi-day span. */
|
||||
/** An all-day event, 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
|
||||
@@ -31,7 +30,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.coversWholeDays(zone)) return AgendaTimeLabel.AllDay
|
||||
if (event.isAllDay) return AgendaTimeLabel.AllDay
|
||||
val firstDay = event.spanFirstDay(zone)
|
||||
val lastDay = event.spanLastDay(zone)
|
||||
return when {
|
||||
@@ -88,7 +87,7 @@ fun groupAgendaDays(
|
||||
return byDay.map { (date, dayEvents) ->
|
||||
AgendaDay(
|
||||
date = date,
|
||||
events = dayEvents.sortedForDisplay(zone),
|
||||
events = dayEvents.sortedWith(eventDisplayComparator(zone)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -73,13 +72,11 @@ fun accountGroupTitle(group: CalendarAccountGroup): String {
|
||||
@Composable
|
||||
fun sourceAppName(accountType: String): String {
|
||||
val context = LocalContext.current
|
||||
return remember(accountType) { sourceAppLabel(context, accountType) }
|
||||
}
|
||||
|
||||
/** [sourceAppName] outside composition, e.g. for notification channel groups. */
|
||||
fun sourceAppLabel(context: Context, accountType: String): String {
|
||||
val pm = context.packageManager
|
||||
return sourceAppPackages(context, accountType).firstNotNullOfOrNull { pkg ->
|
||||
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
|
||||
} ?: accountType
|
||||
return remember(accountType) {
|
||||
val pm = context.packageManager
|
||||
val packages = sourceAppPackages(context, accountType)
|
||||
packages.firstNotNullOfOrNull { pkg ->
|
||||
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
|
||||
} ?: accountType
|
||||
}
|
||||
}
|
||||
@@ -30,9 +30,7 @@ fun CalendarDatePickerDialog(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val state = rememberDatePickerState(
|
||||
initialSelectedDateMillis = initial.coerceIn(CalendarSpan.FIRST_DAY, CalendarSpan.LAST_DAY)
|
||||
.toEpochDays() * MILLIS_PER_DAY,
|
||||
yearRange = CalendarSpan.years,
|
||||
initialSelectedDateMillis = initial.toEpochDays() * MILLIS_PER_DAY,
|
||||
)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
|
||||
@@ -38,9 +38,6 @@ 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(
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import java.util.Locale
|
||||
* out in [locale]'s own order, with the year shown only when [date] falls outside
|
||||
* [currentYear].
|
||||
*
|
||||
* Pass [skeleton] *without* a year field, e.g. "LLLL" for a month. A skeleton
|
||||
* is a field list, so wanting the year is just asking for one more field; the
|
||||
* locale still decides where it lands ("July 2026" vs "2026年7月").
|
||||
* Pass [skeleton] *without* a year field — "LLLL" for a month, "EEEdMMM" for a
|
||||
* day. A skeleton is a field list, so wanting the year is just asking for one
|
||||
* more field; the locale still decides where it lands ("July 2026" vs "2026年7月").
|
||||
*
|
||||
* Dropping the year in the current year is the one bit of policy here: the title
|
||||
* sits directly above a grid that already says which year it is, and the year's
|
||||
|
||||
@@ -11,7 +11,6 @@ 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
|
||||
@@ -30,7 +29,6 @@ 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
|
||||
@@ -43,7 +41,6 @@ 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
|
||||
|
||||
@@ -90,11 +90,7 @@ fun EventMoveHost(
|
||||
MoveOutcome.Undone -> stringResource(R.string.event_move_undone)
|
||||
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
|
||||
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
|
||||
MoveOutcome.Stale -> stringResource(R.string.event_move_stale)
|
||||
MoveOutcome.Skipped -> stringResource(R.string.event_move_skipped)
|
||||
MoveOutcome.QueueFull -> stringResource(R.string.event_move_queue_full)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import kotlinx.coroutines.delay
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -19,7 +16,6 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by
|
||||
@@ -41,18 +37,24 @@ class EventMoveScope(
|
||||
val dragEnabled: Boolean,
|
||||
/**
|
||||
* Hand a drop to the writer. Returns nothing on purpose: a drop is answered
|
||||
* before its write resolves, so [drops] — not a return value — is what tells
|
||||
* a held copy whether anything landed.
|
||||
* before its write resolves, so [abandoned] — not a return value — is what
|
||||
* tells a held copy that nothing landed.
|
||||
*/
|
||||
val move: (MoveRequest) -> Unit,
|
||||
/**
|
||||
* Each drop's progress by its token — see `RescheduleViewModel.drops`. A flow
|
||||
* rather than a value so this scope stays the same object across a move —
|
||||
* every visible block reads it as a composition local.
|
||||
* True while a dropped event is being written, including the time its scope
|
||||
* dialog is up. A flow rather than a value so this scope stays the same
|
||||
* object across a move — every visible block reads it as a composition local.
|
||||
*/
|
||||
val drops: StateFlow<DropStates>,
|
||||
/** The undo write that just began — see `RescheduleViewModel.undoStarted`. */
|
||||
val undoStarted: StateFlow<UndoStart?>,
|
||||
val inFlight: StateFlow<Boolean>,
|
||||
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
|
||||
val undoStarted: StateFlow<Int>,
|
||||
/**
|
||||
* Ticks when a drop ends with nothing landing — see
|
||||
* `RescheduleViewModel.abandoned`. [move] answers before the write is
|
||||
* resolved, so this is what tells a held copy to stop waiting.
|
||||
*/
|
||||
val abandoned: StateFlow<Int>,
|
||||
/** Open an event in the edit form — the pointer-free route to the same change. */
|
||||
val edit: (EventInstance) -> Unit,
|
||||
) {
|
||||
@@ -61,42 +63,30 @@ class EventMoveScope(
|
||||
|
||||
val LocalEventMove = compositionLocalOf<EventMoveScope?> { null }
|
||||
|
||||
private val moveTokens = AtomicLong()
|
||||
private val NEVER_IN_FLIGHT = MutableStateFlow(false)
|
||||
|
||||
/** A fresh identity for one drop or undo, unique for the process. */
|
||||
fun nextMoveToken(): Long = moveTokens.incrementAndGet()
|
||||
private val NEVER_UNDONE = MutableStateFlow(0)
|
||||
|
||||
/** How many abandoned drops [DropStates] remembers — far more than can be on screen at once. */
|
||||
private const val ABANDONED_KEPT = 32
|
||||
|
||||
/** Per-drop progress, by token — see `RescheduleViewModel.drops`. */
|
||||
@Immutable
|
||||
data class DropStates(
|
||||
val pending: Set<Long> = emptySet(),
|
||||
val abandoned: Set<Long> = emptySet(),
|
||||
) {
|
||||
fun started(token: Long): DropStates = copy(pending = pending + token)
|
||||
|
||||
fun ended(token: Long, landed: Boolean): DropStates = copy(
|
||||
pending = pending - token,
|
||||
// Insertion-ordered, so the oldest go first.
|
||||
abandoned = if (landed) abandoned else (abandoned + token).let { kept ->
|
||||
if (kept.size > ABANDONED_KEPT) kept.drop(kept.size - ABANDONED_KEPT).toSet() else kept
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private val NO_DROPS = MutableStateFlow(DropStates())
|
||||
|
||||
private val NEVER_UNDONE = MutableStateFlow<UndoStart?>(null)
|
||||
private val NEVER_ABANDONED = MutableStateFlow(0)
|
||||
|
||||
/**
|
||||
* Every drop's progress — empty wherever moving is off. A drag overlay keeps
|
||||
* the token of its own drop and holds its copy while that token is pending.
|
||||
* Whether a dropped event is still being written — false wherever moving is off.
|
||||
* The drag overlays hold a landed block on its target for this window.
|
||||
*/
|
||||
@Composable
|
||||
fun dropStates(): DropStates {
|
||||
val flow = LocalEventMove.current?.drops ?: NO_DROPS
|
||||
fun moveInFlight(): Boolean {
|
||||
val flow = LocalEventMove.current?.inFlight ?: NEVER_IN_FLIGHT
|
||||
return flow.collectAsStateWithLifecycle().value
|
||||
}
|
||||
|
||||
/**
|
||||
* How many drops have ended with nothing landing. Read as a plain count so a
|
||||
* drag overlay can capture it at the moment of the drop and tell, later, whether
|
||||
* *its own* drop was one of them.
|
||||
*/
|
||||
@Composable
|
||||
fun abandonedMoves(): Int {
|
||||
val flow = LocalEventMove.current?.abandoned ?: NEVER_ABANDONED
|
||||
return flow.collectAsStateWithLifecycle().value
|
||||
}
|
||||
|
||||
@@ -105,53 +95,17 @@ fun dropStates(): DropStates {
|
||||
* this composable came on screen.
|
||||
*/
|
||||
@Composable
|
||||
fun OnUndoStarted(onUndo: (UndoStart) -> Unit) {
|
||||
fun OnUndoStarted(onUndo: () -> Unit) {
|
||||
val flow = LocalEventMove.current?.undoStarted ?: NEVER_UNDONE
|
||||
val start by flow.collectAsStateWithLifecycle()
|
||||
var seen by remember { mutableStateOf(start) }
|
||||
LaunchedEffect(start) {
|
||||
val started = start
|
||||
if (started == null || started == seen) return@LaunchedEffect
|
||||
seen = started
|
||||
onUndo(started)
|
||||
val tick by flow.collectAsStateWithLifecycle()
|
||||
var seen by remember { mutableIntStateOf(tick) }
|
||||
LaunchedEffect(tick) {
|
||||
if (tick == seen) return@LaunchedEffect
|
||||
seen = tick
|
||||
onUndo()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The life of a drop parked by a newer pickup (#494): held while its [token]
|
||||
* is pending, then a beat after the grid [seated] it — or [timeoutMillis] if it
|
||||
* never does — handed over and faded, then released. Returns the copy's alpha.
|
||||
* An abandoned drop skips the wait; nothing is coming to seat it.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberHeldSettle(
|
||||
token: Long,
|
||||
seated: Boolean,
|
||||
timeoutMillis: Long,
|
||||
onHandOver: () -> Unit,
|
||||
onRelease: () -> Unit,
|
||||
): Float {
|
||||
val drops = dropStates()
|
||||
val pending = token in drops.pending
|
||||
val abandoned = token in drops.abandoned
|
||||
val handOver by rememberUpdatedState(onHandOver)
|
||||
val release by rememberUpdatedState(onRelease)
|
||||
var fading by remember(token) { mutableStateOf(false) }
|
||||
LaunchedEffect(token, pending, abandoned, seated) {
|
||||
if (pending) return@LaunchedEffect
|
||||
if (!abandoned) delay(if (seated) SETTLE_GRACE_MILLIS else timeoutMillis)
|
||||
fading = true
|
||||
handOver()
|
||||
delay(SETTLE_FADE_MILLIS.toLong())
|
||||
release()
|
||||
}
|
||||
return animateFloatAsState(
|
||||
targetValue = if (fading) 0f else 1f,
|
||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||
label = "held-settle",
|
||||
).value
|
||||
}
|
||||
|
||||
/** Opacity the source block keeps while its floating copy travels. */
|
||||
const val GHOST_ALPHA: Float = 0.3f
|
||||
|
||||
|
||||
@@ -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.isCrossedOut
|
||||
import de.jeanlucmakiola.calendula.domain.isOutlined
|
||||
import de.jeanlucmakiola.calendula.domain.EventResponse
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
|
||||
/** 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) and on the event's own status (#388).
|
||||
* invitation (#180, #230).
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
@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.isOutlined) {
|
||||
if (event.response == EventResponse.Invited) {
|
||||
// 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.isCrossedOut),
|
||||
decoration = declinedDecoration(event.isDeclined),
|
||||
titleWeight = null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@ 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
|
||||
|
||||
@@ -42,17 +45,33 @@ 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 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.
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberWidestTimeSample(): String {
|
||||
fun rememberInlineTimeWidth(style: TextStyle): Dp {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
val locale = currentLocale()
|
||||
val is24Hour = LocalUse24HourFormat.current
|
||||
return remember(is24Hour, locale) { formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, is24Hour, locale) }
|
||||
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() }
|
||||
}
|
||||
}
|
||||
@@ -56,8 +56,7 @@ 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. Its content ends in the same [FabClearance]
|
||||
* the day columns do, or the two drift apart at the bottom of the scroll.
|
||||
* row the block would land on.
|
||||
*/
|
||||
@Composable
|
||||
fun HourGutter(
|
||||
@@ -87,8 +86,7 @@ fun HourGutter(
|
||||
.width(GUTTER_WIDTH)
|
||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(bottom = FabClearance),
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
Column {
|
||||
(0 until 24).forEach { h ->
|
||||
|
||||
@@ -70,24 +70,27 @@ 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 [renderedSpan] enters the viewport.
|
||||
* part of its rendered height enters the viewport.
|
||||
*/
|
||||
fun outOfViewEvents(
|
||||
blocks: List<TimedBlock>,
|
||||
topMin: Float,
|
||||
bottomMin: Float,
|
||||
): OutOfViewEvents {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,9 +107,8 @@ fun revealScrollTarget(
|
||||
marginPx: Float,
|
||||
maxScroll: Int,
|
||||
): Int {
|
||||
val span = block.renderedSpan()
|
||||
val startPx = span.topMin / 60f * hourPx
|
||||
val endPx = span.bottomMin / 60f * hourPx
|
||||
val startPx = block.startMin / 60f * hourPx
|
||||
val endPx = renderedEndMin(block) / 60f * hourPx
|
||||
val target = when (edge) {
|
||||
ViewportEdge.Top -> startPx - marginPx
|
||||
ViewportEdge.Bottom -> minOf(endPx + marginPx - viewportPx, startPx - marginPx)
|
||||
@@ -291,7 +293,6 @@ 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,
|
||||
@@ -302,8 +303,8 @@ private fun EdgeBar(
|
||||
.height(EDGE_TOUCH_HEIGHT)
|
||||
.graphicsLayer {
|
||||
val gapPx = when (edge) {
|
||||
ViewportEdge.Top -> scrollState.value - span.bottomMin / 60f * hourPx
|
||||
ViewportEdge.Bottom -> span.topMin / 60f * hourPx -
|
||||
ViewportEdge.Top -> scrollState.value - renderedEndMin(block) / 60f * hourPx
|
||||
ViewportEdge.Bottom -> block.startMin / 60f * hourPx -
|
||||
(scrollState.value + scrollState.viewportSize)
|
||||
}
|
||||
alpha = edgeBarAlpha(gapPx, fadeLeadPx)
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
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.delay
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* @param keep whether a cached key is still worth holding once [get] asks for another.
|
||||
*/
|
||||
@@ -30,54 +26,28 @@ class PageStateCache<K, S>(
|
||||
private val keep: (cached: K, requested: K) -> Boolean,
|
||||
private val load: (K) -> Flow<S>,
|
||||
) {
|
||||
private val entries = HashMap<K, Entry>()
|
||||
private class Entry<S>(val state: StateFlow<S>, val job: Job)
|
||||
|
||||
private val entries = HashMap<K, Entry<S>>()
|
||||
|
||||
/** The number of entries held, for tests. */
|
||||
internal val size: Int get() = entries.size
|
||||
|
||||
fun get(key: K): StateFlow<S> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
val iterator = entries.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val (cached, entry) = iterator.next()
|
||||
if (cached != key && !keep(cached, key)) {
|
||||
entry.job.cancel()
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Long enough to ride out a configuration change, as `stateWhileSubscribed` does. */
|
||||
const val STOP_TIMEOUT_MILLIS = 5_000L
|
||||
return entries.getOrPut(key) {
|
||||
val job = SupervisorJob(scope.coroutineContext[Job])
|
||||
Entry(
|
||||
state = load(key).stateWhileSubscribed(scope + job, initial),
|
||||
job = job,
|
||||
)
|
||||
}.state
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ fun EventInstance.isPastOn(
|
||||
day: LocalDate,
|
||||
now: Instant,
|
||||
zone: TimeZone = TimeZone.currentSystemDefault(),
|
||||
): Boolean = hasEnded(now, zone) || day < now.toLocalDateTime(zone).date
|
||||
): Boolean = hasEnded(now) || day < now.toLocalDateTime(zone).date
|
||||
|
||||
/** Opacity applied to a completed/past event chip when it is dimmed. */
|
||||
const val EventDimAlpha = 0.4f
|
||||
@@ -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(isCrossedOut: Boolean): TextDecoration? =
|
||||
if (isCrossedOut) TextDecoration.LineThrough else null
|
||||
fun declinedDecoration(isDeclined: Boolean): TextDecoration? =
|
||||
if (isDeclined) TextDecoration.LineThrough else null
|
||||
|
||||
/** [declinedDecoration] for the rows that take styled text rather than a `String`. */
|
||||
fun declinedTitle(title: String, isCrossedOut: Boolean): AnnotatedString = if (isCrossedOut) {
|
||||
fun declinedTitle(title: String, isDeclined: Boolean): AnnotatedString = if (isDeclined) {
|
||||
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, isCrossedOut: Boolean): AnnotatedString =
|
||||
if (!isCrossedOut) {
|
||||
fun declinedTitle(title: AnnotatedString, isDeclined: Boolean): AnnotatedString =
|
||||
if (!isDeclined) {
|
||||
title
|
||||
} else {
|
||||
buildAnnotatedString {
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
@@ -32,7 +31,6 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -41,21 +39,12 @@ import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.TimeMark
|
||||
import kotlin.time.TimeSource
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val MILLIS_PER_MINUTE = 60_000
|
||||
private const val MINUTES_PER_DAY = 24 * 60
|
||||
|
||||
/** How many drops may wait behind the one in flight before more are refused. */
|
||||
private const val MAX_QUEUED = 8
|
||||
|
||||
/** How long after a write its event's drawn times may still be the old ones. */
|
||||
private val RECENT_WRITE = 3.seconds
|
||||
|
||||
/** Where a dragged event should land. */
|
||||
sealed interface MoveTarget {
|
||||
/** A new start instant — a timeline drag, which moves time and day at once. */
|
||||
@@ -68,10 +57,7 @@ sealed interface MoveTarget {
|
||||
*/
|
||||
data class ByDays(val days: Int) : MoveTarget
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** New start and end instants — a timeline resize, which changes the length. */
|
||||
data class Span(val start: Instant, val end: Instant) : MoveTarget
|
||||
}
|
||||
|
||||
@@ -84,10 +70,6 @@ data class MoveRequest(
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
val target: MoveTarget,
|
||||
/** Whether the dragged occurrence is all-day, which decides the zone its day is read in. */
|
||||
val isAllDay: Boolean,
|
||||
/** The drop's own identity, from [nextMoveToken], which the view holding its copy waits on. */
|
||||
val token: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -106,13 +88,8 @@ data class MoveUndo(
|
||||
val moved: EventForm,
|
||||
/** The form as it stood before the move. */
|
||||
val restored: EventForm,
|
||||
/** The token of the drop this undoes, so the view that drew it carries the right chip back. */
|
||||
val dropToken: Long,
|
||||
)
|
||||
|
||||
/** An undo write that just began: its own [token], and the [dropToken] of the drop it undoes. */
|
||||
data class UndoStart(val token: Long, val dropToken: Long)
|
||||
|
||||
sealed interface MoveOutcome {
|
||||
/**
|
||||
* Written. [startMillis] is where the dragged occurrence now begins, for the
|
||||
@@ -134,28 +111,9 @@ sealed interface MoveOutcome {
|
||||
/** The event vanished (sync, another device) between the read and the write. */
|
||||
data object Gone : MoveOutcome
|
||||
|
||||
/** Drops that waited behind a scope dialog, cancelled rather than run after it (#494). */
|
||||
data object Skipped : MoveOutcome
|
||||
|
||||
/** More drops waiting than the queue holds; this one was not taken. */
|
||||
data object QueueFull : MoveOutcome
|
||||
|
||||
/**
|
||||
* The drop's recurring occurrence may no longer be where it was picked up —
|
||||
* its series was just written — and can't be followed there, so it is not
|
||||
* written (#494).
|
||||
*/
|
||||
data object Stale : 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
|
||||
}
|
||||
|
||||
@@ -184,32 +142,36 @@ class RescheduleViewModel @Inject constructor(
|
||||
|
||||
private val _inFlight = MutableStateFlow(false)
|
||||
|
||||
/** True while any drop or undo runs or waits — what the confirmation chip holds for. */
|
||||
/**
|
||||
* True from the moment a drop is accepted until its write settles — the
|
||||
* window the dropped block holds its landing position for.
|
||||
*/
|
||||
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
|
||||
|
||||
private val _drops = MutableStateFlow(DropStates())
|
||||
private val _abandoned = MutableStateFlow(0)
|
||||
|
||||
/**
|
||||
* Each drop's own state, by its [MoveRequest.token]: pending from the moment
|
||||
* it is accepted until its write settles, and abandoned once it ends with
|
||||
* nothing on the target day — refused, failed, or its scope dialog dismissed.
|
||||
* Per drop, so a queued drop's held copy waits on its own write and is never
|
||||
* let go by another drop's failure (#253, #494).
|
||||
* Ticks when a drop ends with nothing on the target day: refused outright,
|
||||
* refused before the write, written and failed, or its scope dialog
|
||||
* dismissed. [move] answers before any of that is known, so without this
|
||||
* signal the view that drew the drop held its copy out for the full settle
|
||||
* timeout waiting for a grid that was never going to draw it (#253).
|
||||
*/
|
||||
val drops: StateFlow<DropStates> = _drops.asStateFlow()
|
||||
val abandoned: StateFlow<Int> = _abandoned.asStateFlow()
|
||||
|
||||
private val _undoStarted = MutableStateFlow<UndoStart?>(null)
|
||||
private val _undoStarted = MutableStateFlow(0)
|
||||
|
||||
/**
|
||||
* An undo write the moment it begins, before the provider has anything to
|
||||
* Ticks the moment an undo write begins, before the provider has anything to
|
||||
* re-read, so the view that drew the drop can carry its chip back rather than
|
||||
* let it reappear on the old day. Its fate then reads off [drops].
|
||||
* let it reappear on the old day.
|
||||
*/
|
||||
val undoStarted: StateFlow<UndoStart?> = _undoStarted.asStateFlow()
|
||||
val undoStarted: StateFlow<Int> = _undoStarted.asStateFlow()
|
||||
|
||||
/**
|
||||
* Set while a drop is being prepared, waits on its scope dialog, or is
|
||||
* written. Drops arriving in that window wait in [queue] and run in order.
|
||||
* Set from the moment a drop is accepted until its write settles. Two drops
|
||||
* of the same recurring event inside that window would compute their shift
|
||||
* from the same pre-move occurrence, and the shifts would compound.
|
||||
*/
|
||||
private var busy = false
|
||||
set(value) {
|
||||
@@ -217,22 +179,6 @@ class RescheduleViewModel @Inject constructor(
|
||||
_inFlight.value = value
|
||||
}
|
||||
|
||||
/** Whether a move has been announced since the pipeline was last idle — see [report]. */
|
||||
private var movedThisRun = false
|
||||
|
||||
/** Outcomes waiting for the chip, shown one after another as each is consumed. */
|
||||
private val notices = ArrayDeque<MoveOutcome>()
|
||||
|
||||
/**
|
||||
* When each event's series (by id) or single occurrence (by id and begin)
|
||||
* was last written. The grid draws the old times until it re-reads, so a
|
||||
* drop on a recurring event written moments ago may carry stale times (#494).
|
||||
*/
|
||||
private val written = mutableMapOf<Pair<Long, Long?>, TimeMark>()
|
||||
|
||||
/** Drops that arrived while another was in flight, oldest first (#494). */
|
||||
private val queue = ArrayDeque<MoveRequest>()
|
||||
|
||||
/**
|
||||
* The calendars whose events may be dragged. Nothing below the UI guards
|
||||
* this, so a block outside this set registers no drag gesture at all.
|
||||
@@ -257,63 +203,43 @@ 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: run it now, or queue it behind the ones in flight. False means
|
||||
* nothing will be written — the queue is full — and the drop is marked
|
||||
* abandoned in [drops], so its held copy is let go by the one signal rather
|
||||
* than by every caller remembering to.
|
||||
* Take a drop, unless one is already being written. False means nothing will
|
||||
* be written; [abandoned] ticks for that refusal too, so a held copy is let
|
||||
* go by the one signal rather than by every caller remembering to.
|
||||
*
|
||||
* The Boolean is diagnostic — the tests read it, [EventMoveScope] does not
|
||||
* hand it on. It answers before the write is resolved anyway.
|
||||
* hand it on. A caller that gated on it would be back to releasing its copy
|
||||
* from two places, and it answers before the write is resolved anyway.
|
||||
*/
|
||||
fun move(request: MoveRequest): Boolean {
|
||||
if (queue.size >= MAX_QUEUED) {
|
||||
_drops.update { it.ended(request.token, landed = false) }
|
||||
report(MoveOutcome.QueueFull)
|
||||
if (busy || _scopePrompt.value != null) {
|
||||
_abandoned.value += 1
|
||||
return false
|
||||
}
|
||||
if (_scopePrompt.value != null) {
|
||||
_drops.update { it.ended(request.token, landed = false) }
|
||||
report(MoveOutcome.Skipped)
|
||||
return false
|
||||
}
|
||||
_drops.update { it.started(request.token) }
|
||||
if (busy) queue.addLast(request) else run(request)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun run(request: MoveRequest) {
|
||||
busy = true
|
||||
viewModelScope.launch {
|
||||
val prepared = prepare(request)
|
||||
if (prepared == null) {
|
||||
settle(request.token, landed = false)
|
||||
_abandoned.value += 1
|
||||
busy = false
|
||||
return@launch
|
||||
}
|
||||
// A cancelled series only moves as a whole (#388).
|
||||
val seriesOnly = !prepared.original.canSaveApartFromSeries()
|
||||
if (!prepared.isRecurring || (seriesOnly && prepared.canRealign)) {
|
||||
settle(request.token, write(prepared, RecurringWriteScope.AllEvents))
|
||||
} else if (seriesOnly) {
|
||||
report(MoveOutcome.BlockedCancelledSeries)
|
||||
settle(request.token, landed = false)
|
||||
if (!prepared.isRecurring) {
|
||||
write(prepared, RecurringWriteScope.AllEvents)
|
||||
busy = false
|
||||
} else {
|
||||
// Still busy: the scope dialog is now the thing in flight. Drops
|
||||
// waiting behind it are cancelled, not held behind a question
|
||||
// about another event.
|
||||
skipQueued()
|
||||
// Still busy: the scope dialog is now the thing in flight.
|
||||
pending = prepared
|
||||
_scopePrompt.value = MoveScopePrompt(
|
||||
occurrenceOnly = !prepared.canRealign,
|
||||
resize = prepared.isResize,
|
||||
resize = request.target is MoveTarget.Span,
|
||||
)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Answer the scope dialog. */
|
||||
@@ -324,92 +250,35 @@ class RescheduleViewModel @Inject constructor(
|
||||
pending = null
|
||||
_scopePrompt.value = null
|
||||
viewModelScope.launch {
|
||||
settle(prepared.request.token, write(prepared, scope))
|
||||
write(prepared, scope)
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss the scope dialog without writing. */
|
||||
fun cancelScope() {
|
||||
val prepared = pending ?: return
|
||||
pending = null
|
||||
busy = false
|
||||
_scopePrompt.value = null
|
||||
settle(prepared.request.token, landed = false)
|
||||
}
|
||||
|
||||
/** End the drop [token], then run the next queued one, if any. */
|
||||
private fun settle(token: Long, landed: Boolean) {
|
||||
_drops.update { it.ended(token, landed) }
|
||||
// Straight on to the next, so busy never reads false between two drops.
|
||||
val next = queue.removeFirstOrNull()
|
||||
if (next != null) {
|
||||
run(next)
|
||||
} else {
|
||||
busy = false
|
||||
movedThisRun = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun skipQueued() {
|
||||
if (queue.isEmpty()) return
|
||||
val skipped = queue.map { it.token }
|
||||
queue.clear()
|
||||
_drops.update { states -> skipped.fold(states) { acc, token -> acc.ended(token, landed = false) } }
|
||||
report(MoveOutcome.Skipped)
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue [outcome] for the chip. While drops run back to back only the first
|
||||
* move is announced, so its Undo stays on offer; every failure gets its own
|
||||
* turn after it, since the grid shows what moved but only the chip says what
|
||||
* didn't. A later write to the announced move's event takes that Undo away,
|
||||
* as putting the event back would undo the later move too.
|
||||
*/
|
||||
private fun report(outcome: MoveOutcome, eventId: Long? = null) {
|
||||
if (outcome is MoveOutcome.Moved) {
|
||||
if (movedThisRun) {
|
||||
dropUndoFor(eventId)
|
||||
return
|
||||
}
|
||||
movedThisRun = true
|
||||
} else if (outcome == (notices.lastOrNull() ?: _outcome.value)) {
|
||||
return
|
||||
}
|
||||
if (_outcome.value == null) _outcome.value = outcome else notices.addLast(outcome)
|
||||
}
|
||||
|
||||
private fun dropUndoFor(eventId: Long?) {
|
||||
fun MoveOutcome.withoutUndo(): MoveOutcome =
|
||||
if (this is MoveOutcome.Moved && undo?.eventId == eventId) copy(undo = null) else this
|
||||
_outcome.value = _outcome.value?.withoutUndo()
|
||||
val rest = notices.map { it.withoutUndo() }
|
||||
notices.clear()
|
||||
notices.addAll(rest)
|
||||
}
|
||||
|
||||
private fun markWritten(eventId: Long, beginMillis: Long?) {
|
||||
written[eventId to beginMillis] = TimeSource.Monotonic.markNow()
|
||||
}
|
||||
|
||||
private fun writtenRecently(eventId: Long, beginMillis: Long): Boolean {
|
||||
written.entries.removeAll { it.value.elapsedNow() > RECENT_WRITE }
|
||||
return (eventId to null) in written || (eventId to beginMillis) in written
|
||||
_abandoned.value += 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a completed move back where it came from. The outcome stands until the
|
||||
* inverse write reports back, so the one chip changes what it says rather
|
||||
* than closing and reopening. False when another write is already running —
|
||||
* the chip hides its action for that window, so this is the backstop. An
|
||||
* undo is a journey of its own: [undoStarted] names its token, and [drops]
|
||||
* reports whether it landed.
|
||||
* the chip hides its action for that window, so this is the backstop.
|
||||
*
|
||||
* [abandoned] ticks once an inverse that *started* fails to land: [undoStarted]
|
||||
* has sent the chip back on its journey by then and nothing else will seat it.
|
||||
* A refusal ticks nothing — it launched no journey of its own, and the counter
|
||||
* is global, so a tick here would release some other drop's held copy before
|
||||
* the grid has drawn it.
|
||||
*/
|
||||
fun undo(undo: MoveUndo): Boolean {
|
||||
if (busy) return false
|
||||
val token = nextMoveToken()
|
||||
_drops.update { it.started(token) }
|
||||
busy = true
|
||||
_undoStarted.value = UndoStart(token, undo.dropToken)
|
||||
markWritten(undo.eventId, null)
|
||||
_undoStarted.value += 1
|
||||
viewModelScope.launch {
|
||||
_outcome.value = try {
|
||||
repository.updateEvent(undo.eventId, undo.moved, undo.restored)
|
||||
@@ -423,40 +292,33 @@ class RescheduleViewModel @Inject constructor(
|
||||
} catch (e: Exception) {
|
||||
MoveOutcome.Failed
|
||||
}
|
||||
settle(token, landed = _outcome.value is MoveOutcome.Undone)
|
||||
// Read off the outcome, the way [write] does — no second copy of the
|
||||
// same fact to drift from it.
|
||||
if (_outcome.value !is MoveOutcome.Undone) _abandoned.value += 1
|
||||
busy = false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Clear the outcome once the screen has shown it, bringing on the next one waiting. */
|
||||
/** Clear the outcome once the screen has shown it. */
|
||||
fun consumeOutcome() {
|
||||
_outcome.value = notices.removeFirstOrNull()
|
||||
_outcome.value = null
|
||||
}
|
||||
|
||||
private suspend fun prepare(dropped: MoveRequest): PreparedMove? {
|
||||
val zone = zoneSource.current()
|
||||
private suspend fun prepare(request: MoveRequest): PreparedMove? {
|
||||
val detail = try {
|
||||
repository.eventDetail(dropped.eventId)
|
||||
repository.eventDetail(request.eventId)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: NoSuchEventException) {
|
||||
report(MoveOutcome.Gone)
|
||||
_outcome.value = MoveOutcome.Gone
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
report(MoveOutcome.Failed)
|
||||
_outcome.value = MoveOutcome.Failed
|
||||
return null
|
||||
}
|
||||
// The drop carries the times the grid drew, which an earlier write may
|
||||
// have moved on since (#494).
|
||||
val request = if (detail.rrule == null || detail.isException) {
|
||||
followed(dropped, detail, zone)
|
||||
} else if (writtenRecently(dropped.eventId, dropped.beginMillis)) {
|
||||
report(MoveOutcome.Stale)
|
||||
return null
|
||||
} else {
|
||||
dropped
|
||||
}
|
||||
|
||||
val zone = zoneSource.current()
|
||||
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
|
||||
val shifted = when (val target = request.target) {
|
||||
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
|
||||
@@ -469,7 +331,7 @@ class RescheduleViewModel @Inject constructor(
|
||||
// The UNTIL check is deferred to the write, which knows how far the move
|
||||
// reaches and so which date has to clear it.
|
||||
if ((shifted.problems(zone) - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
|
||||
report(MoveOutcome.Failed)
|
||||
_outcome.value = MoveOutcome.Failed
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -514,14 +376,14 @@ class RescheduleViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
/** Write [prepared] in [scope]; true when it landed. */
|
||||
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope): Boolean {
|
||||
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) {
|
||||
val request = prepared.request
|
||||
if (endsBeforeItStarts(prepared, scope)) {
|
||||
report(MoveOutcome.BlockedSeriesEnd)
|
||||
return false
|
||||
_outcome.value = MoveOutcome.BlockedSeriesEnd
|
||||
_abandoned.value += 1
|
||||
return
|
||||
}
|
||||
val outcome = try {
|
||||
_outcome.value = try {
|
||||
when {
|
||||
!prepared.isRecurring || scope == RecurringWriteScope.AllEvents ->
|
||||
repository.updateEvent(request.eventId, prepared.original, prepared.updated)
|
||||
@@ -556,37 +418,9 @@ class RescheduleViewModel @Inject constructor(
|
||||
} catch (e: Exception) {
|
||||
MoveOutcome.Failed
|
||||
}
|
||||
if (outcome is MoveOutcome.Moved) {
|
||||
markWritten(request.eventId, request.beginMillis.takeIf { scope == RecurringWriteScope.ThisEvent && prepared.isRecurring })
|
||||
}
|
||||
report(outcome, request.eventId)
|
||||
return outcome is MoveOutcome.Moved
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-off [dropped] rebased onto where its event now is, so the last drop
|
||||
* wins: absolute targets stand as they are, and a whole-day shift is re-aimed
|
||||
* at the day it was dropped on. Unchanged when the event hasn't moved.
|
||||
*/
|
||||
private fun followed(dropped: MoveRequest, detail: EventDetail, zone: TimeZone): MoveRequest {
|
||||
val now = detail.instance
|
||||
val target = when (val target = dropped.target) {
|
||||
is MoveTarget.ByDays -> {
|
||||
// Each day read the way its own kind anchors it: all-day at UTC.
|
||||
val aimed = Instant.fromEpochMilliseconds(dropped.beginMillis)
|
||||
.toLocalDateTime(if (dropped.isAllDay) TimeZone.UTC else zone).date
|
||||
.plus(target.days, DateTimeUnit.DAY)
|
||||
val from = now.start.toLocalDateTime(if (now.isAllDay) TimeZone.UTC else zone).date
|
||||
MoveTarget.ByDays((aimed.toEpochDays() - from.toEpochDays()).toInt())
|
||||
}
|
||||
else -> target
|
||||
}
|
||||
return dropped.copy(
|
||||
beginMillis = now.start.toEpochMilliseconds(),
|
||||
endMillis = now.end.toEpochMilliseconds(),
|
||||
target = target,
|
||||
isAllDay = now.isAllDay,
|
||||
)
|
||||
// Nothing landed, so the copy the view is holding has nothing to fade
|
||||
// into — let it go now rather than at the end of the settle timeout.
|
||||
if (_outcome.value !is MoveOutcome.Moved) _abandoned.value += 1
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -625,9 +459,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
|
||||
// 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
|
||||
val delta = (
|
||||
shifted.start.time.toMillisecondOfDay() - original.start.time.toMillisecondOfDay()
|
||||
) / MILLIS_PER_MINUTE
|
||||
return anchorMinute + delta in 0 until MINUTES_PER_DAY
|
||||
}
|
||||
|
||||
@@ -653,7 +487,6 @@ class RescheduleViewModel @Inject constructor(
|
||||
eventId = prepared.request.eventId,
|
||||
moved = prepared.updated,
|
||||
restored = prepared.original,
|
||||
dropToken = prepared.request.token,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -661,7 +494,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.isResize) return null
|
||||
if (prepared.request.target !is MoveTarget.Span) return null
|
||||
val form = prepared.updated
|
||||
return form.endInstant(form.resolvedZone(zoneSource.current())).toEpochMilliseconds()
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -61,6 +60,7 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
@@ -78,9 +78,6 @@ 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
|
||||
|
||||
@@ -129,29 +126,6 @@ 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
|
||||
|
||||
/** The drop's identity, which its held copy waits on. */
|
||||
val token: Long
|
||||
|
||||
data class Days(override val event: EventInstance, val days: Int, override val token: Long) : StripDrop
|
||||
|
||||
data class Timed(
|
||||
override val event: EventInstance,
|
||||
val start: Instant,
|
||||
val end: Instant,
|
||||
override val token: Long,
|
||||
) : StripDrop
|
||||
}
|
||||
|
||||
/** The event a long press left selected (#141). */
|
||||
data class TimelineSelection(
|
||||
val event: EventInstance,
|
||||
@@ -275,10 +249,9 @@ internal fun dragPieceBounds(
|
||||
): DragPieceBounds {
|
||||
val top = if (slice.continuesBefore) 0f else insetPx
|
||||
val bottom = if (slice.continuesAfter) 0f else insetPx
|
||||
val span = renderedSpan(slice.startMin, slice.startMin + slice.spanMin, slice.continuesAfter)
|
||||
val full = (span.bottomMin - span.topMin) / 60f * hourPx
|
||||
val full = maxOf(slice.spanMin / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
|
||||
return DragPieceBounds(
|
||||
top = span.topMin / 60f * hourPx + top,
|
||||
top = slice.startMin / 60f * hourPx + top,
|
||||
height = (full - top - bottom).coerceAtLeast(0f),
|
||||
)
|
||||
}
|
||||
@@ -305,9 +278,7 @@ data class TimelineDrop(
|
||||
val date: LocalDate,
|
||||
val startMin: Int,
|
||||
/** Minutes from the event's start to the dragged block's top edge. */
|
||||
val clipOffsetMin: Int,
|
||||
/** The drop's identity, which its held copy waits on. */
|
||||
val token: Long,
|
||||
val clipOffsetMin: Int = 0,
|
||||
)
|
||||
|
||||
/** The id a drafted block's stand-in event carries, which no provider row has (#153). */
|
||||
@@ -338,49 +309,19 @@ 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, dayEnd)
|
||||
val end = minOf(draft.end, date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone))
|
||||
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, continuesAfter = draft.end > dayEnd)
|
||||
return TimedBlock(draft, startMin, endMin, lane = 0, laneCount = 1)
|
||||
}
|
||||
|
||||
/** Which end of an event a resize handle moves (#141). */
|
||||
enum class ResizeEdge { Top, Bottom }
|
||||
|
||||
/** A drop parked by a newer pickup while its write still runs — see [TimelineDragController.held]. */
|
||||
data class HeldDrop(
|
||||
val copy: TimelineDrag,
|
||||
val token: Long,
|
||||
val instanceId: Long,
|
||||
/** The event row and start the drop came from, as [TimelineDragController.ghosts] matches them. */
|
||||
val origin: Pair<Long, Instant>,
|
||||
/** A resize's new end, which has to have landed too before the drop counts as seated. */
|
||||
val end: Instant?,
|
||||
/** Whether the grid now draws the drop at its landing slot. */
|
||||
val seated: Boolean = false,
|
||||
val handedOver: Boolean = false,
|
||||
) {
|
||||
fun ghosts(block: TimedBlock): Boolean = block.event.instanceId == instanceId ||
|
||||
(block.event.eventId == origin.first && block.event.start == origin.second)
|
||||
}
|
||||
|
||||
/** A strip drop parked by a newer one while its write still runs — see [TimelineDragController.heldStrips]. */
|
||||
data class HeldStrip(val drag: StripDrag, val token: Long, val seated: Boolean = false)
|
||||
|
||||
/** An event's new start and end, as a resize leaves them. */
|
||||
data class ResizedSpan(val start: Instant, val end: Instant)
|
||||
|
||||
/** Where a finished resize asks its event to begin and end. */
|
||||
data class TimelineResizeDrop(
|
||||
val event: EventInstance,
|
||||
val start: Instant,
|
||||
val end: Instant,
|
||||
/** The drop's identity, which its held copy waits on. */
|
||||
val token: Long,
|
||||
)
|
||||
data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant)
|
||||
|
||||
/**
|
||||
* [event] resized by its [edge] to the span [startMin]..+[spanMin] from [date]'s
|
||||
@@ -391,16 +332,17 @@ data class TimelineResizeDrop(
|
||||
* it was drawn (see [repicked]). An end takes a pass the grid draws at the drop
|
||||
* point, so the block settles under the finger.
|
||||
*/
|
||||
internal fun resizedSpan(
|
||||
internal fun resizedDrop(
|
||||
event: EventInstance,
|
||||
edge: ResizeEdge?,
|
||||
date: LocalDate,
|
||||
startMin: Int,
|
||||
spanMin: Int,
|
||||
zone: TimeZone,
|
||||
): ResizedSpan {
|
||||
): TimelineResizeDrop {
|
||||
val drawn = wallClockSpan(event, zone)
|
||||
return ResizedSpan(
|
||||
return TimelineResizeDrop(
|
||||
event = event,
|
||||
start = if (edge == ResizeEdge.Top) {
|
||||
event.start.toFormTime(zone).repicked(
|
||||
wallClockTime(date, startMin),
|
||||
@@ -551,14 +493,6 @@ class TimelineDragController {
|
||||
*/
|
||||
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
|
||||
|
||||
/**
|
||||
* Earlier drops still being written when another block was picked up. They
|
||||
* keep their copy and their ghost until their own token settles, rather than
|
||||
* vanishing and flashing back at the old slot (#494).
|
||||
*/
|
||||
var held: List<HeldDrop> by mutableStateOf(emptyList())
|
||||
private set
|
||||
|
||||
/** How long a block drafted on an empty slot starts out, in minutes (#153). */
|
||||
var draftMinutes: Int = SettingsPrefs.DEFAULT_EVENT_DURATION
|
||||
|
||||
@@ -657,8 +591,14 @@ class TimelineDragController {
|
||||
var settlingSelected: Boolean = false
|
||||
private set
|
||||
|
||||
/** The token of the drop [settling] waits on. */
|
||||
var settledToken: Long = 0L
|
||||
/**
|
||||
* The abandoned-move counter, read at the drop so the mark can't be a
|
||||
* composition behind the tick it is compared against.
|
||||
*/
|
||||
var abandonedTicks: StateFlow<Int>? = null
|
||||
|
||||
/** What [abandonedTicks] stood at when the settling drop was let go. */
|
||||
var settledAbandonedMark: Int = 0
|
||||
private set
|
||||
|
||||
private var source: TimedBlock? = null
|
||||
@@ -689,32 +629,6 @@ 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
|
||||
|
||||
/** Strip drops parked by a newer strip drop while their writes still run (#494). */
|
||||
var heldStrips: List<HeldStrip> by mutableStateOf(emptyList())
|
||||
private set
|
||||
|
||||
/** The token of the drop [stripSettling] waits on. */
|
||||
var stripToken: Long = 0L
|
||||
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,
|
||||
@@ -731,7 +645,7 @@ class TimelineDragController {
|
||||
dragDuration = (block.event.end - block.event.start).coerceAtLeast(Duration.ZERO)
|
||||
dragKept = zone.repeatedHourOffsetAt(block.event.start)
|
||||
this.titleLines = titleLines
|
||||
parkSettling()
|
||||
settling = null
|
||||
isDragging = true
|
||||
liftedInstanceId = block.event.instanceId
|
||||
grab = pointerInRoot - blockInRoot
|
||||
@@ -753,86 +667,6 @@ 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
|
||||
parkSettling()
|
||||
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) {
|
||||
val token = settle(landed)
|
||||
val start = dropStartInstant(landed.event, landed.date, landed.startMin, clipOffsetMin = 0, zone)
|
||||
return StripDrop.Timed(held.event, start, start + draftMinutes.minutes, token)
|
||||
}
|
||||
if (held.overGrid || held.dayShift == 0) return null
|
||||
// One still settling keeps its place on the strip until its own write lands (#494).
|
||||
stripSettling?.let { heldStrips = heldStrips + HeldStrip(it, stripToken, stripSeated) }
|
||||
stripSettling = held
|
||||
stripSeated = false
|
||||
stripToken = nextMoveToken()
|
||||
drops++
|
||||
return StripDrop.Days(held.event, held.dayShift, stripToken)
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** Note that the strip draws the parked strip drop [token] at its new days. */
|
||||
fun seatHeldStrip(token: Long) {
|
||||
if (heldStrips.any { it.token == token && !it.seated }) {
|
||||
heldStrips = heldStrips.map { if (it.token == token) it.copy(seated = true) else it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Let the parked strip drop [token] go. */
|
||||
fun releaseHeldStrip(token: Long) {
|
||||
heldStrips = heldStrips.filterNot { it.token == token }
|
||||
}
|
||||
|
||||
/** Every strip drop still shown at its new days: the parked ones, then the latest. */
|
||||
fun stripPreviews(): List<StripDrag> = heldStrips.map { it.drag } + listOfNotNull(stripDrag ?: stripSettling)
|
||||
|
||||
fun move(pointerInRoot: Offset) {
|
||||
pointer = pointerInRoot
|
||||
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
|
||||
@@ -841,7 +675,6 @@ class TimelineDragController {
|
||||
|
||||
fun cancel() {
|
||||
resize = null
|
||||
stripDrag = null
|
||||
source = null
|
||||
clipOffsetMin = 0
|
||||
dragZone = null
|
||||
@@ -867,36 +700,12 @@ class TimelineDragController {
|
||||
* an event that crosses midnight (#253).
|
||||
*/
|
||||
fun ghosts(block: TimedBlock): Boolean {
|
||||
if (held.any { !it.handedOver && it.ghosts(block) }) return true
|
||||
if (liftedInstanceId == null) return false
|
||||
if (block.event.instanceId == liftedInstanceId) return true
|
||||
val (eventId, start) = settledOrigin ?: return false
|
||||
return block.event.eventId == eventId && block.event.start == start
|
||||
}
|
||||
|
||||
/** Move a still-settling drop to [held] before a new pickup takes the slot. */
|
||||
private fun parkSettling() {
|
||||
val landed = settling
|
||||
val origin = settledOrigin
|
||||
if (landed != null && origin != null && !landed.event.isDraft) {
|
||||
held = held + HeldDrop(landed, settledToken, landed.event.instanceId, origin, settledEnd, settledOnGrid)
|
||||
}
|
||||
settling = null
|
||||
settledOnGrid = false
|
||||
settledOrigin = null
|
||||
settledEnd = null
|
||||
}
|
||||
|
||||
/** Stop ghosting the held drop [token]'s source while its copy fades. */
|
||||
fun handOverHeld(token: Long) {
|
||||
held = held.map { if (it.token == token) it.copy(handedOver = true) else it }
|
||||
}
|
||||
|
||||
/** Drop the held copy [token] once it has faded. */
|
||||
fun releaseHeld(token: Long) {
|
||||
held = held.filterNot { it.token == token }
|
||||
}
|
||||
|
||||
/**
|
||||
* What a day column now holds, so a settled drop can tell when the grid has
|
||||
* caught up with it. Matched on the landing slot plus either the event row
|
||||
@@ -904,23 +713,16 @@ class TimelineDragController {
|
||||
* blank title matches nothing: every untitled event has one (#253).
|
||||
*/
|
||||
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
|
||||
if (held.any { !it.seated && drawsDrop(it.copy, it.end, date, blocks) }) {
|
||||
held = held.map { if (!it.seated && drawsDrop(it.copy, it.end, date, blocks)) it.copy(seated = true) else it }
|
||||
}
|
||||
val landed = settling ?: return
|
||||
if (settledOnGrid) return
|
||||
settledOnGrid = drawsDrop(landed, settledEnd, date, blocks)
|
||||
}
|
||||
|
||||
/** Whether [blocks], [date]'s column, draws the drop [landed] where it landed. */
|
||||
private fun drawsDrop(landed: TimelineDrag, end: Instant?, date: LocalDate, blocks: List<TimedBlock>): Boolean {
|
||||
// Where this day would draw it, which for a drop clipped at midnight is
|
||||
// the day's own start rather than the edge the finger held (#253) — and
|
||||
// which for a tail dragged clear of its day is the day before it.
|
||||
val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt()
|
||||
val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset)
|
||||
.firstOrNull() ?: return false
|
||||
return blocks.any { block ->
|
||||
.firstOrNull() ?: return
|
||||
val end = settledEnd
|
||||
settledOnGrid = blocks.any { block ->
|
||||
block.startMin == slice.startMin &&
|
||||
// A resize may keep its start, so its end has to have landed too.
|
||||
(end == null || block.event.end == end) &&
|
||||
@@ -947,31 +749,23 @@ class TimelineDragController {
|
||||
if (landed == null || landed.slot == origin) return null
|
||||
// A draft dropped on a day off screen would vanish, so it stays where it was (#153).
|
||||
if (landed.edgeOnly && landed.event.isDraft) return null
|
||||
val token = settle(landed)
|
||||
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset, token)
|
||||
settle(landed)
|
||||
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep [landed]'s copy on the target as [settling] until [release]; [end] is a
|
||||
* resize's. Returns the drop's fresh token.
|
||||
*/
|
||||
private fun settle(landed: TimelineDrag, end: Instant? = null): Long {
|
||||
// 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
|
||||
/** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */
|
||||
private fun settle(landed: TimelineDrag, end: Instant? = null) {
|
||||
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
|
||||
// An edge piece is a hint for the finger, not a preview of the landing
|
||||
// slot: the event belongs to a day this timeline has no column for, so
|
||||
// [noteGrid] can never seat it and it would sit at the top of today for
|
||||
// the whole settle timeout, long after the event moved off-screen.
|
||||
settling = if (landed.edgeOnly || leavesGrid) landed.copy(pieces = emptyList()) else landed
|
||||
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledOrigin = landed.event.eventId to landed.event.start
|
||||
settledToken = nextMoveToken()
|
||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||
settledEnd = end
|
||||
drops++
|
||||
return settledToken
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -991,7 +785,7 @@ class TimelineDragController {
|
||||
resize = session
|
||||
clipOffsetMin = 0
|
||||
this.titleLines = titleLines
|
||||
parkSettling()
|
||||
settling = null
|
||||
isDragging = true
|
||||
pointerMoved = true
|
||||
liftedInstanceId = block.event.instanceId
|
||||
@@ -1001,9 +795,7 @@ class TimelineDragController {
|
||||
val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY))
|
||||
session.column = edgeIndex.takeIf { it >= 0 }
|
||||
?.let { column(it) }
|
||||
// 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 -
|
||||
session.grabMin = (pointerResizeMinutes(session) ?: 0f) -
|
||||
if (edge == ResizeEdge.Top) session.startMin else session.endMin
|
||||
originSlot = null
|
||||
recompute()
|
||||
@@ -1022,10 +814,10 @@ class TimelineDragController {
|
||||
val event = source?.event ?: return null
|
||||
val edge = resize?.edge
|
||||
if (edge != null) {
|
||||
val drop = resizedSpan(event, edge, date, startMin, spanMin, zone)
|
||||
val drop = resizedDrop(event, edge, date, startMin, spanMin, zone)
|
||||
return event.copy(start = drop.start, end = drop.end)
|
||||
}
|
||||
val start = dropStartInstant(event, date, startMin, clipOffsetMin, zone)
|
||||
val start = TimelineDrop(event, date, startMin, clipOffsetMin).startInstant(zone)
|
||||
return event.copy(start = start, end = start + dragDuration)
|
||||
}
|
||||
|
||||
@@ -1039,8 +831,9 @@ class TimelineDragController {
|
||||
val edge = resize?.edge
|
||||
cancel()
|
||||
if (landed == null || landed.slot == origin) return null
|
||||
val span = resizedSpan(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
|
||||
return TimelineResizeDrop(landed.event, span.start, span.end, settle(landed, end = span.end))
|
||||
val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
|
||||
settle(landed, end = drop.end)
|
||||
return drop
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1062,9 +855,7 @@ class TimelineDragController {
|
||||
session.column = held
|
||||
val dayOffset = (days[column(held)].toEpochDays() - session.day.toEpochDays()).toInt()
|
||||
session.edgeDay = dayOffset
|
||||
// Held to its column's midnight: the clearance under it is not the next day.
|
||||
val minutes = ((pointer.y - origin.y) / hourPx * 60f).coerceAtMost(MINUTES_PER_DAY.toFloat())
|
||||
return dayOffset * MINUTES_PER_DAY + minutes
|
||||
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
|
||||
}
|
||||
|
||||
private fun recomputeResize(session: ResizeSession) {
|
||||
@@ -1137,29 +928,6 @@ 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
|
||||
@@ -1245,18 +1013,11 @@ 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
|
||||
@@ -1294,6 +1055,7 @@ fun rememberTimelineDragController(): TimelineDragController {
|
||||
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
|
||||
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
|
||||
controller.slopPx = LocalViewConfiguration.current.touchSlop
|
||||
controller.abandonedTicks = LocalEventMove.current?.abandoned
|
||||
return controller
|
||||
}
|
||||
|
||||
@@ -1315,6 +1077,10 @@ 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
|
||||
@@ -1323,16 +1089,7 @@ fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean =
|
||||
* visibly settles there.
|
||||
*/
|
||||
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
|
||||
dropStartInstant(event, date, startMin, clipOffsetMin, zone)
|
||||
|
||||
/** Where [event] starts when its block's top edge lands [startMin] past [date]'s midnight — see [TimelineDrop]. */
|
||||
internal fun dropStartInstant(
|
||||
event: EventInstance,
|
||||
date: LocalDate,
|
||||
startMin: Int,
|
||||
clipOffsetMin: Int,
|
||||
zone: TimeZone,
|
||||
): Instant = wallClockInstant(date, startMin - clipOffsetMin, zone, kept = zone.repeatedHourOffsetAt(event.start))
|
||||
wallClockInstant(date, startMin - clipOffsetMin, zone, kept = zone.repeatedHourOffsetAt(event.start))
|
||||
|
||||
/**
|
||||
* The wall-clock minutes an event lasting [duration] covers when it starts
|
||||
@@ -1404,7 +1161,6 @@ private const val SETTLE_TIMEOUT_MILLIS = 900L
|
||||
*/
|
||||
const val SETTLE_FADE_MILLIS: Int = 250
|
||||
|
||||
|
||||
/**
|
||||
* The floating block, drawn over the whole calendar so it is free of the day
|
||||
* column's clip and of the scroll viewport's rounded corners.
|
||||
@@ -1412,14 +1168,16 @@ const val SETTLE_FADE_MILLIS: Int = 250
|
||||
@Composable
|
||||
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
|
||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||
val density = LocalDensity.current
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val drops = dropStates()
|
||||
val moveInFlight = controller.settledToken in drops.pending
|
||||
val stripInFlight = controller.stripToken in drops.pending
|
||||
val moveInFlight = moveInFlight()
|
||||
|
||||
// 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.stripDrag?.dayShift, controller.drops)
|
||||
DragSnapHaptics(controller.drag?.slot, 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) {
|
||||
@@ -1429,11 +1187,12 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
// recurring drop's scope dialog is up — and then until the grid draws the
|
||||
// drop itself.
|
||||
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
||||
val abandoned = controller.settledToken in drops.abandoned
|
||||
val abandoned = abandonedMoves()
|
||||
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid, abandoned) {
|
||||
if (controller.settling == null || moveInFlight) return@LaunchedEffect
|
||||
// An abandoned drop: nothing will land and there is nothing to wait for.
|
||||
if (!abandoned) {
|
||||
// Past the mark this drop was let go on, it was abandoned: nothing will
|
||||
// land and there is nothing to wait for.
|
||||
if (abandoned == controller.settledAbandonedMark) {
|
||||
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
|
||||
}
|
||||
handingOver = true
|
||||
@@ -1442,16 +1201,6 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
controller.release()
|
||||
}
|
||||
|
||||
// The same hold for a bar moved along the all-day strip.
|
||||
val stripAbandoned = controller.stripToken in drops.abandoned
|
||||
LaunchedEffect(controller.stripSettling, stripInFlight, controller.stripSeated, stripAbandoned) {
|
||||
if (controller.stripSettling == null || stripInFlight) return@LaunchedEffect
|
||||
if (!stripAbandoned) {
|
||||
delay(if (controller.stripSeated) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
|
||||
}
|
||||
controller.releaseStrip()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
@@ -1460,12 +1209,6 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
.clearAndSetSemantics { }
|
||||
.onGloballyPositioned { origin = it.positionInRoot() },
|
||||
) {
|
||||
controller.held.forEach { parked ->
|
||||
key(parked.token) { HeldTimelineCopy(controller, parked, origin) }
|
||||
}
|
||||
controller.heldStrips.forEach { parked ->
|
||||
key(parked.token) { HeldStripSettle(controller, parked) }
|
||||
}
|
||||
val drag = controller.drag ?: controller.settling ?: return@Box
|
||||
// Landed: the copy sinks back to the grid's own plane while the write runs.
|
||||
val landed = controller.drag == null
|
||||
@@ -1479,58 +1222,6 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||
label = "drag-handover",
|
||||
)
|
||||
TimelineCopy(drag, controller.geometry, origin, lift, copyAlpha)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [TimelineDragController.ghosts] for [block], derived so the block recomposes
|
||||
* only when its own answer changes rather than on every lift, park and release.
|
||||
*/
|
||||
@Composable
|
||||
fun TimelineDragController.ghostsDerived(block: TimedBlock): Boolean {
|
||||
val ghosted by remember(this, block) { derivedStateOf { ghosts(block) } }
|
||||
return ghosted
|
||||
}
|
||||
|
||||
/** A drop parked by a newer pickup: held at its landing slot until its own write settles. */
|
||||
@Composable
|
||||
private fun HeldTimelineCopy(controller: TimelineDragController, parked: HeldDrop, origin: Offset) {
|
||||
val alpha = rememberHeldSettle(
|
||||
token = parked.token,
|
||||
seated = parked.seated,
|
||||
timeoutMillis = SETTLE_TIMEOUT_MILLIS,
|
||||
onHandOver = { controller.handOverHeld(parked.token) },
|
||||
onRelease = { controller.releaseHeld(parked.token) },
|
||||
)
|
||||
TimelineCopy(parked.copy, controller.geometry, origin, lift = 0f, copyAlpha = alpha)
|
||||
}
|
||||
|
||||
/** A strip drop parked by a newer one: shown at its new days until its own write settles. */
|
||||
@Composable
|
||||
private fun HeldStripSettle(controller: TimelineDragController, parked: HeldStrip) {
|
||||
rememberHeldSettle(
|
||||
token = parked.token,
|
||||
seated = parked.seated,
|
||||
timeoutMillis = SETTLE_TIMEOUT_MILLIS,
|
||||
onHandOver = {},
|
||||
onRelease = { controller.releaseHeldStrip(parked.token) },
|
||||
)
|
||||
}
|
||||
|
||||
/** [drag]'s pieces drawn over the calendar, clipped to the timeline's viewport. */
|
||||
@Composable
|
||||
private fun TimelineCopy(
|
||||
drag: TimelineDrag,
|
||||
geometry: TimelineGeometry,
|
||||
origin: Offset,
|
||||
lift: Float,
|
||||
copyAlpha: Float,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val paint = eventPaint(drag.event, dark)
|
||||
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
// Read off the event rather than the held block, so grabbing either half
|
||||
@@ -1546,7 +1237,7 @@ private fun TimelineCopy(
|
||||
// so no column clip or rounded corner cuts it, but it still belongs to
|
||||
// the timeline: a piece a whole day tall reaches far past both ends of
|
||||
// the viewport, and unclipped it paints over the headers above (#267).
|
||||
val port = geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
|
||||
val port = controller.geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
|
||||
// How much of each piece the viewport actually shows. A multi-day event
|
||||
// has a piece per day, and the widest one is a full 24 hours — taller
|
||||
// than the screen, its top at a midnight scrolled out of sight. Picking
|
||||
@@ -1598,6 +1289,7 @@ private fun TimelineCopy(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One piece of a dragged block, floating over the calendar. */
|
||||
|
||||
@@ -43,21 +43,14 @@ 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, [clearancePx] of
|
||||
* which is the empty inset under midnight, or null while that
|
||||
* with [maxValue] scroll range over a [viewportPx] viewport, 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,
|
||||
clearancePx: Int = 0,
|
||||
): Int? {
|
||||
internal fun startHourScroll(maxValue: Int, viewportPx: Int, startHour: Int, hourPx: Float): Int? {
|
||||
if (viewportPx <= 0 || maxValue < 0 || maxValue == Int.MAX_VALUE) return null
|
||||
val laidOutHourPx = (maxValue + viewportPx - clearancePx) / 24f
|
||||
val laidOutHourPx = (maxValue + viewportPx) / 24f
|
||||
if (abs(laidOutHourPx - hourPx) > 1f) return null
|
||||
return (startHour * laidOutHourPx).roundToInt().coerceIn(0, maxValue)
|
||||
}
|
||||
@@ -4,8 +4,6 @@ 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).
|
||||
@@ -61,9 +59,8 @@ sealed interface TimelineScale {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Hour height for this scale. [viewportHeight] is the visible height of the
|
||||
* scrolling timeline and is only consulted by [TimelineScale.FitDay].
|
||||
*
|
||||
* 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
|
||||
@@ -88,13 +85,6 @@ 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.
|
||||
@@ -115,28 +105,6 @@ 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,21 +32,16 @@ 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?,
|
||||
private val clearancePx: Float = 0f,
|
||||
) {
|
||||
class TimelineScroll(val state: ScrollState, noonPending: Boolean, private val openingHour: Int?) {
|
||||
/** 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 of day in a [viewport] pixels high. */
|
||||
/** The top of what it shows, or will once open, over [content] pixels 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 + clearancePx - viewport).coerceAtLeast(0f))
|
||||
.coerceIn(0f, (content - viewport).coerceAtLeast(0f))
|
||||
} else state.value.toFloat()
|
||||
}
|
||||
|
||||
@@ -63,7 +58,6 @@ 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,
|
||||
@@ -73,7 +67,6 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
|
||||
scrollState,
|
||||
noonPending = remembered == null,
|
||||
openingHour = zoom.hours.takeIf { it.enabled }?.startHour,
|
||||
clearancePx = clearancePx.toFloat(),
|
||||
)
|
||||
}
|
||||
val currentTop by rememberUpdatedState(topPx)
|
||||
@@ -87,20 +80,20 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
|
||||
val startHour = zoom.open()
|
||||
if (startHour == null) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
scrollState.scrollTo(((scrollState.maxValue - clearancePx) / 2).coerceAtLeast(0))
|
||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||
} 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(dayFitHeight(with(density) { viewport.toDp() }))
|
||||
val hourHeight = zoom.scale.hourHeight(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, clearancePx)
|
||||
startHourScroll(scrollState.maxValue, scrollState.viewportSize, startHour, hourPx)
|
||||
}.filterNotNull().first()
|
||||
} ?: (startHour * (scrollState.maxValue + scrollState.viewportSize - clearancePx) / 24)
|
||||
} ?: (startHour * (scrollState.maxValue + scrollState.viewportSize) / 24)
|
||||
.coerceIn(0, scrollState.maxValue.coerceAtLeast(0))
|
||||
scrollState.scrollTo(target)
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import de.jeanlucmakiola.floret.time.rowWeekNumber
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.temporal.WeekFields
|
||||
|
||||
/**
|
||||
* The week number of the grid row starting on [rowStart], numbered by the
|
||||
* device region's rules (#493). The region, not the app language: an English
|
||||
* UI in the Netherlands still numbers weeks the ISO way. Shared by the Month
|
||||
* gutter and the Week badge so the two always agree.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberRowWeekNumber(rowStart: LocalDate): Int {
|
||||
val fields = remember { WeekFields.of(Resources.getSystem().configuration.locales[0]) }
|
||||
return remember(rowStart, fields) { rowStart.toJavaLocalDate().rowWeekNumber(fields) }
|
||||
}
|
||||
@@ -37,7 +37,6 @@ 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
|
||||
@@ -120,7 +119,6 @@ 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
|
||||
@@ -894,15 +892,6 @@ 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(
|
||||
@@ -1325,33 +1314,33 @@ private fun RecurrenceCard(state: EventEditUiState, locale: Locale, onClick: (()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AvailabilityCard(availability: Availability, onSelect: (Availability) -> Unit) {
|
||||
SegmentedChoiceCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.Default.EventAvailable,
|
||||
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,
|
||||
)
|
||||
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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,12 +48,6 @@ 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,10 +14,8 @@ 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
|
||||
@@ -337,7 +335,6 @@ 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,
|
||||
)
|
||||
@@ -674,7 +671,6 @@ 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. */
|
||||
@@ -770,10 +766,7 @@ 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.
|
||||
// A cancelled one only saves as the whole series (#388).
|
||||
if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar &&
|
||||
form.canSaveApartFromSeries()
|
||||
) {
|
||||
if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar) {
|
||||
_saveState.value = SaveUiState.AwaitingScope
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,28 +2,17 @@ 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.inlineTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWidestTimeSample
|
||||
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth
|
||||
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: 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.
|
||||
* 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.
|
||||
*/
|
||||
internal fun monthChipTime(
|
||||
event: EventInstance,
|
||||
@@ -32,75 +21,11 @@ internal fun monthChipTime(
|
||||
is24Hour: Boolean,
|
||||
locale: Locale,
|
||||
): String? {
|
||||
if (event.coversWholeDays(zone) || continuesLeft) return null
|
||||
if (event.isAllDay || continuesLeft) return null
|
||||
return formatTimeOfDay(event.start, zone, is24Hour, locale)
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
/** The narrowest chip that may show a time — the text it needs, plus its chrome. */
|
||||
@Composable
|
||||
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)
|
||||
}
|
||||
}
|
||||
internal fun rememberMonthTimeChipWidth(): Dp =
|
||||
rememberInlineTimeWidth(MaterialTheme.typography.labelSmall) + MONTH_CHIP_CHROME
|
||||
@@ -14,7 +14,8 @@ import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.nextMoveToken
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlin.math.abs
|
||||
|
||||
@@ -106,22 +107,11 @@ data class MonthChipDrag(
|
||||
val time: String? = null,
|
||||
)
|
||||
|
||||
/** A drop parked by a newer pickup while its write still runs — see [MonthDragController.held]. */
|
||||
data class HeldChip(
|
||||
val copy: MonthChipDrag,
|
||||
val token: Long,
|
||||
val handedOver: Boolean = false,
|
||||
/** Where the grid now draws the drop, in root coordinates, once a row seats it. */
|
||||
val seat: Offset? = null,
|
||||
)
|
||||
|
||||
/** Where a finished chip drag asks its event to go, as a whole-day shift. */
|
||||
data class MonthChipDrop(
|
||||
val event: EventInstance,
|
||||
val grabDate: LocalDate,
|
||||
val targetDate: LocalDate,
|
||||
/** The drop's identity, which its held copy waits on — see [MonthDragController.settledToken]. */
|
||||
val token: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -177,22 +167,19 @@ class MonthDragController {
|
||||
private set
|
||||
|
||||
/**
|
||||
* This controller's recent drops by token, kept past [release] for
|
||||
* [beginUndo] — the confirmation chip carries Undo long after the copy is
|
||||
* gone, and it may be any of them the chip offers to undo.
|
||||
* The last drop this controller made, kept past [release] for [beginUndo] —
|
||||
* the confirmation chip carries Undo long after the copy is gone.
|
||||
*/
|
||||
private val undoable = LinkedHashMap<Long, MonthChipDrag>()
|
||||
private var undoable: MonthChipDrag? = null
|
||||
|
||||
/**
|
||||
* Earlier drops still being written when another chip was picked up. They
|
||||
* keep their copy and their ghost until their own token settles, rather than
|
||||
* vanishing and flashing back on the old day (#494).
|
||||
* The abandoned-move counter, read at the drop so the mark can't be a
|
||||
* composition behind the tick it is compared against.
|
||||
*/
|
||||
var held: List<HeldChip> by mutableStateOf(emptyList())
|
||||
private set
|
||||
var abandonedTicks: StateFlow<Int>? = null
|
||||
|
||||
/** The token of the drop (or undo) [settling] is waiting on. */
|
||||
var settledToken: Long = 0L
|
||||
/** What [abandonedTicks] stood at when the settling chip was let go. */
|
||||
var settledAbandonedMark: Int = 0
|
||||
private set
|
||||
|
||||
private var event: EventInstance? = null
|
||||
@@ -221,7 +208,7 @@ class MonthDragController {
|
||||
this.event = event
|
||||
this.grabDate = grabDate
|
||||
this.time = time
|
||||
parkSettling()
|
||||
settling = null
|
||||
isDragging = true
|
||||
liftedInstanceId = event.instanceId
|
||||
grab = pointerInRoot - chipInRoot
|
||||
@@ -247,39 +234,6 @@ class MonthDragController {
|
||||
settledGhost = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [event]'s chip belongs to a held drop and must stay a ghost: its
|
||||
* source, or once a row seats it, the chip the grid draws on its target day.
|
||||
*/
|
||||
fun isHeldChip(event: EventInstance, days: List<LocalDate>?): Boolean = held.any { parked ->
|
||||
!parked.handedOver && (
|
||||
parked.copy.event.instanceId == event.instanceId ||
|
||||
(
|
||||
parked.seat != null && days != null && parked.copy.targetDate in days &&
|
||||
isSameEvent(event, parked.copy.event)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Move a still-settling drop to [held] before a new pickup takes the slot. */
|
||||
private fun parkSettling() {
|
||||
val landed = settling
|
||||
if (landed != null) held = held + HeldChip(landed, settledToken)
|
||||
settling = null
|
||||
settledInRoot = null
|
||||
settledGhost = false
|
||||
}
|
||||
|
||||
/** Stop ghosting the held drop [token]'s source while its copy fades. */
|
||||
fun handOverHeld(token: Long) {
|
||||
held = held.map { if (it.token == token) it.copy(handedOver = true) else it }
|
||||
}
|
||||
|
||||
/** Drop the held copy [token] once it has faded. */
|
||||
fun releaseHeld(token: Long) {
|
||||
held = held.filterNot { it.token == token }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [days] holds the chip the grid has drawn for the settled drop —
|
||||
* the seat the copy is on its way to, which must ghost until it gets there.
|
||||
@@ -296,13 +250,6 @@ class MonthDragController {
|
||||
* nearest where the chip was let go is the one the finger was over.
|
||||
*/
|
||||
fun noteSettled(token: Any) {
|
||||
val row = rows[token]
|
||||
if (row != null && held.any { it.seat == null }) {
|
||||
held = held.map { parked ->
|
||||
val target = parked.copy.targetDate
|
||||
if (parked.seat != null || target == null) parked else parked.copy(seat = row.seatOf(parked.copy.event, target))
|
||||
}
|
||||
}
|
||||
val landed = settling ?: return
|
||||
val target = landed.targetDate ?: return
|
||||
val at = rows[token]?.seatOf(landed.event, target) ?: return
|
||||
@@ -318,16 +265,16 @@ class MonthDragController {
|
||||
* there is no drop of this controller's left to undo, or the grid doesn't
|
||||
* seat the moved event where the chip would have to start from.
|
||||
*/
|
||||
fun beginUndo(token: Long, dropToken: Long): Boolean {
|
||||
val last = undoable.remove(dropToken) ?: return false
|
||||
parkSettling()
|
||||
fun beginUndo(): Boolean {
|
||||
val last = undoable ?: return false
|
||||
undoable = null
|
||||
val from = last.targetDate ?: return false
|
||||
val at = rows.values.firstNotNullOfOrNull { it.seatOf(last.event, from) } ?: return false
|
||||
settling = last.copy(grabDate = from, targetDate = last.grabDate, topLeftInRoot = at)
|
||||
liftedInstanceId = last.event.instanceId
|
||||
settledGhost = true
|
||||
settledInRoot = null
|
||||
settledToken = token
|
||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -347,11 +294,10 @@ class MonthDragController {
|
||||
)
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledGhost = true
|
||||
settledToken = nextMoveToken()
|
||||
undoable[settledToken] = requireNotNull(settling)
|
||||
if (undoable.size > UNDOABLE_KEPT) undoable.remove(undoable.keys.first())
|
||||
undoable = settling
|
||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||
drops++
|
||||
return MonthChipDrop(landed.event, landed.grabDate, target, settledToken)
|
||||
return MonthChipDrop(landed.event, landed.grabDate, target)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -409,10 +355,9 @@ class MonthDragController {
|
||||
|
||||
@Composable
|
||||
fun rememberMonthDragController(): MonthDragController {
|
||||
return remember { MonthDragController() }
|
||||
val controller = remember { MonthDragController() }
|
||||
controller.abandonedTicks = LocalEventMove.current?.abandoned
|
||||
return controller
|
||||
}
|
||||
|
||||
val LocalMonthDrag = compositionLocalOf<MonthDragController?> { null }
|
||||
|
||||
/** How many recent drops a month controller keeps for an undo to carry back. */
|
||||
private const val UNDOABLE_KEPT = 8
|
||||
@@ -34,10 +34,6 @@ 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
|
||||
@@ -115,8 +111,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. Event marks are the exception: they
|
||||
* render in the overlay so the expanding day backgrounds cannot cover them.
|
||||
* stack of pieces sliding past each other. Nothing here needs to escape its
|
||||
* ancestors: the cells, marks and markers all travel within the grid.
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
@@ -145,7 +141,6 @@ 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),
|
||||
@@ -153,8 +148,7 @@ internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
||||
renderInOverlayDuringTransition = isEvent,
|
||||
zIndexInOverlay = if (isEvent) 1f else 0f,
|
||||
renderInOverlayDuringTransition = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,7 @@ 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
|
||||
@@ -16,8 +11,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
|
||||
@@ -68,7 +63,6 @@ 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
|
||||
@@ -125,11 +119,11 @@ import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.dropStates
|
||||
import de.jeanlucmakiola.calendula.ui.common.moveInFlight
|
||||
import de.jeanlucmakiola.calendula.ui.common.abandonedMoves
|
||||
import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberHeldSettle
|
||||
import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS
|
||||
import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||
@@ -163,9 +157,10 @@ 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.agendaDayLabel
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader
|
||||
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
|
||||
@@ -187,6 +182,7 @@ import de.jeanlucmakiola.floret.identity.itemEnter
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
@@ -204,7 +200,6 @@ import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
import kotlinx.datetime.toJavaMonth
|
||||
import kotlinx.datetime.yearMonth
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberRowWeekNumber
|
||||
|
||||
private val LocalTodayPulse = staticCompositionLocalOf { 0 }
|
||||
|
||||
@@ -283,8 +278,6 @@ 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).
|
||||
@@ -370,7 +363,7 @@ fun MonthScreen(
|
||||
if (!scrolling) return@LaunchedEffect
|
||||
snapshotFlow {
|
||||
// Empty before the first layout pass — and reporting a default of 0
|
||||
// for it would claim the span's first month is on screen and drag the whole
|
||||
// for it would claim January 1900 is on screen and drag the whole
|
||||
// window back there.
|
||||
val visible = listState.layoutInfo.visibleItemsInfo
|
||||
when {
|
||||
@@ -455,8 +448,7 @@ fun MonthScreen(
|
||||
}
|
||||
}
|
||||
// Drawer jump-to-date.
|
||||
val jumpToDate: (LocalDate) -> Unit = { date ->
|
||||
val target = coerceToMonthRange(date)
|
||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||
if (scrolling) {
|
||||
val targetMonth = target.yearMonth
|
||||
pendingScrollMonth = targetMonth
|
||||
@@ -515,7 +507,7 @@ fun MonthScreen(
|
||||
val chipDrag = rememberMonthDragController()
|
||||
// Undo moves the event back, so the chip travels back too. The
|
||||
// signal comes from the write, not from the chip that offers it.
|
||||
OnUndoStarted { start -> chipDrag.beginUndo(start.token, start.dropToken) }
|
||||
OnUndoStarted { chipDrag.beginUndo() }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
@@ -532,20 +524,17 @@ fun MonthScreen(
|
||||
) {
|
||||
if (scrolling) {
|
||||
ContinuousMonthContent(
|
||||
// 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,
|
||||
state = continuousState,
|
||||
listState = listState,
|
||||
dense = dense,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
showStartTimes = showStartTimes == true,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
} else {
|
||||
SplitMonthContent(
|
||||
state = if (showStartTimes == null) MonthUiState.Loading else state,
|
||||
state = state,
|
||||
selected = selectedDate,
|
||||
initiallyExpanded = viewStyle == MonthViewStyle.Paged,
|
||||
expanded = splitExpanded,
|
||||
@@ -554,8 +543,6 @@ 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,
|
||||
@@ -564,10 +551,8 @@ fun MonthScreen(
|
||||
// month, pager and all — straight away, or a swipe
|
||||
// still settling would land and take the tap back.
|
||||
onSelectDay = { date ->
|
||||
if (isInMonthRange(date)) {
|
||||
viewModel.selectDate(date)
|
||||
jumpToMonth(date.yearMonth)
|
||||
}
|
||||
viewModel.selectDate(date)
|
||||
jumpToMonth(date.yearMonth)
|
||||
},
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
@@ -576,7 +561,7 @@ fun MonthScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
MonthDragOverlay(chipDrag, showStartTimes = showStartTimes == true)
|
||||
MonthDragOverlay(chipDrag)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -589,13 +574,13 @@ 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, showStartTimes: Boolean) {
|
||||
private fun MonthDragOverlay(controller: MonthDragController) {
|
||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||
val timeFit = rememberMonthChipTimeFit(showStartTimes)
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
val density = LocalDensity.current
|
||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val drops = dropStates()
|
||||
val moveInFlight = controller.settledToken in drops.pending
|
||||
val abandoned = controller.settledToken in drops.abandoned
|
||||
val moveInFlight = moveInFlight()
|
||||
// Here rather than beside the controller: this reads [drag], which changes
|
||||
// every frame, and this composable is the one that is meant to.
|
||||
DragSnapHaptics(controller.drag?.targetDate, controller.drops)
|
||||
@@ -608,12 +593,14 @@ private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Bo
|
||||
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
|
||||
val seat = controller.settledInRoot
|
||||
val abandoned = abandonedMoves()
|
||||
LaunchedEffect(controller.settling, moveInFlight, seat, abandoned) {
|
||||
val landed = controller.settling
|
||||
if (landed == null || moveInFlight) return@LaunchedEffect
|
||||
if (seat == null) {
|
||||
// An abandoned drop has no seat coming.
|
||||
if (!abandoned) delay(MONTH_SETTLE_TIMEOUT_MILLIS)
|
||||
// Past the mark this drop was let go on, it was abandoned and no
|
||||
// seat is ever coming.
|
||||
if (abandoned == controller.settledAbandonedMark) delay(MONTH_SETTLE_TIMEOUT_MILLIS)
|
||||
} else {
|
||||
if (!gliding) glide.snapTo(landed.topLeftInRoot)
|
||||
gliding = true
|
||||
@@ -633,9 +620,6 @@ private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Bo
|
||||
.clearAndSetSemantics { }
|
||||
.onGloballyPositioned { origin = it.positionInRoot() },
|
||||
) {
|
||||
controller.held.forEach { parked ->
|
||||
key(parked.token) { HeldMonthChip(controller, parked, origin, timeFit) }
|
||||
}
|
||||
val drag = controller.drag ?: controller.settling ?: return@Box
|
||||
// Landed: the copy sinks back to the grid's own plane while the write runs.
|
||||
val lift by animateFloatAsState(
|
||||
@@ -647,93 +631,41 @@ private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Bo
|
||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||
label = "chip-handover",
|
||||
)
|
||||
MonthChipCopy(
|
||||
drag = drag,
|
||||
// Read at layout, so the glide doesn't recompose every frame.
|
||||
at = { if (gliding) glide.value else drag.topLeftInRoot },
|
||||
origin = origin,
|
||||
lift = lift,
|
||||
alpha = copyAlpha,
|
||||
timeFit = timeFit,
|
||||
MonthBar(
|
||||
event = drag.event,
|
||||
dark = dark,
|
||||
continuesLeft = false,
|
||||
continuesRight = false,
|
||||
// The time the source chip carried, drawn only if this one-column
|
||||
// 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,
|
||||
modifier = Modifier
|
||||
// Absolute: these are root coordinates, and the direction-aware
|
||||
// offset would mirror them across the screen in an RTL layout.
|
||||
.absoluteOffset {
|
||||
val at = if (gliding) glide.value else drag.topLeftInRoot
|
||||
IntOffset(
|
||||
(at.x - origin.x).roundToInt(),
|
||||
(at.y - origin.y).roundToInt(),
|
||||
)
|
||||
}
|
||||
.width(with(density) { drag.sizePx.width.toDp() })
|
||||
.height(with(density) { drag.sizePx.height.toDp() })
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = 1f + 0.04f * lift
|
||||
scaleY = 1f + 0.04f * lift
|
||||
shadowElevation = 8.dp.toPx() * lift
|
||||
alpha = copyAlpha
|
||||
shape = EventChipShape
|
||||
clip = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A drop parked by a newer pickup: held on its landing day until its own write settles. */
|
||||
@Composable
|
||||
private fun HeldMonthChip(
|
||||
controller: MonthDragController,
|
||||
parked: HeldChip,
|
||||
origin: Offset,
|
||||
timeFit: MonthChipTimeFit?,
|
||||
) {
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val glide = remember { Animatable(parked.copy.topLeftInRoot, Offset.VectorConverter) }
|
||||
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
|
||||
val seat = parked.seat
|
||||
val landed = parked.token !in dropStates().pending
|
||||
// Carried onto its seat once the write is in, as the settling chip is.
|
||||
LaunchedEffect(seat, landed) {
|
||||
if (seat == null || !landed) return@LaunchedEffect
|
||||
if (reduceMotion) glide.snapTo(seat) else glide.animateTo(seat, glideSpec)
|
||||
}
|
||||
val alpha = rememberHeldSettle(
|
||||
token = parked.token,
|
||||
seated = seat != null,
|
||||
timeoutMillis = MONTH_SETTLE_TIMEOUT_MILLIS,
|
||||
onHandOver = { controller.handOverHeld(parked.token) },
|
||||
onRelease = { controller.releaseHeld(parked.token) },
|
||||
)
|
||||
MonthChipCopy(parked.copy, { glide.value }, origin, lift = 0f, alpha = alpha, timeFit = timeFit)
|
||||
}
|
||||
|
||||
/** [drag]'s chip floating over the grid with its top left at [at], in root coordinates. */
|
||||
@Composable
|
||||
private fun MonthChipCopy(
|
||||
drag: MonthChipDrag,
|
||||
at: () -> Offset,
|
||||
origin: Offset,
|
||||
lift: Float,
|
||||
alpha: Float,
|
||||
timeFit: MonthChipTimeFit?,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
MonthBar(
|
||||
event = drag.event,
|
||||
dark = dark,
|
||||
continuesLeft = false,
|
||||
continuesRight = false,
|
||||
// The time the source chip carried, drawn only if this one-column
|
||||
// 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,
|
||||
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.
|
||||
.absoluteOffset {
|
||||
val topLeft = at()
|
||||
IntOffset(
|
||||
(topLeft.x - origin.x).roundToInt(),
|
||||
(topLeft.y - origin.y).roundToInt(),
|
||||
)
|
||||
}
|
||||
.width(with(density) { drag.sizePx.width.toDp() })
|
||||
.height(with(density) { drag.sizePx.height.toDp() })
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = 1f + 0.04f * lift
|
||||
scaleY = 1f + 0.04f * lift
|
||||
shadowElevation = 8.dp.toPx() * lift
|
||||
this.alpha = alpha
|
||||
shape = EventChipShape
|
||||
clip = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Content for both scrolling styles. No swipe detector and no [AnimatedContent]:
|
||||
* vertical scrolling owns the gesture, and the list never swaps wholesale — it
|
||||
@@ -745,7 +677,6 @@ private fun ContinuousMonthContent(
|
||||
listState: LazyListState,
|
||||
dense: Boolean,
|
||||
showWeekNumbers: Boolean,
|
||||
showStartTimes: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
@@ -762,7 +693,6 @@ private fun ContinuousMonthContent(
|
||||
state = state,
|
||||
listState = listState,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
showStartTimes = showStartTimes,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
@@ -771,7 +701,6 @@ private fun ContinuousMonthContent(
|
||||
state = state,
|
||||
listState = listState,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
showStartTimes = showStartTimes,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
@@ -944,7 +873,6 @@ 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. */
|
||||
@@ -969,13 +897,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 timeFit = rememberMonthChipTimeFit(showStartTimes)
|
||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||
state.weeks.forEach { week ->
|
||||
MonthWeekRow(
|
||||
week = week,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
timeFit = timeFit,
|
||||
timeChipWidth = timeChipWidth,
|
||||
rowHeight = rowHeight,
|
||||
inMonth = { it.month == month.month && it.year == month.year },
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
@@ -1011,19 +939,18 @@ 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 timeFit = rememberMonthChipTimeFit(showStartTimes)
|
||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
contentPadding = PaddingValues(bottom = FabClearance),
|
||||
contentPadding = PaddingValues(bottom = 96.dp),
|
||||
) {
|
||||
repeat(monthCount) { index ->
|
||||
val month = yearMonthForIndex(index)
|
||||
@@ -1041,7 +968,7 @@ internal fun ContinuousMonthGrid(
|
||||
weekStart = state.weekStart,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
timeFit = timeFit,
|
||||
timeChipWidth = timeChipWidth,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
@@ -1063,7 +990,7 @@ private fun ContinuousMonthBlock(
|
||||
weekStart: DayOfWeek,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
timeFit: MonthChipTimeFit?,
|
||||
timeChipWidth: Dp,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
@@ -1084,7 +1011,7 @@ private fun ContinuousMonthBlock(
|
||||
week = week,
|
||||
today = today,
|
||||
zone = zone,
|
||||
timeFit = timeFit,
|
||||
timeChipWidth = timeChipWidth,
|
||||
rowHeight = CONTINUOUS_ROW_HEIGHT,
|
||||
inMonth = { it.month == month.month && it.year == month.year },
|
||||
// The block owns its month alone: a day from either
|
||||
@@ -1150,13 +1077,12 @@ 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 timeFit = rememberMonthChipTimeFit(showStartTimes)
|
||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier
|
||||
@@ -1169,7 +1095,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 = FabClearance),
|
||||
contentPadding = PaddingValues(bottom = 96.dp),
|
||||
) {
|
||||
items(count = weekCount, key = { it }) { index ->
|
||||
val week = state.weeksByIndex[index]
|
||||
@@ -1180,7 +1106,7 @@ internal fun DenseMonthGrid(
|
||||
week = week,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
timeFit = timeFit,
|
||||
timeChipWidth = timeChipWidth,
|
||||
rowHeight = CONTINUOUS_ROW_HEIGHT,
|
||||
// Every day in the stream belongs to a month equally — there
|
||||
// is no "other month" to recede here.
|
||||
@@ -1272,8 +1198,6 @@ private fun SplitMonthContent(
|
||||
weekStart: DayOfWeek,
|
||||
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
|
||||
showWeekNumbers: Boolean,
|
||||
showStartTimes: Boolean,
|
||||
showToday: Boolean,
|
||||
onPaneSwipeNext: () -> Unit,
|
||||
onPaneSwipePrev: () -> Unit,
|
||||
onClearSelection: () -> Unit,
|
||||
@@ -1283,12 +1207,9 @@ 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)
|
||||
}
|
||||
@@ -1357,9 +1278,8 @@ 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.
|
||||
// Fading the faces made the shared cell backgrounds briefly
|
||||
// disappear beneath their incoming counterparts.
|
||||
transitionSpec = { EnterTransition.None togetherWith ExitTransition.None },
|
||||
// The travel between them is the shared elements' job.
|
||||
transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
|
||||
label = "split-expand",
|
||||
) { isExpanded ->
|
||||
// The view switch's morph stays off a face until it has fully
|
||||
@@ -1390,7 +1310,6 @@ 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
|
||||
@@ -1416,11 +1335,7 @@ 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 = {
|
||||
@@ -1462,9 +1377,7 @@ private fun SplitMonthCollapsed(
|
||||
state: MonthUiState.Success,
|
||||
paneReady: Boolean,
|
||||
selected: LocalDate?,
|
||||
selectionIndicatorVisible: Boolean,
|
||||
showWeekNumbers: Boolean,
|
||||
showToday: Boolean,
|
||||
expandDrag: Modifier,
|
||||
onSelectDay: (LocalDate) -> Unit,
|
||||
onPaneSwipeNext: () -> Unit,
|
||||
@@ -1484,7 +1397,6 @@ private fun SplitMonthCollapsed(
|
||||
SplitMonthGrid(
|
||||
state = state,
|
||||
selected = selected,
|
||||
selectionIndicatorVisible = selectionIndicatorVisible,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onSelectDay = onSelectDay,
|
||||
modifier = Modifier.height(splitGridHeight(state.weeks.size)),
|
||||
@@ -1498,7 +1410,6 @@ private fun SplitMonthCollapsed(
|
||||
Box(Modifier.weight(1f).fillMaxWidth().then(paneModifier)) {
|
||||
SplitMonthAgendaPane(
|
||||
state = state,
|
||||
showToday = showToday,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
@@ -1512,13 +1423,24 @@ 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(),
|
||||
@@ -1565,7 +1487,6 @@ 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,
|
||||
@@ -1578,7 +1499,6 @@ private fun SplitMonthExpanded(
|
||||
MonthGrid(
|
||||
state = state,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
showStartTimes = showStartTimes,
|
||||
onOpenDay = onPickDay,
|
||||
onEventClick = onEventClick,
|
||||
selected = selected,
|
||||
@@ -1692,7 +1612,6 @@ 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,
|
||||
@@ -1738,7 +1657,6 @@ 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 },
|
||||
@@ -1767,7 +1685,6 @@ private fun SplitDayCell(
|
||||
hidden: List<EventInstance>,
|
||||
isToday: Boolean,
|
||||
isSelected: Boolean,
|
||||
selectionIndicatorVisible: Boolean,
|
||||
inMonth: Boolean,
|
||||
relativeToToday: Int?,
|
||||
rippleProgress: () -> Float,
|
||||
@@ -1797,7 +1714,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 && selectionIndicatorVisible) 1f else 0f,
|
||||
targetValue = if (isSelected) 1f else 0f,
|
||||
animationSpec = if (reduceMotion) snap() else outlineSpec,
|
||||
label = "split-day-selection",
|
||||
)
|
||||
@@ -1809,19 +1726,12 @@ private fun SplitDayCell(
|
||||
// built — and so the pill can be tagged for the morph on its own. Tagged as
|
||||
// one piece with its contents inside, the dots would be nested shared
|
||||
// elements within a shared element and travel twice.
|
||||
// The cell keeps the click and selection semantics, so TalkBack reads the
|
||||
// day number with them; the topmost layer only draws the ripple.
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.todayRipple(relativeToToday, rippleProgress)
|
||||
.selectable(
|
||||
selected = isSelected,
|
||||
interactionSource = interaction,
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
),
|
||||
.clip(CELL_SHAPE)
|
||||
.selectable(selected = isSelected, onClick = onClick),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Box(
|
||||
@@ -1883,13 +1793,6 @@ 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1911,10 +1814,7 @@ 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,
|
||||
) {
|
||||
@@ -1949,7 +1849,6 @@ 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),
|
||||
)
|
||||
@@ -1960,33 +1859,22 @@ private fun SplitDots(
|
||||
@Composable
|
||||
private fun SplitMonthAgendaPane(
|
||||
state: MonthUiState.Success,
|
||||
showToday: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateEvent: (LocalDate) -> Unit,
|
||||
) {
|
||||
val days = remember(state.month, state.today, state.instancesByDay, showToday) {
|
||||
splitAgendaDays(state.month, state.today, state.instancesByDay, showToday)
|
||||
}
|
||||
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 dimCutoff = LocalDimCutoff.current
|
||||
val listState = key(state.month) { rememberLazyListState() }
|
||||
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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
LazyColumn(state = listState, contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
|
||||
agendaDays(
|
||||
days = days,
|
||||
days = days.map { AgendaDay(it, state.instancesByDay[it].orEmpty()) },
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
emptyText = R.string.month_split_no_events,
|
||||
@@ -1994,7 +1882,6 @@ private fun SplitMonthAgendaPane(
|
||||
dimmed = { event, day -> dimCutoff != null && event.isPastOn(day, dimCutoff) },
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
compact = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2002,8 +1889,7 @@ 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). It is shown as the filter chip itself,
|
||||
* whose ✕ clears the day filter back to the whole month (#142).
|
||||
* the Week and Agenda headers (#37).
|
||||
*/
|
||||
@Composable
|
||||
internal fun SplitDayPane(
|
||||
@@ -2013,7 +1899,6 @@ internal fun SplitDayPane(
|
||||
events: List<EventInstance>?,
|
||||
zone: TimeZone,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onClearSelection: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateEvent: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -2025,23 +1910,7 @@ internal fun SplitDayPane(
|
||||
val enter = itemEnter()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
Column(modifier = modifier) {
|
||||
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),
|
||||
)
|
||||
AgendaDayHeader(date = date, today = today, onOpenDay = onOpenDay)
|
||||
AnimatedContent(
|
||||
targetState = date to events,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
@@ -2064,7 +1933,7 @@ internal fun SplitDayPane(
|
||||
else -> LazyColumn(
|
||||
// Bottom inset clears the FAB stack so the last row stays
|
||||
// tappable.
|
||||
contentPadding = PaddingValues(bottom = FabClearance),
|
||||
contentPadding = PaddingValues(bottom = 96.dp),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = dayEvents,
|
||||
@@ -2220,8 +2089,8 @@ private fun MonthWeekRow(
|
||||
today: LocalDate,
|
||||
/** The zone the grid was laid out in — never re-read here (see [MonthUiState.Success.zone]). */
|
||||
zone: TimeZone,
|
||||
/** Prices chips' start times, once per grid; null when they are switched off (#219, #476). */
|
||||
timeFit: MonthChipTimeFit?,
|
||||
/** The narrowest chip that may carry a start time, measured once per grid (#219). */
|
||||
timeChipWidth: Dp,
|
||||
/** The height this row was given, which decides its lanes — see [laneCapFor]. */
|
||||
rowHeight: Dp,
|
||||
inMonth: (LocalDate) -> Boolean,
|
||||
@@ -2310,7 +2179,7 @@ private fun MonthWeekRow(
|
||||
// Once the grid holds the settled chip, tell the controller where this row
|
||||
// has seated it, so the copy in flight can glide onto it. Keyed on the week,
|
||||
// which is what changes when the re-read lands.
|
||||
LaunchedEffect(week, dragController?.settling, dragController?.held) {
|
||||
LaunchedEffect(week, dragController?.settling) {
|
||||
dragController?.noteSettled(rowToken)
|
||||
}
|
||||
|
||||
@@ -2347,8 +2216,8 @@ private fun MonthWeekRow(
|
||||
),
|
||||
),
|
||||
) {
|
||||
// What a chip has to spend, against what [timeFit] says its start
|
||||
// time costs it (#219).
|
||||
// What a chip has to spend, against the [timeChipWidth] a 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
|
||||
@@ -2438,8 +2307,7 @@ private fun MonthWeekRow(
|
||||
continuesRight = span.continuesRight,
|
||||
days = week.days.subList(span.startCol, span.endCol + 1),
|
||||
time = chipTimes[span.event.instanceId],
|
||||
timeFit = timeFit,
|
||||
chipWidth = colW * cols,
|
||||
showTime = colW * cols >= timeChipWidth,
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * span.startCol,
|
||||
@@ -2516,8 +2384,7 @@ private fun MonthWeekRow(
|
||||
continuesRight = false,
|
||||
days = listOf(d),
|
||||
time = chipTimes[ev.instanceId],
|
||||
timeFit = timeFit,
|
||||
chipWidth = colW,
|
||||
showTime = colW >= timeChipWidth,
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * col,
|
||||
@@ -2615,7 +2482,6 @@ 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
|
||||
@@ -2743,8 +2609,6 @@ private fun monthChipDragModifier(
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.ByDays(delta),
|
||||
isAllDay = drop.event.isAllDay,
|
||||
token = drop.token,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -2755,20 +2619,19 @@ private fun monthChipDragModifier(
|
||||
/**
|
||||
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
|
||||
* day cells' geometry, set apart by the secondaryContainer tint (matching the
|
||||
* Week view's badge), with the week number centred like a day number. It is
|
||||
* numbered by the device region from the row's mid-week day, as in the Week
|
||||
* view, so it is right for any first weekday (#493).
|
||||
* Week view's badge), with the ISO week number centred like a day number. The
|
||||
* week is computed on the row's first day — the same basis as the Week view — so
|
||||
* the two agree.
|
||||
*/
|
||||
@Composable
|
||||
private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) {
|
||||
val weekNumber = rememberRowWeekNumber(weekStart)
|
||||
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
|
||||
val label = stringResource(R.string.week_number_label)
|
||||
val openLabel = stringResource(R.string.month_open_week, weekNumber)
|
||||
val onOpenWeek = LocalOpenWeek.current
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.morphElement(MonthMorphKey.WeekNumber(weekStart))
|
||||
.clip(CELL_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||
.then(if (onOpenWeek != null) Modifier.clickable(onClickLabel = openLabel) {
|
||||
@@ -2847,7 +2710,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 ([timeFit], #219, #476).
|
||||
* with the start time before it where the chip is wide enough ([showTime], #219).
|
||||
*/
|
||||
@Composable
|
||||
private fun MonthBar(
|
||||
@@ -2864,13 +2727,10 @@ private fun MonthBar(
|
||||
days: List<LocalDate>? = null,
|
||||
/** The chip's start time, or null where it has none — see `monthChipTime` (#219). */
|
||||
time: String? = null,
|
||||
/** 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,
|
||||
/** Whether this chip has the width to draw [time]; a screen reader gets it either way. */
|
||||
showTime: Boolean = false,
|
||||
) {
|
||||
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 &&
|
||||
@@ -2890,17 +2750,8 @@ private fun MonthBar(
|
||||
val moveAction = eventMoveAction(event)
|
||||
// The source stays put as a ghost while its floating copy travels.
|
||||
val monthDrag = LocalMonthDrag.current
|
||||
// Derived, so a chip recomposes only when its own answer changes rather than
|
||||
// on every lift, park and release anywhere in the grid.
|
||||
val lifted by remember(monthDrag, event, days) {
|
||||
derivedStateOf {
|
||||
monthDrag != null && (
|
||||
monthDrag.liftedInstanceId == event.instanceId ||
|
||||
monthDrag.isSettledChip(event, days) ||
|
||||
monthDrag.isHeldChip(event, days)
|
||||
)
|
||||
}
|
||||
}
|
||||
val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
|
||||
monthDrag?.isSettledChip(event, days) == true
|
||||
val ghost = ghostAlpha(lifted)
|
||||
val shape = monthBarShape(continuesLeft, continuesRight)
|
||||
Box(
|
||||
|
||||
@@ -31,7 +31,6 @@ import kotlinx.datetime.yearMonth
|
||||
internal fun MonthStylePreview(
|
||||
style: MonthViewStyle,
|
||||
weekStart: DayOfWeek,
|
||||
showStartTimes: Boolean,
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -45,7 +44,6 @@ internal fun MonthStylePreview(
|
||||
MonthViewStyle.Paged -> MonthGrid(
|
||||
state = sample.month,
|
||||
showWeekNumbers = false,
|
||||
showStartTimes = showStartTimes,
|
||||
onOpenDay = {},
|
||||
onEventClick = {},
|
||||
)
|
||||
@@ -57,7 +55,6 @@ internal fun MonthStylePreview(
|
||||
),
|
||||
),
|
||||
showWeekNumbers = false,
|
||||
showStartTimes = showStartTimes,
|
||||
onOpenDay = {},
|
||||
onEventClick = {},
|
||||
)
|
||||
@@ -70,7 +67,6 @@ internal fun MonthStylePreview(
|
||||
initialFirstVisibleItemIndex = weekIndexOf(today, weekStart) - 1,
|
||||
),
|
||||
showWeekNumbers = false,
|
||||
showStartTimes = showStartTimes,
|
||||
onOpenDay = {},
|
||||
onEventClick = {},
|
||||
)
|
||||
@@ -87,7 +83,6 @@ internal fun MonthStylePreview(
|
||||
events = sample.month.instancesByDay[today].orEmpty(),
|
||||
zone = zone,
|
||||
onOpenDay = {},
|
||||
onClearSelection = {},
|
||||
onEventClick = {},
|
||||
onCreateEvent = {},
|
||||
modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT),
|
||||
|
||||
@@ -13,15 +13,11 @@ 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.isCrossedOut
|
||||
import de.jeanlucmakiola.calendula.domain.sortedForDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
|
||||
@@ -85,23 +81,6 @@ 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(
|
||||
@@ -116,7 +95,7 @@ class MonthViewModel @Inject constructor(
|
||||
val today: StateFlow<LocalDate> =
|
||||
todaySource.today.stateFollowingDays(viewModelScope, todayDate, ::followToday)
|
||||
|
||||
private val _month = MutableStateFlow(yearMonthForIndex(monthIndexOf(todayDate.yearMonth)))
|
||||
private val _month = MutableStateFlow(todayDate.yearMonth)
|
||||
val month: StateFlow<YearMonth> = _month
|
||||
|
||||
/**
|
||||
@@ -170,7 +149,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
|
||||
setMonth(yearMonthForIndex(page))
|
||||
_month.value = yearMonthForIndex(page)
|
||||
// Moving the grid to another month restores its full agenda.
|
||||
_selectedDate.value = null
|
||||
}
|
||||
@@ -319,11 +298,9 @@ 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) setMonth(target)
|
||||
if (target != _month.value) _month.value = target
|
||||
}
|
||||
|
||||
fun clearSelection() { _selectedDate.value = null }
|
||||
@@ -332,16 +309,16 @@ class MonthViewModel @Inject constructor(
|
||||
fun stepSplitPane(days: Int) {
|
||||
val selected = _selectedDate.value
|
||||
if (selected == null) {
|
||||
setMonth(yearMonthForIndex(monthIndexOf(_month.value) + days))
|
||||
_month.value = yearMonthForIndex(monthIndexOf(_month.value) + days)
|
||||
} else {
|
||||
val next = coerceToMonthRange(selected.plus(days, DateTimeUnit.DAY))
|
||||
val next = selected.plus(days, DateTimeUnit.DAY)
|
||||
_selectedDate.value = next
|
||||
setMonth(next.yearMonth)
|
||||
_month.value = next.yearMonth
|
||||
}
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
setMonth(todayDate.yearMonth)
|
||||
_month.value = todayDate.yearMonth
|
||||
_selectedDate.value = null
|
||||
}
|
||||
|
||||
@@ -353,7 +330,7 @@ class MonthViewModel @Inject constructor(
|
||||
private fun followToday(old: LocalDate, new: LocalDate) {
|
||||
if (viewStyle.value.isScrolling) return
|
||||
if (_month.value != old.yearMonth) return
|
||||
setMonth(new.yearMonth)
|
||||
_month.value = new.yearMonth
|
||||
_selectedDate.value = null
|
||||
}
|
||||
|
||||
@@ -367,24 +344,14 @@ class MonthViewModel @Inject constructor(
|
||||
*/
|
||||
fun syncScrollMonth(ym: YearMonth) {
|
||||
if (_month.value == ym) return
|
||||
setMonth(ym)
|
||||
_month.value = ym
|
||||
_selectedDate.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Jump to the month containing [date] (drawer jump-to-date). */
|
||||
fun goToDate(date: LocalDate) {
|
||||
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))
|
||||
_month.value = date.yearMonth
|
||||
_selectedDate.value = date
|
||||
}
|
||||
|
||||
private fun buildState(
|
||||
@@ -502,7 +469,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.coversWholeDays(zone) || ev.spansMultipleDays(zone)
|
||||
ev.isAllDay || ev.spansMultipleDays(zone)
|
||||
}
|
||||
val spans = layoutAllDay(bars, days, zone).map { s ->
|
||||
MonthSpan(
|
||||
@@ -521,7 +488,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.isCrossedOut }.thenBy { it.start })
|
||||
.sortedWith(compareBy<EventInstance> { it.isDeclined }.thenBy { it.start })
|
||||
},
|
||||
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
||||
)
|
||||
@@ -542,7 +509,7 @@ internal fun instancesByDay(
|
||||
days.associateWith { day ->
|
||||
instances
|
||||
.filter { it.coversDay(day, zone) }
|
||||
.sortedForDisplay(zone)
|
||||
.sortedWith(eventDisplayComparator(zone))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -589,25 +556,6 @@ 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.
|
||||
*
|
||||
@@ -630,39 +578,24 @@ 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 the first month of [CalendarSpan]; the list runs to [continuousMonthCount].
|
||||
* Index 0 is January 1900; 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 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)
|
||||
private const val MONTH_INDEX_EPOCH_YEAR = 1900
|
||||
private const val MONTH_INDEX_END_YEAR = 2100
|
||||
|
||||
internal fun monthIndexOf(ym: YearMonth): Int =
|
||||
(ym.year - MONTH_INDEX_EPOCH_YEAR) * 12 + ym.month.ordinal
|
||||
|
||||
/** 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])
|
||||
}
|
||||
internal fun yearMonthForIndex(index: Int): YearMonth = YearMonth(
|
||||
MONTH_INDEX_EPOCH_YEAR + index / 12,
|
||||
Month.entries[index % 12],
|
||||
)
|
||||
|
||||
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. */
|
||||
/** Total months the continuous grid scrolls through (1900 → 2100). */
|
||||
internal fun continuousMonthCount(): Int =
|
||||
(MONTH_INDEX_END_YEAR - MONTH_INDEX_EPOCH_YEAR + 1) * 12
|
||||
|
||||
@@ -678,23 +611,25 @@ 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
|
||||
* span, and [yearMonthForIndex] has no month to hand back for one.
|
||||
* 1900–2100 span, and [yearMonthForIndex] has no month to hand back for one.
|
||||
*/
|
||||
internal fun clampMonthWindow(window: IntRange): IntRange =
|
||||
clampMonthIndex(window.first)..clampMonthIndex(window.last)
|
||||
internal fun clampMonthWindow(window: IntRange): IntRange {
|
||||
val last = continuousMonthCount() - 1
|
||||
return window.first.coerceIn(0, last)..window.last.coerceIn(0, 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 span's first week under the active week-start,
|
||||
* scrolling past. Index 0 is the first week of 1900 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 = GRID_FIRST_DAY
|
||||
private val WEEK_INDEX_END = GRID_LAST_DAY
|
||||
private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1)
|
||||
private val WEEK_INDEX_END = LocalDate(2100, 12, 31)
|
||||
|
||||
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
|
||||
val base = WEEK_INDEX_EPOCH.startOfWeek(weekStart)
|
||||
@@ -704,7 +639,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. */
|
||||
/** Total weeks the Dense grid scrolls through (1900 → 2100). */
|
||||
internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
|
||||
weekIndexOf(WEEK_INDEX_END, weekStart) + 1
|
||||
|
||||
|
||||
@@ -220,7 +220,6 @@ class OnboardingViewModel @Inject constructor(
|
||||
prefs.defaultView,
|
||||
prefs.monthViewStyle,
|
||||
prefs.weekStart,
|
||||
prefs.monthShowStartTimes,
|
||||
::ViewChoice,
|
||||
).stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
@@ -465,7 +464,6 @@ data class ViewChoice(
|
||||
val defaultView: CalendarView,
|
||||
val monthViewStyle: MonthViewStyle,
|
||||
val weekStart: WeekStartPref,
|
||||
val monthShowStartTimes: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,7 +63,6 @@ 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)
|
||||
@@ -119,12 +118,7 @@ internal fun MonthStyleStep(
|
||||
navigationIcon = navigationIcon,
|
||||
) {
|
||||
StepPreview(selected = choice.monthViewStyle) { style ->
|
||||
MonthStylePreview(
|
||||
style = style,
|
||||
weekStart = weekStart,
|
||||
showStartTimes = choice.monthShowStartTimes,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
MonthStylePreview(style = style, weekStart = weekStart, height = PREVIEW_HEIGHT)
|
||||
}
|
||||
GroupedHint(stringResource(choice.monthViewStyle.descriptionRes), verticalPadding = 8.dp)
|
||||
options.forEachIndexed { index, style ->
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.reminders
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.reminders.FullScreenAlerts
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderActionReceiver
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderActionReceiver.Companion.putAlert
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlert
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderNotifier
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderRingService
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||
import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.isDark
|
||||
import de.jeanlucmakiola.floret.prefs.isPureBlack
|
||||
import de.jeanlucmakiola.floret.ring.showOverLockScreen
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The full-screen reminder alert (#171), opened by the reminder's full-screen
|
||||
* intent over the lock screen, or, for a ringing one, from its heads-up.
|
||||
* It stays until the reminder is dismissed or snoozed — here, from the
|
||||
* notification, or by the ring giving up — and back does not close it.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ReminderAlertActivity : ComponentActivity() {
|
||||
|
||||
@Inject lateinit var notifier: ReminderNotifier
|
||||
@Inject lateinit var alerts: FullScreenAlerts
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
@Inject lateinit var calendarDataSource: CalendarDataSource
|
||||
|
||||
@Inject @IoDispatcher
|
||||
lateinit var io: CoroutineDispatcher
|
||||
|
||||
private var state by mutableStateOf<ReminderAlertUiState>(ReminderAlertUiState.Loading)
|
||||
private var alert: ReminderAlert? = null
|
||||
private var loading: Job? = null
|
||||
private var screenOn: Job? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
showOverLockScreen()
|
||||
enableEdgeToEdge()
|
||||
show(intent)
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||
alerts.handled.collect { key -> if (key == alert?.key) finish() }
|
||||
}
|
||||
}
|
||||
val cachedTheme = WindowThemeCache.read(this) ?: ThemeMode.SYSTEM
|
||||
val cachedDynamicColor = WindowThemeCache.readDynamicColor(this) ?: true
|
||||
setContent {
|
||||
val themeMode by settingsPrefs.themeMode.collectAsStateWithLifecycle(cachedTheme)
|
||||
val dynamicColor by settingsPrefs.dynamicColor.collectAsStateWithLifecycle(cachedDynamicColor)
|
||||
val soften by settingsPrefs.softenCalendarColors.collectAsStateWithLifecycle(true)
|
||||
CalendulaTheme(
|
||||
darkTheme = themeMode.isDark(isSystemInDarkTheme()),
|
||||
dynamicColor = dynamicColor,
|
||||
pureBlack = themeMode.isPureBlack,
|
||||
) {
|
||||
CompositionLocalProvider(LocalSoftenColors provides soften) {
|
||||
BackHandler(enabled = true) { /* Handled by Dismiss or Snooze, not by leaving. */ }
|
||||
when (val current = state) {
|
||||
ReminderAlertUiState.Loading ->
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
|
||||
is ReminderAlertUiState.Showing -> ReminderAlertScreen(
|
||||
state = current,
|
||||
onSnooze = { act(ReminderActionReceiver.ACTION_SNOOZE) },
|
||||
onDismiss = { act(ReminderActionReceiver.ACTION_DISMISS) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
show(intent)
|
||||
}
|
||||
|
||||
/** Tapped from a stale notification, or handled while this window was away. */
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val current = alert
|
||||
if (current != null && !notifier.isShowing(current)) finish()
|
||||
}
|
||||
|
||||
private fun show(intent: Intent) {
|
||||
val next = ReminderActionReceiver.alertFrom(intent)
|
||||
if (next == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
alert = next
|
||||
// A ring still going, this one's or another's, keeps the screen up as long as it rings.
|
||||
val ringing = alerts.ringing != null
|
||||
keepScreenOn(if (ringing) ReminderRingService.RING_TIMEOUT_MILLIS else ONCE_SCREEN_ON_MILLIS)
|
||||
loading?.cancel()
|
||||
state = ReminderAlertUiState.Loading
|
||||
loading = lifecycleScope.launch {
|
||||
val calendar = try {
|
||||
withContext(io) { calendarDataSource.calendars().firstOrNull { it.id == next.calendarId } }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// The alert still shows, only without its calendar.
|
||||
Log.w(TAG, "Could not read the calendar of event ${next.eventId}", e)
|
||||
null
|
||||
}
|
||||
state = ReminderAlertUiState.Showing(
|
||||
title = next.title,
|
||||
time = notifier.timeText(next),
|
||||
location = next.location?.takeIf { it.isNotBlank() },
|
||||
calendarName = calendar?.displayName,
|
||||
calendarColor = calendar?.color,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** An alert nobody answers lets the display sleep again; it stays on the lock screen. */
|
||||
private fun keepScreenOn(millis: Long) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
screenOn?.cancel()
|
||||
screenOn = lifecycleScope.launch {
|
||||
delay(millis)
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
|
||||
private fun act(action: String) {
|
||||
val current = alert ?: return
|
||||
sendBroadcast(ReminderActionReceiver.intent(this, action, current))
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ReminderAlertActivity"
|
||||
private const val ONCE_SCREEN_ON_MILLIS = 60_000L
|
||||
|
||||
fun intent(context: Context, alert: ReminderAlert): Intent =
|
||||
Intent(context, ReminderAlertActivity::class.java)
|
||||
.setData("calendula://reminder-alert/${alert.key}".toUri())
|
||||
.putAlert(alert)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION)
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.reminders
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Event
|
||||
import androidx.compose.material.icons.outlined.LocationOn
|
||||
import androidx.compose.material.icons.outlined.Snooze
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import de.jeanlucmakiola.floret.ring.RingBadge
|
||||
|
||||
/**
|
||||
* The alert itself, after the incoming-call screen: the calendar's pulsing
|
||||
* badge, what and when in the middle, and the two ways out at the bottom,
|
||||
* large enough to hit half awake.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ReminderAlertScreen(
|
||||
state: ReminderAlertUiState.Showing,
|
||||
onSnooze: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.safeDrawingPadding()
|
||||
.padding(horizontal = 24.dp, vertical = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// Centred, but scrolls rather than pushing the buttons off a short landscape screen.
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.heightIn(min = maxHeight),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
state.calendarName?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.size(24.dp))
|
||||
}
|
||||
CalendarBadge(state.calendarColor)
|
||||
Spacer(Modifier.size(32.dp))
|
||||
Text(
|
||||
text = state.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Text(
|
||||
text = state.time,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
state.location?.let { location ->
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.LocationOn,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Text(
|
||||
text = location,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(24.dp))
|
||||
Row(
|
||||
modifier = Modifier.widthIn(max = 480.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
AlertButton(
|
||||
label = stringResource(R.string.reminder_action_snooze),
|
||||
icon = Icons.Outlined.Snooze,
|
||||
tonal = true,
|
||||
onClick = onSnooze,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
AlertButton(
|
||||
label = stringResource(R.string.reminder_action_dismiss),
|
||||
icon = Icons.Rounded.Close,
|
||||
tonal = false,
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The calendar's colour as the badge fill, or the theme's primary container without one. */
|
||||
@Composable
|
||||
private fun CalendarBadge(color: Int?) {
|
||||
if (color == null) {
|
||||
RingBadge(icon = Icons.Outlined.Event, size = 120.dp)
|
||||
return
|
||||
}
|
||||
val fill = eventFill(color, LocalFloretDarkTheme.current, LocalSoftenColors.current)
|
||||
RingBadge(
|
||||
icon = Icons.Outlined.Event,
|
||||
containerColor = fill,
|
||||
contentColor = eventInk(fill, alpha = 1f),
|
||||
size = 120.dp,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun AlertButton(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
tonal: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val height = ButtonDefaults.LargeContainerHeight
|
||||
val content: @Composable () -> Unit = {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(ButtonDefaults.iconSizeFor(height)))
|
||||
Spacer(Modifier.size(ButtonDefaults.iconSpacingFor(height)))
|
||||
Text(label, style = ButtonDefaults.textStyleFor(height), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
val buttonModifier = modifier.heightIn(min = height)
|
||||
val padding = ButtonDefaults.contentPaddingFor(height)
|
||||
if (tonal) {
|
||||
FilledTonalButton(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier,
|
||||
shapes = ButtonDefaults.shapesFor(height),
|
||||
contentPadding = padding,
|
||||
) { content() }
|
||||
} else {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier,
|
||||
shapes = ButtonDefaults.shapesFor(height),
|
||||
contentPadding = padding,
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.reminders
|
||||
|
||||
sealed interface ReminderAlertUiState {
|
||||
/** The calendar is still being read; the surface is already up. */
|
||||
data object Loading : ReminderAlertUiState
|
||||
|
||||
data class Showing(
|
||||
/** Raw title; blank shows as "(no title)". */
|
||||
val title: String,
|
||||
val time: String,
|
||||
val location: String?,
|
||||
/** Null when the calendar could not be read. */
|
||||
val calendarName: String?,
|
||||
val calendarColor: Int?,
|
||||
) : ReminderAlertUiState
|
||||
}
|
||||
@@ -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.isCrossedOut
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
@@ -94,7 +94,6 @@ 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
|
||||
@@ -500,7 +499,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.isCrossedOut),
|
||||
title = declinedTitle(marked(title, hit.titleSpans, highlight), event.isDeclined),
|
||||
summary = searchSummary(hit, highlight),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
@@ -610,7 +609,7 @@ private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString
|
||||
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())
|
||||
}
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val timeText = if (event.coversWholeDays(TimeZone.currentSystemDefault())) {
|
||||
val timeText = if (event.isAllDay) {
|
||||
stringResource(R.string.event_detail_all_day)
|
||||
} else {
|
||||
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
||||
|
||||
@@ -33,7 +33,6 @@ import kotlinx.datetime.DayOfWeek
|
||||
internal fun MonthViewStylePicker(
|
||||
selected: MonthViewStyle,
|
||||
weekStart: DayOfWeek,
|
||||
showStartTimes: Boolean,
|
||||
onSelect: (MonthViewStyle) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
@@ -51,7 +50,6 @@ internal fun MonthViewStylePicker(
|
||||
MonthStylePreview(
|
||||
style = shown,
|
||||
weekStart = weekStart,
|
||||
showStartTimes = showStartTimes,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
}
|
||||
|
||||
+56
-327
@@ -9,39 +9,28 @@ import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.reminders.RING_CHANNEL_ID
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.alertStyleFor
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||
@@ -50,23 +39,17 @@ import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||
import de.jeanlucmakiola.calendula.ui.common.durationLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import de.jeanlucmakiola.floret.android.openNotificationSettings
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.ExpandIcon
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
|
||||
import de.jeanlucmakiola.floret.components.IconInfoCard
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.ring.canUseFullScreenIntent
|
||||
import de.jeanlucmakiola.floret.ring.openFullScreenIntentSettings
|
||||
import de.jeanlucmakiola.floret.reminders.minutesOrEmpty
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalTime
|
||||
|
||||
/**
|
||||
@@ -101,21 +84,8 @@ internal fun NotificationsScreen(
|
||||
var showAllDayReminderTime by remember { mutableStateOf(false) }
|
||||
var showSnooze by remember { mutableStateOf(false) }
|
||||
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
|
||||
var alertStylePicker by remember { mutableStateOf<Long?>(null) }
|
||||
var alertSoundPicker by remember { mutableStateOf<Long?>(null) }
|
||||
val canFullScreen = rememberCanUseFullScreenIntent()
|
||||
var calendarSectionExpanded by remember { mutableStateOf(false) }
|
||||
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val channelStatus by viewModel.channelStatus.collectAsStateWithLifecycle()
|
||||
val calendarIds = state.reminderCalendars.map { it.id }
|
||||
LifecycleResumeEffect(calendarIds) {
|
||||
viewModel.refreshChannelStatus(calendarIds)
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
val openCalendarSound: (Long) -> Unit = { calendarId ->
|
||||
scope.launch { context.openNotificationSettings(viewModel.soundSettingsChannel(calendarId)) }
|
||||
}
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_notifications),
|
||||
@@ -123,19 +93,6 @@ internal fun NotificationsScreen(
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
if (IS_SAMSUNG && !state.notificationCategoryHintDismissed) {
|
||||
NotificationCategoryHint(
|
||||
onOpen = {
|
||||
viewModel.dismissNotificationCategoryHint()
|
||||
scope.launch {
|
||||
viewModel.syncReminderChannels()
|
||||
context.openNotificationSettings()
|
||||
}
|
||||
},
|
||||
onDismiss = viewModel::dismissNotificationCategoryHint,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
GroupedSwitchRow(
|
||||
title = stringResource(R.string.settings_reminders),
|
||||
checked = state.remindersEnabled,
|
||||
@@ -143,22 +100,6 @@ internal fun NotificationsScreen(
|
||||
summary = stringResource(R.string.settings_reminders_hint),
|
||||
position = Position.Top,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_notification_sound),
|
||||
summary = if (channelStatus.appBlocked) {
|
||||
stringResource(R.string.settings_notifications_blocked_hint)
|
||||
} else {
|
||||
stringResource(R.string.settings_notification_sound_hint)
|
||||
},
|
||||
position = Position.Middle,
|
||||
trailing = { ExternalLinkIcon() },
|
||||
onClick = {
|
||||
scope.launch {
|
||||
viewModel.syncReminderChannels()
|
||||
context.openNotificationSettings()
|
||||
}
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
summary = reminderChoiceLabel(state.defaultReminderMinutes),
|
||||
@@ -208,8 +149,10 @@ internal fun NotificationsScreen(
|
||||
onClick = { showSnooze = true },
|
||||
)
|
||||
|
||||
// Per-calendar notifications and reminder overrides (#145, #272).
|
||||
if (state.reminderCalendars.isNotEmpty()) {
|
||||
// Per-calendar overrides, folded behind one header. Each writable
|
||||
// calendar may keep, drop or replace the global default, separately for
|
||||
// timed and all-day events.
|
||||
if (state.writableCalendars.isNotEmpty()) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_calendar_reminders_title),
|
||||
@@ -224,26 +167,30 @@ internal fun NotificationsScreen(
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
Column {
|
||||
state.reminderCalendars.forEach { calendar ->
|
||||
state.writableCalendars.forEach { calendar ->
|
||||
Spacer(Modifier.height(16.dp))
|
||||
// Special-dates calendars own their reminders in their
|
||||
// own section — link there instead.
|
||||
if (calendar.id in state.managedCalendarIds) {
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
||||
position = Position.Alone,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = onOpenSpecialDates,
|
||||
)
|
||||
return@forEach
|
||||
}
|
||||
val expanded = calendar.id in expandedCalendars
|
||||
val notifying = calendar.id !in state.mutedReminderCalendarIds
|
||||
val blocked = notifying && calendar.id in channelStatus.blockedCalendarIds
|
||||
val heldBack = fullScreenHeldBack(
|
||||
style = state.reminderAlertStyles.alertStyleFor(calendar.id),
|
||||
notifying = notifying,
|
||||
blocked = blocked,
|
||||
ringBlocked = channelStatus.ringBlocked,
|
||||
canFullScreen = canFullScreen,
|
||||
)
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
summary = when {
|
||||
!notifying -> stringResource(R.string.settings_calendar_notifications_off)
|
||||
blocked -> stringResource(R.string.settings_notifications_blocked)
|
||||
heldBack.any -> stringResource(R.string.settings_full_screen_held_back)
|
||||
else -> null
|
||||
},
|
||||
position = if (expanded) Position.Top else Position.Alone,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = { ExpandIcon(expanded) },
|
||||
@@ -260,21 +207,22 @@ internal fun NotificationsScreen(
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
CalendarNotificationRows(
|
||||
calendar = calendar,
|
||||
state = state,
|
||||
notifying = notifying,
|
||||
blocked = blocked,
|
||||
heldBack = heldBack,
|
||||
onOpenRingSettings = { context.openNotificationSettings(RING_CHANNEL_ID) },
|
||||
onSetNotifying = { viewModel.setCalendarRemindersEnabled(calendar.id, it) },
|
||||
onOpenSound = { openCalendarSound(calendar.id) },
|
||||
onEditAlertStyle = { alertStylePicker = calendar.id },
|
||||
onEditAlertSound = { alertSoundPicker = calendar.id },
|
||||
onAllowFullScreen = { context.openFullScreenIntentSettings() },
|
||||
onOpenSpecialDates = onOpenSpecialDates,
|
||||
onEditOverride = { overrideDialog = it },
|
||||
)
|
||||
Column {
|
||||
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
||||
position = Position.Middle,
|
||||
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
|
||||
)
|
||||
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,51 +230,6 @@ internal fun NotificationsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
alertStylePicker?.let { calendarId ->
|
||||
val current = state.reminderAlertStyles.alertStyleFor(calendarId)
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_alert_style),
|
||||
options = listOf(ReminderAlertStyle.Notification, ReminderAlertStyle.FullScreen),
|
||||
selected = if (current.isFullScreen) ReminderAlertStyle.FullScreen else ReminderAlertStyle.Notification,
|
||||
label = { alertStyleLabel(it) },
|
||||
summary = {
|
||||
stringResource(
|
||||
if (it.isFullScreen) {
|
||||
R.string.settings_alert_style_full_screen_summary
|
||||
} else {
|
||||
R.string.settings_alert_style_notification_summary
|
||||
},
|
||||
)
|
||||
},
|
||||
onSelect = { picked ->
|
||||
if (picked.isFullScreen != current.isFullScreen) {
|
||||
viewModel.setCalendarAlertStyle(calendarId, picked)
|
||||
// Guide the user straight to the switch Android 14+ may hold back.
|
||||
if (picked.isFullScreen && !canFullScreen) context.openFullScreenIntentSettings()
|
||||
}
|
||||
},
|
||||
onDismiss = { alertStylePicker = null },
|
||||
)
|
||||
}
|
||||
alertSoundPicker?.let { calendarId ->
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_alert_sound),
|
||||
options = listOf(ReminderAlertStyle.FullScreen, ReminderAlertStyle.FullScreenRing),
|
||||
selected = state.reminderAlertStyles.alertStyleFor(calendarId),
|
||||
label = { alertSoundLabel(it) },
|
||||
summary = {
|
||||
stringResource(
|
||||
if (it == ReminderAlertStyle.FullScreenRing) {
|
||||
R.string.settings_alert_sound_ring_summary
|
||||
} else {
|
||||
R.string.settings_alert_sound_once_summary
|
||||
},
|
||||
)
|
||||
},
|
||||
onSelect = { viewModel.setCalendarAlertStyle(calendarId, it) },
|
||||
onDismiss = { alertSoundPicker = null },
|
||||
)
|
||||
}
|
||||
if (showSnooze) {
|
||||
DurationPicker(
|
||||
title = stringResource(R.string.settings_snooze_duration),
|
||||
@@ -406,149 +309,6 @@ internal fun NotificationsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One calendar's expanded rows: its notification switch and channel deep-link
|
||||
* for every calendar, then the default-reminder overrides where it takes them —
|
||||
* or, for a special-dates calendar, a link to the section that owns them.
|
||||
*/
|
||||
@Composable
|
||||
private fun CalendarNotificationRows(
|
||||
calendar: CalendarSource,
|
||||
state: SettingsUiState,
|
||||
notifying: Boolean,
|
||||
blocked: Boolean,
|
||||
heldBack: FullScreenHeldBack,
|
||||
onOpenRingSettings: () -> Unit,
|
||||
onSetNotifying: (Boolean) -> Unit,
|
||||
onOpenSound: () -> Unit,
|
||||
onEditAlertStyle: () -> Unit,
|
||||
onEditAlertSound: () -> Unit,
|
||||
onAllowFullScreen: () -> Unit,
|
||||
onOpenSpecialDates: () -> Unit,
|
||||
onEditOverride: (OverrideTarget) -> Unit,
|
||||
) {
|
||||
val managed = calendar.id in state.managedCalendarIds
|
||||
val takesOverrides = calendar.canModifyContents && !managed
|
||||
val alertStyle = state.reminderAlertStyles.alertStyleFor(calendar.id)
|
||||
val needsPermission = heldBack.permission
|
||||
val ringHeldBack = heldBack.ring
|
||||
Column {
|
||||
GroupedSwitchRow(
|
||||
title = stringResource(R.string.settings_calendar_notifications),
|
||||
checked = notifying,
|
||||
onCheckedChange = onSetNotifying,
|
||||
summary = stringResource(R.string.settings_calendar_notifications_hint),
|
||||
position = Position.Middle,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_notification_sound),
|
||||
summary = when {
|
||||
blocked -> stringResource(R.string.settings_notifications_blocked_hint)
|
||||
alertStyle == ReminderAlertStyle.FullScreenRing ->
|
||||
stringResource(R.string.settings_calendar_notification_sound_ring_hint)
|
||||
else -> stringResource(R.string.settings_calendar_notification_sound_hint)
|
||||
},
|
||||
position = Position.Middle,
|
||||
trailing = { ExternalLinkIcon() },
|
||||
onClick = onOpenSound,
|
||||
)
|
||||
val lastAlertRow = !(managed || takesOverrides)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_alert_style),
|
||||
summary = alertStyleLabel(alertStyle),
|
||||
position = if (lastAlertRow && !alertStyle.isFullScreen) Position.Bottom else Position.Middle,
|
||||
onClick = onEditAlertStyle,
|
||||
)
|
||||
if (alertStyle.isFullScreen) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_alert_sound),
|
||||
summary = alertSoundLabel(alertStyle),
|
||||
position = if (lastAlertRow && !needsPermission && !ringHeldBack) Position.Bottom else Position.Middle,
|
||||
onClick = onEditAlertSound,
|
||||
)
|
||||
}
|
||||
if (ringHeldBack) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.reminder_ring_channel_name),
|
||||
summary = stringResource(R.string.settings_ring_blocked_hint),
|
||||
position = if (lastAlertRow && !needsPermission) Position.Bottom else Position.Middle,
|
||||
trailing = { ExternalLinkIcon() },
|
||||
onClick = onOpenRingSettings,
|
||||
)
|
||||
}
|
||||
if (needsPermission) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_full_screen_permission),
|
||||
summary = stringResource(R.string.settings_full_screen_permission_hint),
|
||||
position = if (lastAlertRow) Position.Bottom else Position.Middle,
|
||||
trailing = { ExternalLinkIcon() },
|
||||
onClick = onAllowFullScreen,
|
||||
)
|
||||
}
|
||||
if (managed) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = onOpenSpecialDates,
|
||||
)
|
||||
} else if (takesOverrides) {
|
||||
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
||||
position = Position.Middle,
|
||||
onClick = { onEditOverride(OverrideTarget(calendar.id, isAllDay = false)) },
|
||||
)
|
||||
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { onEditOverride(OverrideTarget(calendar.id, isAllDay = true)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Samsung's One UI can start a new notification category switched off (#145). */
|
||||
private val IS_SAMSUNG = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
|
||||
/** The one-time Samsung note, with a way into the app's notification settings. */
|
||||
@Composable
|
||||
private fun NotificationCategoryHint(onOpen: () -> Unit, onDismiss: () -> Unit) {
|
||||
IconInfoCard(
|
||||
icon = Icons.Outlined.Info,
|
||||
iconContentDescription = null,
|
||||
iconAlignment = Alignment.Top,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_notification_category_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.reminder_action_dismiss)) }
|
||||
TextButton(onClick = onOpen) { Text(stringResource(R.string.settings_notification_category_hint_open)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExternalLinkIcon() {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
|
||||
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
||||
|
||||
@@ -568,39 +328,6 @@ private fun calendarOverrideSummary(
|
||||
is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
|
||||
}
|
||||
|
||||
/** What keeps a full-screen calendar from alerting as chosen: the missing permission, or the blocked ring channel. */
|
||||
private data class FullScreenHeldBack(val permission: Boolean, val ring: Boolean) {
|
||||
val any: Boolean get() = permission || ring
|
||||
}
|
||||
|
||||
private fun fullScreenHeldBack(
|
||||
style: ReminderAlertStyle,
|
||||
notifying: Boolean,
|
||||
blocked: Boolean,
|
||||
ringBlocked: Boolean,
|
||||
canFullScreen: Boolean,
|
||||
): FullScreenHeldBack {
|
||||
val live = notifying && !blocked
|
||||
return FullScreenHeldBack(
|
||||
permission = live && style.isFullScreen && !canFullScreen,
|
||||
ring = live && style == ReminderAlertStyle.FullScreenRing && ringBlocked,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun alertStyleLabel(style: ReminderAlertStyle): String = stringResource(
|
||||
if (style.isFullScreen) R.string.settings_alert_style_full_screen else R.string.settings_alert_style_notification,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun alertSoundLabel(style: ReminderAlertStyle): String = stringResource(
|
||||
if (style == ReminderAlertStyle.FullScreenRing) R.string.settings_alert_sound_ring else R.string.settings_alert_sound_once,
|
||||
)
|
||||
|
||||
/** Whether Android lets full-screen alerts open, re-read on every `ON_RESUME` like [rememberBatteryOptimizationExempt]. */
|
||||
@Composable
|
||||
private fun rememberCanUseFullScreenIntent(): Boolean = rememberOnResume { it.canUseFullScreenIntent() }
|
||||
|
||||
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
|
||||
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||
|
||||
@@ -609,18 +336,20 @@ private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||
* `ON_RESUME` so a change made in system settings shows up at once.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberBatteryOptimizationExempt(): Boolean = rememberOnResume(::isIgnoringBatteryOptimizations)
|
||||
|
||||
/** [read], re-read on every `ON_RESUME` so a change made in system settings shows up at once. */
|
||||
@Composable
|
||||
private fun rememberOnResume(read: (Context) -> Boolean): Boolean {
|
||||
private fun rememberBatteryOptimizationExempt(): Boolean {
|
||||
val context = LocalContext.current
|
||||
var value by remember { mutableStateOf(read(context)) }
|
||||
LifecycleResumeEffect(context) {
|
||||
value = read(context)
|
||||
onPauseOrDispose {}
|
||||
var exempt by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
exempt = isIgnoringBatteryOptimizations(context)
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
return value
|
||||
return exempt
|
||||
}
|
||||
|
||||
private fun isIgnoringBatteryOptimizations(context: Context): Boolean {
|
||||
|
||||
@@ -63,7 +63,6 @@ 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
|
||||
@@ -259,16 +258,15 @@ 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),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
@@ -43,8 +42,6 @@ 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). */
|
||||
@@ -97,16 +94,8 @@ data class SettingsUiState(
|
||||
val perCalendarReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
|
||||
val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** Switched-on calendars you can edit: the per-calendar event-duration rows. */
|
||||
/** Writable calendars, shown as per-calendar reminder-override rows. */
|
||||
val writableCalendars: List<CalendarSource> = emptyList(),
|
||||
/** Switched-on calendars, each with its own notification switch and channel (#145, #272). */
|
||||
val reminderCalendars: List<CalendarSource> = emptyList(),
|
||||
/** Calendars whose reminders Calendula doesn't post (#272). */
|
||||
val mutedReminderCalendarIds: Set<Long> = emptySet(),
|
||||
/** Per-calendar alert styles (#171); absent means a plain notification. */
|
||||
val reminderAlertStyles: Map<Long, ReminderAlertStyle> = emptyMap(),
|
||||
/** Whether the Samsung notification-category hint has been dismissed (#145); hidden until prefs load. */
|
||||
val notificationCategoryHintDismissed: Boolean = true,
|
||||
/**
|
||||
* Whether the event-colour picker is offered on calendars that publish no
|
||||
* colour palette (the colour may then not survive their next sync).
|
||||
|
||||
@@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.ui.settings
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -21,7 +20,6 @@ import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
@@ -35,13 +33,8 @@ import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderChannelStatus
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderChannels
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderNotifier
|
||||
import de.jeanlucmakiola.calendula.data.reminders.RingMatch
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
@@ -52,9 +45,7 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -71,14 +62,11 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
calendarPrefs: CalendarPrefs,
|
||||
repository: CalendarRepository,
|
||||
private val specialDatesEngine: SpecialDatesSyncEngine,
|
||||
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||
private val launcherNameManager: LauncherNameManager,
|
||||
private val reminderScanner: ReminderScanner,
|
||||
private val reminderChannels: ReminderChannels,
|
||||
private val reminderNotifier: ReminderNotifier,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : ViewModel() {
|
||||
@@ -86,16 +74,16 @@ class SettingsViewModel @Inject constructor(
|
||||
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
|
||||
/**
|
||||
* Calendars that are switched on — the only ones that plan reminders at
|
||||
* all (#75), so the only ones with notification settings.
|
||||
* Writable calendars that are switched on — the only ones that take a
|
||||
* per-calendar reminder override, since a switched-off one plans no
|
||||
* reminders at all (#75).
|
||||
*/
|
||||
private val visibleCalendars: Flow<List<CalendarSource>> =
|
||||
combine(repository.calendars(), calendarPrefs.pendingDisabledCalendarIds) { calendars, pending ->
|
||||
calendars.filter { it.isVisibleInSystem && it.id !in pending }
|
||||
}.catch { emit(emptyList()) }
|
||||
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
|
||||
.map { calendars -> calendars.filter { it.canModifyContents && it.isVisibleInSystem } }
|
||||
.catch { emit(emptyList()) }
|
||||
|
||||
val state: StateFlow<SettingsUiState> =
|
||||
combine(prefs.data, visibleCalendars) { stored, calendars -> stored.toUiState(calendars) }
|
||||
combine(prefs.data, writableCalendars) { stored, calendars -> stored.toUiState(calendars) }
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
||||
@@ -113,7 +101,6 @@ 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],
|
||||
@@ -137,11 +124,7 @@ class SettingsViewModel @Inject constructor(
|
||||
snoozeMinutes = this[SettingsKeys.snoozeMinutes],
|
||||
perCalendarReminderOverride = this[SettingsKeys.perCalendarReminderOverride],
|
||||
perCalendarAllDayReminderOverride = this[SettingsKeys.perCalendarAllDayReminderOverride],
|
||||
writableCalendars = calendars.filter { it.canModifyContents },
|
||||
reminderCalendars = calendars,
|
||||
mutedReminderCalendarIds = this[SettingsKeys.mutedReminderCalendarIds],
|
||||
reminderAlertStyles = this[SettingsKeys.reminderAlertStyles],
|
||||
notificationCategoryHintDismissed = this[SettingsKeys.notificationCategoryHintDismissed],
|
||||
writableCalendars = calendars,
|
||||
allowColorOnUnsupportedCalendars = this[SettingsKeys.allowColorOnUnsupportedCalendars],
|
||||
managedCalendarIds = specialDatesCalendars.values.toSet(),
|
||||
)
|
||||
@@ -309,8 +292,6 @@ 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) }
|
||||
@@ -369,87 +350,10 @@ class SettingsViewModel @Inject constructor(
|
||||
fun setRemindersEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setRemindersEnabled(enabled)
|
||||
if (!enabled) reminderNotifier.stopRing(RingMatch.Any)
|
||||
reminderScanner.scan()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-plans, since a muted calendar armed no wake-up for its next reminder.
|
||||
* Unmuting scans once more before the write, so the watermark starts at the
|
||||
* unmute and nothing that came due while muted is replayed.
|
||||
*/
|
||||
fun setCalendarRemindersEnabled(calendarId: Long, enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
if (enabled) reminderScanner.scan()
|
||||
prefs.setCalendarRemindersEnabled(calendarId, enabled)
|
||||
if (!enabled) reminderNotifier.stopRing(RingMatch.Calendar(calendarId))
|
||||
reminderScanner.scan()
|
||||
}
|
||||
}
|
||||
|
||||
fun setCalendarAlertStyle(calendarId: Long, style: ReminderAlertStyle) {
|
||||
viewModelScope.launch {
|
||||
prefs.setCalendarAlertStyle(calendarId, style)
|
||||
// Up front, so it can be checked in system settings before the first ring.
|
||||
if (style == ReminderAlertStyle.FullScreenRing) {
|
||||
onChannels { ensureRingChannel() }
|
||||
refreshChannelStatus(state.value.reminderCalendars.map { it.id })
|
||||
}
|
||||
// A ring for it goes quiet but stays in the shade.
|
||||
if (style != ReminderAlertStyle.FullScreenRing) {
|
||||
reminderNotifier.stopRing(RingMatch.Calendar(calendarId), handBack = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val _channelStatus = MutableStateFlow(ReminderChannelStatus())
|
||||
|
||||
/** What the system blocks of the reminders; re-read with [refreshChannelStatus] (#145). */
|
||||
val channelStatus: StateFlow<ReminderChannelStatus> = _channelStatus.asStateFlow()
|
||||
|
||||
private var channelStatusJob: Job? = null
|
||||
|
||||
fun refreshChannelStatus(calendarIds: Collection<Long>) {
|
||||
channelStatusJob?.cancel()
|
||||
channelStatusJob = viewModelScope.launch {
|
||||
onChannels { status(calendarIds) }?.let { _channelStatus.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissNotificationCategoryHint() = save { dismissNotificationCategoryHint() }
|
||||
|
||||
/**
|
||||
* The channel whose system screen to open for [calendarId] (#340), created
|
||||
* if needed; null when only the app's own screen can help — the app or the
|
||||
* calendar's account group is blocked there, or the channel couldn't be made.
|
||||
*/
|
||||
suspend fun soundSettingsChannel(calendarId: Long): String? = onChannels {
|
||||
val channelId = channelFor(calendarId) ?: return@onChannels null
|
||||
val status = status(listOf(calendarId))
|
||||
channelId.takeUnless { status.appBlocked || calendarId in status.groupBlockedCalendarIds }
|
||||
}
|
||||
|
||||
/** Create the per-calendar channels before showing the app's channel list, even with reminders off. */
|
||||
suspend fun syncReminderChannels() {
|
||||
val ringing = state.value.reminderAlertStyles.containsValue(ReminderAlertStyle.FullScreenRing)
|
||||
onChannels {
|
||||
sync()
|
||||
if (ringing) ensureRingChannel()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> onChannels(block: ReminderChannels.() -> T): T? = withContext(io) {
|
||||
try {
|
||||
reminderChannels.block()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w("SettingsViewModel", "Reminder channel update failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutofocusEventTitle(enabled: Boolean) = save { setAutofocusEventTitle(enabled) }
|
||||
|
||||
fun setDefaultEventDuration(minutes: Int) = save { setDefaultEventDurationMinutes(minutes) }
|
||||
|
||||
@@ -160,13 +160,6 @@ 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,
|
||||
)
|
||||
|
||||
@@ -303,7 +296,6 @@ 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 },
|
||||
)
|
||||
@@ -332,7 +324,6 @@ 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,7 +27,6 @@ import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
internal fun WeekStartPicker(
|
||||
selected: WeekStartPref,
|
||||
monthStyle: MonthViewStyle,
|
||||
showStartTimes: Boolean,
|
||||
options: List<WeekStartPref>,
|
||||
onSelect: (WeekStartPref) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
@@ -47,7 +46,6 @@ internal fun WeekStartPicker(
|
||||
MonthStylePreview(
|
||||
style = monthStyle,
|
||||
weekStart = day,
|
||||
showStartTimes = showStartTimes,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
@@ -26,7 +25,6 @@ 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. */
|
||||
@@ -39,14 +37,12 @@ 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,
|
||||
@@ -56,7 +52,6 @@ internal fun rememberTimelineActions(
|
||||
onCreateAt = { date, minutes -> createAt.value(date, minutes) },
|
||||
onDrop = { drop.value(it) },
|
||||
onResize = { resize.value(it) },
|
||||
onStripDrop = { stripDrop.value(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
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
|
||||
@@ -8,7 +7,16 @@ import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.plus
|
||||
|
||||
/** A fixed day every grid's pages are counted from. */
|
||||
/** 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. */
|
||||
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. */
|
||||
@@ -16,19 +24,13 @@ 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 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.
|
||||
* [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.
|
||||
*/
|
||||
data class DaysGrid(val dayCount: Int, val phase: Int) {
|
||||
data class DaysGrid(val dayCount: Int, val phase: Int, val pageCount: Int = DAYS_PAGE_COUNT) {
|
||||
|
||||
/** 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)
|
||||
private val epochPage: Int get() = pageCount / 2
|
||||
|
||||
/** First day shown on pager [page]. */
|
||||
fun startForPage(page: Int): LocalDate =
|
||||
@@ -36,16 +38,18 @@ data class DaysGrid(val dayCount: Int, val phase: Int) {
|
||||
|
||||
/** The pager page showing [date]. */
|
||||
fun pageFor(date: LocalDate): Int =
|
||||
(epochPage + pagesFromEpoch(date)).coerceIn(0, pageCount - 1)
|
||||
(epochPage + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount))
|
||||
.coerceIn(0, pageCount - 1)
|
||||
|
||||
companion object {
|
||||
/** Single days: every date starts a page of its own. */
|
||||
val days: DaysGrid = DaysGrid(dayCount = 1, phase = 0)
|
||||
val days: DaysGrid = DaysGrid(dayCount = 1, phase = 0, pageCount = DAY_PAGE_COUNT)
|
||||
|
||||
/** 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,16 +106,9 @@ 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
|
||||
@@ -132,9 +125,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.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
|
||||
@@ -157,12 +147,10 @@ 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.ghostsDerived
|
||||
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
|
||||
@@ -197,10 +185,8 @@ 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.renderedSpan
|
||||
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
||||
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
|
||||
@@ -210,8 +196,8 @@ import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset
|
||||
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
|
||||
@@ -221,7 +207,6 @@ import kotlinx.datetime.toLocalDateTime
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
import kotlinx.datetime.yearMonth
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberRowWeekNumber
|
||||
|
||||
/** One lane of the all-day strip, sized to a trimmed bar line (#190). */
|
||||
private val ALL_DAY_ROW_HEIGHT = 20.dp
|
||||
@@ -311,6 +296,14 @@ internal enum class TimelineKind(
|
||||
// A run of days often straddles two weeks, so no single number fits it.
|
||||
MultiDay(showWeekNumber = false, todayText = R.string.week_today_action, headerBias = 0f),
|
||||
Day(showWeekNumber = false, todayText = R.string.day_today_action, headerBias = -1f),
|
||||
;
|
||||
|
||||
fun title(start: LocalDate, end: LocalDate, locale: Locale, currentYear: Int, abbreviated: Boolean): String =
|
||||
if (this == Day) {
|
||||
formatDayTitle(start, locale, currentYear, abbreviated)
|
||||
} else {
|
||||
formatWeekTitle(start, end, locale, currentYear, abbreviated)
|
||||
}
|
||||
}
|
||||
|
||||
internal val CalendarView.timelineKind: TimelineKind
|
||||
@@ -485,10 +478,9 @@ private fun PagedTimelineScreen(
|
||||
val jumpToToday = { jumpToDate(today) }
|
||||
|
||||
val locale = currentLocale()
|
||||
// The day view names the month too: its header already shows weekday and date (#501).
|
||||
val (title, shortTitle) = remember(weekStart, weekEnd, locale, today.year) {
|
||||
formatTimelineTitle(weekStart, weekEnd, locale, today.year, abbreviated = false) to
|
||||
formatTimelineTitle(weekStart, weekEnd, locale, today.year, abbreviated = true)
|
||||
val (title, shortTitle) = remember(titleKind, weekStart, weekEnd, locale, today.year) {
|
||||
titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = false) to
|
||||
titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = true)
|
||||
}
|
||||
PublishChrome(
|
||||
ScreenChrome(
|
||||
@@ -585,9 +577,7 @@ private fun WeekContent(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
isAllDay = drop.event.isAllDay,
|
||||
target = MoveTarget.Start(start),
|
||||
token = drop.token,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -606,26 +596,7 @@ private fun WeekContent(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
isAllDay = drop.event.isAllDay,
|
||||
target = MoveTarget.Span(drop.start, drop.end),
|
||||
token = drop.token,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// 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(),
|
||||
isAllDay = drop.event.isAllDay,
|
||||
target = when (drop) {
|
||||
is StripDrop.Days -> MoveTarget.ByDays(drop.days)
|
||||
is StripDrop.Timed -> MoveTarget.Span(drop.start, drop.end)
|
||||
},
|
||||
token = drop.token,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -690,7 +661,6 @@ private fun WeekContent(
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
onStripDrop = onStripDrop,
|
||||
)
|
||||
|
||||
Box(modifier = modifier) {
|
||||
@@ -766,11 +736,7 @@ 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) }
|
||||
// A bar held over other days in the strip makes room there before it is dropped.
|
||||
val held = dragController.stripPreviews().takeIf { page == pagerState.currentPage }.orEmpty()
|
||||
val zone = LocalTimelineZone.current
|
||||
val shown = remember(loaded, held, zone) { loaded?.withStripPreview(held, zone) }
|
||||
val stripHeight = shown?.allDayStripHeight()
|
||||
val stripHeight = loaded?.allDayStripHeight()
|
||||
DisposableEffect(page, stripHeight) {
|
||||
if (stripHeight != null) stripHeights[page] = stripHeight
|
||||
onDispose { stripHeights.remove(page) }
|
||||
@@ -778,8 +744,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(shown, empty, today) {
|
||||
shown?.takeIf { it.today != today }?.copy(today = today) ?: shown ?: empty
|
||||
state = remember(loaded, empty, today) {
|
||||
loaded?.takeIf { it.today != today }?.copy(today = today) ?: loaded ?: empty
|
||||
},
|
||||
headerBias = kind.headerBias,
|
||||
allDayHeight = allDayHeight,
|
||||
@@ -791,43 +757,6 @@ private fun WeekPager(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [this] with the strip bars [held] sideways laid out at the days they are over,
|
||||
* so the bars there move aside live. Each 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: List<StripDrag>, zone: TimeZone): WeekUiState.Success {
|
||||
val shifts = held.filter { !it.overGrid && it.dayShift != 0 }.associate { it.event.instanceId to it.dayShift }
|
||||
if (shifts.isEmpty()) return this
|
||||
val originals = allDaySpans.filter { it.event.instanceId in shifts }.associateBy { it.event.instanceId }
|
||||
if (originals.isEmpty()) return this
|
||||
val events = allDaySpans.map { span ->
|
||||
shifts[span.event.instanceId]?.let { span.event.shiftedDays(it, zone) } ?: span.event
|
||||
}
|
||||
val spans = layoutAllDay(events, days, zone).map { span ->
|
||||
originals[span.event.instanceId]?.let { span.copy(event = it.event) } ?: span
|
||||
}
|
||||
val laidOut = spans.mapTo(HashSet()) { it.event.instanceId }
|
||||
return copy(allDaySpans = spans + originals.values.filter { it.event.instanceId !in laidOut }.map { it.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))
|
||||
}
|
||||
|
||||
/** Whether these bars draw the strip drop [drop] at the days it was dropped on. */
|
||||
private fun List<PlacedBar>.drawStripDrop(drop: StripDrag, zone: TimeZone): Boolean {
|
||||
val target = drop.event.spanFirstDay(zone).plus(drop.dayShift, DateTimeUnit.DAY)
|
||||
return any { bar ->
|
||||
bar.event.instanceId != drop.event.instanceId &&
|
||||
(bar.event.eventId == drop.event.eventId ||
|
||||
(drop.event.title.isNotBlank() && bar.event.title == drop.event.title)) &&
|
||||
bar.event.spanFirstDay(zone) == target
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) }
|
||||
@@ -963,9 +892,8 @@ private fun WeekFrame(
|
||||
),
|
||||
) {
|
||||
val zoom = LocalTimelineZoom.current
|
||||
val fitHeight = dayFitHeight(
|
||||
if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight,
|
||||
)
|
||||
val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight
|
||||
else maxHeight
|
||||
val hourHeight = zoom.scale.hourHeight(fitHeight)
|
||||
HourGutter(
|
||||
scrollState = actions.scrollState,
|
||||
@@ -1064,7 +992,7 @@ private fun TimelinePageContent(
|
||||
LocalChipGround provides MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
AllDayStrip(geometry = geometry, bars = bars, height = allDayHeight,
|
||||
animatedHeight = animatedAllDayHeight, actions = actions, active = active)
|
||||
animatedHeight = animatedAllDayHeight, onEventClick = actions.onEventClick)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(TIMELINE_TOP_GAP))
|
||||
@@ -1154,7 +1082,7 @@ private fun WeekDayHeader(
|
||||
*/
|
||||
@Composable
|
||||
private fun WeekNumberCell(weekStart: LocalDate, showBadge: Boolean, modifier: Modifier = Modifier) {
|
||||
val weekNumber = rememberRowWeekNumber(weekStart)
|
||||
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
@@ -1199,23 +1127,9 @@ private fun AllDayStrip(
|
||||
bars: List<PlacedBar>,
|
||||
height: Dp,
|
||||
animatedHeight: (() -> Dp)? = null,
|
||||
actions: TimelineActions,
|
||||
active: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
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 }
|
||||
val heldStrips = dragController.heldStrips.takeIf { active }.orEmpty()
|
||||
// Seated once the re-read draws the event at the days it was dropped on.
|
||||
LaunchedEffect(settling, heldStrips, bars) {
|
||||
if (settling != null && bars.drawStripDrop(settling, zone)) dragController.seatStrip()
|
||||
heldStrips.forEach { if (!it.seated && bars.drawStripDrop(it.drag, zone)) dragController.seatHeldStrip(it.token) }
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -1236,20 +1150,12 @@ 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,
|
||||
dragController = dragController,
|
||||
lifted = moving,
|
||||
ghosted = lifted == bar.event.instanceId,
|
||||
onClick = { actions.onEventClick(bar.event) },
|
||||
onDrop = actions.onStripDrop,
|
||||
onClick = { onEventClick(bar.event) },
|
||||
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),
|
||||
)
|
||||
@@ -1266,19 +1172,10 @@ private fun AllDayStrip(
|
||||
val progress = geometry.progress()
|
||||
val placed = measurables.mapIndexed { i, measurable ->
|
||||
val bar = bars[i]
|
||||
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 extent = geometry.extent(bar.fromRange, bar.toRange, width, 0f)
|
||||
val left = extent.left.roundToInt()
|
||||
val barWidth = (extent.right.roundToInt() - left).coerceAtLeast(0)
|
||||
// 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()
|
||||
val y = (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) {
|
||||
@@ -1288,31 +1185,12 @@ 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) }
|
||||
@@ -1320,29 +1198,8 @@ 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)
|
||||
@@ -1397,9 +1254,7 @@ private fun Timeline(
|
||||
else Modifier.stableMorphViewport(animatedAllDayHeight, fullViewportHeight),
|
||||
),
|
||||
) {
|
||||
val fitHeight = dayFitHeight(
|
||||
if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight,
|
||||
)
|
||||
val fitHeight = 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
|
||||
@@ -1432,11 +1287,9 @@ private fun Timeline(
|
||||
.fillMaxSize()
|
||||
.then(pinch)
|
||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||
.clip(RoundedCornerShape(TIMELINE_CORNER))
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.verticalScroll(scrollState)
|
||||
.onGloballyPositioned { if (active) dragController.geometry.viewport = it }
|
||||
// Past midnight, outside the columns: nothing there takes a tap or a drop.
|
||||
.padding(bottom = FabClearance),
|
||||
.onGloballyPositioned { if (active) dragController.geometry.viewport = it },
|
||||
) {
|
||||
TimelineColumns(
|
||||
geometry = geometry,
|
||||
@@ -1472,7 +1325,6 @@ private fun Timeline(
|
||||
visibleTop = visibleTop,
|
||||
viewportHeight = visibleViewport,
|
||||
actions = actions,
|
||||
shape = columnShape(day == geometry.dates.first(), day == geometry.dates.last()),
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
@@ -1480,19 +1332,6 @@ 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>,
|
||||
@@ -1504,7 +1343,6 @@ private fun DayColumnCard(
|
||||
visibleTop: () -> Float,
|
||||
viewportHeight: () -> Float,
|
||||
actions: TimelineActions,
|
||||
shape: Shape = RectangleShape,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scrollState = actions.scrollState
|
||||
@@ -1535,8 +1373,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, apart from the outer ones' bottoms ([columnShape]).
|
||||
shape = shape,
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
shape = RectangleShape,
|
||||
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
|
||||
@@ -1576,6 +1414,7 @@ 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.
|
||||
@@ -1583,24 +1422,24 @@ private fun DayColumnCard(
|
||||
shown.forEach { block ->
|
||||
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
||||
key(block.event.eventId, ordinal) {
|
||||
// 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 top = hourHeight * (block.startMin / 60f)
|
||||
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||
val place = animatedBlockPlacement(
|
||||
xFraction = block.lane.toFloat() / block.laneCount,
|
||||
widthFraction = 1f / block.laneCount,
|
||||
y = top,
|
||||
height = height,
|
||||
landing = dragController.ghostsDerived(block),
|
||||
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)
|
||||
@@ -1767,7 +1606,7 @@ private fun EventBlock(
|
||||
onCancel = dragController::cancel,
|
||||
onRelease = { dragController.select(block.event, fromLift = draggable) },
|
||||
)
|
||||
val lifted = draggable && dragController.ghostsDerived(block)
|
||||
val lifted = draggable && dragController.ghosts(block)
|
||||
// Released in place, the block takes over the copy's lift without a dip.
|
||||
val takesOverLift = selected && dragController.selection?.fromLift == true
|
||||
val ghostFade = ghostAlpha(lifted)
|
||||
@@ -1936,7 +1775,7 @@ private fun rememberDraftSurface(
|
||||
block = block,
|
||||
zone = zone,
|
||||
pointerInRoot = pointer,
|
||||
blockInRoot = node + Offset(0f, block.renderedSpan().topMin / 60f * hour),
|
||||
blockInRoot = node + Offset(0f, startMin / 60f * hour),
|
||||
)
|
||||
true
|
||||
},
|
||||
@@ -1984,10 +1823,8 @@ 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 = dayFitHeight(
|
||||
maxHeight + SKELETON_HEADER_HEIGHT + SKELETON_HEADER_PADDING * 2 -
|
||||
headerHeight - TIMELINE_TOP_GAP,
|
||||
)
|
||||
val fitHeight = 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
|
||||
@@ -2000,8 +1837,7 @@ private fun WeekLoading(dayCount: Int) {
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||
.verticalScroll(scrollState)
|
||||
.padding(bottom = FabClearance),
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
repeat(dayCount) {
|
||||
@@ -2027,7 +1863,7 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
|
||||
formatMinuteOfDay(min, is24Hour, locale)
|
||||
|
||||
/**
|
||||
* Every timeline's title, the day view's included: just the month [weekStart] falls in.
|
||||
* The week's title: just the month [weekStart] falls in.
|
||||
*
|
||||
* The day numbers are already in the column headers directly below, so spelling
|
||||
* out "13.–19. Jul" restates them in the widest string in the bar. Naming the
|
||||
@@ -2042,7 +1878,7 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
|
||||
* "December" with no year would be actively misleading while the reader is
|
||||
* looking straight at January dates.
|
||||
*/
|
||||
private fun formatTimelineTitle(
|
||||
private fun formatWeekTitle(
|
||||
weekStart: LocalDate,
|
||||
weekEnd: LocalDate,
|
||||
locale: Locale,
|
||||
@@ -2057,3 +1893,17 @@ private fun formatTimelineTitle(
|
||||
forceYear = weekEnd.year != weekStart.year,
|
||||
)
|
||||
}
|
||||
|
||||
/** The day's title: its weekday and date, abbreviated to the date alone when short of room. */
|
||||
private fun formatDayTitle(
|
||||
date: LocalDate,
|
||||
locale: Locale,
|
||||
currentYear: Int,
|
||||
abbreviated: Boolean = false,
|
||||
): String =
|
||||
formatCalendarTitle(
|
||||
date = date.toJavaLocalDate(),
|
||||
locale = locale,
|
||||
currentYear = currentYear,
|
||||
skeleton = if (abbreviated) "dMMM" else "EEEdMMM",
|
||||
)
|
||||
@@ -15,8 +15,6 @@ 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,
|
||||
@@ -24,7 +22,6 @@ data class TimedBlock(
|
||||
val endMin: Int,
|
||||
val lane: Int,
|
||||
val laneCount: Int,
|
||||
val continuesAfter: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -14,9 +13,7 @@ import de.jeanlucmakiola.calendula.data.time.currentDate
|
||||
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
||||
import de.jeanlucmakiola.calendula.domain.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
|
||||
@@ -209,7 +206,8 @@ 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, timed) = instances.splitWholeDays(zone)
|
||||
val allDay = instances.filter { it.isAllDay }
|
||||
val timed = instances.filterNot { it.isAllDay }
|
||||
return WeekUiState.Success(
|
||||
weekStart = span.start,
|
||||
today = today,
|
||||
@@ -294,21 +292,20 @@ internal fun layoutAllDay(
|
||||
days: List<LocalDate>,
|
||||
zone: TimeZone,
|
||||
): List<AllDaySpan> {
|
||||
data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int, val priority: Int)
|
||||
data class Raw(val event: EventInstance, val startCol: Int, val endCol: 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(), ev.displayPriority(zone))
|
||||
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last())
|
||||
}
|
||||
// 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.priority }
|
||||
compareBy<Raw> { it.event.displayPriority(zone) }
|
||||
.thenBy { it.startCol }
|
||||
.thenBy { it.endCol }
|
||||
.thenBy { it.event.displayStart(zone) }
|
||||
.thenBy { it.event.title }
|
||||
.thenBy { it.event.start }
|
||||
.thenBy { it.event.instanceId },
|
||||
)
|
||||
|
||||
@@ -429,21 +426,12 @@ 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.
|
||||
*
|
||||
* Takes the timed half of [splitWholeDays]; the rest lives in the all-day strip.
|
||||
* All-day events are ignored here — they live in the all-day strip.
|
||||
*/
|
||||
internal fun layoutDay(
|
||||
events: List<EventInstance>,
|
||||
@@ -453,53 +441,55 @@ 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)
|
||||
TimedBlock(ev, m, m, lane = 0, laneCount = 1)
|
||||
Raw(ev, m, m)
|
||||
} 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)
|
||||
TimedBlock(ev, startMin, endMin, lane = 0, laneCount = 1, continuesAfter = ev.end > dayEnd)
|
||||
Raw(ev, startMin, endMin)
|
||||
}
|
||||
}
|
||||
.map { it to it.renderedSpan() }
|
||||
.sortedWith(compareBy({ it.second.topMin }, { it.second.bottomMin }))
|
||||
.sortedWith(compareBy({ it.startMin }, { it.endMin }))
|
||||
.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].second.bottomMin
|
||||
var clusterEnd = raw[i].endMin
|
||||
var j = i + 1
|
||||
while (j < raw.size && raw[j].second.topMin < clusterEnd) {
|
||||
clusterEnd = maxOf(clusterEnd, raw[j].second.bottomMin)
|
||||
while (j < raw.size && raw[j].startMin < clusterEnd) {
|
||||
clusterEnd = maxOf(clusterEnd, raw[j].endMin)
|
||||
j++
|
||||
}
|
||||
val cluster = raw.subList(i, j)
|
||||
// Greedy first-fit column assignment (= max overlap depth in the cluster).
|
||||
val laneEnd = ArrayList<Float>()
|
||||
val laneEnd = ArrayList<Int>()
|
||||
val lanes = IntArray(cluster.size)
|
||||
cluster.forEachIndexed { k, (_, span) ->
|
||||
var placed = laneEnd.indexOfFirst { it <= span.topMin }
|
||||
cluster.forEachIndexed { k, r ->
|
||||
var placed = laneEnd.indexOfFirst { it <= r.startMin }
|
||||
if (placed == -1) {
|
||||
laneEnd.add(span.bottomMin)
|
||||
laneEnd.add(r.endMin)
|
||||
placed = laneEnd.size - 1
|
||||
} else {
|
||||
laneEnd[placed] = span.bottomMin
|
||||
laneEnd[placed] = r.endMin
|
||||
}
|
||||
lanes[k] = placed
|
||||
}
|
||||
val laneCount = laneEnd.size
|
||||
cluster.forEachIndexed { k, (block, _) ->
|
||||
result.add(block.copy(lane = lanes[k], laneCount = laneCount))
|
||||
cluster.forEachIndexed { k, r ->
|
||||
result.add(TimedBlock(r.event, r.startMin, r.endMin, lanes[k], laneCount))
|
||||
}
|
||||
i = j
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ internal fun WeekViewPreview(
|
||||
onCreateAt = { _, _ -> },
|
||||
onDrop = {},
|
||||
onResize = {},
|
||||
onStripDrop = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -76,12 +75,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 (allDay, timed) = sampleTimelineEvents(days, today, zone).splitWholeDays(zone)
|
||||
val events = sampleTimelineEvents(days, today, zone)
|
||||
return WeekUiState.Success(
|
||||
weekStart = start,
|
||||
today = today,
|
||||
days = days,
|
||||
allDaySpans = layoutAllDay(allDay, days, zone),
|
||||
timedByDay = days.associateWith { layoutDay(timed, it, zone) },
|
||||
allDaySpans = layoutAllDay(events.filter { it.isAllDay }, days, zone),
|
||||
timedByDay = days.associateWith { layoutDay(events, it, zone) },
|
||||
)
|
||||
}
|
||||
@@ -27,14 +27,6 @@ abstract class CalendulaWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.glance.GlanceId
|
||||
@@ -27,22 +26,14 @@ enum class WidgetTheme { FOLLOW_APP, LIGHT, DARK }
|
||||
data class WidgetAppearance(
|
||||
val background: WidgetBackground = WidgetBackground.TINTED,
|
||||
val theme: WidgetTheme = WidgetTheme.FOLLOW_APP,
|
||||
/** How opaque a Solid or Tinted fill is (#489). */
|
||||
val opacity: Float = 1f,
|
||||
)
|
||||
|
||||
/** The agenda widget's look: the 2.24 day cards, or v2.23's plain day headers and stripes (#489). */
|
||||
enum class AgendaWidgetStyle { CARDS, CLASSIC }
|
||||
|
||||
/** What the agenda widget lists, per placed widget. */
|
||||
data class AgendaWidgetOptions(
|
||||
val range: AgendaRange,
|
||||
val textSize: WidgetSize,
|
||||
val showToday: Boolean,
|
||||
val pastDisplay: PastEventDisplay,
|
||||
val style: AgendaWidgetStyle = AgendaWidgetStyle.CARDS,
|
||||
/** Classic style only: draw the text in the opposite theme's colours, for a faded fill over the wallpaper. */
|
||||
val invertText: Boolean = false,
|
||||
)
|
||||
|
||||
internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this) {
|
||||
@@ -57,9 +48,6 @@ internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this)
|
||||
|
||||
private val BackgroundKey = stringPreferencesKey("cfg_background")
|
||||
private val ThemeKey = stringPreferencesKey("cfg_theme")
|
||||
private val OpacityKey = floatPreferencesKey("cfg_opacity")
|
||||
private val AgendaStyleKey = stringPreferencesKey("cfg_agenda_style")
|
||||
private val InvertTextKey = booleanPreferencesKey("cfg_invert_text")
|
||||
private val AgendaRangeKey = stringPreferencesKey("cfg_agenda_range")
|
||||
private val TextSizeKey = stringPreferencesKey("cfg_text_size")
|
||||
private val ShowTodayKey = booleanPreferencesKey("cfg_show_today")
|
||||
@@ -70,7 +58,6 @@ internal val Preferences.widgetAppearance: WidgetAppearance
|
||||
get() = WidgetAppearance(
|
||||
background = this[BackgroundKey].toEnum(WidgetBackground.TINTED),
|
||||
theme = this[ThemeKey].toEnum(WidgetTheme.FOLLOW_APP),
|
||||
opacity = this[OpacityKey] ?: 1f,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -83,8 +70,6 @@ internal fun Preferences.agendaWidgetOptions(defaults: AgendaWidgetOptions) = Ag
|
||||
textSize = this[TextSizeKey].toEnum(defaults.textSize),
|
||||
showToday = this[ShowTodayKey] ?: defaults.showToday,
|
||||
pastDisplay = this[PastDisplayKey].toEnum(defaults.pastDisplay),
|
||||
style = this[AgendaStyleKey].toEnum(defaults.style),
|
||||
invertText = this[InvertTextKey] ?: defaults.invertText,
|
||||
)
|
||||
|
||||
/** Whether this widget has agenda options of its own, rather than following the app's. */
|
||||
@@ -93,7 +78,6 @@ internal val Preferences.hasAgendaOptions: Boolean get() = this[AgendaRangeKey]
|
||||
internal fun MutablePreferences.putAppearance(appearance: WidgetAppearance) {
|
||||
this[BackgroundKey] = appearance.background.name
|
||||
this[ThemeKey] = appearance.theme.name
|
||||
this[OpacityKey] = appearance.opacity
|
||||
}
|
||||
|
||||
internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) {
|
||||
@@ -101,8 +85,6 @@ internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) {
|
||||
this[TextSizeKey] = options.textSize.name
|
||||
this[ShowTodayKey] = options.showToday
|
||||
this[PastDisplayKey] = options.pastDisplay.name
|
||||
this[AgendaStyleKey] = options.style.name
|
||||
this[InvertTextKey] = options.invertText
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
@@ -126,8 +125,6 @@ sealed interface AgendaWidgetData {
|
||||
/** "Always show today" (#35): anchor today at the top even with no events left. */
|
||||
val showToday: Boolean,
|
||||
val textSize: WidgetSize,
|
||||
val style: AgendaWidgetStyle = AgendaWidgetStyle.CARDS,
|
||||
val invertText: Boolean = false,
|
||||
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||
val now: Instant,
|
||||
) : AgendaWidgetData
|
||||
@@ -173,7 +170,7 @@ internal suspend fun Context.loadAgendaWidgetData(
|
||||
val zone = systemZone()
|
||||
val anchor = today(zone)
|
||||
val ep = widgetEntryPoint()
|
||||
val weekStart = settings.weekStart.resolveFirstDay(widgetLocale())
|
||||
val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
|
||||
val dayCount = options.range.dayCount(anchor, weekStart)
|
||||
val window = agendaRange(anchor, dayCount - 1, zone)
|
||||
val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY)
|
||||
@@ -186,16 +183,13 @@ internal suspend fun Context.loadAgendaWidgetData(
|
||||
pastDisplay = options.pastDisplay,
|
||||
showToday = options.showToday,
|
||||
textSize = options.textSize,
|
||||
style = options.style,
|
||||
invertText = options.invertText,
|
||||
now = Clock.System.now(),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.ordinal
|
||||
|
||||
internal fun firstDayOfMonthIndex(index: Int): LocalDate =
|
||||
LocalDate(Math.floorDiv(index, 12), Math.floorMod(index, 12) + 1, 1)
|
||||
private fun firstDayOfMonthIndex(index: Int): LocalDate = LocalDate(index / 12, index % 12 + 1, 1)
|
||||
|
||||
/**
|
||||
* The month the widget loads its window around while [index] is shown. Snapped
|
||||
@@ -213,50 +207,28 @@ 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>)
|
||||
|
||||
// Keyed by app-widget id, which a removed widget can still be named by.
|
||||
private val entries = ConcurrentHashMap<Int, Entry>()
|
||||
private val entries = ConcurrentHashMap<GlanceId, Entry>()
|
||||
|
||||
fun get(id: Int, center: Int, stamp: Long): Themed<MonthWidgetSource>? =
|
||||
fun get(id: GlanceId, center: Int, stamp: Long): Themed<MonthWidgetSource>? =
|
||||
entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.content.today == today(systemZone()) }
|
||||
?.source
|
||||
|
||||
fun put(id: Int, center: Int, stamp: Long, source: Themed<MonthWidgetSource>) {
|
||||
fun put(id: GlanceId, center: Int, stamp: Long, source: Themed<MonthWidgetSource>) {
|
||||
val ready = source.content as? MonthWidgetSource.Ready ?: return
|
||||
entries[id] = Entry(center, stamp, Themed(ready, source.appTheme))
|
||||
}
|
||||
|
||||
fun remove(id: Int) {
|
||||
entries.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops the cached windows of removed widgets [appWidgetIds]. */
|
||||
internal fun evictMonthSources(appWidgetIds: IntArray) = appWidgetIds.forEach(MonthSourceCache::remove)
|
||||
|
||||
/**
|
||||
* [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds.
|
||||
* A [load] already read for this pass saves reading the settings and widget state again.
|
||||
*/
|
||||
internal suspend fun Context.loadCachedMonthSource(
|
||||
id: GlanceId,
|
||||
centerIndex: Int,
|
||||
stamp: Long,
|
||||
load: WidgetLoadContext? = null,
|
||||
): Themed<MonthWidgetSource> {
|
||||
val key = GlanceAppWidgetManager(this).getAppWidgetId(id)
|
||||
return MonthSourceCache.get(key, centerIndex, stamp)
|
||||
?: loadThemedMonthSource(centerIndex, load?.hiddenCalendars ?: widgetState(id).hiddenCalendars, load?.settings)
|
||||
.also { MonthSourceCache.put(key, centerIndex, stamp, it) }
|
||||
}
|
||||
/** [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, widgetState(id).hiddenCalendars)
|
||||
.also { MonthSourceCache.put(id, centerIndex, stamp, it) }
|
||||
|
||||
/** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */
|
||||
internal suspend fun Context.loadThemedMonthSource(
|
||||
centerIndex: Int,
|
||||
hiddenCalendars: Set<Long>,
|
||||
settings: WidgetSettings? = null,
|
||||
): Themed<MonthWidgetSource> {
|
||||
val resolved = settings ?: widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||
return Themed(queryMonthSource(centerIndex, resolved, hiddenCalendars), resolved.themeMode)
|
||||
internal suspend fun Context.loadThemedMonthSource(centerIndex: Int, hiddenCalendars: Set<Long>): Themed<MonthWidgetSource> {
|
||||
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||
return Themed(queryMonthSource(centerIndex, settings, hiddenCalendars), settings.themeMode)
|
||||
}
|
||||
|
||||
private suspend fun Context.queryMonthSource(
|
||||
@@ -278,7 +250,7 @@ private suspend fun Context.queryMonthSource(
|
||||
.toInstant(zone)
|
||||
return MonthWidgetSource.Ready(
|
||||
today = today(zone),
|
||||
weekStart = settings.weekStart.resolveFirstDay(widgetLocale()),
|
||||
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
|
||||
soften = settings.soften,
|
||||
instances = ep.calendarRepository().instances(from..to).first().withoutCalendars(hiddenCalendars),
|
||||
months = months,
|
||||
@@ -326,7 +298,7 @@ internal suspend fun Context.loadDays(
|
||||
if (!hasCalendarPermission()) return Themed(DaysWidgetData.NeedsPermission, settings.themeMode)
|
||||
val zone = systemZone()
|
||||
val anchor = today(zone)
|
||||
val weekStart = settings.weekStart.resolveFirstDay(widgetLocale())
|
||||
val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
|
||||
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)
|
||||
|
||||
@@ -2,7 +2,6 @@ package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -19,41 +18,20 @@ 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.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
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.ui.theme.CalendulaDarkFallback
|
||||
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
|
||||
|
||||
/** How much of a past event's tone stays in its dimmed fill (#90). */
|
||||
private const val WidgetDimFillAlpha = 0.3f
|
||||
|
||||
/** A dimmed event's ink, held above its fill. */
|
||||
private const val WidgetDimInkAlpha = 0.65f
|
||||
|
||||
/**
|
||||
* An event's fill in the light or [dark] theme. A dimmed one is its tone faded
|
||||
* into that theme's surface, kept opaque: a translucent tint lands on the row
|
||||
* shape's white, not on the widget, and its ink would be picked for the wrong tone (#475).
|
||||
*/
|
||||
internal fun widgetEventFill(rawArgb: Int, dark: Boolean, soften: Boolean, dimmed: Boolean): Color {
|
||||
val tone = eventFill(rawArgb, dark, soften)
|
||||
if (!dimmed) return tone
|
||||
val surface = if (dark) CalendulaDarkFallback.surface else Color.White
|
||||
return tone.copy(alpha = WidgetDimFillAlpha).compositeOver(surface)
|
||||
}
|
||||
|
||||
/** Ink for an event on [fill], faded when the row is [dimmed]. */
|
||||
internal fun widgetEventInk(fill: Color, alpha: Float, dimmed: Boolean): Color =
|
||||
eventInk(fill, if (dimmed) 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)
|
||||
|
||||
@@ -85,8 +63,11 @@ internal fun WidgetEventRow(
|
||||
quiet: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val day = widgetEventFill(event.color, dark = false, soften, dimmed)
|
||||
val night = widgetEventFill(event.color, dark = true, soften, dimmed)
|
||||
fun tone(dark: Boolean): Color = eventFill(event.color, dark, soften).let { if (dimmed) it.copy(alpha = EventDimAlpha) else it }
|
||||
val day = tone(dark = false)
|
||||
val night = tone(dark = true)
|
||||
// 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 card = widgetCardColor()
|
||||
WidgetGroupedRow(position = position, color = if (quiet) card else themedColor(day = day, night = night)) {
|
||||
Column(
|
||||
@@ -96,7 +77,13 @@ internal fun WidgetEventRow(
|
||||
.padding(horizontal = 12.dp)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
event.openIntent(context, source),
|
||||
MainActivity.openEventIntent(
|
||||
context = context,
|
||||
eventId = event.eventId,
|
||||
beginMillis = event.start.toEpochMilliseconds(),
|
||||
endMillis = event.end.toEpochMilliseconds(),
|
||||
source = source,
|
||||
),
|
||||
),
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -104,12 +91,12 @@ internal fun WidgetEventRow(
|
||||
val titleInk = if (quiet) {
|
||||
GlanceTheme.colors.onSurface
|
||||
} else {
|
||||
themedColor(day = widgetEventInk(day, 1f, dimmed), night = widgetEventInk(night, 1f, dimmed))
|
||||
themedColor(day = ink(day, 1f), night = ink(night, 1f))
|
||||
}
|
||||
val timeInk = if (quiet) {
|
||||
GlanceTheme.colors.onSurfaceVariant
|
||||
} else {
|
||||
themedColor(day = widgetEventInk(day, SECONDARY_INK_ALPHA, dimmed), night = widgetEventInk(night, SECONDARY_INK_ALPHA, dimmed))
|
||||
themedColor(day = ink(day, SECONDARY_INK_ALPHA), night = ink(night, SECONDARY_INK_ALPHA))
|
||||
}
|
||||
Text(
|
||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||
@@ -118,7 +105,7 @@ internal fun WidgetEventRow(
|
||||
color = titleInk,
|
||||
fontSize = metrics.title,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textDecoration = glanceDeclinedDecoration(event.isCrossedOut),
|
||||
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||
),
|
||||
)
|
||||
Text(text = secondary, maxLines = 1, style = TextStyle(color = timeInk, fontSize = metrics.time))
|
||||
|
||||
@@ -26,7 +26,7 @@ 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 { STYLE, RANGE, TEXT_SIZE, PAST_DISPLAY, SHOW_TODAY, CALENDARS }
|
||||
enum class WidgetOption { RANGE, TEXT_SIZE, PAST_DISPLAY, SHOW_TODAY, CALENDARS }
|
||||
|
||||
/**
|
||||
* Every widget the app ships, told apart by their receiver: what redraws,
|
||||
@@ -42,14 +42,7 @@ enum class WidgetKind(
|
||||
AGENDA(
|
||||
AgendaWidgetReceiver::class.java,
|
||||
R.string.widget_agenda_label,
|
||||
setOf(
|
||||
WidgetOption.STYLE,
|
||||
WidgetOption.RANGE,
|
||||
WidgetOption.TEXT_SIZE,
|
||||
WidgetOption.PAST_DISPLAY,
|
||||
WidgetOption.SHOW_TODAY,
|
||||
WidgetOption.CALENDARS,
|
||||
),
|
||||
setOf(WidgetOption.RANGE, WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.SHOW_TODAY, WidgetOption.CALENDARS),
|
||||
DpSize(260.dp, 280.dp),
|
||||
),
|
||||
MONTH(
|
||||
@@ -102,9 +95,6 @@ enum class WidgetKind(
|
||||
),
|
||||
;
|
||||
|
||||
/** 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()
|
||||
|
||||
@@ -18,10 +18,8 @@ import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toJavaDayOfWeek
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.util.Locale
|
||||
import kotlin.time.Instant
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
|
||||
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
|
||||
@Composable
|
||||
@@ -30,19 +28,6 @@ internal fun widgetCardColor(): ColorProvider = when (LocalWidgetBackground.curr
|
||||
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground
|
||||
}
|
||||
|
||||
/** Opens this occurrence in the app, backing out to [source]. */
|
||||
internal fun EventInstance.openIntent(context: Context, source: CalendarView) = MainActivity.openEventIntent(
|
||||
context = context,
|
||||
eventId = eventId,
|
||||
beginMillis = start.toEpochMilliseconds(),
|
||||
endMillis = end.toEpochMilliseconds(),
|
||||
source = source,
|
||||
)
|
||||
|
||||
/** Opens [date] in the app's agenda. */
|
||||
internal fun openDayAction(context: Context, date: LocalDate) =
|
||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda))
|
||||
|
||||
/** 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())) {
|
||||
@@ -54,20 +39,15 @@ internal fun widgetTimeLabel(context: Context, event: EventInstance, day: LocalD
|
||||
|
||||
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
|
||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
|
||||
}
|
||||
|
||||
/** "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 {
|
||||
val locale = Locale.getDefault()
|
||||
return when {
|
||||
date == today -> context.getString(R.string.agenda_header_today)
|
||||
date == today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
|
||||
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())
|
||||
|
||||
@@ -40,29 +40,15 @@ import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import java.util.Locale
|
||||
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.
|
||||
*/
|
||||
/** Republishes the generated picker previews (Android 15+) after an app update or a language change. */
|
||||
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("-"),
|
||||
version = "${context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime}-${Locale.getDefault().toLanguageTag()}",
|
||||
receivers = listOf(
|
||||
AgendaWidgetReceiver::class,
|
||||
MonthWidgetReceiver::class,
|
||||
@@ -87,7 +73,7 @@ internal suspend fun GlanceAppWidget.provideSamplePreview(context: Context, kind
|
||||
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 weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
|
||||
val is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
val options = settings.agendaDefaults
|
||||
val appearance = WidgetAppearance()
|
||||
|
||||
@@ -6,22 +6,18 @@ import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.repointCountdowns
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRefresher
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||
import de.jeanlucmakiola.floret.glance.updatePlaced
|
||||
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
@@ -38,20 +34,6 @@ internal val WIDGET_RECEIVER_CLASSES: List<Class<*>> = WidgetKind.entries.map {
|
||||
/** 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
|
||||
|
||||
@@ -70,22 +52,10 @@ internal fun syncWidgetRollover(context: Context) =
|
||||
* Reloads and redraws every placed widget, a running session included: the
|
||||
* refresh stamp moves, and every widget keys its data load on it (#420).
|
||||
*/
|
||||
internal suspend fun redrawWidgets(context: Context, kinds: List<WidgetKind> = WidgetKind.entries) {
|
||||
// One kind failing, say a widget removed mid-pass, mustn't stop the others
|
||||
// or escape the broadcast's coroutine.
|
||||
kinds.forEach { kind ->
|
||||
try {
|
||||
redrawWidgets(context, kind)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Redrawing $kind widgets failed", e)
|
||||
}
|
||||
}
|
||||
internal suspend fun redrawWidgets(context: Context) {
|
||||
WidgetKind.entries.forEach { redrawWidgets(context, it) }
|
||||
}
|
||||
|
||||
private const val TAG = "WidgetRedraw"
|
||||
|
||||
internal suspend fun redrawWidgets(context: Context, kind: WidgetKind) =
|
||||
kind.widget().updatePlaced(context, kind.receiver, bumpRefreshStamp = true)
|
||||
|
||||
@@ -96,14 +66,8 @@ private suspend fun redrawUnconfiguredWidgets(context: Context) {
|
||||
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)
|
||||
}
|
||||
val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
|
||||
if (!appContext.widgetState(glanceId).hasAgendaOptions) kind.widget().updateWithRefresh(appContext, glanceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,32 +77,9 @@ private val lastLocalChange = AtomicLong(0L)
|
||||
|
||||
private const val PROVIDER_ECHO_WINDOW_MILLIS = 5_000L
|
||||
|
||||
/** Set while a `PROVIDER_CHANGED` redraw waits out the echo window, so a burst of them redraws once. */
|
||||
private val echoRedrawQueued = AtomicBoolean(false)
|
||||
|
||||
private fun providerEchoRemainingMillis(): Long {
|
||||
internal fun isProviderEcho(): Boolean {
|
||||
val last = lastLocalChange.get()
|
||||
if (last == 0L) return 0L
|
||||
return PROVIDER_ECHO_WINDOW_MILLIS - (SystemClock.elapsedRealtime() - last)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `PROVIDER_CHANGED` should redraw. Within the echo window of one of
|
||||
* the app's own changes it may only echo that change, or be a sync landing
|
||||
* right after it, so it isn't dropped: the first one redraws once the window
|
||||
* has passed ([awaitProviderEcho]) and the rest of the burst ride along.
|
||||
*/
|
||||
internal fun claimProviderRedraw(): Boolean =
|
||||
providerEchoRemainingMillis() <= 0L || echoRedrawQueued.compareAndSet(false, true)
|
||||
|
||||
/** Waits out the echo window, if one is open, before a `PROVIDER_CHANGED` redraw. */
|
||||
internal suspend fun awaitProviderEcho() {
|
||||
try {
|
||||
val wait = providerEchoRemainingMillis()
|
||||
if (wait > 0L) delay(wait)
|
||||
} finally {
|
||||
echoRedrawQueued.set(false)
|
||||
}
|
||||
return last != 0L && SystemClock.elapsedRealtime() - last < PROVIDER_ECHO_WINDOW_MILLIS
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,28 +102,23 @@ 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 refreshers = WidgetKind.entries.associateWith { kind -> WidgetRefresher(scope) { redrawWidgets(context, kind) } }
|
||||
val all = refreshers.values
|
||||
val all = WidgetKind.entries.map { kind -> WidgetRefresher(scope) { redrawWidgets(context, kind) } }
|
||||
val unconfigured = WidgetRefresher(scope) { redrawUnconfiguredWidgets(context) }
|
||||
watch(repository.localChanges) {
|
||||
lastLocalChange.set(SystemClock.elapsedRealtime())
|
||||
EVENT_WIDGET_KINDS.forEach { refreshers.getValue(it).request() }
|
||||
}
|
||||
watch(repository.seriesSplits) { split ->
|
||||
scope.launch(Dispatchers.IO) { repointCountdowns(context, split.oldEventId, split.newEventId) }
|
||||
all.forEach { it.request() }
|
||||
}
|
||||
watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) {
|
||||
all.forEach { it.request() }
|
||||
scope.launch(Dispatchers.IO) { publishCalendulaWidgetPreviews(context) }
|
||||
}
|
||||
watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) {
|
||||
unconfigured.request()
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> watch(changes: Flow<T>, onChange: (T) -> Unit) {
|
||||
private fun watch(changes: Flow<*>, onChange: () -> Unit) {
|
||||
scope.launch(Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) {
|
||||
changes.collect { onChange(it) }
|
||||
changes.collect { onChange() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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,17 +1,12 @@
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -34,7 +29,7 @@ fun CalendulaGlanceTheme(mode: WidgetThemeMode, content: @Composable () -> Unit)
|
||||
@Composable
|
||||
internal fun CalendulaWidgetRoot(appearance: WidgetAppearance, appTheme: ThemeMode, content: @Composable () -> Unit) {
|
||||
CalendulaGlanceTheme(appearance.theme.resolve(appTheme)) {
|
||||
WidgetSurface(background = appearance.background, opacity = appearance.opacity, content = content)
|
||||
WidgetSurface(background = appearance.background, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,22 +37,5 @@ 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(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)
|
||||
}
|
||||
fun glanceDeclinedDecoration(isDeclined: Boolean): TextDecoration =
|
||||
if (isDeclined) TextDecoration.LineThrough else TextDecoration.None
|
||||
@@ -1,79 +0,0 @@
|
||||
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,18 +4,16 @@ 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 the widgets when their data, day or language goes stale, and keeps
|
||||
* Redraws both widgets when their data, day or language goes stale, and keeps
|
||||
* the midnight rollover armed. `PROVIDER_CHANGED` covers changes made outside
|
||||
* 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]).
|
||||
* 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.
|
||||
*/
|
||||
class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() {
|
||||
override val rolloverAction = ACTION_WIDGET_ROLLOVER
|
||||
@@ -23,20 +21,9 @@ 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): Boolean {
|
||||
this.action = action
|
||||
return action == Intent.ACTION_PROVIDER_CHANGED && !claimProviderRedraw()
|
||||
}
|
||||
override fun skipRedraw(action: String) = action == Intent.ACTION_PROVIDER_CHANGED && isProviderEcho()
|
||||
|
||||
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)
|
||||
}
|
||||
override suspend fun redraw(context: Context) = redrawWidgets(context)
|
||||
}
|
||||
@@ -12,15 +12,6 @@ internal val ROW_H_PAD = 12.dp
|
||||
/** Space between the date-pill gutter and a day's card. */
|
||||
internal val GUTTER_GAP = 6.dp
|
||||
|
||||
/** The classic style's row inset, colour stripe and the gap after it (#489). */
|
||||
internal val CLASSIC_ROW_H_PAD = 4.dp
|
||||
internal val STRIPE_WIDTH = 5.dp
|
||||
internal val STRIPE_GAP = 10.dp
|
||||
|
||||
/** The stripe and its gap, and where the classic style's event text starts, so its placeholder lines up with the titles. */
|
||||
internal val STRIPE_INSET = STRIPE_WIDTH + STRIPE_GAP
|
||||
internal val TEXT_INDENT = CLASSIC_ROW_H_PAD + STRIPE_INSET
|
||||
|
||||
/**
|
||||
* Every size the agenda widget varies by [WidgetSize]. Values that genuinely
|
||||
* shouldn't grow (the horizontal row constants above, corner radii) stay
|
||||
@@ -38,8 +29,6 @@ internal data class AgendaMetrics(
|
||||
val rowVPad: Dp, // event row vertical padding
|
||||
val dayGap: Dp, // space between two days' cards
|
||||
val monthLabel: TextUnit, // month name above a new month's first day
|
||||
val dayHeader: TextUnit, // classic style's day label ("Today · …")
|
||||
val dayHeaderTopPad: Dp, // classic style's space above a day label
|
||||
) {
|
||||
/** One event row's height, shared by the date pill beside it and the placeholder row. */
|
||||
val rowHeight: Dp get() = contentH + rowVPad * 2
|
||||
@@ -72,8 +61,6 @@ private val SMALL_METRICS = AgendaMetrics(
|
||||
rowVPad = 4.dp,
|
||||
dayGap = 8.dp,
|
||||
monthLabel = 12.sp,
|
||||
dayHeader = 13.sp,
|
||||
dayHeaderTopPad = 10.dp,
|
||||
)
|
||||
|
||||
private val MEDIUM_METRICS = AgendaMetrics(
|
||||
@@ -88,8 +75,6 @@ private val MEDIUM_METRICS = AgendaMetrics(
|
||||
rowVPad = 5.dp,
|
||||
dayGap = 9.dp,
|
||||
monthLabel = 14.sp,
|
||||
dayHeader = 14.sp, // M3 Title Small
|
||||
dayHeaderTopPad = 11.dp,
|
||||
)
|
||||
|
||||
private val LARGE_METRICS = AgendaMetrics(
|
||||
@@ -104,8 +89,6 @@ private val LARGE_METRICS = AgendaMetrics(
|
||||
rowVPad = 6.dp,
|
||||
dayGap = 10.dp,
|
||||
monthLabel = 14.sp,
|
||||
dayHeader = 16.sp, // M3 Title Medium
|
||||
dayHeaderTopPad = 12.dp,
|
||||
)
|
||||
|
||||
private val EXTRA_LARGE_METRICS = AgendaMetrics(
|
||||
@@ -120,8 +103,6 @@ private val EXTRA_LARGE_METRICS = AgendaMetrics(
|
||||
rowVPad = 8.dp,
|
||||
dayGap = 12.dp,
|
||||
monthLabel = 16.sp,
|
||||
dayHeader = 18.sp, // †
|
||||
dayHeaderTopPad = 14.dp,
|
||||
)
|
||||
|
||||
/** Indexed by [WidgetSize.ordinal] so lookup allocates nothing per recomposition. */
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
package de.jeanlucmakiola.calendula.widget.agenda
|
||||
|
||||
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.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||
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.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
@@ -54,18 +50,11 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
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.floret.locale.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.widget.widgetEventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetStyle
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetSeeThrough
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.withAlpha
|
||||
import de.jeanlucmakiola.calendula.widget.widgetEventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
@@ -73,8 +62,6 @@ 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.openIntent
|
||||
import de.jeanlucmakiola.calendula.widget.openDayAction
|
||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
@@ -94,11 +81,11 @@ import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toJavaDayOfWeek
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
||||
* of events: each day one card of grouped rows with its date pill beside it, or
|
||||
* in the classic style a label line over plain colour-stripe rows (#489).
|
||||
* of events, each day one card of grouped rows with its date pill beside it.
|
||||
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||
*/
|
||||
class AgendaWidget : GlanceAppWidget() {
|
||||
@@ -123,13 +110,11 @@ 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) }
|
||||
InAppLanguage {
|
||||
AgendaWidgetContent(
|
||||
data = loaded.content,
|
||||
appearance = currentState<Preferences>().widgetAppearance,
|
||||
appTheme = loaded.appTheme,
|
||||
)
|
||||
}
|
||||
AgendaWidgetContent(
|
||||
data = loaded.content,
|
||||
appearance = currentState<Preferences>().widgetAppearance,
|
||||
appTheme = loaded.appTheme,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,10 +146,10 @@ private const val PREVIEW_ROWS = 10
|
||||
* user scrolling a home-screen widget past a hundred rows is not a case worth
|
||||
* risking a failed update for.
|
||||
*/
|
||||
internal const val MAX_AGENDA_ROWS = 100
|
||||
private const val MAX_AGENDA_ROWS = 100
|
||||
|
||||
/** Flat row model: one [LazyColumn] item per event or placeholder, a day's first one carrying its date pill. */
|
||||
internal sealed interface AgendaRow {
|
||||
private sealed interface AgendaRow {
|
||||
val date: LocalDate
|
||||
|
||||
data class Event(override val date: LocalDate, val event: EventInstance) : AgendaRow
|
||||
@@ -179,21 +164,16 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) {
|
||||
val size = (data as? AgendaWidgetData.Ready)?.textSize ?: WidgetSize.SMALL
|
||||
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
|
||||
val metrics = metricsFor(size).scaledForFont(fontScale)
|
||||
// The classic style adds no backings for a faded fill, only on a transparent one (#489).
|
||||
val classic = (data as? AgendaWidgetData.Ready)?.style == AgendaWidgetStyle.CLASSIC
|
||||
val seeThrough = if (classic) LocalWidgetBackground.current == WidgetBackground.TRANSPARENT else LocalWidgetSeeThrough.current
|
||||
CompositionLocalProvider(LocalWidgetSeeThrough provides seeThrough) {
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
AgendaHeader()
|
||||
Spacer(GlanceModifier.height(6.dp))
|
||||
when (data) {
|
||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
|
||||
is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics, preview = preview)
|
||||
}
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
AgendaHeader()
|
||||
Spacer(GlanceModifier.height(6.dp))
|
||||
when (data) {
|
||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
|
||||
is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics, preview = preview)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,248 +215,60 @@ private fun AgendaRows(
|
||||
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
when (data.style) {
|
||||
AgendaWidgetStyle.CARDS -> CardRows(rows, data, metrics, preview)
|
||||
AgendaWidgetStyle.CLASSIC -> ClassicRows(rows, data, metrics, preview)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Each day is one card of grouped rows (#71), with its date pill beside the first row. */
|
||||
@Composable
|
||||
private fun CardRows(allRows: List<AgendaRow>, data: AgendaWidgetData.Ready, metrics: AgendaMetrics, preview: Boolean) {
|
||||
val rows = allRows.take(MAX_AGENDA_ROWS)
|
||||
val positions = dayPositions(rows.map { it.date })
|
||||
val cardColor = widgetCardColor()
|
||||
val item: @Composable (Int) -> Unit = { index ->
|
||||
val row = rows[index]
|
||||
val position = positions[index]
|
||||
val first = position == Position.Top || position == Position.Alone
|
||||
val dayRow: @Composable (GlanceModifier) -> Unit = { modifier ->
|
||||
Row(modifier = modifier.fillMaxWidth()) {
|
||||
Box(modifier = GlanceModifier.width(metrics.pillWidth)) {
|
||||
if (first) DatePill(row.date, data.today, cardColor, metrics)
|
||||
}
|
||||
Spacer(GlanceModifier.width(GUTTER_GAP))
|
||||
Box(modifier = GlanceModifier.defaultWeight()) {
|
||||
val rowColor = when (row) {
|
||||
is AgendaRow.Event -> row.fill(data).let { themedColor(day = it.day, night = it.night) }
|
||||
is AgendaRow.Placeholder -> cardColor
|
||||
}.take(MAX_AGENDA_ROWS)
|
||||
// Each day is one card of grouped rows (#71), with its date pill
|
||||
// beside the first row.
|
||||
val positions = dayPositions(rows)
|
||||
val cardColor = widgetCardColor()
|
||||
val item: @Composable (Int) -> Unit = { index ->
|
||||
val row = rows[index]
|
||||
val position = positions[index]
|
||||
val first = position == Position.Top || position == Position.Alone
|
||||
val dayRow: @Composable (GlanceModifier) -> Unit = { modifier ->
|
||||
Row(modifier = modifier.fillMaxWidth()) {
|
||||
Box(modifier = GlanceModifier.width(metrics.pillWidth)) {
|
||||
if (first) DatePill(row.date, data.today, cardColor, metrics)
|
||||
}
|
||||
WidgetGroupedRow(position = position, color = rowColor) {
|
||||
AgendaRowContent(row, data, metrics)
|
||||
Spacer(GlanceModifier.width(GUTTER_GAP))
|
||||
Box(modifier = GlanceModifier.defaultWeight()) {
|
||||
val rowColor = when (row) {
|
||||
is AgendaRow.Event -> row.fill(data).let { themedColor(day = it.day, night = it.night) }
|
||||
is AgendaRow.Placeholder -> cardColor
|
||||
}
|
||||
WidgetGroupedRow(position = position, color = rowColor) {
|
||||
AgendaRowContent(row, data, metrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val gap = GlanceModifier.padding(top = if (first && index > 0) metrics.dayGap else 0.dp)
|
||||
// The pills carry no month, so a new month gets its name above its first day.
|
||||
if (first && index > 0 && row.date.month != rows[index - 1].date.month) {
|
||||
Column(modifier = gap.fillMaxWidth()) {
|
||||
MonthLabel(row.date, data.today, metrics)
|
||||
dayRow(GlanceModifier)
|
||||
}
|
||||
} else {
|
||||
dayRow(gap)
|
||||
}
|
||||
}
|
||||
RowList(rows.size, preview, item)
|
||||
}
|
||||
|
||||
/** [count] rows of [item]: a plain column in a [preview], else a [LazyColumn]. */
|
||||
@Composable
|
||||
private fun RowList(count: Int, preview: Boolean, item: @Composable (Int) -> Unit) {
|
||||
if (preview) {
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||
repeat(minOf(count, PREVIEW_ROWS)) { item(it) }
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||
items(count) { item(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A classic list entry: a day's label line, or one of its rows. */
|
||||
internal sealed interface ClassicItem {
|
||||
val date: LocalDate
|
||||
|
||||
data class Header(override val date: LocalDate) : ClassicItem
|
||||
data class Entry(val row: AgendaRow) : ClassicItem {
|
||||
override val date: LocalDate get() = row.date
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [rows] with a label before each day's first, held to [limit] items in all,
|
||||
* headers included; a label the cut leaves with nothing under it is dropped.
|
||||
*/
|
||||
internal fun classicItems(rows: List<AgendaRow>, limit: Int = MAX_AGENDA_ROWS): List<ClassicItem> = buildList {
|
||||
rows.forEachIndexed { index, row ->
|
||||
if (index == 0 || row.date != rows[index - 1].date) add(ClassicItem.Header(row.date))
|
||||
add(ClassicItem.Entry(row))
|
||||
}
|
||||
}.take(limit).dropLastWhile { it is ClassicItem.Header }
|
||||
|
||||
/**
|
||||
* v2.23's look (#489): a label line per day over plain rows with a colour
|
||||
* stripe. On a transparent root each day sits on its own backing card, so the
|
||||
* text stays readable over the wallpaper; a faded fill is left to the opacity
|
||||
* and "Invert text colour" options.
|
||||
*/
|
||||
@Composable
|
||||
private fun ClassicRows(rows: List<AgendaRow>, data: AgendaWidgetData.Ready, metrics: AgendaMetrics, preview: Boolean) {
|
||||
val items = classicItems(rows, if (preview) PREVIEW_ROWS else MAX_AGENDA_ROWS)
|
||||
val backed = LocalWidgetBackground.current == WidgetBackground.TRANSPARENT
|
||||
val ink = classicInk(inverted = data.invertText && !backed)
|
||||
val positions = if (backed) dayPositions(items.map { it.date }) else emptyList()
|
||||
val cardColor = widgetCardColor()
|
||||
val item: @Composable (Int) -> Unit = { index ->
|
||||
val entry = items[index]
|
||||
val content: @Composable () -> Unit = {
|
||||
when (entry) {
|
||||
is ClassicItem.Header -> DayHeaderRow(
|
||||
date = entry.date,
|
||||
today = data.today,
|
||||
startPad = if (backed) CLASSIC_CARD_H_PAD else 8.dp,
|
||||
topPad = when {
|
||||
backed -> CLASSIC_CARD_TOP_PAD
|
||||
index == 0 -> 0.dp
|
||||
else -> metrics.dayHeaderTopPad
|
||||
},
|
||||
ink = ink,
|
||||
metrics = metrics,
|
||||
)
|
||||
is ClassicItem.Entry -> when (val row = entry.row) {
|
||||
is AgendaRow.Placeholder -> ClassicPlaceholderRow(row.date, backed, ink, metrics)
|
||||
is AgendaRow.Event -> ClassicEventRow(row, data, backed, ink, metrics)
|
||||
val gap = GlanceModifier.padding(top = if (first && index > 0) metrics.dayGap else 0.dp)
|
||||
// The pills carry no month, so a new month gets its name above its first day.
|
||||
if (first && index > 0 && row.date.month != rows[index - 1].date.month) {
|
||||
Column(modifier = gap.fillMaxWidth()) {
|
||||
MonthLabel(row.date, data.today, metrics)
|
||||
dayRow(GlanceModifier)
|
||||
}
|
||||
} else {
|
||||
dayRow(gap)
|
||||
}
|
||||
}
|
||||
if (backed) {
|
||||
val gap = if (entry is ClassicItem.Header && index > 0) metrics.dayGap else 0.dp
|
||||
WidgetGroupedRow(
|
||||
position = positions[index],
|
||||
modifier = GlanceModifier.padding(top = gap),
|
||||
color = cardColor,
|
||||
gapBelow = false,
|
||||
content = content,
|
||||
)
|
||||
if (preview) {
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||
repeat(minOf(rows.size, PREVIEW_ROWS)) { item(it) }
|
||||
}
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
}
|
||||
RowList(items.size, preview, item)
|
||||
}
|
||||
|
||||
/** The classic style's text colours: the theme's, or its inverse for the "Invert text colour" option. */
|
||||
private data class ClassicInk(val title: ColorProvider, val secondary: ColorProvider, val today: ColorProvider)
|
||||
|
||||
@Composable
|
||||
private fun classicInk(inverted: Boolean): ClassicInk = with(GlanceTheme.colors) {
|
||||
if (inverted) {
|
||||
ClassicInk(inverseOnSurface, inverseOnSurface.withAlpha(INVERTED_SECONDARY_INK), inversePrimary)
|
||||
} else {
|
||||
ClassicInk(onSurface, onSurfaceVariant, primary)
|
||||
}
|
||||
}
|
||||
|
||||
/** A classic day label, "Today · Wed, 17 Jun"; opens that day in the app. */
|
||||
@Composable
|
||||
private fun DayHeaderRow(date: LocalDate, today: LocalDate, startPad: Dp, topPad: Dp, ink: ClassicInk, metrics: AgendaMetrics) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
Text(
|
||||
text = agendaDayLabel(context, date, today),
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = if (date == today) ink.today else ink.secondary,
|
||||
fontSize = metrics.dayHeader,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = startPad, end = 8.dp, top = topPad, bottom = metrics.rowVPad)
|
||||
.clickable(openDayAction(context, date)),
|
||||
)
|
||||
}
|
||||
|
||||
/** "No more events today" as a muted line under an anchored, event-less today (#35), indented to the titles. */
|
||||
@Composable
|
||||
private fun ClassicPlaceholderRow(date: LocalDate, backed: Boolean, ink: ClassicInk, metrics: AgendaMetrics) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
Text(
|
||||
text = context.getString(R.string.agenda_no_more_today),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = ink.secondary, fontSize = metrics.placeholder),
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = if (backed) CLASSIC_CARD_H_PAD + STRIPE_INSET else TEXT_INDENT, end = 8.dp, top = 2.dp, bottom = metrics.rowVPad + 2.dp)
|
||||
.clickable(openDayAction(context, date)),
|
||||
)
|
||||
}
|
||||
|
||||
/** A classic event row: the calendar's colour stripe beside the title and time; a dimmed one fades both. */
|
||||
@Composable
|
||||
private fun ClassicEventRow(row: AgendaRow.Event, data: AgendaWidgetData.Ready, backed: Boolean, ink: ClassicInk, metrics: AgendaMetrics) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
val event = row.event
|
||||
val dimmed = row.dimmed(data)
|
||||
fun accent(dark: Boolean) = eventAccent(event.color, dark, data.soften).let {
|
||||
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||
}
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = if (backed) CLASSIC_CARD_H_PAD else CLASSIC_ROW_H_PAD, vertical = metrics.rowVPad)
|
||||
.clickable(actionStartActivity(event.openIntent(context, CalendarView.Agenda))),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(STRIPE_WIDTH)
|
||||
.height(metrics.contentH)
|
||||
.cornerRadius(3.dp)
|
||||
.background(themedColor(day = accent(dark = false), night = accent(dark = true))),
|
||||
) {}
|
||||
Spacer(GlanceModifier.width(STRIPE_GAP))
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
Text(
|
||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = if (dimmed) ink.title.withAlpha(CLASSIC_DIM_INK) else ink.title,
|
||||
fontSize = metrics.eventTitle,
|
||||
textDecoration = glanceDeclinedDecoration(event.isCrossedOut),
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = eventTimeSummary(context, event, row.date, data.is24Hour),
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = if (dimmed) ink.secondary.withAlpha(CLASSIC_DIM_INK) else ink.secondary,
|
||||
fontSize = metrics.eventTime,
|
||||
),
|
||||
)
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||
items(rows.size) { item(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Space above a day's label on its own backing card, and its rows' inset, clear of the card's corners. */
|
||||
private val CLASSIC_CARD_TOP_PAD = 8.dp
|
||||
private val CLASSIC_CARD_H_PAD = 10.dp
|
||||
|
||||
/** How strongly a finished event's text shows in the classic style, on any fill. */
|
||||
private const val CLASSIC_DIM_INK = 0.5f
|
||||
|
||||
/** The inverted secondary text's strength, as `onSurfaceVariant` sits below `onSurface`. */
|
||||
private const val INVERTED_SECONDARY_INK = 0.75f
|
||||
|
||||
/** Where each row sits among the rows of its day, from each row's date, for the grouped-card shapes. */
|
||||
private fun dayPositions(dates: List<LocalDate>): List<Position> = buildList {
|
||||
/** Where each row sits among the rows of its day, for the grouped-card shapes. */
|
||||
private fun dayPositions(rows: List<AgendaRow>): List<Position> = buildList {
|
||||
var start = 0
|
||||
for (end in 1..dates.size) {
|
||||
if (end == dates.size || dates[end] != dates[start]) {
|
||||
for (end in 1..rows.size) {
|
||||
if (end == rows.size || rows[end].date != rows[start].date) {
|
||||
for (i in start until end) add(positionOf(i - start, end - start))
|
||||
start = end
|
||||
}
|
||||
@@ -547,7 +339,7 @@ private fun DatePill(date: LocalDate, today: LocalDate, cardColor: ColorProvider
|
||||
.height(metrics.rowHeight)
|
||||
.cornerRadius(metrics.pillWidth / 2)
|
||||
.background(container)
|
||||
.clickable(openDayAction(context, date))
|
||||
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda)))
|
||||
.semantics { contentDescription = label },
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -579,7 +371,11 @@ private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
|
||||
.fillMaxWidth()
|
||||
.height(metrics.rowHeight)
|
||||
.padding(horizontal = ROW_H_PAD)
|
||||
.clickable(openDayAction(context, date)),
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||
),
|
||||
),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Text(
|
||||
@@ -591,19 +387,14 @@ private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
|
||||
}
|
||||
|
||||
/** An event row's light and dark fill: its calendar's container tone, faded when the row is dimmed. */
|
||||
private data class EventFill(val day: Color, val night: Color, val dimmed: Boolean)
|
||||
|
||||
/** Whether this row is drawn faded as a finished event. */
|
||||
private fun AgendaRow.Event.dimmed(data: AgendaWidgetData.Ready): Boolean =
|
||||
data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now)
|
||||
private data class EventFill(val day: Color, val night: Color)
|
||||
|
||||
private fun AgendaRow.Event.fill(data: AgendaWidgetData.Ready): EventFill {
|
||||
val dimmed = dimmed(data)
|
||||
return EventFill(
|
||||
day = widgetEventFill(event.color, dark = false, data.soften, dimmed),
|
||||
night = widgetEventFill(event.color, dark = true, data.soften, dimmed),
|
||||
dimmed = dimmed,
|
||||
)
|
||||
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
|
||||
}
|
||||
return EventFill(day = tone(dark = false), night = tone(dark = true))
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -616,17 +407,29 @@ private fun EventRow(
|
||||
) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
val titleInk = themedColor(day = widgetEventInk(fill.day, 1f, fill.dimmed), night = widgetEventInk(fill.night, 1f, fill.dimmed))
|
||||
// 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 timeInk = themedColor(
|
||||
day = widgetEventInk(fill.day, SECONDARY_INK_ALPHA, fill.dimmed),
|
||||
night = widgetEventInk(fill.night, SECONDARY_INK_ALPHA, fill.dimmed),
|
||||
day = ink(fill.day, SECONDARY_INK_ALPHA),
|
||||
night = ink(fill.night, SECONDARY_INK_ALPHA),
|
||||
)
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.height(metrics.rowHeight)
|
||||
.padding(horizontal = ROW_H_PAD)
|
||||
.clickable(actionStartActivity(event.openIntent(context, CalendarView.Agenda))),
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openEventIntent(
|
||||
context = context,
|
||||
eventId = event.eventId,
|
||||
beginMillis = event.start.toEpochMilliseconds(),
|
||||
endMillis = event.end.toEpochMilliseconds(),
|
||||
source = CalendarView.Agenda,
|
||||
),
|
||||
),
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
@@ -636,7 +439,7 @@ private fun EventRow(
|
||||
color = titleInk,
|
||||
fontSize = metrics.eventTitle,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textDecoration = glanceDeclinedDecoration(event.isCrossedOut),
|
||||
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||
),
|
||||
)
|
||||
Text(
|
||||
@@ -664,24 +467,28 @@ private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
|
||||
|
||||
/** "Today · Wed, 17 Jun" — relative word for today/tomorrow, else the date. */
|
||||
private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
|
||||
val relative = relativeDayWord(context, date, today)
|
||||
val locale = widgetLocale()
|
||||
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 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.
|
||||
val formatted = localizedDateFormatter(locale, if (date.year == today.year) "EEEdMMM" else "EEEdMMMy").format(java)
|
||||
val formatted = localizedDateFormatter(locale, "EEEdMMM").format(java)
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
private fun monthName(date: LocalDate, today: LocalDate): String = formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, 1),
|
||||
locale = widgetLocale(),
|
||||
locale = Locale.getDefault(),
|
||||
currentYear = today.year,
|
||||
skeleton = "LLLL",
|
||||
)
|
||||
|
||||
private fun weekdayShort(date: LocalDate): String =
|
||||
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, widgetLocale())
|
||||
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, Locale.getDefault())
|
||||
|
||||
private fun eventTimeSummary(
|
||||
context: Context,
|
||||
|
||||
+1
-2
@@ -25,7 +25,6 @@ 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
|
||||
@@ -149,7 +148,7 @@ private fun CountdownEventPicker(
|
||||
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())
|
||||
val date = formatter.format(candidate.event.start.toLocalDateTime(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,
|
||||
|
||||
Loaded 100 of 254 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user