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
@@ -7,46 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [2.24.0] — 2026-11-06
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Six new home-screen widgets.** Quick add (1×1) opens a new event on today with the same defaults as the app's + button ([#435]). Date (1×1) shows today's weekday and day number on a rounded shape and opens today ([#376]). Week strip (4×1) shows this week's days with a dot in each event's colour and today filled ([#436]). Upcoming (2×2) shows today's date over your next events, as many as fit ([#437]). Today lists today's events; once they're over it says "Nothing left today" and shows tomorrow's first one or two ([#438]). Countdown counts down to your next timed event, or to an event or series you pick, like a holiday in 12 days ([#439]).
|
|
||||||
- **A month and day widget.** A compact month grid sits beside the events of one day. Tap a day in the grid to see its events, and tap the list to open that day in the app ([#441]).
|
|
||||||
- **Each widget has its own settings.** Placing a widget, or long-pressing it and choosing Edit, opens a settings screen with a live preview. Pick a solid, tinted or transparent background, a light or dark theme or the app's, and which calendars it shows. Widgets that list events also have their time range, text size and how past events look. Settings → Widgets lists the widgets on your home screen so you can change them from there. These options used to be shared by all widgets; widgets you already placed keep what you had set ([#292]).
|
|
||||||
- **Turn off start times in the month view.** Settings → Views → Month → Start times hides the time in front of event titles, so narrow fonts keep more of the title. With it on, the time now only shows when the whole title, or at least its first 12 characters, still fits beside it ([#476]).
|
|
||||||
- **Drag all-day events.** In the day, 3-day and week views, long-press an event in the all-day strip and move it sideways to another day, or down into the grid to turn it into a timed event ([#296]).
|
|
||||||
- **Traditional Chinese**, started as a community translation through [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/). It covers only a few screens so far, so the rest still shows in English until it fills out. Pick it under Settings → Language. Thanks to AventineC.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **The agenda and month widgets have a new look.** The agenda puts each day in its own card, with a round date pill beside the first event and today's pill filled. The month widget has rounded day cells, weekday pills, shaded days outside the month and a + for a new event. The agenda's refresh button is gone, since widgets now update on their own ([#71], [#90]).
|
|
||||||
- **Tapping a day in the month widget opens that day,** wherever in its cell you tap ([#386]).
|
|
||||||
- **The widget picker shows what each widget looks like.** On Android 15 and later the previews are the real widgets with sample events, in your language and colours; on older versions they are redrawn to match.
|
|
||||||
- **Widgets are listed by their name alone,** without "Calendula" in front, and each has a short description in the picker ([#71]).
|
|
||||||
- **Past events in widgets stay readable when dimmed.** Their background fades further and their text less ([#90]).
|
|
||||||
- **Events from midnight to midnight show as all-day events.** A timed event that starts and ends at midnight now sits in the all-day strip, shows as "All day" in the agenda and widgets, and its reminder names the day instead of 00:00 – 00:00. The event itself stays as it was saved ([#296]).
|
|
||||||
- **Cancelled and tentative events are marked.** Cancelled events are struck through and listed last, like declined invites, and tentative ones get the outline used for unanswered invites. Cancelled one-off events and series no longer disappear ([#388]).
|
|
||||||
- **One notification per event.** An event with several reminders now keeps a single notification, which each later reminder updates in place ([#463]).
|
|
||||||
- **Go further back and forward in time.** Every view and the date picker now reach from 1600 to 2399 ([#457]).
|
|
||||||
- **The split month agenda only lists days with events.** In other months it no longer starts with an empty 1st, and a month with nothing in it says so in one row ([#142]).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Widgets show your changes straight away.** After you added, edited or deleted an event, a widget could keep showing the old events until something else redrew it ([#420], [#229]).
|
|
||||||
- **Widgets follow the app's language.** After you picked a language in Settings, widgets kept the old one until something redrew them ([#90]).
|
|
||||||
- **The month widget handles busy months.** It could show "Can't show content", or only one or two events per day. Each day now shows as many events as its height fits, and the rest fold into +N ([#372]).
|
|
||||||
- **Short events late in the day are fully visible.** An event like 23:58–23:59 used to be cut down to a sliver at the bottom of the day, week and 3-day views. It now keeps its usual minimum height and sits flush against midnight ([#483]).
|
|
||||||
- **The month view no longer crashes after going before 1900** in the day or week view ([#457]).
|
|
||||||
- **Month pages keep updating.** After a small swipe that snapped back, a month page could stop showing new or edited events, and opening an event from the split view could show its old times ([#440], [#454]).
|
|
||||||
- **Smoother expand and collapse in the split month view.** The day cells morph cleanly between the two layouts, and week numbers now animate along with them ([#473]).
|
|
||||||
- **Back clears the selected day in the split month view.** With a day picked, the back gesture used to close the app; it now goes back to the whole month's agenda first ([#142]).
|
|
||||||
|
|
||||||
## [2.23.1] — 2026-10-28
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **The split month view fits more events.** With a day selected, its date is no longer shown twice: the filter chip now stands in for the day's header. Tapping the chip opens the day, and its ✕ clears the filter. The day headers in the month agenda below the grid take less room too ([#142]).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Event dots in the split month grid stay in place on busy days.** A day with a "+N" next to its dots used to push them further down the cell ([#142]).
|
|
||||||
|
|
||||||
## [2.23.0] — 2026-10-21
|
## [2.23.0] — 2026-10-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1879,7 +1839,6 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#146]: https://codeberg.org/jlmakiola/calendula/issues/146
|
[#146]: https://codeberg.org/jlmakiola/calendula/issues/146
|
||||||
[#267]: https://codeberg.org/jlmakiola/calendula/issues/267
|
[#267]: https://codeberg.org/jlmakiola/calendula/issues/267
|
||||||
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239
|
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239
|
||||||
[#296]: https://codeberg.org/jlmakiola/calendula/issues/296
|
|
||||||
[#297]: https://codeberg.org/jlmakiola/calendula/issues/297
|
[#297]: https://codeberg.org/jlmakiola/calendula/issues/297
|
||||||
[#298]: https://codeberg.org/jlmakiola/calendula/issues/298
|
[#298]: https://codeberg.org/jlmakiola/calendula/issues/298
|
||||||
[#309]: https://codeberg.org/jlmakiola/calendula/issues/309
|
[#309]: https://codeberg.org/jlmakiola/calendula/issues/309
|
||||||
@@ -1929,26 +1888,4 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#306]: https://codeberg.org/jlmakiola/calendula/issues/306
|
[#306]: https://codeberg.org/jlmakiola/calendula/issues/306
|
||||||
[#178]: https://codeberg.org/jlmakiola/calendula/issues/178
|
[#178]: https://codeberg.org/jlmakiola/calendula/issues/178
|
||||||
[#116]: https://codeberg.org/jlmakiola/calendula/issues/116
|
[#116]: https://codeberg.org/jlmakiola/calendula/issues/116
|
||||||
[#388]: https://codeberg.org/jlmakiola/calendula/issues/388
|
|
||||||
[#413]: https://codeberg.org/jlmakiola/calendula/issues/413
|
[#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
|
|
||||||
@@ -32,8 +32,8 @@ android {
|
|||||||
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
|
// (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.
|
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
|
||||||
// See docs/RELEASING.md.
|
// See docs/RELEASING.md.
|
||||||
versionCode = 2240099
|
versionCode = 2230099
|
||||||
versionName = "2.24.0"
|
versionName = "2.23.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -426,7 +426,7 @@
|
|||||||
|
|
||||||
<!-- A countdown widget's redraw for when its label changes (#439). -->
|
<!-- A countdown widget's redraw for when its label changes (#439). -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.WidgetTickReceiver"
|
android:name=".widget.countdown.CountdownTickReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
@@ -450,12 +450,6 @@
|
|||||||
move the boundary, boot / package-replace wipe the alarm, and
|
move the boundary, boot / package-replace wipe the alarm, and
|
||||||
LOCALE_CHANGED redraws the labels in the new language.
|
LOCALE_CHANGED redraws the labels in the new language.
|
||||||
Exported: the system broadcasts arrive from outside the app. -->
|
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
|
<receiver
|
||||||
android:name=".widget.WidgetUpdateReceiver"
|
android:name=".widget.WidgetUpdateReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
|
|||||||
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
||||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -46,9 +45,6 @@ class CalendulaApp : Application() {
|
|||||||
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
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).
|
// Before anything that writes, so the widgets see every change (#420).
|
||||||
widgetEntryPoint().widgetRefreshCoordinator().start()
|
widgetEntryPoint().widgetRefreshCoordinator().start()
|
||||||
reconcileAutoBackup()
|
reconcileAutoBackup()
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
|
||||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.floret.prefs.isDark
|
import de.jeanlucmakiola.floret.prefs.isDark
|
||||||
import de.jeanlucmakiola.floret.prefs.isPureBlack
|
import de.jeanlucmakiola.floret.prefs.isPureBlack
|
||||||
@@ -279,7 +278,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
super.onResume()
|
super.onResume()
|
||||||
// Reaching a running UI means startup succeeded; reset the loop trail.
|
// Reaching a running UI means startup succeeded; reset the loop trail.
|
||||||
CrashReporter.markHealthy(this)
|
CrashReporter.markHealthy(this)
|
||||||
AppLanguage.syncMirror(this)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
@@ -595,9 +593,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
*/
|
*/
|
||||||
fun openDateIntent(context: Context, date: LocalDate, source: CalendarView): Intent =
|
fun openDateIntent(context: Context, date: LocalDate, source: CalendarView): Intent =
|
||||||
Intent(context, MainActivity::class.java).apply {
|
Intent(context, MainActivity::class.java).apply {
|
||||||
// The source is part of the URI so widgets of different kinds
|
data = "calendula://date/$date".toUri()
|
||||||
// tapping the same date don't share one PendingIntent.
|
|
||||||
data = "calendula://date/$date/${source.name}".toUri()
|
|
||||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||||
putExtra(EXTRA_SOURCE_VIEW, source.name)
|
putExtra(EXTRA_SOURCE_VIEW, source.name)
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
@@ -616,7 +612,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
endMillis: Long,
|
endMillis: Long,
|
||||||
source: CalendarView,
|
source: CalendarView,
|
||||||
): Intent = eventDetailIntent(context, eventId, beginMillis, endMillis).apply {
|
): Intent = eventDetailIntent(context, eventId, beginMillis, endMillis).apply {
|
||||||
data = "calendula://event/$eventId/$beginMillis/${source.name}".toUri()
|
|
||||||
putExtra(EXTRA_SOURCE_VIEW, source.name)
|
putExtra(EXTRA_SOURCE_VIEW, source.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -567,41 +567,11 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||||
val listed = queryInstances(beginMillis, endMillis, null, null)
|
queryInstances(beginMillis, endMillis, null, null)
|
||||||
// A provider that does expand cancelled events already listed them.
|
|
||||||
val shown = listed.mapTo(HashSet()) { it.eventId }
|
|
||||||
val cancelled = queryCancelledOneOffs(beginMillis, endMillis).filter { it.eventId !in shown }
|
|
||||||
return if (cancelled.isEmpty()) listed else (listed + cancelled).sortedBy { it.start }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Cancelled one-off events overlapping the window, which AOSP leaves out of Instances (#388). */
|
|
||||||
private fun queryCancelledOneOffs(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
|
||||||
val selection = "${CalendarContract.Events.STATUS} = ${CalendarContract.Events.STATUS_CANCELED} AND " +
|
|
||||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
|
||||||
"${CalendarContract.Events.RRULE} IS NULL AND ${CalendarContract.Events.RDATE} IS NULL AND " +
|
|
||||||
"${CalendarContract.Events.ORIGINAL_ID} IS NULL AND " +
|
|
||||||
"${CalendarContract.Events.ORIGINAL_SYNC_ID} IS NULL AND " +
|
|
||||||
"${CalendarContract.Events.DTSTART} < ? AND " +
|
|
||||||
"COALESCE(${CalendarContract.Events.DTEND}, ${CalendarContract.Events.DTSTART}) >= ?"
|
|
||||||
return resolver.query(
|
|
||||||
CalendarContract.Events.CONTENT_URI,
|
|
||||||
CancelledEventProjection.COLUMNS,
|
|
||||||
selection,
|
|
||||||
arrayOf(endMillis.toString(), beginMillis.toString()),
|
|
||||||
null,
|
|
||||||
)?.use { c -> c.mapRowsNotNull { toCancelledOneOff() } } ?: emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List<EventInstance> =
|
override fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||||
// The series' own rows plus its exceptions, so an occurrence edited on
|
queryInstances(beginMillis, endMillis, "${CalendarContract.Instances.EVENT_ID} = ?", arrayOf(eventId.toString()))
|
||||||
// its own is still one of the series' occurrences.
|
|
||||||
queryInstances(
|
|
||||||
beginMillis,
|
|
||||||
endMillis,
|
|
||||||
"(${CalendarContract.Instances.EVENT_ID} = ? OR ${CalendarContract.Instances.ORIGINAL_ID} = ?)",
|
|
||||||
arrayOf(eventId.toString(), eventId.toString()),
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun queryInstances(
|
private fun queryInstances(
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
@@ -610,12 +580,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
args: Array<String>?,
|
args: Array<String>?,
|
||||||
): List<EventInstance> {
|
): List<EventInstance> {
|
||||||
ensureObserversRegistered()
|
ensureObserversRegistered()
|
||||||
// Hide deleted occurrences (cancelled exceptions, #47) but keep a cancelled
|
// Hide cancelled occurrences: "delete only this event" writes a
|
||||||
// event of its own (#388); NULL status is a normal event. DELETED: #364.
|
// 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 " +
|
val visible = "(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED} OR " +
|
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
|
||||||
"(${CalendarContract.Instances.ORIGINAL_ID} IS NULL AND " +
|
|
||||||
"${CalendarContract.Instances.ORIGINAL_SYNC_ID} IS NULL)) AND " +
|
|
||||||
"${CalendarContract.Events.DELETED} = 0"
|
"${CalendarContract.Events.DELETED} = 0"
|
||||||
return resolver.query(
|
return resolver.query(
|
||||||
instancesUri(beginMillis, endMillis),
|
instancesUri(beginMillis, endMillis),
|
||||||
@@ -645,8 +617,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
.toTypedArray()
|
.toTypedArray()
|
||||||
val selection = "($match) AND " +
|
val selection = "($match) AND " +
|
||||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||||
"${CalendarContract.Events.ORIGINAL_ID} IS NULL AND " +
|
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
|
||||||
"${CalendarContract.Events.ORIGINAL_SYNC_ID} IS NULL"
|
|
||||||
return resolver.query(
|
return resolver.query(
|
||||||
CalendarContract.Events.CONTENT_URI,
|
CalendarContract.Events.CONTENT_URI,
|
||||||
SearchProjection.COLUMNS,
|
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.IcsImportSummary
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
interface CalendarRepository {
|
interface CalendarRepository {
|
||||||
@@ -109,12 +108,6 @@ interface CalendarRepository {
|
|||||||
form: EventForm,
|
form: EventForm,
|
||||||
): Long
|
): 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);
|
* Change a recurring event from [beginMillis] onwards (series split);
|
||||||
* returns the new event's `Events._ID`.
|
* 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. */
|
/** A ContentResolver write affected no rows or returned no URI. */
|
||||||
class WriteFailedException(operation: String) :
|
class WriteFailedException(operation: String) :
|
||||||
RuntimeException("Calendar write failed: $operation")
|
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(
|
dataSource.updateEventFromOccurrence(
|
||||||
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
|
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 {
|
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = write {
|
||||||
dataSource.deleteOccurrence(eventId, beginMillis)
|
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.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.FormTime
|
import de.jeanlucmakiola.calendula.domain.FormTime
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.forNewEvent
|
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.nearestTo
|
import de.jeanlucmakiola.calendula.domain.nearestTo
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
@@ -133,8 +132,6 @@ internal fun buildEventInsertValues(
|
|||||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.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() }
|
form.location.trim().takeIf { it.isNotEmpty() }
|
||||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||||
form.description.trim().takeIf { it.isNotEmpty() }
|
form.description.trim().takeIf { it.isNotEmpty() }
|
||||||
@@ -182,9 +179,6 @@ internal fun buildEventUpdateValues(
|
|||||||
if (updated.accessLevel != original.accessLevel) {
|
if (updated.accessLevel != original.accessLevel) {
|
||||||
put(CalendarContract.Events.ACCESS_LEVEL, updated.accessLevel.toProviderValue())
|
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) {
|
if (updated.colorKey != original.colorKey || updated.color != original.color) {
|
||||||
putAll(eventColorColumns(updated.colorKey, updated.color))
|
putAll(eventColorColumns(updated.colorKey, updated.color))
|
||||||
}
|
}
|
||||||
@@ -285,7 +279,6 @@ internal fun buildOccurrenceExceptionValues(
|
|||||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.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.EVENT_LOCATION, form.location.trim().ifEmpty { null })
|
||||||
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
|
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
|
||||||
if (form.colorKey == null) putAll(eventColorColumns(null, form.color))
|
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.AVAILABILITY, snapshot.availability)
|
||||||
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
|
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
|
||||||
snapshot.status?.takeIf { it != CalendarContract.Events.STATUS_CANCELED }
|
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
|
||||||
?.let { put(CalendarContract.Events.STATUS, it) }
|
|
||||||
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
|
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
|
||||||
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.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.
|
// timed rows keep the event's own zone.
|
||||||
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
|
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
|
||||||
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
|
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() }
|
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||||
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
|||||||
@@ -38,17 +38,9 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
|||||||
color = color,
|
color = color,
|
||||||
location = getString(InstanceProjection.IDX_LOCATION),
|
location = getString(InstanceProjection.IDX_LOCATION),
|
||||||
response = mapEventResponse(getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS)),
|
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"
|
* `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 —
|
* 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.CALENDAR_COLOR,
|
||||||
CalendarContract.Instances.EVENT_LOCATION,
|
CalendarContract.Instances.EVENT_LOCATION,
|
||||||
CalendarContract.Instances.SELF_ATTENDEE_STATUS,
|
CalendarContract.Instances.SELF_ATTENDEE_STATUS,
|
||||||
CalendarContract.Instances.STATUS,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_INSTANCE_ID = 0
|
const val IDX_INSTANCE_ID = 0
|
||||||
@@ -76,29 +75,6 @@ internal object InstanceProjection {
|
|||||||
const val IDX_CALENDAR_COLOR = 8
|
const val IDX_CALENDAR_COLOR = 8
|
||||||
const val IDX_LOCATION = 9
|
const val IDX_LOCATION = 9
|
||||||
const val IDX_SELF_ATTENDEE_STATUS = 10
|
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 {
|
internal object EventDetailProjection {
|
||||||
@@ -222,7 +198,6 @@ internal object SearchProjection {
|
|||||||
// Excerpted, not just filtered on: a hit has to show what it matched.
|
// Excerpted, not just filtered on: a hit has to show what it matched.
|
||||||
CalendarContract.Events.DESCRIPTION,
|
CalendarContract.Events.DESCRIPTION,
|
||||||
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||||
CalendarContract.Events.STATUS,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_ID = 0
|
const val IDX_ID = 0
|
||||||
@@ -239,7 +214,6 @@ internal object SearchProjection {
|
|||||||
const val IDX_RDATE = 11
|
const val IDX_RDATE = 11
|
||||||
const val IDX_DESCRIPTION = 12
|
const val IDX_DESCRIPTION = 12
|
||||||
const val IDX_SELF_ATTENDEE_STATUS = 13
|
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() ||
|
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
||||||
response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)),
|
response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||||
status = eventStatus(SearchProjection.IDX_STATUS),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -146,7 +146,6 @@ class SettingsPrefs @Inject constructor(
|
|||||||
val pastEventDisplay: Flow<PastEventDisplay> = prefs.flow(SettingsKeys.pastEventDisplay)
|
val pastEventDisplay: Flow<PastEventDisplay> = prefs.flow(SettingsKeys.pastEventDisplay)
|
||||||
val dimCompletedEvents: Flow<Boolean> = prefs.flow(SettingsKeys.dimCompletedEvents)
|
val dimCompletedEvents: Flow<Boolean> = prefs.flow(SettingsKeys.dimCompletedEvents)
|
||||||
val showWeekNumbers: Flow<Boolean> = prefs.flow(SettingsKeys.showWeekNumbers)
|
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 monthViewStyle: Flow<MonthViewStyle> = prefs.flow(SettingsKeys.monthViewStyle)
|
||||||
val timelineScale: Flow<TimelineScale> = prefs.flow(SettingsKeys.timelineScale)
|
val timelineScale: Flow<TimelineScale> = prefs.flow(SettingsKeys.timelineScale)
|
||||||
val multiDayCount: Flow<Int> = prefs.flow(SettingsKeys.multiDayCount)
|
val multiDayCount: Flow<Int> = prefs.flow(SettingsKeys.multiDayCount)
|
||||||
@@ -213,7 +212,6 @@ class SettingsPrefs @Inject constructor(
|
|||||||
suspend fun setPastEventDisplay(mode: PastEventDisplay) = prefs.set(SettingsKeys.pastEventDisplay, mode)
|
suspend fun setPastEventDisplay(mode: PastEventDisplay) = prefs.set(SettingsKeys.pastEventDisplay, mode)
|
||||||
suspend fun setDimCompletedEvents(enabled: Boolean) = prefs.set(SettingsKeys.dimCompletedEvents, enabled)
|
suspend fun setDimCompletedEvents(enabled: Boolean) = prefs.set(SettingsKeys.dimCompletedEvents, enabled)
|
||||||
suspend fun setShowWeekNumbers(enabled: Boolean) = prefs.set(SettingsKeys.showWeekNumbers, 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 setMonthViewStyle(style: MonthViewStyle) = prefs.set(SettingsKeys.monthViewStyle, style)
|
||||||
suspend fun setTimelineScale(scale: TimelineScale) = prefs.set(SettingsKeys.timelineScale, scale)
|
suspend fun setTimelineScale(scale: TimelineScale) = prefs.set(SettingsKeys.timelineScale, scale)
|
||||||
suspend fun setMultiDayCount(count: Int) = prefs.set(SettingsKeys.multiDayCount, count)
|
suspend fun setMultiDayCount(count: Int) = prefs.set(SettingsKeys.multiDayCount, count)
|
||||||
@@ -447,9 +445,6 @@ object SettingsKeys {
|
|||||||
*/
|
*/
|
||||||
val showWeekNumbers: Pref<Boolean> = booleanPref("show_week_numbers", false)
|
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). */
|
/** How the Month view lays itself out (#38, #53). */
|
||||||
val monthViewStyle: Pref<MonthViewStyle> = enumPref("month_view_style", MonthViewStyle.Paged)
|
val monthViewStyle: Pref<MonthViewStyle> = enumPref("month_view_style", MonthViewStyle.Paged)
|
||||||
|
|
||||||
|
|||||||
+4
-13
@@ -38,8 +38,6 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
ACTION_DISMISS -> notifier.cancel(alert)
|
ACTION_DISMISS -> notifier.cancel(alert)
|
||||||
|
|
||||||
ACTION_SNOOZE -> {
|
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;
|
// Cancel now so the notification doesn't linger until the alarm;
|
||||||
// the snooze delay read is the only async work.
|
// the snooze delay read is the only async work.
|
||||||
notifier.cancel(alert)
|
notifier.cancel(alert)
|
||||||
@@ -52,8 +50,6 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
|
|
||||||
ACTION_SHOW -> {
|
ACTION_SHOW -> {
|
||||||
goAsync {
|
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()) {
|
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
|
||||||
notifier.post(alert)
|
notifier.post(alert)
|
||||||
}
|
}
|
||||||
@@ -86,14 +82,12 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
/**
|
/**
|
||||||
* An explicit intent to this receiver carrying [alert] as extras. The
|
* An explicit intent to this receiver carrying [alert] as extras. The
|
||||||
* data URI duplicates no information but is what keeps two reminders'
|
* data URI duplicates no information but is what keeps two reminders'
|
||||||
* `PendingIntent`s apart — `filterEquals` never compares extras. The
|
* `PendingIntent`s apart — `filterEquals` never compares extras.
|
||||||
* snooze alarm is the exception: one per occurrence, so a later snooze
|
|
||||||
* replaces an earlier one and a newer reminder can cancel it.
|
|
||||||
*/
|
*/
|
||||||
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
||||||
Intent(context, ReminderActionReceiver::class.java).apply {
|
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||||
this.action = action
|
this.action = action
|
||||||
data = "calendula://reminder/${identity(alert, action)}".toUri()
|
data = "calendula://reminder/${alert.key}".toUri()
|
||||||
putExtra(EXTRA_ALERT_KEY, alert.key)
|
putExtra(EXTRA_ALERT_KEY, alert.key)
|
||||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||||
@@ -107,7 +101,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
/**
|
/**
|
||||||
* A stable request code per (alert, action), so one notification's
|
* A stable request code per (alert, action), so one notification's
|
||||||
* PendingIntents stay distinct. The shift keeps the action slot intact;
|
* 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 {
|
fun requestCode(alert: ReminderAlert, action: String): Int {
|
||||||
val actionOffset = when (action) {
|
val actionOffset = when (action) {
|
||||||
@@ -117,12 +111,9 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
ACTION_OPEN -> 4
|
ACTION_OPEN -> 4
|
||||||
else -> 0
|
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
|
|
||||||
|
|
||||||
private fun alertFrom(intent: Intent): ReminderAlert? {
|
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||||
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
|
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
|
||||||
return ReminderAlert(
|
return ReminderAlert(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder
|
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
|
* One reminder as the notification layer needs it: what to show, and the stable
|
||||||
@@ -21,13 +20,6 @@ data class ReminderAlert(
|
|||||||
val isAllDay: Boolean,
|
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(
|
fun PlannedReminder.toAlert(): ReminderAlert = ReminderAlert(
|
||||||
key = key,
|
key = key,
|
||||||
eventId = instance.eventId,
|
eventId = instance.eventId,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import android.app.NotificationManager
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
@@ -29,13 +28,13 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Posts one notification per occurrence on a dedicated channel. Tapping opens
|
* Posts one notification per due reminder on a dedicated channel. Tapping opens
|
||||||
* the event's detail screen.
|
* the event's detail screen.
|
||||||
*
|
*
|
||||||
* The tag is the [occurrenceKey], so each reminder for an occurrence replaces
|
* The tag is the reminder's stable key, so a scan that posts the same reminder
|
||||||
* the one before it in place (#463). A different reminder alerts afresh; the
|
* again — a catch-up pass overlapping the alarm that already fired — replaces
|
||||||
* same one posted again — a catch-up pass overlapping the alarm that already
|
* its notification silently ([NotificationCompat.Builder.setOnlyAlertOnce])
|
||||||
* fired — replaces its notification silently.
|
* instead of stacking a second one.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class ReminderNotifier @Inject constructor(
|
class ReminderNotifier @Inject constructor(
|
||||||
@@ -66,7 +65,7 @@ class ReminderNotifier @Inject constructor(
|
|||||||
suspend fun post(alert: ReminderAlert) {
|
suspend fun post(alert: ReminderAlert) {
|
||||||
if (isSilenced(alert.calendarId)) return
|
if (isSilenced(alert.calendarId)) return
|
||||||
ensureChannel()
|
ensureChannel()
|
||||||
val title = displayTitle(alert)
|
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
val is24Hour = settingsPrefs.timeFormat.first()
|
val is24Hour = settingsPrefs.timeFormat.first()
|
||||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||||
val zone = ZoneId.systemDefault()
|
val zone = ZoneId.systemDefault()
|
||||||
@@ -97,8 +96,7 @@ class ReminderNotifier @Inject constructor(
|
|||||||
.setCategory(NotificationCompat.CATEGORY_EVENT)
|
.setCategory(NotificationCompat.CATEGORY_EVENT)
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setOnlyAlertOnce(shownKey(alert) == alert.key)
|
.setOnlyAlertOnce(true)
|
||||||
.addExtras(Bundle().apply { putLong(EXTRA_REMINDER_KEY, alert.key) })
|
|
||||||
.setContentIntent(detailIntent(alert))
|
.setContentIntent(detailIntent(alert))
|
||||||
.addAction(
|
.addAction(
|
||||||
R.drawable.ic_notification_snooze,
|
R.drawable.ic_notification_snooze,
|
||||||
@@ -111,58 +109,20 @@ class ReminderNotifier @Inject constructor(
|
|||||||
actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
|
actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
val manager = NotificationManagerCompat.from(context)
|
|
||||||
cancelLegacy(alert)
|
|
||||||
try {
|
try {
|
||||||
manager.notify(alert.occurrenceKey.toString(), NOTIFICATION_ID, notification)
|
NotificationManagerCompat.from(context)
|
||||||
|
.notify(alert.key.toString(), NOTIFICATION_ID, notification)
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
||||||
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
||||||
* Remove [alert]'s notification (snooze re-shows it later; dismiss is final),
|
|
||||||
* unless a later reminder has replaced it — an action tapped on a stale view
|
|
||||||
* mustn't take the newer one with it.
|
|
||||||
*/
|
|
||||||
fun cancel(alert: ReminderAlert) {
|
fun cancel(alert: ReminderAlert) {
|
||||||
val manager = NotificationManagerCompat.from(context)
|
NotificationManagerCompat.from(context).cancel(alert.key.toString(), NOTIFICATION_ID)
|
||||||
if (shownKey(alert) == alert.key) manager.cancel(alert.occurrenceKey.toString(), NOTIFICATION_ID)
|
|
||||||
// Posted before reminders shared a notification, under the reminder's own key.
|
|
||||||
manager.cancel(alert.key.toString(), NOTIFICATION_ID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a different reminder for [alert]'s occurrence is on screen now. */
|
|
||||||
fun isSuperseded(alert: ReminderAlert): Boolean = shownKey(alert).let { it != null && it != alert.key }
|
|
||||||
|
|
||||||
/** The key of the reminder showing for [alert]'s occurrence, if any. */
|
|
||||||
private fun shownKey(alert: ReminderAlert): Long? {
|
|
||||||
val tag = alert.occurrenceKey.toString()
|
|
||||||
return NotificationManagerCompat.from(context).activeNotifications
|
|
||||||
.firstOrNull { it.tag == tag && it.id == NOTIFICATION_ID }
|
|
||||||
?.notification?.extras
|
|
||||||
?.takeIf { it.containsKey(EXTRA_REMINDER_KEY) }
|
|
||||||
?.getLong(EXTRA_REMINDER_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a reminder for the same occurrence posted before reminders shared a
|
|
||||||
* notification. It carries no reminder key; its time and title identify it.
|
|
||||||
*/
|
|
||||||
private fun cancelLegacy(alert: ReminderAlert) {
|
|
||||||
val manager = NotificationManagerCompat.from(context)
|
|
||||||
manager.activeNotifications
|
|
||||||
.filter { it.id == NOTIFICATION_ID && it.notification.channelId == CHANNEL_ID }
|
|
||||||
.filter { !it.notification.extras.containsKey(EXTRA_REMINDER_KEY) }
|
|
||||||
.filter { it.notification.`when` == alert.beginMillis }
|
|
||||||
.filter { it.notification.extras.getCharSequence(NotificationCompat.EXTRA_TITLE)?.toString() == displayTitle(alert) }
|
|
||||||
.forEach { manager.cancel(it.tag, it.id) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun displayTitle(alert: ReminderAlert): String =
|
|
||||||
alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
|
||||||
|
|
||||||
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
||||||
PendingIntent.getBroadcast(
|
PendingIntent.getBroadcast(
|
||||||
context,
|
context,
|
||||||
@@ -197,10 +157,8 @@ class ReminderNotifier @Inject constructor(
|
|||||||
const val TAG = "ReminderNotifier"
|
const val TAG = "ReminderNotifier"
|
||||||
const val CHANNEL_ID = "reminders"
|
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.
|
// notification, so a fixed id keeps cancellation/replacement simple.
|
||||||
const val NOTIFICATION_ID = 1
|
const val NOTIFICATION_ID = 1
|
||||||
|
|
||||||
const val EXTRA_REMINDER_KEY = "de.jeanlucmakiola.calendula.reminder_key"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,6 @@ class ReminderScanner @Inject constructor(
|
|||||||
private val source: ReminderInstanceSource,
|
private val source: ReminderInstanceSource,
|
||||||
private val calendarDataSource: CalendarDataSource,
|
private val calendarDataSource: CalendarDataSource,
|
||||||
private val notifier: ReminderNotifier,
|
private val notifier: ReminderNotifier,
|
||||||
private val snoozes: ReminderSnoozeScheduler,
|
|
||||||
private val alarms: ReminderAlarmScheduler,
|
private val alarms: ReminderAlarmScheduler,
|
||||||
private val state: ReminderStatePrefs,
|
private val state: ReminderStatePrefs,
|
||||||
private val settingsPrefs: SettingsPrefs,
|
private val settingsPrefs: SettingsPrefs,
|
||||||
@@ -105,11 +104,7 @@ class ReminderScanner @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (notifier.canPost()) {
|
if (notifier.canPost()) {
|
||||||
schedule.due.forEach {
|
schedule.due.forEach { notifier.post(it.toAlert()) }
|
||||||
val alert = it.toAlert()
|
|
||||||
snoozes.cancel(alert)
|
|
||||||
notifier.post(alert)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Advance even when nothing could be posted, so muting notifications
|
// Advance even when nothing could be posted, so muting notifications
|
||||||
// doesn't build a backlog.
|
// doesn't build a backlog.
|
||||||
|
|||||||
-12
@@ -33,16 +33,4 @@ class ReminderSnoozeScheduler @Inject constructor(
|
|||||||
)
|
)
|
||||||
alarmManager.setWakeup(triggerAtMillis, pendingIntent)
|
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
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.runsMidnightToMidnight
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -12,8 +11,6 @@ import java.time.format.FormatStyle
|
|||||||
import java.time.format.TextStyle
|
import java.time.format.TextStyle
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
import java.util.Locale
|
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
|
* 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, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end)
|
||||||
*
|
*
|
||||||
* All-day instances store UTC midnights with an exclusive end, so they are read
|
* 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
|
* in UTC and the end day is the last *covered* day (issue #308).
|
||||||
* from local midnight to local midnight reads like one too, in [zone] (#296).
|
|
||||||
*/
|
*/
|
||||||
fun reminderTimeText(
|
fun reminderTimeText(
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
@@ -51,17 +47,11 @@ fun reminderTimeText(
|
|||||||
tomorrowLabel: String,
|
tomorrowLabel: String,
|
||||||
yesterdayLabel: String,
|
yesterdayLabel: String,
|
||||||
): String {
|
): String {
|
||||||
val wholeDays = !isAllDay && runsMidnightToMidnight(
|
if (isAllDay) {
|
||||||
KInstant.fromEpochMilliseconds(beginMillis),
|
|
||||||
KInstant.fromEpochMilliseconds(endMillis),
|
|
||||||
zone.toKotlinTimeZone(),
|
|
||||||
)
|
|
||||||
if (isAllDay || wholeDays) {
|
|
||||||
val dayZone = if (isAllDay) ZoneOffset.UTC else zone
|
|
||||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
// (atZone().toLocalDate() instead of LocalDate.ofInstant — API 34+)
|
// (atZone().toLocalDate() instead of LocalDate.ofInstant — API 34+)
|
||||||
val firstDay = Instant.ofEpochMilli(beginMillis).atZone(dayZone).toLocalDate()
|
val firstDay = Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
val lastDay = Instant.ofEpochMilli(endMillis).atZone(dayZone).toLocalDate()
|
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
.minusDays(1)
|
.minusDays(1)
|
||||||
.coerceAtLeast(firstDay)
|
.coerceAtLeast(firstDay)
|
||||||
val start = relativeDayPrefix(
|
val start = relativeDayPrefix(
|
||||||
|
|||||||
@@ -1,35 +1,18 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
import kotlinx.datetime.TimeZone
|
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. */
|
/** Lower values appear first in lists and get the upper visible lanes in grids. */
|
||||||
fun EventInstance.displayPriority(zone: TimeZone): Int = when {
|
fun EventInstance.displayPriority(zone: TimeZone): Int = when {
|
||||||
isCrossedOut -> 3
|
isDeclined -> 3
|
||||||
spansMultipleDays(zone) -> 0
|
spansMultipleDays(zone) -> 0
|
||||||
coversWholeDays(zone) -> 1
|
isAllDay -> 1
|
||||||
else -> 2
|
else -> 2
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Shared order for agenda and split day lists. Grid bars add their lane geometry. */
|
||||||
* Shared order for agenda and split day lists. Grid bars add their lane geometry.
|
fun eventDisplayComparator(zone: TimeZone): Comparator<EventInstance> =
|
||||||
* Each event's priority is worked out once, not on every comparison.
|
compareBy<EventInstance> { it.displayPriority(zone) }
|
||||||
*/
|
.thenBy { it.start }
|
||||||
fun List<EventInstance>.sortedForDisplay(zone: TimeZone): List<EventInstance> =
|
.thenBy { it.title }
|
||||||
map { Triple(it, it.displayPriority(zone), it.displayStart(zone)) }
|
.thenBy { it.instanceId }
|
||||||
.sortedWith(
|
|
||||||
compareBy<Triple<EventInstance, Int, Instant>> { it.second }
|
|
||||||
.thenBy { it.third }
|
|
||||||
.thenBy { it.first.title }
|
|
||||||
.thenBy { it.first.instanceId },
|
|
||||||
)
|
|
||||||
.map { it.first }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where an event starts for ordering: a whole-day event at local midnight of its
|
|
||||||
* first day, since an all-day one is stored at UTC midnight and would otherwise
|
|
||||||
* sort around timed events by the device's offset (#296).
|
|
||||||
*/
|
|
||||||
internal fun EventInstance.displayStart(zone: TimeZone): Instant =
|
|
||||||
if (coversWholeDays(zone)) spanFirstDay(zone).atStartOfDayIn(zone) else start
|
|
||||||
@@ -42,8 +42,6 @@ data class EventForm(
|
|||||||
val reminders: List<Int> = emptyList(),
|
val reminders: List<Int> = emptyList(),
|
||||||
val availability: Availability = Availability.Busy,
|
val availability: Availability = Availability.Busy,
|
||||||
val accessLevel: AccessLevel = AccessLevel.Default,
|
val accessLevel: AccessLevel = AccessLevel.Default,
|
||||||
/** Confirmed / Tentative / Cancelled (#388). */
|
|
||||||
val status: EventStatus = EventStatus.Confirmed,
|
|
||||||
/**
|
/**
|
||||||
* Bare RRULE value (`Events.RRULE` convention, no "RRULE:" prefix); null
|
* Bare RRULE value (`Events.RRULE` convention, no "RRULE:" prefix); null
|
||||||
* means a one-off event. May hold rules the simple picker can't express —
|
* means a one-off event. May hold rules the simple picker can't express —
|
||||||
@@ -115,7 +113,6 @@ enum class EventFormField {
|
|||||||
Reminders,
|
Reminders,
|
||||||
Recurrence,
|
Recurrence,
|
||||||
Availability,
|
Availability,
|
||||||
Status,
|
|
||||||
Visibility,
|
Visibility,
|
||||||
Color,
|
Color,
|
||||||
Attendees,
|
Attendees,
|
||||||
@@ -181,7 +178,6 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
|||||||
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
||||||
availability = availability,
|
availability = availability,
|
||||||
accessLevel = accessLevel,
|
accessLevel = accessLevel,
|
||||||
status = status,
|
|
||||||
rrule = rrule.normalizedRrule(),
|
rrule = rrule.normalizedRrule(),
|
||||||
// The provider fills EVENT_COLOR from the key, so [color] is the
|
// The provider fills EVENT_COLOR from the key, so [color] is the
|
||||||
// swatch either way; a null colour means the event inherits its
|
// 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
|
* re-deriving with the same occurrence would mask an externally moved
|
||||||
* event. Guests are covered (the form writes editable attendees), so an
|
* event. Guests are covered (the form writes editable attendees), so an
|
||||||
* external attendee change now also trips the conflict check. Still not
|
* 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.
|
* organizer/resource rows, and a recurring event's duration.
|
||||||
*/
|
*/
|
||||||
data class EditSnapshot(
|
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. */
|
/** The bare RRULE value the form edits; blank means none. */
|
||||||
internal fun String?.normalizedRrule(): String? = this?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }
|
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
|
* 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
|
* 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 (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
||||||
if (rrule != null) add(EventFormField.Recurrence)
|
if (rrule != null) add(EventFormField.Recurrence)
|
||||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||||
if (status != EventStatus.Confirmed) add(EventFormField.Status)
|
|
||||||
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
||||||
if (colorKey != null || color != null) add(EventFormField.Color)
|
if (colorKey != null || color != null) add(EventFormField.Color)
|
||||||
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
|
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
|
||||||
@@ -332,5 +311,4 @@ fun EventDetail.toDuplicateForm(canModify: Boolean, zone: TimeZone): EventForm =
|
|||||||
).copy(
|
).copy(
|
||||||
rrule = null,
|
rrule = null,
|
||||||
calendarId = instance.calendarId.takeIf { canModify },
|
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))
|
return copy(start = movedStart, end = (startAt + span).toFormTime(zone))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The form with new start and end, each in the form's own zone — a resize. All-day is untouched. */
|
||||||
* The form with new start and end, each in the form's own zone — a resize. An
|
|
||||||
* all-day event becomes timed: one dragged out of the all-day strip.
|
|
||||||
*/
|
|
||||||
fun EventForm.resizedTo(newStart: Instant, newEnd: Instant, deviceZone: TimeZone): EventForm {
|
fun EventForm.resizedTo(newStart: Instant, newEnd: Instant, deviceZone: TimeZone): EventForm {
|
||||||
val timed = copy(isAllDay = false)
|
if (isAllDay) return this
|
||||||
return timed.withTimes(newStart, newEnd, timed.resolvedZone(deviceZone))
|
return withTimes(newStart, newEnd, resolvedZone(deviceZone))
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@ package de.jeanlucmakiola.calendula.domain
|
|||||||
|
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -73,8 +72,6 @@ data class EventInstance(
|
|||||||
* (#180, #230).
|
* (#180, #230).
|
||||||
*/
|
*/
|
||||||
val response: EventResponse = EventResponse.Going,
|
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,
|
Declined,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Shorthand for the declined case, which most surfaces test on its own (#180). */
|
||||||
* Struck through on every surface and sorted last: an invitation you declined
|
val EventInstance.isDeclined: Boolean get() = response == EventResponse.Declined
|
||||||
* (#180), or an event its organizer cancelled (#388). Neither plans reminders.
|
|
||||||
*/
|
|
||||||
val EventInstance.isCrossedOut: Boolean
|
|
||||||
get() = response == EventResponse.Declined || status == EventStatus.Cancelled
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drawn as an outline, still undecided: an invitation you have not answered
|
|
||||||
* (#230), or a tentative event (#388).
|
|
||||||
*/
|
|
||||||
val EventInstance.isOutlined: Boolean
|
|
||||||
get() = !isCrossedOut && (response == EventResponse.Invited || status == EventStatus.Tentative)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this event has finished relative to [now] — its end is at or before
|
* 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)
|
* 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
|
* 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
|
* so they only count as ended once their day is fully over.
|
||||||
* as ended once their local day is fully over.
|
|
||||||
*/
|
*/
|
||||||
fun EventInstance.hasEnded(now: Instant, zone: TimeZone = TimeZone.currentSystemDefault()): Boolean =
|
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
||||||
localEnd(zone) <= now
|
|
||||||
|
|
||||||
/** [EventInstance.end], with an all-day event's UTC midnight moved to local midnight in [zone]. */
|
|
||||||
fun EventInstance.localEnd(zone: TimeZone): Instant =
|
|
||||||
if (isAllDay) end.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone) else end
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The zone this event's calendar dates live in: the device [zone] for timed
|
* 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 =
|
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
|
||||||
spanFirstDay(zone) != spanLastDay(zone)
|
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(
|
data class EventDetail(
|
||||||
val instance: EventInstance,
|
val instance: EventInstance,
|
||||||
val description: String?,
|
val description: String?,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package de.jeanlucmakiola.calendula.domain.ics
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.allDayFormTimes
|
import de.jeanlucmakiola.calendula.domain.allDayFormTimes
|
||||||
import de.jeanlucmakiola.calendula.domain.forNewEvent
|
|
||||||
import de.jeanlucmakiola.calendula.domain.normalizedRrule
|
import de.jeanlucmakiola.calendula.domain.normalizedRrule
|
||||||
import de.jeanlucmakiola.calendula.domain.toFormTime
|
import de.jeanlucmakiola.calendula.domain.toFormTime
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
@@ -36,7 +35,6 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
|||||||
description = description.orEmpty(),
|
description = description.orEmpty(),
|
||||||
reminders = semanticReminderMinutes(),
|
reminders = semanticReminderMinutes(),
|
||||||
availability = availability,
|
availability = availability,
|
||||||
status = status.forNewEvent(),
|
|
||||||
rrule = recurrenceRule.normalizedRrule(),
|
rrule = recurrenceRule.normalizedRrule(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -32,26 +32,19 @@ data class PlannedReminder(
|
|||||||
val minutes: Int,
|
val minutes: Int,
|
||||||
val alarmMillis: Long,
|
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.
|
* `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. */
|
private companion object {
|
||||||
fun occurrenceKey(eventId: Long, beginMillis: Long): Long =
|
fun key(eventId: Long, beginMillis: Long, minutes: Int): Long {
|
||||||
mix64(mix64(eventId) + beginMillis)
|
var h = eventId * 1_000_003L
|
||||||
|
h = (h xor beginMillis) * 31L
|
||||||
/** SplitMix64's finaliser: spreads close inputs across all 64 bits. */
|
return h + minutes
|
||||||
private fun mix64(value: Long): Long {
|
}
|
||||||
var z = value + -0x61c8864680b583ebL
|
}
|
||||||
z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L
|
|
||||||
z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L
|
|
||||||
return z xor (z ushr 31)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What one scan concluded: post these now, and wake up again at [nextAlarmMillis]. */
|
/** 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
|
* 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
|
* 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
|
* whose event has ended are dropped ([isStillRelevant]). [nextAlarmMillis] is
|
||||||
* one occurrence only the latest is kept — they'd replace each other anyway (#463). [nextAlarmMillis] is
|
|
||||||
* capped at [horizonMillis] so the lookahead window keeps rolling forward.
|
* capped at [horizonMillis] so the lookahead window keeps rolling forward.
|
||||||
*/
|
*/
|
||||||
fun scheduleReminders(
|
fun scheduleReminders(
|
||||||
@@ -166,8 +158,7 @@ fun scheduleReminders(
|
|||||||
val due = planned
|
val due = planned
|
||||||
.filter { it.alarmMillis in (lastFiredMillis + 1)..nowMillis }
|
.filter { it.alarmMillis in (lastFiredMillis + 1)..nowMillis }
|
||||||
.filter { it.instance.isStillRelevant(nowMillis) }
|
.filter { it.instance.isStillRelevant(nowMillis) }
|
||||||
.groupBy { it.occurrenceKey }
|
.distinctBy { it.key }
|
||||||
.values.map { siblings -> siblings.maxBy { it.alarmMillis } }
|
|
||||||
.sortedWith(compareBy({ it.instance.beginMillis }, { it.key }))
|
.sortedWith(compareBy({ it.instance.beginMillis }, { it.key }))
|
||||||
val nextPending = planned
|
val nextPending = planned
|
||||||
.filter { it.alarmMillis > nowMillis }
|
.filter { it.alarmMillis > nowMillis }
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
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.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
|
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
|
||||||
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
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
|
// split style can list a day with exactly the same visual language instead of
|
||||||
// growing a parallel set of event rows.
|
// 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
|
@Composable
|
||||||
internal fun AgendaDayHeader(
|
internal fun AgendaDayHeader(
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
compact: Boolean = false,
|
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
@@ -84,12 +79,7 @@ internal fun AgendaDayHeader(
|
|||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
},
|
},
|
||||||
modifier = Modifier.padding(
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||||
start = 28.dp,
|
|
||||||
end = 28.dp,
|
|
||||||
top = if (compact) 8.dp else 16.dp,
|
|
||||||
bottom = if (compact) 4.dp else 8.dp,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,7 +138,7 @@ internal fun AgendaEventRow(
|
|||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||||
title = declinedTitle(title, event.isCrossedOut),
|
title = declinedTitle(title, event.isDeclined),
|
||||||
summary = AnnotatedString(agendaTimeSummary(event, day, zone)),
|
summary = AnnotatedString(agendaTimeSummary(event, day, zone)),
|
||||||
position = position,
|
position = position,
|
||||||
minHeight = 64.dp,
|
minHeight = 64.dp,
|
||||||
@@ -230,11 +220,10 @@ internal fun LazyListScope.agendaDays(
|
|||||||
dimmed: (EventInstance, LocalDate) -> Boolean,
|
dimmed: (EventInstance, LocalDate) -> Boolean,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
compact: Boolean = false,
|
|
||||||
) {
|
) {
|
||||||
days.forEach { day ->
|
days.forEach { day ->
|
||||||
stickyHeader(key = "header-${day.date}") {
|
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()) {
|
if (day.events.isEmpty()) {
|
||||||
item(key = "placeholder-${day.date}") {
|
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
|
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.PublishChrome
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
|
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
|
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
|
||||||
@@ -433,7 +432,7 @@ private fun AgendaList(
|
|||||||
modifier = modifier.nestedScroll(loadOnScrollUp),
|
modifier = modifier.nestedScroll(loadOnScrollUp),
|
||||||
state = listState,
|
state = listState,
|
||||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
// 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(
|
agendaDays(
|
||||||
days = days,
|
days = days,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.agenda
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||||
import de.jeanlucmakiola.calendula.domain.sortedForDisplay
|
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||||
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
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.
|
* surface only formats the instants into its own locale/24h string.
|
||||||
*/
|
*/
|
||||||
sealed interface AgendaTimeLabel {
|
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
|
data object AllDay : AgendaTimeLabel
|
||||||
/** The first day of a multi-day timed event: when it begins. */
|
/** The first day of a multi-day timed event: when it begins. */
|
||||||
data class Starts(val start: Instant) : AgendaTimeLabel
|
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]. */
|
/** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */
|
||||||
fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel {
|
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 firstDay = event.spanFirstDay(zone)
|
||||||
val lastDay = event.spanLastDay(zone)
|
val lastDay = event.spanLastDay(zone)
|
||||||
return when {
|
return when {
|
||||||
@@ -88,7 +87,7 @@ fun groupAgendaDays(
|
|||||||
return byDay.map { (date, dayEvents) ->
|
return byDay.map { (date, dayEvents) ->
|
||||||
AgendaDay(
|
AgendaDay(
|
||||||
date = date,
|
date = date,
|
||||||
events = dayEvents.sortedForDisplay(zone),
|
events = dayEvents.sortedWith(eventDisplayComparator(zone)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,7 @@ fun CalendarDatePickerDialog(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val state = rememberDatePickerState(
|
val state = rememberDatePickerState(
|
||||||
initialSelectedDateMillis = initial.coerceIn(CalendarSpan.FIRST_DAY, CalendarSpan.LAST_DAY)
|
initialSelectedDateMillis = initial.toEpochDays() * MILLIS_PER_DAY,
|
||||||
.toEpochDays() * MILLIS_PER_DAY,
|
|
||||||
yearRange = CalendarSpan.years,
|
|
||||||
)
|
)
|
||||||
DatePickerDialog(
|
DatePickerDialog(
|
||||||
onDismissRequest = onDismiss,
|
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). */
|
/** The standard FAB's size, shared by the selection bar that unrolls from it (#141). */
|
||||||
val FabSize = 56.dp
|
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. */
|
/** What the FAB is while an event is selected (#141): the toggle for its action bar. */
|
||||||
@Stable
|
@Stable
|
||||||
class FabSelection(
|
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
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ import androidx.compose.material.icons.filled.People
|
|||||||
import androidx.compose.material.icons.filled.Place
|
import androidx.compose.material.icons.filled.Place
|
||||||
import androidx.compose.material.icons.filled.Public
|
import androidx.compose.material.icons.filled.Public
|
||||||
import androidx.compose.material.icons.filled.Repeat
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
import androidx.compose.material.icons.filled.TaskAlt
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
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.Reminders -> R.string.event_detail_reminders
|
||||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||||
EventFormField.Availability -> R.string.event_edit_availability
|
EventFormField.Availability -> R.string.event_edit_availability
|
||||||
EventFormField.Status -> R.string.event_edit_status
|
|
||||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||||
EventFormField.Color -> R.string.event_edit_color
|
EventFormField.Color -> R.string.event_edit_color
|
||||||
EventFormField.Attendees -> R.string.event_edit_attendees
|
EventFormField.Attendees -> R.string.event_edit_attendees
|
||||||
@@ -43,7 +41,6 @@ fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
|
|||||||
EventFormField.Reminders -> Icons.Default.Notifications
|
EventFormField.Reminders -> Icons.Default.Notifications
|
||||||
EventFormField.Recurrence -> Icons.Default.Repeat
|
EventFormField.Recurrence -> Icons.Default.Repeat
|
||||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||||
EventFormField.Status -> Icons.Default.TaskAlt
|
|
||||||
EventFormField.Visibility -> Icons.Default.Lock
|
EventFormField.Visibility -> Icons.Default.Lock
|
||||||
EventFormField.Color -> Icons.Default.Palette
|
EventFormField.Color -> Icons.Default.Palette
|
||||||
EventFormField.Attendees -> Icons.Default.People
|
EventFormField.Attendees -> Icons.Default.People
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ fun EventMoveHost(
|
|||||||
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
|
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
|
||||||
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
|
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
|
||||||
MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end)
|
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)
|
MoveOutcome.Failed -> stringResource(R.string.event_move_failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ import androidx.compose.ui.text.style.TextDecoration
|
|||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.isCrossedOut
|
import de.jeanlucmakiola.calendula.domain.EventResponse
|
||||||
import de.jeanlucmakiola.calendula.domain.isOutlined
|
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||||
|
|
||||||
/** Stroke an invitation you have not answered is outlined with. */
|
/** Stroke an invitation you have not answered is outlined with. */
|
||||||
val EVENT_OUTLINE_WIDTH = 1.dp
|
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
|
* 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
|
* An invitation you have not answered is drawn as an outline: its calendar's
|
||||||
* outline: its calendar's colour on the border and on the title, over the
|
* colour on the border and on the title, over the surface the chip sits on. So
|
||||||
* surface the chip sits on. So it holds a chip's shape and a chip's weight
|
* it holds a chip's shape and a chip's weight without claiming the filled
|
||||||
* without claiming the filled container an event you are going to gets. A
|
* container an event you are going to gets. A declined one keeps the fill and
|
||||||
* declined or cancelled one keeps the fill and its strike-through.
|
* its strike-through.
|
||||||
*/
|
*/
|
||||||
@Immutable
|
@Immutable
|
||||||
data class EventPaint(
|
data class EventPaint(
|
||||||
@@ -87,7 +87,7 @@ data class EventPaint(
|
|||||||
@Composable
|
@Composable
|
||||||
fun eventPaint(event: EventInstance, dark: Boolean): EventPaint {
|
fun eventPaint(event: EventInstance, dark: Boolean): EventPaint {
|
||||||
val soften = LocalSoftenColors.current
|
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
|
// Harmonised even when the setting is off. Raw mode exists so a filled
|
||||||
// container matches what the sync source paints, and an outlined chip
|
// container matches what the sync source paints, and an outlined chip
|
||||||
// has no container — what it has is coloured *text*, which needs the
|
// 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,
|
outline = null,
|
||||||
titleInk = eventInk(fill, alpha = TITLE_INK_ALPHA),
|
titleInk = eventInk(fill, alpha = TITLE_INK_ALPHA),
|
||||||
secondaryInk = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
secondaryInk = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||||
decoration = declinedDecoration(event.isCrossedOut),
|
decoration = declinedDecoration(event.isDeclined),
|
||||||
titleWeight = null,
|
titleWeight = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ package de.jeanlucmakiola.calendula.ui.common
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
|
||||||
@@ -42,17 +45,33 @@ fun inlineTimeLabel(time: String?, title: String, timeInk: Color): AnnotatedStri
|
|||||||
append(title)
|
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. */
|
/** 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_HOUR = 12
|
||||||
private const val SAMPLE_MINUTE = 45
|
private const val SAMPLE_MINUTE = 45
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The widest time the current convention writes, for pricing a time prefix by
|
* The narrowest run of [style] text that may carry a time in front of a title:
|
||||||
* measurement: it follows the 12/24-hour setting and the locale's own format.
|
* 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
|
@Composable
|
||||||
fun rememberWidestTimeSample(): String {
|
fun rememberInlineTimeWidth(style: TextStyle): Dp {
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
val density = LocalDensity.current
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val is24Hour = LocalUse24HourFormat.current
|
val sample = formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, LocalUse24HourFormat.current, locale)
|
||||||
return remember(is24Hour, locale) { formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, is24Hour, 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
|
* 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
|
* 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
|
* 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]
|
* row the block would land on.
|
||||||
* the day columns do, or the two drift apart at the bottom of the scroll.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun HourGutter(
|
fun HourGutter(
|
||||||
@@ -87,8 +86,7 @@ fun HourGutter(
|
|||||||
.width(GUTTER_WIDTH)
|
.width(GUTTER_WIDTH)
|
||||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState),
|
||||||
.padding(bottom = FabClearance),
|
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
(0 until 24).forEach { h ->
|
(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. */
|
/** Which edge of the viewport a hidden event sits past. */
|
||||||
enum class ViewportEdge { Top, Bottom }
|
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]
|
* 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
|
* (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(
|
fun outOfViewEvents(
|
||||||
blocks: List<TimedBlock>,
|
blocks: List<TimedBlock>,
|
||||||
topMin: Float,
|
topMin: Float,
|
||||||
bottomMin: Float,
|
bottomMin: Float,
|
||||||
): OutOfViewEvents {
|
): OutOfViewEvents {
|
||||||
val spans = blocks.map { it to it.renderedSpan() }
|
val above = blocks
|
||||||
val above = spans
|
.filter { renderedEndMin(it) <= topMin }
|
||||||
.filter { (_, span) -> span.bottomMin <= topMin }
|
.maxWithOrNull(compareBy<TimedBlock>({ renderedEndMin(it) }, { it.startMin }))
|
||||||
.maxWithOrNull(compareBy({ it.second.bottomMin }, { it.first.startMin }))
|
val below = blocks
|
||||||
val below = spans
|
.filter { it.startMin >= bottomMin }
|
||||||
.filter { (_, span) -> span.topMin >= bottomMin }
|
.minWithOrNull(compareBy<TimedBlock>({ it.startMin }, { -renderedEndMin(it) }))
|
||||||
.minWithOrNull(compareBy({ it.second.topMin }, { -it.second.bottomMin }))
|
return OutOfViewEvents(above, below)
|
||||||
return OutOfViewEvents(above?.first, below?.first)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,9 +107,8 @@ fun revealScrollTarget(
|
|||||||
marginPx: Float,
|
marginPx: Float,
|
||||||
maxScroll: Int,
|
maxScroll: Int,
|
||||||
): Int {
|
): Int {
|
||||||
val span = block.renderedSpan()
|
val startPx = block.startMin / 60f * hourPx
|
||||||
val startPx = span.topMin / 60f * hourPx
|
val endPx = renderedEndMin(block) / 60f * hourPx
|
||||||
val endPx = span.bottomMin / 60f * hourPx
|
|
||||||
val target = when (edge) {
|
val target = when (edge) {
|
||||||
ViewportEdge.Top -> startPx - marginPx
|
ViewportEdge.Top -> startPx - marginPx
|
||||||
ViewportEdge.Bottom -> minOf(endPx + marginPx - viewportPx, startPx - marginPx)
|
ViewportEdge.Bottom -> minOf(endPx + marginPx - viewportPx, startPx - marginPx)
|
||||||
@@ -291,7 +293,6 @@ private fun EdgeBar(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
block ?: return
|
block ?: return
|
||||||
val span = remember(block) { block.renderedSpan() }
|
|
||||||
val color = eventFill(block.event.color, dark, LocalSoftenColors.current)
|
val color = eventFill(block.event.color, dark, LocalSoftenColors.current)
|
||||||
val label = stringResource(
|
val label = stringResource(
|
||||||
if (edge == ViewportEdge.Top) R.string.timeline_earlier_events else R.string.timeline_later_events,
|
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)
|
.height(EDGE_TOUCH_HEIGHT)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
val gapPx = when (edge) {
|
val gapPx = when (edge) {
|
||||||
ViewportEdge.Top -> scrollState.value - span.bottomMin / 60f * hourPx
|
ViewportEdge.Top -> scrollState.value - renderedEndMin(block) / 60f * hourPx
|
||||||
ViewportEdge.Bottom -> span.topMin / 60f * hourPx -
|
ViewportEdge.Bottom -> block.startMin / 60f * hourPx -
|
||||||
(scrollState.value + scrollState.viewportSize)
|
(scrollState.value + scrollState.viewportSize)
|
||||||
}
|
}
|
||||||
alpha = edgeBarAlpha(gapPx, fadeLeadPx)
|
alpha = edgeBarAlpha(gapPx, fadeLeadPx)
|
||||||
|
|||||||
@@ -1,26 +1,22 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.FlowCollector
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
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
|
* 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
|
* screen's anchor read the same query, and the pages either side stay loaded
|
||||||
* across a swipe.
|
* across a swipe.
|
||||||
*
|
*
|
||||||
* An entry queries only while something collects it, stopping [STOP_TIMEOUT_MILLIS]
|
* Every entry shares on its own job, cancelled when [keep] lets it go: a
|
||||||
* after the last collector leaves, and starts again when one comes back. Letting
|
* `stateIn` on the ViewModel's scope would stay running, holding its last state,
|
||||||
* an entry go only forgets it: a page still holding it keeps a working state
|
* until the ViewModel itself was cleared. Main thread only.
|
||||||
* however far the pager composes or prefetches, and however long the screen sat
|
|
||||||
* stopped (#440, #454). A forgotten entry nobody collects holds no coroutine, so
|
|
||||||
* paging through a year doesn't keep a year of queries around. Main thread only.
|
|
||||||
*
|
*
|
||||||
* @param keep whether a cached key is still worth holding once [get] asks for another.
|
* @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 keep: (cached: K, requested: K) -> Boolean,
|
||||||
private val load: (K) -> Flow<S>,
|
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. */
|
/** The number of entries held, for tests. */
|
||||||
internal val size: Int get() = entries.size
|
internal val size: Int get() = entries.size
|
||||||
|
|
||||||
fun get(key: K): StateFlow<S> {
|
fun get(key: K): StateFlow<S> {
|
||||||
// One still collected stays, so the anchor asking for it again shares its query.
|
val iterator = entries.iterator()
|
||||||
entries.entries.removeAll { (cached, entry) ->
|
while (iterator.hasNext()) {
|
||||||
cached != key && !keep(cached, key) && !entry.isCollected
|
val (cached, entry) = iterator.next()
|
||||||
}
|
if (cached != key && !keep(cached, key)) {
|
||||||
return entries.getOrPut(key) { Entry(key) }
|
entry.job.cancel()
|
||||||
}
|
iterator.remove()
|
||||||
|
|
||||||
@OptIn(ExperimentalForInheritanceCoroutinesApi::class)
|
|
||||||
private inner class Entry(private val key: K) : StateFlow<S> {
|
|
||||||
private val state = MutableStateFlow(initial)
|
|
||||||
private var collectors = 0
|
|
||||||
private var loading: Job? = null
|
|
||||||
private var stopping: Job? = null
|
|
||||||
|
|
||||||
val isCollected: Boolean get() = collectors > 0
|
|
||||||
|
|
||||||
override val value: S get() = state.value
|
|
||||||
override val replayCache: List<S> get() = state.replayCache
|
|
||||||
|
|
||||||
override suspend fun collect(collector: FlowCollector<S>): Nothing {
|
|
||||||
collectors++
|
|
||||||
stopping?.cancel()
|
|
||||||
stopping = null
|
|
||||||
if (loading?.isActive != true) {
|
|
||||||
loading = scope.launch { load(key).collect { state.value = it } }
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
state.collect(collector)
|
|
||||||
} finally {
|
|
||||||
if (--collectors == 0) {
|
|
||||||
stopping = scope.launch {
|
|
||||||
delay(STOP_TIMEOUT_MILLIS)
|
|
||||||
loading?.cancel()
|
|
||||||
loading = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return entries.getOrPut(key) {
|
||||||
|
val job = SupervisorJob(scope.coroutineContext[Job])
|
||||||
private companion object {
|
Entry(
|
||||||
/** Long enough to ride out a configuration change, as `stateWhileSubscribed` does. */
|
state = load(key).stateWhileSubscribed(scope + job, initial),
|
||||||
const val STOP_TIMEOUT_MILLIS = 5_000L
|
job = job,
|
||||||
|
)
|
||||||
|
}.state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ fun EventInstance.isPastOn(
|
|||||||
day: LocalDate,
|
day: LocalDate,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
zone: TimeZone = TimeZone.currentSystemDefault(),
|
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. */
|
/** Opacity applied to a completed/past event chip when it is dimmed. */
|
||||||
const val EventDimAlpha = 0.4f
|
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
|
* is still something the organiser expects you at. Null for everything else, so
|
||||||
* it drops straight into a `Text`'s `textDecoration`.
|
* it drops straight into a `Text`'s `textDecoration`.
|
||||||
*/
|
*/
|
||||||
fun declinedDecoration(isCrossedOut: Boolean): TextDecoration? =
|
fun declinedDecoration(isDeclined: Boolean): TextDecoration? =
|
||||||
if (isCrossedOut) TextDecoration.LineThrough else null
|
if (isDeclined) TextDecoration.LineThrough else null
|
||||||
|
|
||||||
/** [declinedDecoration] for the rows that take styled text rather than a `String`. */
|
/** [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))
|
AnnotatedString(title, SpanStyle(textDecoration = TextDecoration.LineThrough))
|
||||||
} else {
|
} else {
|
||||||
AnnotatedString(title)
|
AnnotatedString(title)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** [declinedTitle] over already-styled text, e.g. a search hit's marked runs. */
|
/** [declinedTitle] over already-styled text, e.g. a search hit's marked runs. */
|
||||||
fun declinedTitle(title: AnnotatedString, isCrossedOut: Boolean): AnnotatedString =
|
fun declinedTitle(title: AnnotatedString, isDeclined: Boolean): AnnotatedString =
|
||||||
if (!isCrossedOut) {
|
if (!isDeclined) {
|
||||||
title
|
title
|
||||||
} else {
|
} else {
|
||||||
buildAnnotatedString {
|
buildAnnotatedString {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||||
import de.jeanlucmakiola.calendula.domain.canSaveApartFromSeries
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
@@ -58,10 +57,7 @@ sealed interface MoveTarget {
|
|||||||
*/
|
*/
|
||||||
data class ByDays(val days: Int) : MoveTarget
|
data class ByDays(val days: Int) : MoveTarget
|
||||||
|
|
||||||
/**
|
/** New start and end instants — a timeline resize, which changes the length. */
|
||||||
* New start and end instants — a timeline resize, which changes the length,
|
|
||||||
* or an all-day event dragged into the time grid, which becomes timed.
|
|
||||||
*/
|
|
||||||
data class Span(val start: Instant, val end: Instant) : MoveTarget
|
data class Span(val start: Instant, val end: Instant) : MoveTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,12 +114,6 @@ sealed interface MoveOutcome {
|
|||||||
/** The drop would push the series past its own `UNTIL`, generating nothing. */
|
/** The drop would push the series past its own `UNTIL`, generating nothing. */
|
||||||
data object BlockedSeriesEnd : MoveOutcome
|
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
|
data object Failed : MoveOutcome
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,10 +203,7 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
val canRealign: Boolean,
|
val canRealign: Boolean,
|
||||||
/** The series row's DTSTART date after the move — what its `UNTIL` must clear. */
|
/** The series row's DTSTART date after the move — what its `UNTIL` must clear. */
|
||||||
val newAnchorDate: LocalDate,
|
val newAnchorDate: LocalDate,
|
||||||
) {
|
)
|
||||||
/** A timed event's new length, rather than an all-day one dragged into the grid. */
|
|
||||||
val isResize: Boolean get() = request.target is MoveTarget.Span && !original.isAllDay
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Take a drop, unless one is already being written. False means nothing will
|
* Take a drop, unless one is already being written. False means nothing will
|
||||||
@@ -240,21 +227,15 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
busy = false
|
busy = false
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
// A cancelled series only moves as a whole (#388).
|
if (!prepared.isRecurring) {
|
||||||
val seriesOnly = !prepared.original.canSaveApartFromSeries()
|
|
||||||
if (!prepared.isRecurring || (seriesOnly && prepared.canRealign)) {
|
|
||||||
write(prepared, RecurringWriteScope.AllEvents)
|
write(prepared, RecurringWriteScope.AllEvents)
|
||||||
busy = false
|
busy = false
|
||||||
} else if (seriesOnly) {
|
|
||||||
_outcome.value = MoveOutcome.BlockedCancelledSeries
|
|
||||||
_abandoned.value += 1
|
|
||||||
busy = false
|
|
||||||
} else {
|
} else {
|
||||||
// Still busy: the scope dialog is now the thing in flight.
|
// Still busy: the scope dialog is now the thing in flight.
|
||||||
pending = prepared
|
pending = prepared
|
||||||
_scopePrompt.value = MoveScopePrompt(
|
_scopePrompt.value = MoveScopePrompt(
|
||||||
occurrenceOnly = !prepared.canRealign,
|
occurrenceOnly = !prepared.canRealign,
|
||||||
resize = prepared.isResize,
|
resize = request.target is MoveTarget.Span,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -478,9 +459,9 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
val anchorZone = if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone)
|
val anchorZone = if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone)
|
||||||
val anchorMinute = detail.instance.start.toLocalDateTime(anchorZone)
|
val anchorMinute = detail.instance.start.toLocalDateTime(anchorZone)
|
||||||
.time.toMillisecondOfDay() / MILLIS_PER_MINUTE
|
.time.toMillisecondOfDay() / MILLIS_PER_MINUTE
|
||||||
// An all-day form's time is a placeholder; its anchor sits at midnight.
|
val delta = (
|
||||||
val fromMillis = if (original.isAllDay) 0 else original.start.time.toMillisecondOfDay()
|
shifted.start.time.toMillisecondOfDay() - original.start.time.toMillisecondOfDay()
|
||||||
val delta = (shifted.start.time.toMillisecondOfDay() - fromMillis) / MILLIS_PER_MINUTE
|
) / MILLIS_PER_MINUTE
|
||||||
return anchorMinute + delta in 0 until MINUTES_PER_DAY
|
return anchorMinute + delta in 0 until MINUTES_PER_DAY
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,7 +494,7 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
|
|
||||||
/** Where a resized occurrence now ends; null for a move, which keeps its length. */
|
/** Where a resized occurrence now ends; null for a move, which keeps its length. */
|
||||||
private fun resizedEndMillis(prepared: PreparedMove): Long? {
|
private fun resizedEndMillis(prepared: PreparedMove): Long? {
|
||||||
if (!prepared.isResize) return null
|
if (prepared.request.target !is MoveTarget.Span) return null
|
||||||
val form = prepared.updated
|
val form = prepared.updated
|
||||||
return form.endInstant(form.resolvedZone(zoneSource.current())).toEpochMilliseconds()
|
return form.endInstant(form.resolvedZone(zoneSource.current())).toEpochMilliseconds()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,9 +78,6 @@ import kotlin.time.Instant
|
|||||||
/** Start granularity a dragged block snaps to, matching the big calendar apps. */
|
/** Start granularity a dragged block snaps to, matching the big calendar apps. */
|
||||||
const val DRAG_SNAP_MINUTES: Int = 15
|
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. */
|
/** How close to a timeline edge the finger must get before the view scrolls. */
|
||||||
private val AUTO_SCROLL_EDGE = 64.dp
|
private val AUTO_SCROLL_EDGE = 64.dp
|
||||||
|
|
||||||
@@ -129,21 +126,6 @@ data class TimelineDrag(
|
|||||||
val slot: Triple<LocalDate, Int, Int> get() = Triple(date, startMin, eventSpanMin)
|
val slot: Triple<LocalDate, Int, Int> get() = Triple(date, startMin, eventSpanMin)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* An event lifted off the all-day strip: held over the strip it moves [dayShift]
|
|
||||||
* days and stays whole-day, held over the time grid it becomes a timed block.
|
|
||||||
*/
|
|
||||||
data class StripDrag(val event: EventInstance, val dayShift: Int = 0, val overGrid: Boolean = false)
|
|
||||||
|
|
||||||
/** Where a strip drag asks its event to go. */
|
|
||||||
sealed interface StripDrop {
|
|
||||||
val event: EventInstance
|
|
||||||
|
|
||||||
data class Days(override val event: EventInstance, val days: Int) : StripDrop
|
|
||||||
|
|
||||||
data class Timed(override val event: EventInstance, val start: Instant, val end: Instant) : StripDrop
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The event a long press left selected (#141). */
|
/** The event a long press left selected (#141). */
|
||||||
data class TimelineSelection(
|
data class TimelineSelection(
|
||||||
val event: EventInstance,
|
val event: EventInstance,
|
||||||
@@ -267,10 +249,9 @@ internal fun dragPieceBounds(
|
|||||||
): DragPieceBounds {
|
): DragPieceBounds {
|
||||||
val top = if (slice.continuesBefore) 0f else insetPx
|
val top = if (slice.continuesBefore) 0f else insetPx
|
||||||
val bottom = if (slice.continuesAfter) 0f else insetPx
|
val bottom = if (slice.continuesAfter) 0f else insetPx
|
||||||
val span = renderedSpan(slice.startMin, slice.startMin + slice.spanMin, slice.continuesAfter)
|
val full = maxOf(slice.spanMin / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
|
||||||
val full = (span.bottomMin - span.topMin) / 60f * hourPx
|
|
||||||
return DragPieceBounds(
|
return DragPieceBounds(
|
||||||
top = span.topMin / 60f * hourPx + top,
|
top = slice.startMin / 60f * hourPx + top,
|
||||||
height = (full - top - bottom).coerceAtLeast(0f),
|
height = (full - top - bottom).coerceAtLeast(0f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -328,13 +309,12 @@ internal fun draftStartMin(minutes: Float): Int =
|
|||||||
internal fun draftBlock(draft: EventInstance, date: LocalDate, zone: TimeZone): TimedBlock? {
|
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).
|
// 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.
|
// 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 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
|
if (end <= start) return null
|
||||||
val startMin = wallClockMinutes(start, date, zone)
|
val startMin = wallClockMinutes(start, date, zone)
|
||||||
val endMin = wallClockEndMinutes(startMin, start, end, date, zone).coerceAtMost(MINUTES_PER_DAY)
|
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). */
|
/** Which end of an event a resize handle moves (#141). */
|
||||||
@@ -649,28 +629,6 @@ class TimelineDragController {
|
|||||||
/** The resize in progress, or null for a plain drag (#141). */
|
/** The resize in progress, or null for a plain drag (#141). */
|
||||||
private var resize: ResizeSession? = null
|
private var resize: ResizeSession? = null
|
||||||
|
|
||||||
/** The strip drag in progress; over the grid it drives [drag] through a timed stand-in. */
|
|
||||||
var stripDrag: StripDrag? by mutableStateOf(null)
|
|
||||||
private set
|
|
||||||
|
|
||||||
/** A strip drop held at its new days until the strip redraws the event there. */
|
|
||||||
var stripSettling: StripDrag? by mutableStateOf(null)
|
|
||||||
private set
|
|
||||||
|
|
||||||
/** Whether the strip now draws [stripSettling] at its new days itself. */
|
|
||||||
var stripSeated: Boolean by mutableStateOf(false)
|
|
||||||
private set
|
|
||||||
|
|
||||||
/** What [abandonedTicks] stood at when [stripSettling] was let go. */
|
|
||||||
var stripAbandonedMark: Int = 0
|
|
||||||
private set
|
|
||||||
|
|
||||||
/** The shown day the finger picked the strip bar up on. */
|
|
||||||
private var stripGrabIndex = 0
|
|
||||||
|
|
||||||
/** Whether a strip drag over the grid has cleared its top edge, and may now scroll up. */
|
|
||||||
private var stripScrollArmed = false
|
|
||||||
|
|
||||||
fun begin(
|
fun begin(
|
||||||
block: TimedBlock,
|
block: TimedBlock,
|
||||||
clipOffsetMin: Int,
|
clipOffsetMin: Int,
|
||||||
@@ -709,69 +667,6 @@ class TimelineDragController {
|
|||||||
begin(block, clipOffsetMin = 0, zone, titleLines = 1, pointerInRoot = pointerInRoot, blockInRoot = blockInRoot)
|
begin(block, clipOffsetMin = 0, zone, titleLines = 1, pointerInRoot = pointerInRoot, blockInRoot = blockInRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Lift [event] off the all-day strip. Over the time grid it is dragged as a
|
|
||||||
* block [draftMinutes] long, whose top sits a little above the finger.
|
|
||||||
*/
|
|
||||||
fun beginStrip(event: EventInstance, zone: TimeZone, pointerInRoot: Offset) {
|
|
||||||
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
|
|
||||||
if (geometry.columnWidthPx <= 0f || geometry.days.isEmpty()) return
|
|
||||||
deselect()
|
|
||||||
if (stripSettling?.event?.instanceId == event.instanceId) releaseStrip()
|
|
||||||
stripScrollArmed = false
|
|
||||||
val timed = event.copy(isAllDay = false, end = event.start + draftMinutes.minutes)
|
|
||||||
source = TimedBlock(timed, 0, draftMinutes, lane = 0, laneCount = 1)
|
|
||||||
clipOffsetMin = 0
|
|
||||||
dragZone = zone
|
|
||||||
dragDuration = draftMinutes.minutes
|
|
||||||
dragKept = null
|
|
||||||
titleLines = 1
|
|
||||||
settling = null
|
|
||||||
isDragging = true
|
|
||||||
liftedInstanceId = event.instanceId
|
|
||||||
grab = Offset(0f, geometry.hourPx * STRIP_GRAB_MINUTES / 60f)
|
|
||||||
pointer = pointerInRoot
|
|
||||||
pickupPointer = pointerInRoot
|
|
||||||
pointerMoved = false
|
|
||||||
originSlot = null
|
|
||||||
stripGrabIndex = dayIndexAt(pointerInRoot.x, grid.positionInRoot().x)
|
|
||||||
stripDrag = StripDrag(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The shown day under [x], clamped to the days on screen. */
|
|
||||||
private fun dayIndexAt(x: Float, originX: Float): Int =
|
|
||||||
column(((x - originX) / geometry.columnWidthPx).toInt().coerceIn(0, geometry.days.lastIndex))
|
|
||||||
|
|
||||||
/** End a strip drag, handing back where it landed — null when it went nowhere. */
|
|
||||||
fun finishStrip(): StripDrop? {
|
|
||||||
val held = stripDrag ?: return null
|
|
||||||
val landed = drag
|
|
||||||
val zone = dragZone
|
|
||||||
cancel()
|
|
||||||
if (held.overGrid && landed != null && zone != null && !landed.edgeOnly) {
|
|
||||||
settle(landed)
|
|
||||||
val start = TimelineDrop(landed.event, landed.date, landed.startMin).startInstant(zone)
|
|
||||||
return StripDrop.Timed(held.event, start, start + draftMinutes.minutes)
|
|
||||||
}
|
|
||||||
if (held.overGrid || held.dayShift == 0) return null
|
|
||||||
stripSettling = held
|
|
||||||
stripSeated = false
|
|
||||||
stripAbandonedMark = abandonedTicks?.value ?: 0
|
|
||||||
drops++
|
|
||||||
return StripDrop.Days(held.event, held.dayShift)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Let the held strip drop go, once the strip shows the event at its new days or the move failed. */
|
|
||||||
fun releaseStrip() {
|
|
||||||
stripSettling = null
|
|
||||||
stripSeated = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Note that the strip draws the settling drop at its new days. */
|
|
||||||
fun seatStrip() {
|
|
||||||
if (stripSettling != null) stripSeated = true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun move(pointerInRoot: Offset) {
|
fun move(pointerInRoot: Offset) {
|
||||||
pointer = pointerInRoot
|
pointer = pointerInRoot
|
||||||
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
|
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
|
||||||
@@ -780,7 +675,6 @@ class TimelineDragController {
|
|||||||
|
|
||||||
fun cancel() {
|
fun cancel() {
|
||||||
resize = null
|
resize = null
|
||||||
stripDrag = null
|
|
||||||
source = null
|
source = null
|
||||||
clipOffsetMin = 0
|
clipOffsetMin = 0
|
||||||
dragZone = null
|
dragZone = null
|
||||||
@@ -861,16 +755,12 @@ class TimelineDragController {
|
|||||||
|
|
||||||
/** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */
|
/** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */
|
||||||
private fun settle(landed: TimelineDrag, end: Instant? = null) {
|
private fun settle(landed: TimelineDrag, end: Instant? = null) {
|
||||||
// Landed on midnight to midnight, the event moves to the all-day strip (#296).
|
|
||||||
val leavesGrid = !landed.event.isDraft && landed.eventSpanMin > 0 &&
|
|
||||||
landed.eventStartMin.mod(MINUTES_PER_DAY) == 0 &&
|
|
||||||
landed.eventSpanMin % MINUTES_PER_DAY == 0
|
|
||||||
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
|
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
|
||||||
// An edge piece is a hint for the finger, not a preview of the landing
|
// 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
|
// 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
|
// [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.
|
// 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
|
liftedInstanceId = landed.event.instanceId
|
||||||
settledOrigin = landed.event.eventId to landed.event.start
|
settledOrigin = landed.event.eventId to landed.event.start
|
||||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||||
@@ -905,9 +795,7 @@ class TimelineDragController {
|
|||||||
val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY))
|
val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY))
|
||||||
session.column = edgeIndex.takeIf { it >= 0 }
|
session.column = edgeIndex.takeIf { it >= 0 }
|
||||||
?.let { column(it) }
|
?.let { column(it) }
|
||||||
// The top handle sits on the drawn top, which a short block near midnight lifts (#483).
|
session.grabMin = (pointerResizeMinutes(session) ?: 0f) -
|
||||||
val lift = if (edge == ResizeEdge.Top) block.startMin - block.renderedSpan().topMin else 0f
|
|
||||||
session.grabMin = (pointerResizeMinutes(session) ?: 0f) + lift -
|
|
||||||
if (edge == ResizeEdge.Top) session.startMin else session.endMin
|
if (edge == ResizeEdge.Top) session.startMin else session.endMin
|
||||||
originSlot = null
|
originSlot = null
|
||||||
recompute()
|
recompute()
|
||||||
@@ -967,9 +855,7 @@ class TimelineDragController {
|
|||||||
session.column = held
|
session.column = held
|
||||||
val dayOffset = (days[column(held)].toEpochDays() - session.day.toEpochDays()).toInt()
|
val dayOffset = (days[column(held)].toEpochDays() - session.day.toEpochDays()).toInt()
|
||||||
session.edgeDay = dayOffset
|
session.edgeDay = dayOffset
|
||||||
// Held to its column's midnight: the clearance under it is not the next day.
|
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
|
||||||
val minutes = ((pointer.y - origin.y) / hourPx * 60f).coerceAtMost(MINUTES_PER_DAY.toFloat())
|
|
||||||
return dayOffset * MINUTES_PER_DAY + minutes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun recomputeResize(session: ResizeSession) {
|
private fun recomputeResize(session: ResizeSession) {
|
||||||
@@ -1042,29 +928,6 @@ class TimelineDragController {
|
|||||||
recomputeResize(it)
|
recomputeResize(it)
|
||||||
return
|
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 block = source ?: return
|
||||||
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
|
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
|
||||||
val hourPx = geometry.hourPx
|
val hourPx = geometry.hourPx
|
||||||
@@ -1150,18 +1013,11 @@ class TimelineDragController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun edgeStep(): Float {
|
private fun edgeStep(): Float {
|
||||||
val strip = stripDrag
|
|
||||||
if (strip?.overGrid == false) return 0f
|
|
||||||
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return 0f
|
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return 0f
|
||||||
val edge = geometry.edgePx
|
val edge = geometry.edgePx
|
||||||
if (edge <= 0f) return 0f
|
if (edge <= 0f) return 0f
|
||||||
val bounds = viewport.boundsInRoot()
|
val bounds = viewport.boundsInRoot()
|
||||||
val fromTop = pointer.y - bounds.top
|
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
|
val fromBottom = bounds.bottom - pointer.y
|
||||||
return when {
|
return when {
|
||||||
fromTop < edge -> -(edge - fromTop) / edge * geometry.stepPx
|
fromTop < edge -> -(edge - fromTop) / edge * geometry.stepPx
|
||||||
@@ -1221,6 +1077,10 @@ fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int {
|
|||||||
fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean =
|
fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean =
|
||||||
event.start < day.atStartOfDayIn(zone)
|
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
|
* 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
|
* overnight event drops back onto the day its own start belongs to. Read in the
|
||||||
@@ -1317,7 +1177,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
|||||||
|
|
||||||
// Both live here rather than beside the controller: they read [drag], which
|
// 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.
|
// 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
|
// 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.
|
// scroll the drop target out from under a finger that only means to select.
|
||||||
LaunchedEffect(controller.isDragging && controller.pointerMoved) {
|
LaunchedEffect(controller.isDragging && controller.pointerMoved) {
|
||||||
@@ -1341,15 +1201,6 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
|||||||
controller.release()
|
controller.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
// The same hold for a bar moved along the all-day strip.
|
|
||||||
LaunchedEffect(controller.stripSettling, moveInFlight, controller.stripSeated, abandoned) {
|
|
||||||
if (controller.stripSettling == null || moveInFlight) return@LaunchedEffect
|
|
||||||
if (abandoned == controller.stripAbandonedMark) {
|
|
||||||
delay(if (controller.stripSeated) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
|
|
||||||
}
|
|
||||||
controller.releaseStrip()
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|||||||
@@ -43,21 +43,14 @@ data class TimelineHours(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The scroll offset that puts [startHour] at the top of a timeline laid out
|
* The scroll offset that puts [startHour] at the top of a timeline laid out
|
||||||
* with [maxValue] scroll range over a [viewportPx] viewport, [clearancePx] of
|
* with [maxValue] scroll range over a [viewportPx] viewport, or null while that
|
||||||
* which is the empty inset under midnight, or null while that
|
|
||||||
* layout is not yet at [hourPx] per hour — the scale changes a frame before the
|
* 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
|
* content grows to match it, and scrolling against the old layout would land on
|
||||||
* the wrong hour.
|
* the wrong hour.
|
||||||
*/
|
*/
|
||||||
internal fun startHourScroll(
|
internal fun startHourScroll(maxValue: Int, viewportPx: Int, startHour: Int, hourPx: Float): Int? {
|
||||||
maxValue: Int,
|
|
||||||
viewportPx: Int,
|
|
||||||
startHour: Int,
|
|
||||||
hourPx: Float,
|
|
||||||
clearancePx: Int = 0,
|
|
||||||
): Int? {
|
|
||||||
if (viewportPx <= 0 || maxValue < 0 || maxValue == Int.MAX_VALUE) return null
|
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
|
if (abs(laidOutHourPx - hourPx) > 1f) return null
|
||||||
return (startHour * laidOutHourPx).roundToInt().coerceIn(0, maxValue)
|
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 androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
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).
|
* 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
|
* Hour height for this scale. [viewportHeight] is the visible height of the
|
||||||
* to, the scrolling timeline's [dayFitHeight], and is not consulted by the
|
* scrolling timeline and is only consulted by [TimelineScale.FitDay].
|
||||||
* fixed presets.
|
|
||||||
*
|
*
|
||||||
* The fit-day result is clamped: below [FIT_DAY_MIN] the 24 gutter labels stop
|
* 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
|
* 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))
|
.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
|
* The minute of the day a tap [offsetY] px down a timeline column means, snapped
|
||||||
* to the hour it landed in.
|
* to the hour it landed in.
|
||||||
@@ -115,28 +105,6 @@ fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
|
|||||||
*/
|
*/
|
||||||
const val MIN_EVENT_FRACTION = 26f / 60f
|
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. */
|
/** Smallest hour height [TimelineScale.FitDay] will resolve to. */
|
||||||
val FIT_DAY_MIN = 24.dp
|
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. */
|
/** A timeline's scroll, and where it opens before it can scroll there. */
|
||||||
@Stable
|
@Stable
|
||||||
class TimelineScroll(
|
class TimelineScroll(val state: ScrollState, noonPending: Boolean, private val openingHour: Int?) {
|
||||||
val state: ScrollState,
|
|
||||||
noonPending: Boolean,
|
|
||||||
private val openingHour: Int?,
|
|
||||||
private val clearancePx: Float = 0f,
|
|
||||||
) {
|
|
||||||
/** Whether it is still to be put on noon, which it only can be once laid out. */
|
/** Whether it is still to be put on noon, which it only can be once laid out. */
|
||||||
var noonPending by mutableStateOf(noonPending)
|
var noonPending by mutableStateOf(noonPending)
|
||||||
internal set
|
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 =
|
fun top(viewport: Float, content: Float): Float =
|
||||||
if (noonPending) {
|
if (noonPending) {
|
||||||
(openingHour?.let { it * content / 24f } ?: (content - viewport) / 2f)
|
(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()
|
} else state.value.toFloat()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +58,6 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
|
|||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val zoom = LocalTimelineZoom.current
|
val zoom = LocalTimelineZoom.current
|
||||||
val topPx = with(density) { timelineTop.toPx() }
|
val topPx = with(density) { timelineTop.toPx() }
|
||||||
val clearancePx = with(density) { FabClearance.roundToPx() }
|
|
||||||
val remembered = memory?.midnightY
|
val remembered = memory?.midnightY
|
||||||
val scrollState = rememberScrollState(
|
val scrollState = rememberScrollState(
|
||||||
initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0,
|
initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0,
|
||||||
@@ -73,7 +67,6 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
|
|||||||
scrollState,
|
scrollState,
|
||||||
noonPending = remembered == null,
|
noonPending = remembered == null,
|
||||||
openingHour = zoom.hours.takeIf { it.enabled }?.startHour,
|
openingHour = zoom.hours.takeIf { it.enabled }?.startHour,
|
||||||
clearancePx = clearancePx.toFloat(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val currentTop by rememberUpdatedState(topPx)
|
val currentTop by rememberUpdatedState(topPx)
|
||||||
@@ -87,20 +80,20 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
|
|||||||
val startHour = zoom.open()
|
val startHour = zoom.open()
|
||||||
if (startHour == null) {
|
if (startHour == null) {
|
||||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||||
scrollState.scrollTo(((scrollState.maxValue - clearancePx) / 2).coerceAtLeast(0))
|
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||||
} else {
|
} else {
|
||||||
// Fitted to the scroll viewport itself: the layout resolves scales
|
// Fitted to the scroll viewport itself: the layout resolves scales
|
||||||
// against the viewport plus the all-day strip, which would push the
|
// against the viewport plus the all-day strip, which would push the
|
||||||
// end hour below the screen.
|
// end hour below the screen.
|
||||||
val viewport = snapshotFlow { scrollState.viewportSize }.first { it > 0 }
|
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)
|
zoom.holdFit(hourHeight)
|
||||||
val hourPx = with(density) { hourHeight.toPx() }
|
val hourPx = with(density) { hourHeight.toPx() }
|
||||||
val target = withTimeoutOrNull(FIT_LAYOUT_TIMEOUT_MILLIS) {
|
val target = withTimeoutOrNull(FIT_LAYOUT_TIMEOUT_MILLIS) {
|
||||||
snapshotFlow {
|
snapshotFlow {
|
||||||
startHourScroll(scrollState.maxValue, scrollState.viewportSize, startHour, hourPx, clearancePx)
|
startHourScroll(scrollState.maxValue, scrollState.viewportSize, startHour, hourPx)
|
||||||
}.filterNotNull().first()
|
}.filterNotNull().first()
|
||||||
} ?: (startHour * (scrollState.maxValue + scrollState.viewportSize - clearancePx) / 24)
|
} ?: (startHour * (scrollState.maxValue + scrollState.viewportSize) / 24)
|
||||||
.coerceIn(0, scrollState.maxValue.coerceAtLeast(0))
|
.coerceIn(0, scrollState.maxValue.coerceAtLeast(0))
|
||||||
scrollState.scrollTo(target)
|
scrollState.scrollTo(target)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||||
import de.jeanlucmakiola.floret.components.IconInfoCard
|
import de.jeanlucmakiola.floret.components.IconInfoCard
|
||||||
import de.jeanlucmakiola.floret.components.SegmentedChoiceCard
|
|
||||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
|
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.R
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
@@ -894,15 +892,6 @@ private fun EventEditContent(
|
|||||||
AvailabilityCard(availability = form.availability, onSelect = viewModel::setAvailability)
|
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) {
|
OptionalFormSection(visible = EventFormField.Visibility in state.visibleFields) {
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
IconInfoCard(
|
IconInfoCard(
|
||||||
@@ -1325,33 +1314,33 @@ private fun RecurrenceCard(state: EventEditUiState, locale: Locale, onClick: (()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun AvailabilityCard(availability: Availability, onSelect: (Availability) -> Unit) {
|
private fun AvailabilityCard(availability: Availability, onSelect: (Availability) -> Unit) {
|
||||||
SegmentedChoiceCard(
|
IconInfoCard(
|
||||||
icon = Icons.Default.EventAvailable,
|
icon = Icons.Default.EventAvailable,
|
||||||
title = stringResource(R.string.event_edit_availability),
|
iconContentDescription = null,
|
||||||
options = listOf(
|
header = {
|
||||||
Availability.Busy to stringResource(R.string.event_availability_busy),
|
Text(
|
||||||
Availability.Free to stringResource(R.string.event_availability_free),
|
text = stringResource(R.string.event_edit_availability),
|
||||||
),
|
style = MaterialTheme.typography.titleMedium,
|
||||||
selected = availability,
|
)
|
||||||
onSelect = onSelect,
|
},
|
||||||
)
|
) {
|
||||||
}
|
Spacer(Modifier.height(8.dp))
|
||||||
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
@Composable
|
SegmentedButton(
|
||||||
private fun StatusCard(status: EventStatus, offerCancelled: Boolean, onSelect: (EventStatus) -> Unit) {
|
selected = availability == Availability.Busy,
|
||||||
SegmentedChoiceCard(
|
onClick = { onSelect(Availability.Busy) },
|
||||||
icon = eventFormFieldIcon(EventFormField.Status),
|
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
|
||||||
title = stringResource(R.string.event_edit_status),
|
) { Text(stringResource(R.string.event_availability_busy)) }
|
||||||
options = listOfNotNull(
|
SegmentedButton(
|
||||||
EventStatus.Confirmed to stringResource(R.string.event_status_confirmed),
|
selected = availability == Availability.Free,
|
||||||
EventStatus.Tentative to stringResource(R.string.event_status_tentative),
|
onClick = { onSelect(Availability.Free) },
|
||||||
(EventStatus.Cancelled to stringResource(R.string.event_status_cancelled)).takeIf { offerCancelled },
|
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
|
||||||
),
|
) { Text(stringResource(R.string.event_availability_free)) }
|
||||||
selected = status,
|
}
|
||||||
onSelect = onSelect,
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -48,12 +48,6 @@ data class EventEditUiState(
|
|||||||
* then drops "only this event" (an exception row can't carry a rule).
|
* then drops "only this event" (an exception row can't carry a rule).
|
||||||
*/
|
*/
|
||||||
val recurrenceChanged: Boolean = false,
|
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
|
* The event-colour palette the resolved target calendar publishes; empty
|
||||||
* when it exposes none. Non-empty → the colour picker offers these swatches
|
* 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.firstDayOfWeek
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration
|
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
||||||
import de.jeanlucmakiola.calendula.domain.canSaveApartFromSeries
|
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
||||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
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.
|
// the scope dialog drops "only this event" after a rule change.
|
||||||
recurrenceChanged = local.editTarget != null &&
|
recurrenceChanged = local.editTarget != null &&
|
||||||
resolved.rrule != local.editTarget.original.rrule,
|
resolved.rrule != local.editTarget.original.rrule,
|
||||||
offerCancelled = local.editTarget?.original?.status == EventStatus.Cancelled,
|
|
||||||
colorPalette = palette,
|
colorPalette = palette,
|
||||||
allowColorOnUnsupportedCalendars = external.allowColorOnUnsupported,
|
allowColorOnUnsupportedCalendars = external.allowColorOnUnsupported,
|
||||||
)
|
)
|
||||||
@@ -674,7 +671,6 @@ class EventEditViewModel @Inject constructor(
|
|||||||
applyDefaultDuration(id)
|
applyDefaultDuration(id)
|
||||||
}
|
}
|
||||||
fun setAvailability(value: Availability) = edit { it.copy(availability = value) }
|
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) }
|
fun setAccessLevel(value: AccessLevel) = edit { it.copy(accessLevel = value) }
|
||||||
|
|
||||||
/** Pick a palette colour (round-trips via its key); [argb] is its swatch. */
|
/** 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,
|
// Managed events are a yearly series whose editable fields (reminders,
|
||||||
// notes, location) live on the series row — never offer the scope dialog,
|
// notes, location) live on the series row — never offer the scope dialog,
|
||||||
// which would split the series into an exception the sync then reverts.
|
// 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) {
|
||||||
if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar &&
|
|
||||||
form.canSaveApartFromSeries()
|
|
||||||
) {
|
|
||||||
_saveState.value = SaveUiState.AwaitingScope
|
_saveState.value = SaveUiState.AwaitingScope
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,28 +2,17 @@ package de.jeanlucmakiola.calendula.ui.month
|
|||||||
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
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 androidx.compose.ui.unit.Dp
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
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.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberWidestTimeSample
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The start time a chip shows before its title (#219), or null when it has none
|
* 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
|
* to show: an all-day chip never carries a time, and neither does a bar carried
|
||||||
* does a bar carried in from an earlier week, whose start is not in this segment.
|
* in from an earlier week, whose start is not in this segment.
|
||||||
*/
|
*/
|
||||||
internal fun monthChipTime(
|
internal fun monthChipTime(
|
||||||
event: EventInstance,
|
event: EventInstance,
|
||||||
@@ -32,75 +21,11 @@ internal fun monthChipTime(
|
|||||||
is24Hour: Boolean,
|
is24Hour: Boolean,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
): String? {
|
): String? {
|
||||||
if (event.coversWholeDays(zone) || continuesLeft) return null
|
if (event.isAllDay || continuesLeft) return null
|
||||||
return formatTimeOfDay(event.start, zone, is24Hour, locale)
|
return formatTimeOfDay(event.start, zone, is24Hour, locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How many characters of a longer title a chip must keep beside its start time (#476). */
|
/** The narrowest chip that may show a time — the text it needs, plus its chrome. */
|
||||||
internal const val MONTH_TITLE_MIN_CHARS = 12
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The part of [title] that has to stay readable beside a start time: all of it,
|
|
||||||
* or its first [MONTH_TITLE_MIN_CHARS] characters, counted in code points so an
|
|
||||||
* emoji is never cut in half.
|
|
||||||
*/
|
|
||||||
internal fun monthTitleToKeep(title: String): String {
|
|
||||||
if (title.codePointCount(0, title.length) <= MONTH_TITLE_MIN_CHARS) return title
|
|
||||||
return title.substring(0, title.offsetByCodePoints(0, MONTH_TITLE_MIN_CHARS))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a chip shows its start time (#219, #476): only with the setting
|
|
||||||
* [enabled], and only when [availableWidth] (the chip less its chrome) holds
|
|
||||||
* [labelWidth], the widest time followed by [monthTitleToKeep] of the title.
|
|
||||||
*/
|
|
||||||
internal fun monthChipShowsTime(
|
|
||||||
enabled: Boolean,
|
|
||||||
availableWidth: Float,
|
|
||||||
labelWidth: Float,
|
|
||||||
): Boolean = enabled && labelWidth <= availableWidth
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prices month chips' time labels for [monthChipShowsTime] in the chips' own
|
|
||||||
* text style. One per grid; each title is measured once for its lifetime.
|
|
||||||
*/
|
|
||||||
@Stable
|
|
||||||
internal class MonthChipTimeFit(
|
|
||||||
private val measurer: TextMeasurer,
|
|
||||||
private val style: TextStyle,
|
|
||||||
private val density: Density,
|
|
||||||
private val timeSample: String,
|
|
||||||
) {
|
|
||||||
private val chromePx = with(density) { MONTH_CHIP_CHROME.toPx() }
|
|
||||||
private val labelWidths = HashMap<String, Int>()
|
|
||||||
|
|
||||||
fun showsTime(chipWidth: Dp, title: String): Boolean {
|
|
||||||
if (labelWidths.size >= MAX_CACHED_TITLES) labelWidths.clear()
|
|
||||||
val labelWidth = labelWidths.getOrPut(title) {
|
|
||||||
val label = inlineTimeLabel(timeSample, monthTitleToKeep(title), Color.Unspecified)
|
|
||||||
measurer.measure(label, style, softWrap = false, maxLines = 1).size.width
|
|
||||||
}
|
|
||||||
return monthChipShowsTime(
|
|
||||||
enabled = true,
|
|
||||||
availableWidth = with(density) { chipWidth.toPx() } - chromePx,
|
|
||||||
labelWidth = labelWidth.toFloat(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val MAX_CACHED_TITLES = 1000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The grid's [MonthChipTimeFit], or null when start times are switched off (#476). */
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun rememberMonthChipTimeFit(showStartTimes: Boolean): MonthChipTimeFit? {
|
internal fun rememberMonthTimeChipWidth(): Dp =
|
||||||
if (!showStartTimes) return null
|
rememberInlineTimeWidth(MaterialTheme.typography.labelSmall) + MONTH_CHIP_CHROME
|
||||||
val measurer = rememberTextMeasurer(cacheSize = 0)
|
|
||||||
val style = MaterialTheme.typography.labelSmall.trimmedLines()
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val timeSample = rememberWidestTimeSample()
|
|
||||||
return remember(measurer, style, density, timeSample) {
|
|
||||||
MonthChipTimeFit(measurer, style, density, timeSample)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,10 +34,6 @@ internal sealed interface MonthMorphKey {
|
|||||||
|
|
||||||
data class DayNumber(val date: LocalDate) : 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
|
* 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
|
* 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
|
* 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
|
* 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
|
* stack of pieces sliding past each other. Nothing here needs to escape its
|
||||||
* render in the overlay so the expanding day backgrounds cannot cover them.
|
* ancestors: the cells, marks and markers all travel within the grid.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -145,7 +141,6 @@ internal fun Modifier.morphElement(key: MonthMorphKey): Modifier {
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
|
internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
|
||||||
val morph = LocalMonthMorph.current ?: return this
|
val morph = LocalMonthMorph.current ?: return this
|
||||||
val isEvent = key is MonthMorphKey.Event
|
|
||||||
return with(morph.shared) {
|
return with(morph.shared) {
|
||||||
this@morphBounds.sharedBounds(
|
this@morphBounds.sharedBounds(
|
||||||
sharedContentState = rememberSharedContentState(key),
|
sharedContentState = rememberSharedContentState(key),
|
||||||
@@ -153,8 +148,7 @@ internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
|
|||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut(),
|
exit = fadeOut(),
|
||||||
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
||||||
renderInOverlayDuringTransition = isEvent,
|
renderInOverlayDuringTransition = false,
|
||||||
zIndexInOverlay = if (isEvent) 1f else 0f,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,12 +2,7 @@ package de.jeanlucmakiola.calendula.ui.month
|
|||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedContent
|
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.EnterExitState
|
||||||
import androidx.compose.animation.EnterTransition
|
|
||||||
import androidx.compose.animation.ExitTransition
|
|
||||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
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.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaDays
|
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.PublishChrome
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
|
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
@@ -163,9 +157,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
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.AgendaEmptyDayRow
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
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.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
@@ -283,8 +278,6 @@ fun MonthScreen(
|
|||||||
val selectedDate by viewModel.selectedDate.collectAsStateWithLifecycle()
|
val selectedDate by viewModel.selectedDate.collectAsStateWithLifecycle()
|
||||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||||
val showWeekNumbers by viewModel.showWeekNumbers.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
|
// The instant before which an event counts as completed, or null when dimming
|
||||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||||
// screen while the setting is off (it stays null regardless of the tick).
|
// screen while the setting is off (it stays null regardless of the tick).
|
||||||
@@ -370,7 +363,7 @@ fun MonthScreen(
|
|||||||
if (!scrolling) return@LaunchedEffect
|
if (!scrolling) return@LaunchedEffect
|
||||||
snapshotFlow {
|
snapshotFlow {
|
||||||
// Empty before the first layout pass — and reporting a default of 0
|
// 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.
|
// window back there.
|
||||||
val visible = listState.layoutInfo.visibleItemsInfo
|
val visible = listState.layoutInfo.visibleItemsInfo
|
||||||
when {
|
when {
|
||||||
@@ -455,8 +448,7 @@ fun MonthScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Drawer jump-to-date.
|
// Drawer jump-to-date.
|
||||||
val jumpToDate: (LocalDate) -> Unit = { date ->
|
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||||
val target = coerceToMonthRange(date)
|
|
||||||
if (scrolling) {
|
if (scrolling) {
|
||||||
val targetMonth = target.yearMonth
|
val targetMonth = target.yearMonth
|
||||||
pendingScrollMonth = targetMonth
|
pendingScrollMonth = targetMonth
|
||||||
@@ -532,20 +524,17 @@ fun MonthScreen(
|
|||||||
) {
|
) {
|
||||||
if (scrolling) {
|
if (scrolling) {
|
||||||
ContinuousMonthContent(
|
ContinuousMonthContent(
|
||||||
// Held on the skeleton until the start-time setting has
|
state = continuousState,
|
||||||
// loaded, so chips don't redraw without their times.
|
|
||||||
state = if (showStartTimes == null) ContinuousMonthUiState.Loading else continuousState,
|
|
||||||
listState = listState,
|
listState = listState,
|
||||||
dense = dense,
|
dense = dense,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
showStartTimes = showStartTimes == true,
|
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
SplitMonthContent(
|
SplitMonthContent(
|
||||||
state = if (showStartTimes == null) MonthUiState.Loading else state,
|
state = state,
|
||||||
selected = selectedDate,
|
selected = selectedDate,
|
||||||
initiallyExpanded = viewStyle == MonthViewStyle.Paged,
|
initiallyExpanded = viewStyle == MonthViewStyle.Paged,
|
||||||
expanded = splitExpanded,
|
expanded = splitExpanded,
|
||||||
@@ -554,8 +543,6 @@ fun MonthScreen(
|
|||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
monthState = viewModel::month,
|
monthState = viewModel::month,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
showStartTimes = showStartTimes == true,
|
|
||||||
showToday = showToday,
|
|
||||||
onPaneSwipeNext = { viewModel.stepSplitPane(1) },
|
onPaneSwipeNext = { viewModel.stepSplitPane(1) },
|
||||||
onPaneSwipePrev = { viewModel.stepSplitPane(-1) },
|
onPaneSwipePrev = { viewModel.stepSplitPane(-1) },
|
||||||
onClearSelection = viewModel::clearSelection,
|
onClearSelection = viewModel::clearSelection,
|
||||||
@@ -564,10 +551,8 @@ fun MonthScreen(
|
|||||||
// month, pager and all — straight away, or a swipe
|
// month, pager and all — straight away, or a swipe
|
||||||
// still settling would land and take the tap back.
|
// still settling would land and take the tap back.
|
||||||
onSelectDay = { date ->
|
onSelectDay = { date ->
|
||||||
if (isInMonthRange(date)) {
|
viewModel.selectDate(date)
|
||||||
viewModel.selectDate(date)
|
jumpToMonth(date.yearMonth)
|
||||||
jumpToMonth(date.yearMonth)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
@@ -576,7 +561,7 @@ fun MonthScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MonthDragOverlay(chipDrag, showStartTimes = showStartTimes == true)
|
MonthDragOverlay(chipDrag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -589,11 +574,11 @@ private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L
|
|||||||
|
|
||||||
/** The chip in flight, drawn over the grid and following the finger. */
|
/** The chip in flight, drawn over the grid and following the finger. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Boolean) {
|
private fun MonthDragOverlay(controller: MonthDragController) {
|
||||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||||
val dark = LocalFloretDarkTheme.current
|
val dark = LocalFloretDarkTheme.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val timeFit = rememberMonthChipTimeFit(showStartTimes)
|
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
val moveInFlight = moveInFlight()
|
val moveInFlight = moveInFlight()
|
||||||
// Here rather than beside the controller: this reads [drag], which changes
|
// Here rather than beside the controller: this reads [drag], which changes
|
||||||
@@ -655,8 +640,7 @@ private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Bo
|
|||||||
// cut-out of it has the room — which a slice of a multi-day bar has
|
// cut-out of it has the room — which a slice of a multi-day bar has
|
||||||
// not, however wide the bar was.
|
// not, however wide the bar was.
|
||||||
time = drag.time,
|
time = drag.time,
|
||||||
timeFit = timeFit,
|
showTime = with(density) { drag.sizePx.width.toDp() } >= timeChipWidth,
|
||||||
chipWidth = with(density) { drag.sizePx.width.toDp() },
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
// Absolute: these are root coordinates, and the direction-aware
|
// Absolute: these are root coordinates, and the direction-aware
|
||||||
// offset would mirror them across the screen in an RTL layout.
|
// offset would mirror them across the screen in an RTL layout.
|
||||||
@@ -693,7 +677,6 @@ private fun ContinuousMonthContent(
|
|||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
dense: Boolean,
|
dense: Boolean,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
showStartTimes: Boolean,
|
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
@@ -710,7 +693,6 @@ private fun ContinuousMonthContent(
|
|||||||
state = state,
|
state = state,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
showStartTimes = showStartTimes,
|
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
)
|
)
|
||||||
@@ -719,7 +701,6 @@ private fun ContinuousMonthContent(
|
|||||||
state = state,
|
state = state,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
showStartTimes = showStartTimes,
|
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
)
|
)
|
||||||
@@ -892,7 +873,6 @@ private val DENSE_HEADER_GAP = 4.dp
|
|||||||
internal fun MonthGrid(
|
internal fun MonthGrid(
|
||||||
state: MonthUiState.Success,
|
state: MonthUiState.Success,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
showStartTimes: Boolean,
|
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
/** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */
|
/** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */
|
||||||
@@ -917,13 +897,13 @@ internal fun MonthGrid(
|
|||||||
val month = state.month
|
val month = state.month
|
||||||
// Once per grid: the value depends on the typography, the density, the
|
// 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).
|
// locale and the 24-hour setting, none of which vary by row (#219).
|
||||||
val timeFit = rememberMonthChipTimeFit(showStartTimes)
|
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||||
state.weeks.forEach { week ->
|
state.weeks.forEach { week ->
|
||||||
MonthWeekRow(
|
MonthWeekRow(
|
||||||
week = week,
|
week = week,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
zone = state.zone,
|
zone = state.zone,
|
||||||
timeFit = timeFit,
|
timeChipWidth = timeChipWidth,
|
||||||
rowHeight = rowHeight,
|
rowHeight = rowHeight,
|
||||||
inMonth = { it.month == month.month && it.year == month.year },
|
inMonth = { it.month == month.month && it.year == month.year },
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
@@ -959,19 +939,18 @@ internal fun ContinuousMonthGrid(
|
|||||||
state: ContinuousMonthUiState.Success,
|
state: ContinuousMonthUiState.Success,
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
showStartTimes: Boolean,
|
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val monthCount = remember { continuousMonthCount() }
|
val monthCount = remember { continuousMonthCount() }
|
||||||
val todayMonth = remember(state.today) { state.today.yearMonth }
|
val todayMonth = remember(state.today) { state.today.yearMonth }
|
||||||
val timeFit = rememberMonthChipTimeFit(showStartTimes)
|
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||||
contentPadding = PaddingValues(bottom = FabClearance),
|
contentPadding = PaddingValues(bottom = 96.dp),
|
||||||
) {
|
) {
|
||||||
repeat(monthCount) { index ->
|
repeat(monthCount) { index ->
|
||||||
val month = yearMonthForIndex(index)
|
val month = yearMonthForIndex(index)
|
||||||
@@ -989,7 +968,7 @@ internal fun ContinuousMonthGrid(
|
|||||||
weekStart = state.weekStart,
|
weekStart = state.weekStart,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
zone = state.zone,
|
zone = state.zone,
|
||||||
timeFit = timeFit,
|
timeChipWidth = timeChipWidth,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
@@ -1011,7 +990,7 @@ private fun ContinuousMonthBlock(
|
|||||||
weekStart: DayOfWeek,
|
weekStart: DayOfWeek,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
timeFit: MonthChipTimeFit?,
|
timeChipWidth: Dp,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
@@ -1032,7 +1011,7 @@ private fun ContinuousMonthBlock(
|
|||||||
week = week,
|
week = week,
|
||||||
today = today,
|
today = today,
|
||||||
zone = zone,
|
zone = zone,
|
||||||
timeFit = timeFit,
|
timeChipWidth = timeChipWidth,
|
||||||
rowHeight = CONTINUOUS_ROW_HEIGHT,
|
rowHeight = CONTINUOUS_ROW_HEIGHT,
|
||||||
inMonth = { it.month == month.month && it.year == month.year },
|
inMonth = { it.month == month.month && it.year == month.year },
|
||||||
// The block owns its month alone: a day from either
|
// The block owns its month alone: a day from either
|
||||||
@@ -1098,13 +1077,12 @@ internal fun DenseMonthGrid(
|
|||||||
state: ContinuousMonthUiState.Success,
|
state: ContinuousMonthUiState.Success,
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
showStartTimes: Boolean,
|
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
|
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
|
||||||
val timeFit = rememberMonthChipTimeFit(showStartTimes)
|
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -1117,7 +1095,7 @@ internal fun DenseMonthGrid(
|
|||||||
.padding(top = DENSE_HEADER_GAP),
|
.padding(top = DENSE_HEADER_GAP),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
// 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 ->
|
items(count = weekCount, key = { it }) { index ->
|
||||||
val week = state.weeksByIndex[index]
|
val week = state.weeksByIndex[index]
|
||||||
@@ -1128,7 +1106,7 @@ internal fun DenseMonthGrid(
|
|||||||
week = week,
|
week = week,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
zone = state.zone,
|
zone = state.zone,
|
||||||
timeFit = timeFit,
|
timeChipWidth = timeChipWidth,
|
||||||
rowHeight = CONTINUOUS_ROW_HEIGHT,
|
rowHeight = CONTINUOUS_ROW_HEIGHT,
|
||||||
// Every day in the stream belongs to a month equally — there
|
// Every day in the stream belongs to a month equally — there
|
||||||
// is no "other month" to recede here.
|
// is no "other month" to recede here.
|
||||||
@@ -1220,8 +1198,6 @@ private fun SplitMonthContent(
|
|||||||
weekStart: DayOfWeek,
|
weekStart: DayOfWeek,
|
||||||
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
|
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
showStartTimes: Boolean,
|
|
||||||
showToday: Boolean,
|
|
||||||
onPaneSwipeNext: () -> Unit,
|
onPaneSwipeNext: () -> Unit,
|
||||||
onPaneSwipePrev: () -> Unit,
|
onPaneSwipePrev: () -> Unit,
|
||||||
onClearSelection: () -> Unit,
|
onClearSelection: () -> Unit,
|
||||||
@@ -1231,12 +1207,9 @@ private fun SplitMonthContent(
|
|||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateEvent: (LocalDate) -> Unit,
|
onCreateEvent: (LocalDate) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
val haptics = LocalHapticFeedback.current
|
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) {
|
BackHandler(enabled = expanded != initiallyExpanded) {
|
||||||
onExpandedChange(initiallyExpanded)
|
onExpandedChange(initiallyExpanded)
|
||||||
}
|
}
|
||||||
@@ -1305,9 +1278,8 @@ private fun SplitMonthContent(
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
// Both faces fill the same page — the grid grows into exactly the
|
// 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.
|
// room the pane gives up — so there is no size change to contain.
|
||||||
// Fading the faces made the shared cell backgrounds briefly
|
// The travel between them is the shared elements' job.
|
||||||
// disappear beneath their incoming counterparts.
|
transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
|
||||||
transitionSpec = { EnterTransition.None togetherWith ExitTransition.None },
|
|
||||||
label = "split-expand",
|
label = "split-expand",
|
||||||
) { isExpanded ->
|
) { isExpanded ->
|
||||||
// The view switch's morph stays off a face until it has fully
|
// The view switch's morph stays off a face until it has fully
|
||||||
@@ -1338,7 +1310,6 @@ private fun SplitMonthContent(
|
|||||||
state = page,
|
state = page,
|
||||||
selected = pageSelected,
|
selected = pageSelected,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
showStartTimes = showStartTimes,
|
|
||||||
expandDrag = expandDrag,
|
expandDrag = expandDrag,
|
||||||
onPickDay = onPickDay,
|
onPickDay = onPickDay,
|
||||||
// A tapped chip is asking for that event, not for its
|
// A tapped chip is asking for that event, not for its
|
||||||
@@ -1364,11 +1335,7 @@ private fun SplitMonthContent(
|
|||||||
state = page,
|
state = page,
|
||||||
paneReady = paneReady,
|
paneReady = paneReady,
|
||||||
selected = pageSelected,
|
selected = pageSelected,
|
||||||
selectionIndicatorVisible = reduceMotion ||
|
|
||||||
(transition.currentState == EnterExitState.Visible &&
|
|
||||||
transition.targetState == EnterExitState.Visible),
|
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
showToday = showToday,
|
|
||||||
expandDrag = expandDrag,
|
expandDrag = expandDrag,
|
||||||
onSelectDay = onSelectDay,
|
onSelectDay = onSelectDay,
|
||||||
onPaneSwipeNext = {
|
onPaneSwipeNext = {
|
||||||
@@ -1410,9 +1377,7 @@ private fun SplitMonthCollapsed(
|
|||||||
state: MonthUiState.Success,
|
state: MonthUiState.Success,
|
||||||
paneReady: Boolean,
|
paneReady: Boolean,
|
||||||
selected: LocalDate?,
|
selected: LocalDate?,
|
||||||
selectionIndicatorVisible: Boolean,
|
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
showToday: Boolean,
|
|
||||||
expandDrag: Modifier,
|
expandDrag: Modifier,
|
||||||
onSelectDay: (LocalDate) -> Unit,
|
onSelectDay: (LocalDate) -> Unit,
|
||||||
onPaneSwipeNext: () -> Unit,
|
onPaneSwipeNext: () -> Unit,
|
||||||
@@ -1432,7 +1397,6 @@ private fun SplitMonthCollapsed(
|
|||||||
SplitMonthGrid(
|
SplitMonthGrid(
|
||||||
state = state,
|
state = state,
|
||||||
selected = selected,
|
selected = selected,
|
||||||
selectionIndicatorVisible = selectionIndicatorVisible,
|
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onSelectDay = onSelectDay,
|
onSelectDay = onSelectDay,
|
||||||
modifier = Modifier.height(splitGridHeight(state.weeks.size)),
|
modifier = Modifier.height(splitGridHeight(state.weeks.size)),
|
||||||
@@ -1446,7 +1410,6 @@ private fun SplitMonthCollapsed(
|
|||||||
Box(Modifier.weight(1f).fillMaxWidth().then(paneModifier)) {
|
Box(Modifier.weight(1f).fillMaxWidth().then(paneModifier)) {
|
||||||
SplitMonthAgendaPane(
|
SplitMonthAgendaPane(
|
||||||
state = state,
|
state = state,
|
||||||
showToday = showToday,
|
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
@@ -1460,13 +1423,24 @@ private fun SplitMonthCollapsed(
|
|||||||
.then(paneModifier)
|
.then(paneModifier)
|
||||||
.then(rememberSplitPaneSwipe(onPaneSwipeNext, onPaneSwipePrev)),
|
.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(
|
SplitDayPane(
|
||||||
date = selected,
|
date = selected,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
events = state.instancesByDay[selected],
|
events = state.instancesByDay[selected],
|
||||||
zone = state.zone,
|
zone = state.zone,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onClearSelection = onClearSelection,
|
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
@@ -1513,7 +1487,6 @@ private fun SplitMonthExpanded(
|
|||||||
/** Marked nowhere here; it only anchors the outline's morph. */
|
/** Marked nowhere here; it only anchors the outline's morph. */
|
||||||
selected: LocalDate?,
|
selected: LocalDate?,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
showStartTimes: Boolean,
|
|
||||||
expandDrag: Modifier,
|
expandDrag: Modifier,
|
||||||
onPickDay: (LocalDate) -> Unit,
|
onPickDay: (LocalDate) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
@@ -1526,7 +1499,6 @@ private fun SplitMonthExpanded(
|
|||||||
MonthGrid(
|
MonthGrid(
|
||||||
state = state,
|
state = state,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
showStartTimes = showStartTimes,
|
|
||||||
onOpenDay = onPickDay,
|
onOpenDay = onPickDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
selected = selected,
|
selected = selected,
|
||||||
@@ -1640,7 +1612,6 @@ private val SPLIT_PANE_EASING = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
|
|||||||
internal fun SplitMonthGrid(
|
internal fun SplitMonthGrid(
|
||||||
state: MonthUiState.Success,
|
state: MonthUiState.Success,
|
||||||
selected: LocalDate?,
|
selected: LocalDate?,
|
||||||
selectionIndicatorVisible: Boolean = true,
|
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
onSelectDay: (LocalDate) -> Unit,
|
onSelectDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -1686,7 +1657,6 @@ internal fun SplitMonthGrid(
|
|||||||
// removes that without timing anything against the
|
// removes that without timing anything against the
|
||||||
// page transition.
|
// page transition.
|
||||||
isSelected = day == selected && inMonth,
|
isSelected = day == selected && inMonth,
|
||||||
selectionIndicatorVisible = selectionIndicatorVisible,
|
|
||||||
inMonth = inMonth,
|
inMonth = inMonth,
|
||||||
relativeToToday = todayCol?.let { col - it },
|
relativeToToday = todayCol?.let { col - it },
|
||||||
rippleProgress = { ripple.value },
|
rippleProgress = { ripple.value },
|
||||||
@@ -1715,7 +1685,6 @@ private fun SplitDayCell(
|
|||||||
hidden: List<EventInstance>,
|
hidden: List<EventInstance>,
|
||||||
isToday: Boolean,
|
isToday: Boolean,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
selectionIndicatorVisible: Boolean,
|
|
||||||
inMonth: Boolean,
|
inMonth: Boolean,
|
||||||
relativeToToday: Int?,
|
relativeToToday: Int?,
|
||||||
rippleProgress: () -> Float,
|
rippleProgress: () -> Float,
|
||||||
@@ -1745,7 +1714,7 @@ private fun SplitDayCell(
|
|||||||
// pace it lingered after the thing it marks had already moved.
|
// pace it lingered after the thing it marks had already moved.
|
||||||
val outlineSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
val outlineSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||||
val selection = animateFloatAsState(
|
val selection = animateFloatAsState(
|
||||||
targetValue = if (isSelected && selectionIndicatorVisible) 1f else 0f,
|
targetValue = if (isSelected) 1f else 0f,
|
||||||
animationSpec = if (reduceMotion) snap() else outlineSpec,
|
animationSpec = if (reduceMotion) snap() else outlineSpec,
|
||||||
label = "split-day-selection",
|
label = "split-day-selection",
|
||||||
)
|
)
|
||||||
@@ -1757,19 +1726,12 @@ private fun SplitDayCell(
|
|||||||
// built — and so the pill can be tagged for the morph on its own. Tagged as
|
// 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
|
// one piece with its contents inside, the dots would be nested shared
|
||||||
// elements within a shared element and travel twice.
|
// 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(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||||
.todayRipple(relativeToToday, rippleProgress)
|
.todayRipple(relativeToToday, rippleProgress)
|
||||||
.selectable(
|
.clip(CELL_SHAPE)
|
||||||
selected = isSelected,
|
.selectable(selected = isSelected, onClick = onClick),
|
||||||
interactionSource = interaction,
|
|
||||||
indication = null,
|
|
||||||
onClick = onClick,
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.TopCenter,
|
contentAlignment = Alignment.TopCenter,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -1831,13 +1793,6 @@ private fun SplitDayCell(
|
|||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
SplitDots(date = date, events = events, hidden = hidden, dark = dark)
|
SplitDots(date = date, events = events, hidden = hidden, dark = dark)
|
||||||
}
|
}
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.morphElement(MonthMorphKey.CellRipple(date))
|
|
||||||
.clip(CELL_SHAPE)
|
|
||||||
.indication(interaction, LocalIndication.current),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1859,10 +1814,7 @@ private fun SplitDots(
|
|||||||
if (events.isEmpty()) return
|
if (events.isEmpty()) return
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
val dimCutoff = LocalDimCutoff.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(
|
Row(
|
||||||
modifier = Modifier.height(SPLIT_DOT_SIZE),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
@@ -1897,7 +1849,6 @@ private fun SplitDots(
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.wrapContentHeight(unbounded = true)
|
|
||||||
.morphBounds(MonthMorphKey.Overflow(date))
|
.morphBounds(MonthMorphKey.Overflow(date))
|
||||||
.alpha(if (allEnded(hidden, date, dimCutoff)) EventDimAlpha else 1f),
|
.alpha(if (allEnded(hidden, date, dimCutoff)) EventDimAlpha else 1f),
|
||||||
)
|
)
|
||||||
@@ -1908,33 +1859,22 @@ private fun SplitDots(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SplitMonthAgendaPane(
|
private fun SplitMonthAgendaPane(
|
||||||
state: MonthUiState.Success,
|
state: MonthUiState.Success,
|
||||||
showToday: Boolean,
|
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateEvent: (LocalDate) -> Unit,
|
onCreateEvent: (LocalDate) -> Unit,
|
||||||
) {
|
) {
|
||||||
val days = remember(state.month, state.today, state.instancesByDay, showToday) {
|
val start = agendaStartForMonth(state.month, state.today)
|
||||||
splitAgendaDays(state.month, state.today, state.instancesByDay, showToday)
|
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 dimCutoff = LocalDimCutoff.current
|
||||||
val listState = key(state.month) { rememberLazyListState() }
|
val listState = key(state.month) { rememberLazyListState() }
|
||||||
LazyColumn(state = listState, contentPadding = PaddingValues(bottom = FabClearance)) {
|
LazyColumn(state = listState, contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
|
||||||
// A fixed first item, so a day added above the first listed one shows
|
|
||||||
// up rather than landing above a list that holds its scroll position.
|
|
||||||
item(key = "top") { Spacer(Modifier.height(4.dp)) }
|
|
||||||
if (days.isEmpty()) {
|
|
||||||
val start = agendaStartForMonth(state.month, state.today)
|
|
||||||
item(key = "empty") {
|
|
||||||
AgendaEmptyDayRow(
|
|
||||||
text = stringResource(
|
|
||||||
if (start == state.today) R.string.month_split_no_more_events else R.string.month_split_no_events_month,
|
|
||||||
),
|
|
||||||
onClick = { onCreateEvent(start) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
agendaDays(
|
agendaDays(
|
||||||
days = days,
|
days = days.map { AgendaDay(it, state.instancesByDay[it].orEmpty()) },
|
||||||
today = state.today,
|
today = state.today,
|
||||||
zone = state.zone,
|
zone = state.zone,
|
||||||
emptyText = R.string.month_split_no_events,
|
emptyText = R.string.month_split_no_events,
|
||||||
@@ -1942,7 +1882,6 @@ private fun SplitMonthAgendaPane(
|
|||||||
dimmed = { event, day -> dimCutoff != null && event.isPastOn(day, dimCutoff) },
|
dimmed = { event, day -> dimCutoff != null && event.isPastOn(day, dimCutoff) },
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
compact = true,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1950,8 +1889,7 @@ private fun SplitMonthAgendaPane(
|
|||||||
/**
|
/**
|
||||||
* The selected day's events, in the agenda's own row vocabulary so the two
|
* 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
|
* 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,
|
* the Week and Agenda headers (#37).
|
||||||
* whose ✕ clears the day filter back to the whole month (#142).
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SplitDayPane(
|
internal fun SplitDayPane(
|
||||||
@@ -1961,7 +1899,6 @@ internal fun SplitDayPane(
|
|||||||
events: List<EventInstance>?,
|
events: List<EventInstance>?,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onClearSelection: () -> Unit,
|
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateEvent: (LocalDate) -> Unit,
|
onCreateEvent: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -1973,23 +1910,7 @@ internal fun SplitDayPane(
|
|||||||
val enter = itemEnter()
|
val enter = itemEnter()
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
InputChip(
|
AgendaDayHeader(date = date, today = today, onOpenDay = onOpenDay)
|
||||||
selected = true,
|
|
||||||
onClick = { onOpenDay(date) },
|
|
||||||
label = {
|
|
||||||
Text(agendaDayLabel(date, today), style = MaterialTheme.typography.titleSmall)
|
|
||||||
},
|
|
||||||
trailingIcon = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Close,
|
|
||||||
contentDescription = stringResource(R.string.month_split_clear_filter),
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(CircleShape)
|
|
||||||
.clickable(onClick = onClearSelection),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.padding(start = 16.dp),
|
|
||||||
)
|
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = date to events,
|
targetState = date to events,
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
@@ -2012,7 +1933,7 @@ internal fun SplitDayPane(
|
|||||||
else -> LazyColumn(
|
else -> LazyColumn(
|
||||||
// Bottom inset clears the FAB stack so the last row stays
|
// Bottom inset clears the FAB stack so the last row stays
|
||||||
// tappable.
|
// tappable.
|
||||||
contentPadding = PaddingValues(bottom = FabClearance),
|
contentPadding = PaddingValues(bottom = 96.dp),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = dayEvents,
|
items = dayEvents,
|
||||||
@@ -2168,8 +2089,8 @@ private fun MonthWeekRow(
|
|||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
/** The zone the grid was laid out in — never re-read here (see [MonthUiState.Success.zone]). */
|
/** The zone the grid was laid out in — never re-read here (see [MonthUiState.Success.zone]). */
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
/** Prices chips' start times, once per grid; null when they are switched off (#219, #476). */
|
/** The narrowest chip that may carry a start time, measured once per grid (#219). */
|
||||||
timeFit: MonthChipTimeFit?,
|
timeChipWidth: Dp,
|
||||||
/** The height this row was given, which decides its lanes — see [laneCapFor]. */
|
/** The height this row was given, which decides its lanes — see [laneCapFor]. */
|
||||||
rowHeight: Dp,
|
rowHeight: Dp,
|
||||||
inMonth: (LocalDate) -> Boolean,
|
inMonth: (LocalDate) -> Boolean,
|
||||||
@@ -2295,8 +2216,8 @@ private fun MonthWeekRow(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
// What a chip has to spend, against what [timeFit] says its start
|
// What a chip has to spend, against the [timeChipWidth] a start time
|
||||||
// time costs it (#219).
|
// costs it (#219).
|
||||||
val colW = maxWidth / 7
|
val colW = maxWidth / 7
|
||||||
// Held here rather than read at the offset: the dots are placed
|
// 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
|
// inside a plain lambda, which is no longer in this scope. The box
|
||||||
@@ -2386,8 +2307,7 @@ private fun MonthWeekRow(
|
|||||||
continuesRight = span.continuesRight,
|
continuesRight = span.continuesRight,
|
||||||
days = week.days.subList(span.startCol, span.endCol + 1),
|
days = week.days.subList(span.startCol, span.endCol + 1),
|
||||||
time = chipTimes[span.event.instanceId],
|
time = chipTimes[span.event.instanceId],
|
||||||
timeFit = timeFit,
|
showTime = colW * cols >= timeChipWidth,
|
||||||
chipWidth = colW * cols,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(
|
.offset(
|
||||||
x = colW * span.startCol,
|
x = colW * span.startCol,
|
||||||
@@ -2464,8 +2384,7 @@ private fun MonthWeekRow(
|
|||||||
continuesRight = false,
|
continuesRight = false,
|
||||||
days = listOf(d),
|
days = listOf(d),
|
||||||
time = chipTimes[ev.instanceId],
|
time = chipTimes[ev.instanceId],
|
||||||
timeFit = timeFit,
|
showTime = colW >= timeChipWidth,
|
||||||
chipWidth = colW,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(
|
.offset(
|
||||||
x = colW * col,
|
x = colW * col,
|
||||||
@@ -2563,7 +2482,6 @@ private fun MonthWeekRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||||
.morphElement(MonthMorphKey.CellRipple(d))
|
|
||||||
.clip(CELL_SHAPE)
|
.clip(CELL_SHAPE)
|
||||||
.clickable {
|
.clickable {
|
||||||
// Cleared on read: a click with no fresh down
|
// Cleared on read: a click with no fresh down
|
||||||
@@ -2714,7 +2632,6 @@ private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier
|
|||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||||
.morphElement(MonthMorphKey.WeekNumber(weekStart))
|
|
||||||
.clip(CELL_SHAPE)
|
.clip(CELL_SHAPE)
|
||||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||||
.then(if (onOpenWeek != null) Modifier.clickable(onClickLabel = openLabel) {
|
.then(if (onOpenWeek != null) Modifier.clickable(onClickLabel = openLabel) {
|
||||||
@@ -2793,7 +2710,7 @@ private fun shortMonthName(date: LocalDate): String {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A filled event pill/bar — softened (or raw) fill, title clipped to one line,
|
* 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
|
@Composable
|
||||||
private fun MonthBar(
|
private fun MonthBar(
|
||||||
@@ -2810,13 +2727,10 @@ private fun MonthBar(
|
|||||||
days: List<LocalDate>? = null,
|
days: List<LocalDate>? = null,
|
||||||
/** The chip's start time, or null where it has none — see `monthChipTime` (#219). */
|
/** The chip's start time, or null where it has none — see `monthChipTime` (#219). */
|
||||||
time: String? = null,
|
time: String? = null,
|
||||||
/** Decides whether [time] is drawn at [chipWidth]; null draws none. A screen reader gets it either way. */
|
/** Whether this chip has the width to draw [time]; a screen reader gets it either way. */
|
||||||
timeFit: MonthChipTimeFit? = null,
|
showTime: Boolean = false,
|
||||||
/** The chip's full width, chrome included. */
|
|
||||||
chipWidth: Dp = 0.dp,
|
|
||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val showTime = time != null && timeFit != null && timeFit.showsTime(chipWidth, title)
|
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
// A bar is one piece, so it dims once the last day it covers has passed.
|
// A bar is one piece, so it dims once the last day it covers has passed.
|
||||||
val dimmed = dimCutoff != null &&
|
val dimmed = dimCutoff != null &&
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import kotlinx.datetime.yearMonth
|
|||||||
internal fun MonthStylePreview(
|
internal fun MonthStylePreview(
|
||||||
style: MonthViewStyle,
|
style: MonthViewStyle,
|
||||||
weekStart: DayOfWeek,
|
weekStart: DayOfWeek,
|
||||||
showStartTimes: Boolean,
|
|
||||||
height: Dp,
|
height: Dp,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -45,7 +44,6 @@ internal fun MonthStylePreview(
|
|||||||
MonthViewStyle.Paged -> MonthGrid(
|
MonthViewStyle.Paged -> MonthGrid(
|
||||||
state = sample.month,
|
state = sample.month,
|
||||||
showWeekNumbers = false,
|
showWeekNumbers = false,
|
||||||
showStartTimes = showStartTimes,
|
|
||||||
onOpenDay = {},
|
onOpenDay = {},
|
||||||
onEventClick = {},
|
onEventClick = {},
|
||||||
)
|
)
|
||||||
@@ -57,7 +55,6 @@ internal fun MonthStylePreview(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
showWeekNumbers = false,
|
showWeekNumbers = false,
|
||||||
showStartTimes = showStartTimes,
|
|
||||||
onOpenDay = {},
|
onOpenDay = {},
|
||||||
onEventClick = {},
|
onEventClick = {},
|
||||||
)
|
)
|
||||||
@@ -70,7 +67,6 @@ internal fun MonthStylePreview(
|
|||||||
initialFirstVisibleItemIndex = weekIndexOf(today, weekStart) - 1,
|
initialFirstVisibleItemIndex = weekIndexOf(today, weekStart) - 1,
|
||||||
),
|
),
|
||||||
showWeekNumbers = false,
|
showWeekNumbers = false,
|
||||||
showStartTimes = showStartTimes,
|
|
||||||
onOpenDay = {},
|
onOpenDay = {},
|
||||||
onEventClick = {},
|
onEventClick = {},
|
||||||
)
|
)
|
||||||
@@ -87,7 +83,6 @@ internal fun MonthStylePreview(
|
|||||||
events = sample.month.instancesByDay[today].orEmpty(),
|
events = sample.month.instancesByDay[today].orEmpty(),
|
||||||
zone = zone,
|
zone = zone,
|
||||||
onOpenDay = {},
|
onOpenDay = {},
|
||||||
onClearSelection = {},
|
|
||||||
onEventClick = {},
|
onEventClick = {},
|
||||||
onCreateEvent = {},
|
onCreateEvent = {},
|
||||||
modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT),
|
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.data.time.stateFollowingDays
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
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.ui.common.PageStateCache
|
||||||
import de.jeanlucmakiola.calendula.domain.isCrossedOut
|
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||||
import de.jeanlucmakiola.calendula.domain.sortedForDisplay
|
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||||
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||||
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
|
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
|
||||||
@@ -85,23 +81,6 @@ class MonthViewModel @Inject constructor(
|
|||||||
initialValue = false,
|
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). */
|
/** How the grid is laid out and navigated (#38, #53). */
|
||||||
val viewStyle: StateFlow<MonthViewStyle> = settingsPrefs.monthViewStyle
|
val viewStyle: StateFlow<MonthViewStyle> = settingsPrefs.monthViewStyle
|
||||||
.stateWhileSubscribed(
|
.stateWhileSubscribed(
|
||||||
@@ -116,7 +95,7 @@ class MonthViewModel @Inject constructor(
|
|||||||
val today: StateFlow<LocalDate> =
|
val today: StateFlow<LocalDate> =
|
||||||
todaySource.today.stateFollowingDays(viewModelScope, todayDate, ::followToday)
|
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
|
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. */
|
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
|
||||||
fun onPageSettled(page: Int) {
|
fun onPageSettled(page: Int) {
|
||||||
if (monthIndexOf(_month.value) == page) return
|
if (monthIndexOf(_month.value) == page) return
|
||||||
setMonth(yearMonthForIndex(page))
|
_month.value = yearMonthForIndex(page)
|
||||||
// Moving the grid to another month restores its full agenda.
|
// Moving the grid to another month restores its full agenda.
|
||||||
_selectedDate.value = null
|
_selectedDate.value = null
|
||||||
}
|
}
|
||||||
@@ -319,11 +298,9 @@ class MonthViewModel @Inject constructor(
|
|||||||
* silently list a date the grid isn't pointing at.
|
* silently list a date the grid isn't pointing at.
|
||||||
*/
|
*/
|
||||||
fun selectDate(date: LocalDate) {
|
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
|
val target = date.yearMonth
|
||||||
_selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date
|
_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 }
|
fun clearSelection() { _selectedDate.value = null }
|
||||||
@@ -332,16 +309,16 @@ class MonthViewModel @Inject constructor(
|
|||||||
fun stepSplitPane(days: Int) {
|
fun stepSplitPane(days: Int) {
|
||||||
val selected = _selectedDate.value
|
val selected = _selectedDate.value
|
||||||
if (selected == null) {
|
if (selected == null) {
|
||||||
setMonth(yearMonthForIndex(monthIndexOf(_month.value) + days))
|
_month.value = yearMonthForIndex(monthIndexOf(_month.value) + days)
|
||||||
} else {
|
} else {
|
||||||
val next = coerceToMonthRange(selected.plus(days, DateTimeUnit.DAY))
|
val next = selected.plus(days, DateTimeUnit.DAY)
|
||||||
_selectedDate.value = next
|
_selectedDate.value = next
|
||||||
setMonth(next.yearMonth)
|
_month.value = next.yearMonth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToToday() {
|
fun goToToday() {
|
||||||
setMonth(todayDate.yearMonth)
|
_month.value = todayDate.yearMonth
|
||||||
_selectedDate.value = null
|
_selectedDate.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +330,7 @@ class MonthViewModel @Inject constructor(
|
|||||||
private fun followToday(old: LocalDate, new: LocalDate) {
|
private fun followToday(old: LocalDate, new: LocalDate) {
|
||||||
if (viewStyle.value.isScrolling) return
|
if (viewStyle.value.isScrolling) return
|
||||||
if (_month.value != old.yearMonth) return
|
if (_month.value != old.yearMonth) return
|
||||||
setMonth(new.yearMonth)
|
_month.value = new.yearMonth
|
||||||
_selectedDate.value = null
|
_selectedDate.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,24 +344,14 @@ class MonthViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
fun syncScrollMonth(ym: YearMonth) {
|
fun syncScrollMonth(ym: YearMonth) {
|
||||||
if (_month.value == ym) return
|
if (_month.value == ym) return
|
||||||
setMonth(ym)
|
_month.value = ym
|
||||||
_selectedDate.value = null
|
_selectedDate.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Jump to the month containing [date] (drawer jump-to-date). */
|
||||||
* Jump to the month containing [date] (drawer jump-to-date, or the focus
|
|
||||||
* handed over by another view). The focus itself keeps a date outside the
|
|
||||||
* grid's span, so the day and week views return to it unchanged.
|
|
||||||
*/
|
|
||||||
fun goToDate(date: LocalDate) {
|
fun goToDate(date: LocalDate) {
|
||||||
val target = coerceToMonthRange(date)
|
_month.value = date.yearMonth
|
||||||
setMonth(target.yearMonth)
|
_selectedDate.value = date
|
||||||
_selectedDate.value = target
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every write to [_month] goes through here, so it can't leave the grid's span (#457). */
|
|
||||||
private fun setMonth(ym: YearMonth) {
|
|
||||||
_month.value = yearMonthForIndex(monthIndexOf(ym))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildState(
|
private fun buildState(
|
||||||
@@ -502,7 +469,7 @@ internal fun layoutCalendarWeek(
|
|||||||
// crossing midnight into the next week row covers a single day in each, and
|
// crossing midnight into the next week row covers a single day in each, and
|
||||||
// counting per row drew it as two unrelated pills (#253).
|
// counting per row drew it as two unrelated pills (#253).
|
||||||
val (bars, singles) = weekEvents.partition { ev ->
|
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 ->
|
val spans = layoutAllDay(bars, days, zone).map { s ->
|
||||||
MonthSpan(
|
MonthSpan(
|
||||||
@@ -521,7 +488,7 @@ internal fun layoutCalendarWeek(
|
|||||||
// Declined last, so a day that overflows drops the events you said
|
// Declined last, so a day that overflows drops the events you said
|
||||||
// no to before the ones you are actually going to (#230).
|
// no to before the ones you are actually going to (#230).
|
||||||
singles.filter { it.coversDay(d, zone) }
|
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) } },
|
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
||||||
)
|
)
|
||||||
@@ -542,7 +509,7 @@ internal fun instancesByDay(
|
|||||||
days.associateWith { day ->
|
days.associateWith { day ->
|
||||||
instances
|
instances
|
||||||
.filter { it.coversDay(day, zone) }
|
.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)
|
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.
|
* 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
|
* 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.
|
* 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
|
* 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
|
* the week-start preference — changing it reflows the rows *inside* a month
|
||||||
* block but never moves the block, so a scroll position stays put.
|
* block but never moves the block, so a scroll position stays put.
|
||||||
*/
|
*/
|
||||||
private val MONTH_INDEX_EPOCH_YEAR = CalendarSpan.FIRST_DAY.year
|
private const val MONTH_INDEX_EPOCH_YEAR = 1900
|
||||||
private val MONTH_INDEX_END_YEAR = CalendarSpan.LAST_DAY.year
|
private const val MONTH_INDEX_END_YEAR = 2100
|
||||||
|
|
||||||
/** The first and last day the month and Dense grids can show. */
|
|
||||||
private val GRID_FIRST_DAY = LocalDate(MONTH_INDEX_EPOCH_YEAR, 1, 1)
|
|
||||||
private val GRID_LAST_DAY = LocalDate(MONTH_INDEX_END_YEAR, 12, 31)
|
|
||||||
|
|
||||||
internal fun monthIndexOf(ym: YearMonth): Int =
|
internal fun monthIndexOf(ym: YearMonth): Int =
|
||||||
(ym.year - MONTH_INDEX_EPOCH_YEAR) * 12 + ym.month.ordinal
|
(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 = YearMonth(
|
||||||
internal fun yearMonthForIndex(index: Int): YearMonth {
|
MONTH_INDEX_EPOCH_YEAR + index / 12,
|
||||||
val held = clampMonthIndex(index)
|
Month.entries[index % 12],
|
||||||
return YearMonth(MONTH_INDEX_EPOCH_YEAR + held / 12, Month.entries[held % 12])
|
)
|
||||||
}
|
|
||||||
|
|
||||||
internal fun clampMonthIndex(index: Int): Int = index.coerceIn(0, continuousMonthCount() - 1)
|
/** Total months the continuous grid scrolls through (1900 → 2100). */
|
||||||
|
|
||||||
/**
|
|
||||||
* Hold [date] inside the span the month grid can show. Every view shares
|
|
||||||
* [CalendarSpan], but another app can open Calendula on any date (#457).
|
|
||||||
*/
|
|
||||||
internal fun coerceToMonthRange(date: LocalDate): LocalDate = date.coerceIn(GRID_FIRST_DAY, GRID_LAST_DAY)
|
|
||||||
|
|
||||||
internal fun isInMonthRange(date: LocalDate): Boolean = date in GRID_FIRST_DAY..GRID_LAST_DAY
|
|
||||||
|
|
||||||
/** Total months the continuous grid scrolls through. */
|
|
||||||
internal fun continuousMonthCount(): Int =
|
internal fun continuousMonthCount(): Int =
|
||||||
(MONTH_INDEX_END_YEAR - MONTH_INDEX_EPOCH_YEAR + 1) * 12
|
(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
|
* Hold a load window inside the months that actually exist. The pad
|
||||||
* [nextLoadWindow] adds can otherwise push an index past either end of the
|
* [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 =
|
internal fun clampMonthWindow(window: IntRange): IntRange {
|
||||||
clampMonthIndex(window.first)..clampMonthIndex(window.last)
|
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
|
* 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
|
* 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
|
* so — like the month space — every index is non-negative and the LazyColumn's
|
||||||
* item indices and week indices are the same number.
|
* item indices and week indices are the same number.
|
||||||
*
|
*
|
||||||
* Unlike month indices these *do* shift with the week-start preference, which is
|
* Unlike month indices these *do* shift with the week-start preference, which is
|
||||||
* why the list state is keyed on it.
|
* why the list state is keyed on it.
|
||||||
*/
|
*/
|
||||||
private val WEEK_INDEX_EPOCH = GRID_FIRST_DAY
|
private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1)
|
||||||
private val WEEK_INDEX_END = GRID_LAST_DAY
|
private val WEEK_INDEX_END = LocalDate(2100, 12, 31)
|
||||||
|
|
||||||
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
|
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
|
||||||
val base = WEEK_INDEX_EPOCH.startOfWeek(weekStart)
|
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 =
|
internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate =
|
||||||
WEEK_INDEX_EPOCH.startOfWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
|
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 =
|
internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
|
||||||
weekIndexOf(WEEK_INDEX_END, weekStart) + 1
|
weekIndexOf(WEEK_INDEX_END, weekStart) + 1
|
||||||
|
|
||||||
|
|||||||
@@ -220,7 +220,6 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
prefs.defaultView,
|
prefs.defaultView,
|
||||||
prefs.monthViewStyle,
|
prefs.monthViewStyle,
|
||||||
prefs.weekStart,
|
prefs.weekStart,
|
||||||
prefs.monthShowStartTimes,
|
|
||||||
::ViewChoice,
|
::ViewChoice,
|
||||||
).stateWhileSubscribed(
|
).stateWhileSubscribed(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@@ -465,7 +464,6 @@ data class ViewChoice(
|
|||||||
val defaultView: CalendarView,
|
val defaultView: CalendarView,
|
||||||
val monthViewStyle: MonthViewStyle,
|
val monthViewStyle: MonthViewStyle,
|
||||||
val weekStart: WeekStartPref,
|
val weekStart: WeekStartPref,
|
||||||
val monthShowStartTimes: Boolean,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ internal fun ViewStep(
|
|||||||
CalendarView.Month -> MonthStylePreview(
|
CalendarView.Month -> MonthStylePreview(
|
||||||
style = choice.monthViewStyle,
|
style = choice.monthViewStyle,
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
showStartTimes = choice.monthShowStartTimes,
|
|
||||||
height = PREVIEW_HEIGHT,
|
height = PREVIEW_HEIGHT,
|
||||||
)
|
)
|
||||||
CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT)
|
CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT)
|
||||||
@@ -119,12 +118,7 @@ internal fun MonthStyleStep(
|
|||||||
navigationIcon = navigationIcon,
|
navigationIcon = navigationIcon,
|
||||||
) {
|
) {
|
||||||
StepPreview(selected = choice.monthViewStyle) { style ->
|
StepPreview(selected = choice.monthViewStyle) { style ->
|
||||||
MonthStylePreview(
|
MonthStylePreview(style = style, weekStart = weekStart, height = PREVIEW_HEIGHT)
|
||||||
style = style,
|
|
||||||
weekStart = weekStart,
|
|
||||||
showStartTimes = choice.monthShowStartTimes,
|
|
||||||
height = PREVIEW_HEIGHT,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
GroupedHint(stringResource(choice.monthViewStyle.descriptionRes), verticalPadding = 8.dp)
|
GroupedHint(stringResource(choice.monthViewStyle.descriptionRes), verticalPadding = 8.dp)
|
||||||
options.forEachIndexed { index, style ->
|
options.forEachIndexed { index, style ->
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ import de.jeanlucmakiola.calendula.domain.MatchSpan
|
|||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.SearchHit
|
import de.jeanlucmakiola.calendula.domain.SearchHit
|
||||||
import de.jeanlucmakiola.calendula.domain.SearchMonth
|
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.LocalFloretDarkTheme
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
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.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
|
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
|
||||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
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
|
// Faded like a past event anywhere else in the app — search reaches back
|
||||||
// through the whole history.
|
// through the whole history.
|
||||||
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
|
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),
|
summary = searchSummary(hit, highlight),
|
||||||
position = position,
|
position = position,
|
||||||
minHeight = 64.dp,
|
minHeight = 64.dp,
|
||||||
@@ -610,7 +609,7 @@ private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString
|
|||||||
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())
|
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())
|
||||||
}
|
}
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
val timeText = if (event.coversWholeDays(TimeZone.currentSystemDefault())) {
|
val timeText = if (event.isAllDay) {
|
||||||
stringResource(R.string.event_detail_all_day)
|
stringResource(R.string.event_detail_all_day)
|
||||||
} else {
|
} else {
|
||||||
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import kotlinx.datetime.DayOfWeek
|
|||||||
internal fun MonthViewStylePicker(
|
internal fun MonthViewStylePicker(
|
||||||
selected: MonthViewStyle,
|
selected: MonthViewStyle,
|
||||||
weekStart: DayOfWeek,
|
weekStart: DayOfWeek,
|
||||||
showStartTimes: Boolean,
|
|
||||||
onSelect: (MonthViewStyle) -> Unit,
|
onSelect: (MonthViewStyle) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -51,7 +50,6 @@ internal fun MonthViewStylePicker(
|
|||||||
MonthStylePreview(
|
MonthStylePreview(
|
||||||
style = shown,
|
style = shown,
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
showStartTimes = showStartTimes,
|
|
||||||
height = PREVIEW_HEIGHT,
|
height = PREVIEW_HEIGHT,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ import de.jeanlucmakiola.floret.components.GroupedListInset
|
|||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||||
import de.jeanlucmakiola.floret.components.LanguagePickerRow
|
import de.jeanlucmakiola.floret.components.LanguagePickerRow
|
||||||
import de.jeanlucmakiola.calendula.widget.requestWidgetRedraw
|
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
@@ -259,16 +258,15 @@ private fun SettingsHub(
|
|||||||
leading = { CategoryIcon(Icons.Default.Widgets, ChipAccent.Tertiary) },
|
leading = { CategoryIcon(Icons.Default.Widgets, ChipAccent.Tertiary) },
|
||||||
onClick = { onOpenSection(SettingsSection.Widgets) },
|
onClick = { onOpenSection(SettingsSection.Widgets) },
|
||||||
)
|
)
|
||||||
val context = LocalContext.current
|
|
||||||
LanguagePickerRow(
|
LanguagePickerRow(
|
||||||
position = Position.Middle,
|
position = Position.Middle,
|
||||||
title = stringResource(R.string.settings_language),
|
title = stringResource(R.string.settings_language),
|
||||||
autoLabel = stringResource(R.string.settings_language_auto),
|
autoLabel = stringResource(R.string.settings_language_auto),
|
||||||
localesConfig = R.xml.locales_config,
|
localesConfig = R.xml.locales_config,
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
onChange = { requestWidgetRedraw(context) },
|
|
||||||
// Invite contributions right where users pick their language.
|
// Invite contributions right where users pick their language.
|
||||||
header = {
|
header = {
|
||||||
|
val context = LocalContext.current
|
||||||
val translateUrl = stringResource(R.string.about_translate_url)
|
val translateUrl = stringResource(R.string.about_translate_url)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_translate),
|
title = stringResource(R.string.settings_translate),
|
||||||
|
|||||||
@@ -42,8 +42,6 @@ data class SettingsUiState(
|
|||||||
val dimCompletedEvents: Boolean = false,
|
val dimCompletedEvents: Boolean = false,
|
||||||
/** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */
|
/** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */
|
||||||
val showWeekNumbers: Boolean = false,
|
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). */
|
/** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */
|
||||||
val todayButtonInToolbar: Boolean = false,
|
val todayButtonInToolbar: Boolean = false,
|
||||||
/** Whether events can be dragged to another slot to reschedule them (#68, #173). */
|
/** Whether events can be dragged to another slot to reschedule them (#68, #173). */
|
||||||
|
|||||||
@@ -101,7 +101,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
pastEventDisplay = this[SettingsKeys.pastEventDisplay],
|
pastEventDisplay = this[SettingsKeys.pastEventDisplay],
|
||||||
dimCompletedEvents = this[SettingsKeys.dimCompletedEvents],
|
dimCompletedEvents = this[SettingsKeys.dimCompletedEvents],
|
||||||
showWeekNumbers = this[SettingsKeys.showWeekNumbers],
|
showWeekNumbers = this[SettingsKeys.showWeekNumbers],
|
||||||
monthShowStartTimes = this[SettingsKeys.monthShowStartTimes],
|
|
||||||
todayButtonInToolbar = this[SettingsKeys.todayButtonInToolbar],
|
todayButtonInToolbar = this[SettingsKeys.todayButtonInToolbar],
|
||||||
dragToReschedule = this[SettingsKeys.dragToReschedule],
|
dragToReschedule = this[SettingsKeys.dragToReschedule],
|
||||||
tapOpensEditor = this[SettingsKeys.tapOpensEditor],
|
tapOpensEditor = this[SettingsKeys.tapOpensEditor],
|
||||||
@@ -293,8 +292,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun setShowWeekNumbers(enabled: Boolean) = save { setShowWeekNumbers(enabled) }
|
fun setShowWeekNumbers(enabled: Boolean) = save { setShowWeekNumbers(enabled) }
|
||||||
|
|
||||||
fun setMonthShowStartTimes(enabled: Boolean) = save { setMonthShowStartTimes(enabled) }
|
|
||||||
|
|
||||||
fun setTodayButtonInToolbar(enabled: Boolean) = save { setTodayButtonInToolbar(enabled) }
|
fun setTodayButtonInToolbar(enabled: Boolean) = save { setTodayButtonInToolbar(enabled) }
|
||||||
|
|
||||||
fun setDragToReschedule(enabled: Boolean) = save { setDragToReschedule(enabled) }
|
fun setDragToReschedule(enabled: Boolean) = save { setDragToReschedule(enabled) }
|
||||||
|
|||||||
@@ -160,13 +160,6 @@ internal fun ViewsScreen(
|
|||||||
checked = state.showWeekNumbers,
|
checked = state.showWeekNumbers,
|
||||||
onCheckedChange = viewModel::setShowWeekNumbers,
|
onCheckedChange = viewModel::setShowWeekNumbers,
|
||||||
summary = stringResource(R.string.settings_week_numbers_summary),
|
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,
|
position = Position.Bottom,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -303,7 +296,6 @@ internal fun ViewsScreen(
|
|||||||
selected = state.monthViewStyle,
|
selected = state.monthViewStyle,
|
||||||
// The preview is a real grid, so it uses the real week start too.
|
// The preview is a real grid, so it uses the real week start too.
|
||||||
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
||||||
showStartTimes = state.monthShowStartTimes,
|
|
||||||
onSelect = viewModel::setMonthViewStyle,
|
onSelect = viewModel::setMonthViewStyle,
|
||||||
onDismiss = { showMonthStyle = false },
|
onDismiss = { showMonthStyle = false },
|
||||||
)
|
)
|
||||||
@@ -332,7 +324,6 @@ internal fun ViewsScreen(
|
|||||||
selected = state.weekStart,
|
selected = state.weekStart,
|
||||||
// The preview is a real grid, so it uses the user's own month style.
|
// The preview is a real grid, so it uses the user's own month style.
|
||||||
monthStyle = state.monthViewStyle,
|
monthStyle = state.monthViewStyle,
|
||||||
showStartTimes = state.monthShowStartTimes,
|
|
||||||
options = WEEK_START_OPTIONS,
|
options = WEEK_START_OPTIONS,
|
||||||
onSelect = viewModel::setWeekStart,
|
onSelect = viewModel::setWeekStart,
|
||||||
onDismiss = { showWeekStart = false },
|
onDismiss = { showWeekStart = false },
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import de.jeanlucmakiola.floret.locale.currentLocale
|
|||||||
internal fun WeekStartPicker(
|
internal fun WeekStartPicker(
|
||||||
selected: WeekStartPref,
|
selected: WeekStartPref,
|
||||||
monthStyle: MonthViewStyle,
|
monthStyle: MonthViewStyle,
|
||||||
showStartTimes: Boolean,
|
|
||||||
options: List<WeekStartPref>,
|
options: List<WeekStartPref>,
|
||||||
onSelect: (WeekStartPref) -> Unit,
|
onSelect: (WeekStartPref) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
@@ -47,7 +46,6 @@ internal fun WeekStartPicker(
|
|||||||
MonthStylePreview(
|
MonthStylePreview(
|
||||||
style = monthStyle,
|
style = monthStyle,
|
||||||
weekStart = day,
|
weekStart = day,
|
||||||
showStartTimes = showStartTimes,
|
|
||||||
height = PREVIEW_HEIGHT,
|
height = PREVIEW_HEIGHT,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import androidx.compose.runtime.Stable
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
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.TimelineDragController
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineResizeDrop
|
import de.jeanlucmakiola.calendula.ui.common.TimelineResizeDrop
|
||||||
@@ -26,7 +25,6 @@ internal class TimelineActions(
|
|||||||
val onCreateAt: (LocalDate, Int) -> Unit,
|
val onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
val onDrop: (TimelineDrop) -> Unit,
|
val onDrop: (TimelineDrop) -> Unit,
|
||||||
val onResize: (TimelineResizeDrop) -> Unit,
|
val onResize: (TimelineResizeDrop) -> Unit,
|
||||||
val onStripDrop: (StripDrop) -> Unit,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/** The callbacks are read through their latest values, so the holder outlives them. */
|
/** The callbacks are read through their latest values, so the holder outlives them. */
|
||||||
@@ -39,14 +37,12 @@ internal fun rememberTimelineActions(
|
|||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
onResize: (TimelineResizeDrop) -> Unit,
|
onResize: (TimelineResizeDrop) -> Unit,
|
||||||
onStripDrop: (StripDrop) -> Unit,
|
|
||||||
): TimelineActions {
|
): TimelineActions {
|
||||||
val eventClick = rememberUpdatedState(onEventClick)
|
val eventClick = rememberUpdatedState(onEventClick)
|
||||||
val openDay = rememberUpdatedState(onOpenDay)
|
val openDay = rememberUpdatedState(onOpenDay)
|
||||||
val createAt = rememberUpdatedState(onCreateAt)
|
val createAt = rememberUpdatedState(onCreateAt)
|
||||||
val drop = rememberUpdatedState(onDrop)
|
val drop = rememberUpdatedState(onDrop)
|
||||||
val resize = rememberUpdatedState(onResize)
|
val resize = rememberUpdatedState(onResize)
|
||||||
val stripDrop = rememberUpdatedState(onStripDrop)
|
|
||||||
return remember(scrollState, dragController) {
|
return remember(scrollState, dragController) {
|
||||||
TimelineActions(
|
TimelineActions(
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
@@ -56,7 +52,6 @@ internal fun rememberTimelineActions(
|
|||||||
onCreateAt = { date, minutes -> createAt.value(date, minutes) },
|
onCreateAt = { date, minutes -> createAt.value(date, minutes) },
|
||||||
onDrop = { drop.value(it) },
|
onDrop = { drop.value(it) },
|
||||||
onResize = { resize.value(it) },
|
onResize = { resize.value(it) },
|
||||||
onStripDrop = { stripDrop.value(it) },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.week
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarSpan
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
|
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -8,7 +7,16 @@ import kotlinx.datetime.LocalDate
|
|||||||
import kotlinx.datetime.daysUntil
|
import kotlinx.datetime.daysUntil
|
||||||
import kotlinx.datetime.plus
|
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)
|
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. */
|
/** 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
|
* 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].
|
* [phase] days from a fixed epoch, over [pageCount] pages centred on it. The
|
||||||
* The multi-day view's phase (#193) stays put while paging and only changes when
|
* multi-day view's phase (#193) stays put while paging and only changes when a
|
||||||
* a jump moves another date to the first column.
|
* 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() = pageCount / 2
|
||||||
private val epochPage: Int get() = -pagesFromEpoch(CalendarSpan.FIRST_DAY)
|
|
||||||
|
|
||||||
val pageCount: Int get() = epochPage + pagesFromEpoch(CalendarSpan.LAST_DAY) + 1
|
|
||||||
|
|
||||||
private fun pagesFromEpoch(date: LocalDate): Int =
|
|
||||||
Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount)
|
|
||||||
|
|
||||||
/** First day shown on pager [page]. */
|
/** First day shown on pager [page]. */
|
||||||
fun startForPage(page: Int): LocalDate =
|
fun startForPage(page: Int): LocalDate =
|
||||||
@@ -36,16 +38,18 @@ data class DaysGrid(val dayCount: Int, val phase: Int) {
|
|||||||
|
|
||||||
/** The pager page showing [date]. */
|
/** The pager page showing [date]. */
|
||||||
fun pageFor(date: LocalDate): Int =
|
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 {
|
companion object {
|
||||||
/** Single days: every date starts a page of its own. */
|
/** 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]. */
|
/** Whole weeks, starting on [firstDay]. */
|
||||||
fun weeks(firstDay: DayOfWeek): DaysGrid = DaysGrid(
|
fun weeks(firstDay: DayOfWeek): DaysGrid = DaysGrid(
|
||||||
dayCount = 7,
|
dayCount = 7,
|
||||||
phase = DAYS_EPOCH.daysUntil(WEEK_EPOCH.startOfWeek(firstDay)),
|
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. */
|
/** 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.clip
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
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.layout.layout
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
@@ -132,9 +125,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
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.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
|
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventPaint
|
import de.jeanlucmakiola.calendula.ui.common.eventPaint
|
||||||
@@ -157,11 +147,10 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
||||||
import de.jeanlucmakiola.calendula.ui.common.StripDrag
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.StripDrop
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
||||||
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
|
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.continuesBefore
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ChipCuts
|
import de.jeanlucmakiola.calendula.ui.common.ChipCuts
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts
|
import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts
|
||||||
@@ -196,10 +185,8 @@ import de.jeanlucmakiola.floret.locale.currentLocale
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
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.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.rememberTimelinePinchZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
|
import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
|
||||||
@@ -211,7 +198,6 @@ import de.jeanlucmakiola.calendula.ui.common.hourGridCells
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
@@ -615,21 +601,6 @@ private fun WeekContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Off the all-day strip: sideways moves days, into the grid makes it timed.
|
|
||||||
val onStripDrop: (StripDrop) -> Unit = { drop ->
|
|
||||||
move?.move(
|
|
||||||
MoveRequest(
|
|
||||||
eventId = drop.event.eventId,
|
|
||||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
|
||||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
|
||||||
target = when (drop) {
|
|
||||||
is StripDrop.Days -> MoveTarget.ByDays(drop.days)
|
|
||||||
is StripDrop.Timed -> MoveTarget.Span(drop.start, drop.end)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A drafted block (#153) opens the new-event form at its own instants: the
|
// A drafted block (#153) opens the new-event form at its own instants: the
|
||||||
// end it is drawn to differs on a DST change (#387).
|
// end it is drawn to differs on a DST change (#387).
|
||||||
val createDraft: (EventInstance) -> Unit = { draft ->
|
val createDraft: (EventInstance) -> Unit = { draft ->
|
||||||
@@ -690,7 +661,6 @@ private fun WeekContent(
|
|||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
onResize = onResize,
|
onResize = onResize,
|
||||||
onStripDrop = onStripDrop,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
@@ -766,12 +736,7 @@ private fun WeekPager(
|
|||||||
val loaded = pageState as? WeekUiState.Success
|
val loaded = pageState as? WeekUiState.Success
|
||||||
// Until its days arrive a page shows their frame without events.
|
// Until its days arrive a page shows their frame without events.
|
||||||
val empty = remember(pageSpan, today) { emptySpan(pageSpan, today) }
|
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 stripHeight = loaded?.allDayStripHeight()
|
||||||
val held = (dragController.stripDrag ?: dragController.stripSettling)
|
|
||||||
?.takeIf { page == pagerState.currentPage }
|
|
||||||
val zone = LocalTimelineZone.current
|
|
||||||
val shown = remember(loaded, held, zone) { loaded?.withStripPreview(held, zone) }
|
|
||||||
val stripHeight = shown?.allDayStripHeight()
|
|
||||||
DisposableEffect(page, stripHeight) {
|
DisposableEffect(page, stripHeight) {
|
||||||
if (stripHeight != null) stripHeights[page] = stripHeight
|
if (stripHeight != null) stripHeights[page] = stripHeight
|
||||||
onDispose { stripHeights.remove(page) }
|
onDispose { stripHeights.remove(page) }
|
||||||
@@ -779,8 +744,8 @@ private fun WeekPager(
|
|||||||
ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) {
|
ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) {
|
||||||
WeekPage(
|
WeekPage(
|
||||||
// A cached page may predate midnight; today comes off the clock (#373).
|
// A cached page may predate midnight; today comes off the clock (#373).
|
||||||
state = remember(shown, empty, today) {
|
state = remember(loaded, empty, today) {
|
||||||
shown?.takeIf { it.today != today }?.copy(today = today) ?: shown ?: empty
|
loaded?.takeIf { it.today != today }?.copy(today = today) ?: loaded ?: empty
|
||||||
},
|
},
|
||||||
headerBias = kind.headerBias,
|
headerBias = kind.headerBias,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
@@ -792,25 +757,6 @@ private fun WeekPager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* [this] with the strip bar [held] sideways laid out at the days it is over, so
|
|
||||||
* the bars there move aside live. The bar keeps its own event, and with it its
|
|
||||||
* key and the gesture on it; one moved off the page keeps a lane it is not seen in.
|
|
||||||
*/
|
|
||||||
internal fun WeekUiState.Success.withStripPreview(held: StripDrag?, zone: TimeZone): WeekUiState.Success {
|
|
||||||
if (held == null || held.overGrid || held.dayShift == 0) return this
|
|
||||||
val id = held.event.instanceId
|
|
||||||
val original = allDaySpans.firstOrNull { it.event.instanceId == id } ?: return this
|
|
||||||
val events = allDaySpans.map { if (it.event.instanceId == id) it.event.shiftedDays(held.dayShift, zone) else it.event }
|
|
||||||
val spans = layoutAllDay(events, days, zone).map { if (it.event.instanceId == id) it.copy(event = original.event) else it }
|
|
||||||
return copy(allDaySpans = if (spans.any { it.event.instanceId == id }) spans else spans + original.copy(lane = 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun EventInstance.shiftedDays(days: Int, zone: TimeZone): EventInstance {
|
|
||||||
val dates = dateZone(zone)
|
|
||||||
return copy(start = start.plus(days, DateTimeUnit.DAY, dates), end = end.plus(days, DateTimeUnit.DAY, dates))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A page with no events yet, for one whose days are still loading. */
|
/** A page with no events yet, for one whose days are still loading. */
|
||||||
private fun emptySpan(span: DaySpan, today: LocalDate): WeekUiState.Success {
|
private fun emptySpan(span: DaySpan, today: LocalDate): WeekUiState.Success {
|
||||||
val days = (0 until span.count).map { span.start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) }
|
val days = (0 until span.count).map { span.start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) }
|
||||||
@@ -946,9 +892,8 @@ private fun WeekFrame(
|
|||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val zoom = LocalTimelineZoom.current
|
val zoom = LocalTimelineZoom.current
|
||||||
val fitHeight = dayFitHeight(
|
val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight
|
||||||
if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight,
|
else maxHeight
|
||||||
)
|
|
||||||
val hourHeight = zoom.scale.hourHeight(fitHeight)
|
val hourHeight = zoom.scale.hourHeight(fitHeight)
|
||||||
HourGutter(
|
HourGutter(
|
||||||
scrollState = actions.scrollState,
|
scrollState = actions.scrollState,
|
||||||
@@ -1047,7 +992,7 @@ private fun TimelinePageContent(
|
|||||||
LocalChipGround provides MaterialTheme.colorScheme.surface,
|
LocalChipGround provides MaterialTheme.colorScheme.surface,
|
||||||
) {
|
) {
|
||||||
AllDayStrip(geometry = geometry, bars = bars, height = allDayHeight,
|
AllDayStrip(geometry = geometry, bars = bars, height = allDayHeight,
|
||||||
animatedHeight = animatedAllDayHeight, actions = actions, active = active)
|
animatedHeight = animatedAllDayHeight, onEventClick = actions.onEventClick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(TIMELINE_TOP_GAP))
|
Spacer(Modifier.height(TIMELINE_TOP_GAP))
|
||||||
@@ -1182,29 +1127,9 @@ private fun AllDayStrip(
|
|||||||
bars: List<PlacedBar>,
|
bars: List<PlacedBar>,
|
||||||
height: Dp,
|
height: Dp,
|
||||||
animatedHeight: (() -> Dp)? = null,
|
animatedHeight: (() -> Dp)? = null,
|
||||||
actions: TimelineActions,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
active: Boolean,
|
|
||||||
) {
|
) {
|
||||||
val dark = LocalFloretDarkTheme.current
|
val dark = LocalFloretDarkTheme.current
|
||||||
val zone = LocalTimelineZone.current
|
|
||||||
val dragController = actions.dragController
|
|
||||||
// Only the page the finger is on draws the drag; the controller is shared.
|
|
||||||
val held = (dragController.stripDrag ?: dragController.stripSettling)?.takeIf { active }
|
|
||||||
val sideways = held?.takeIf { !it.overGrid }
|
|
||||||
val lifted = dragController.liftedInstanceId?.takeIf { active && dragController.stripDrag?.overGrid != false }
|
|
||||||
val settling = dragController.stripSettling?.takeIf { active }
|
|
||||||
// Seated once the re-read draws the event at the days it was dropped on.
|
|
||||||
LaunchedEffect(settling, bars) {
|
|
||||||
if (settling == null) return@LaunchedEffect
|
|
||||||
val target = settling.event.spanFirstDay(zone).plus(settling.dayShift, DateTimeUnit.DAY)
|
|
||||||
val landed = bars.any { bar ->
|
|
||||||
bar.event.instanceId != settling.event.instanceId &&
|
|
||||||
(bar.event.eventId == settling.event.eventId ||
|
|
||||||
(settling.event.title.isNotBlank() && bar.event.title == settling.event.title)) &&
|
|
||||||
bar.event.spanFirstDay(zone) == target
|
|
||||||
}
|
|
||||||
if (landed) dragController.seatStrip()
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -1225,20 +1150,12 @@ private fun AllDayStrip(
|
|||||||
content = {
|
content = {
|
||||||
bars.forEach { bar ->
|
bars.forEach { bar ->
|
||||||
key(bar.event.eventId, bar.event.start) {
|
key(bar.event.eventId, bar.event.start) {
|
||||||
val moving = sideways?.event?.instanceId == bar.event.instanceId
|
|
||||||
val lane = animatedStripLane(bar.toLane)
|
|
||||||
AllDayBar(
|
AllDayBar(
|
||||||
event = bar.event,
|
event = bar.event,
|
||||||
lastDay = bar.toRange.endInclusive,
|
lastDay = bar.toRange.endInclusive,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
dragController = dragController,
|
onClick = { onEventClick(bar.event) },
|
||||||
lifted = moving,
|
|
||||||
ghosted = lifted == bar.event.instanceId,
|
|
||||||
onClick = { actions.onEventClick(bar.event) },
|
|
||||||
onDrop = actions.onStripDrop,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.then(if (moving) Modifier.zIndex(1f) else Modifier)
|
|
||||||
.then(StripLane(lane))
|
|
||||||
.morphEvent(ViewMorphKey.event(bar.event, bar.keyDate))
|
.morphEvent(ViewMorphKey.event(bar.event, bar.keyDate))
|
||||||
.padding(horizontal = 1.dp, vertical = 1.dp),
|
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||||
)
|
)
|
||||||
@@ -1255,19 +1172,10 @@ private fun AllDayStrip(
|
|||||||
val progress = geometry.progress()
|
val progress = geometry.progress()
|
||||||
val placed = measurables.mapIndexed { i, measurable ->
|
val placed = measurables.mapIndexed { i, measurable ->
|
||||||
val bar = bars[i]
|
val bar = bars[i]
|
||||||
val shift = sideways?.takeIf { it.event.instanceId == bar.event.instanceId }?.dayShift ?: 0
|
val extent = geometry.extent(bar.fromRange, bar.toRange, width, 0f)
|
||||||
val extent = if (shift == 0) {
|
|
||||||
geometry.extent(bar.fromRange, bar.toRange, width, 0f)
|
|
||||||
} else {
|
|
||||||
// The whole event, not the part this page shows: it may come in from off screen.
|
|
||||||
val moved = bar.event.spanFirstDay(zone)..bar.event.spanLastDay(zone)
|
|
||||||
geometry.extent(moved.shiftedBy(shift), moved.shiftedBy(shift), width, 0f)
|
|
||||||
}
|
|
||||||
val left = extent.left.roundToInt()
|
val left = extent.left.roundToInt()
|
||||||
val barWidth = (extent.right.roundToInt() - left).coerceAtLeast(0)
|
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 y = (lerp(bar.fromLane.toFloat(), bar.toLane.toFloat(), progress) * rowPx).roundToInt()
|
||||||
val lane = (measurable.parentData as? StripLane)?.lane?.takeIf { bar.fromLane == bar.toLane }
|
|
||||||
val y = ((lane?.value ?: lerp(bar.fromLane.toFloat(), bar.toLane.toFloat(), progress)) * rowPx).roundToInt()
|
|
||||||
Triple(measurable.measure(Constraints.fixed(barWidth, rowPx.roundToInt())), left, y)
|
Triple(measurable.measure(Constraints.fixed(barWidth, rowPx.roundToInt())), left, y)
|
||||||
}
|
}
|
||||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||||
@@ -1277,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
|
@Composable
|
||||||
private fun AllDayBar(
|
private fun AllDayBar(
|
||||||
event: EventInstance,
|
event: EventInstance,
|
||||||
lastDay: LocalDate,
|
lastDay: LocalDate,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
dragController: TimelineDragController,
|
|
||||||
lifted: Boolean,
|
|
||||||
ghosted: Boolean,
|
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onDrop: (StripDrop) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
@@ -1309,29 +1198,8 @@ private fun AllDayBar(
|
|||||||
// A bar is one piece, so it dims once the last day it covers here has passed.
|
// A bar is one piece, so it dims once the last day it covers here has passed.
|
||||||
val dimmed = dimCutoff != null && event.isPastOn(lastDay, dimCutoff)
|
val dimmed = dimCutoff != null && event.isPastOn(lastDay, dimCutoff)
|
||||||
val paint = eventPaint(event, dark)
|
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(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
.alpha(ghost)
|
|
||||||
.selectionLift(raise, EventChipShape)
|
|
||||||
.then(dragModifier)
|
|
||||||
.eventSurface(paint, EventChipShape)
|
.eventSurface(paint, EventChipShape)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
@@ -1386,9 +1254,7 @@ private fun Timeline(
|
|||||||
else Modifier.stableMorphViewport(animatedAllDayHeight, fullViewportHeight),
|
else Modifier.stableMorphViewport(animatedAllDayHeight, fullViewportHeight),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val fitHeight = dayFitHeight(
|
val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight
|
||||||
if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight,
|
|
||||||
)
|
|
||||||
val hourHeight = zoom.scale.hourHeight(fitHeight)
|
val hourHeight = zoom.scale.hourHeight(fitHeight)
|
||||||
val totalHeight = hourHeight * 24
|
val totalHeight = hourHeight * 24
|
||||||
// The pinch sits above the scroll viewport: it has to outrank the
|
// The pinch sits above the scroll viewport: it has to outrank the
|
||||||
@@ -1421,11 +1287,9 @@ private fun Timeline(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.then(pinch)
|
.then(pinch)
|
||||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||||
.clip(RoundedCornerShape(TIMELINE_CORNER))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState)
|
||||||
.onGloballyPositioned { if (active) dragController.geometry.viewport = it }
|
.onGloballyPositioned { if (active) dragController.geometry.viewport = it },
|
||||||
// Past midnight, outside the columns: nothing there takes a tap or a drop.
|
|
||||||
.padding(bottom = FabClearance),
|
|
||||||
) {
|
) {
|
||||||
TimelineColumns(
|
TimelineColumns(
|
||||||
geometry = geometry,
|
geometry = geometry,
|
||||||
@@ -1461,7 +1325,6 @@ private fun Timeline(
|
|||||||
visibleTop = visibleTop,
|
visibleTop = visibleTop,
|
||||||
viewportHeight = visibleViewport,
|
viewportHeight = visibleViewport,
|
||||||
actions = actions,
|
actions = actions,
|
||||||
shape = columnShape(day == geometry.dates.first(), day == geometry.dates.last()),
|
|
||||||
modifier = Modifier.fillMaxHeight(),
|
modifier = Modifier.fillMaxHeight(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1469,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
|
@Composable
|
||||||
private fun DayColumnCard(
|
private fun DayColumnCard(
|
||||||
blocks: List<TimedBlock>,
|
blocks: List<TimedBlock>,
|
||||||
@@ -1493,7 +1343,6 @@ private fun DayColumnCard(
|
|||||||
visibleTop: () -> Float,
|
visibleTop: () -> Float,
|
||||||
viewportHeight: () -> Float,
|
viewportHeight: () -> Float,
|
||||||
actions: TimelineActions,
|
actions: TimelineActions,
|
||||||
shape: Shape = RectangleShape,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scrollState = actions.scrollState
|
val scrollState = actions.scrollState
|
||||||
@@ -1524,8 +1373,8 @@ private fun DayColumnCard(
|
|||||||
val draftSurface = rememberDraftSurface(date, edges, hourPx, dragController, actions.onDrop)
|
val draftSurface = rememberDraftSurface(date, edges, hourPx, dragController, actions.onDrop)
|
||||||
Card(
|
Card(
|
||||||
// Plain rectangular columns — the soft corners come from the outer
|
// Plain rectangular columns — the soft corners come from the outer
|
||||||
// rounded scroll viewport, apart from the outer ones' bottoms ([columnShape]).
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
shape = shape,
|
shape = RectangleShape,
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
// With the grid on, the container colour moves onto the hour cells
|
// With the grid on, the container colour moves onto the hour cells
|
||||||
// and the column behind them recedes to surface, so the gap between
|
// and the column behind them recedes to surface, so the gap between
|
||||||
@@ -1565,6 +1414,7 @@ private fun DayColumnCard(
|
|||||||
// block or handle on top can claim the press first.
|
// block or handle on top can claim the press first.
|
||||||
.then(draftSurface),
|
.then(draftSurface),
|
||||||
) {
|
) {
|
||||||
|
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
||||||
// Keyed by event, so a block that changes time or lane is the *same*
|
// Keyed by event, so a block that changes time or lane is the *same*
|
||||||
// composable afterwards and tweens there. The ordinal disambiguates
|
// composable afterwards and tweens there. The ordinal disambiguates
|
||||||
// a column holding two occurrences of one series.
|
// a column holding two occurrences of one series.
|
||||||
@@ -1572,17 +1422,9 @@ private fun DayColumnCard(
|
|||||||
shown.forEach { block ->
|
shown.forEach { block ->
|
||||||
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
||||||
key(block.event.eventId, ordinal) {
|
key(block.event.eventId, ordinal) {
|
||||||
// Where this block is cut at midnight, which decides both
|
val top = hourHeight * (block.startMin / 60f)
|
||||||
// its shape and the half-gap it gives up to the hour cells.
|
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||||
val cuts = remember(block, date, zone) {
|
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||||
timedBlockCuts(
|
|
||||||
block.continuesBefore(date, zone),
|
|
||||||
block.continuesAfter,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val span = remember(block) { block.renderedSpan() }
|
|
||||||
val top = hourHeight * (span.topMin / 60f)
|
|
||||||
val height = hourHeight * ((span.bottomMin - span.topMin) / 60f)
|
|
||||||
val place = animatedBlockPlacement(
|
val place = animatedBlockPlacement(
|
||||||
xFraction = block.lane.toFloat() / block.laneCount,
|
xFraction = block.lane.toFloat() / block.laneCount,
|
||||||
widthFraction = 1f / block.laneCount,
|
widthFraction = 1f / block.laneCount,
|
||||||
@@ -1590,6 +1432,14 @@ private fun DayColumnCard(
|
|||||||
height = height,
|
height = height,
|
||||||
landing = dragController.ghosts(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 topInset = hourCellBlockInset(showHourGrid, cuts.top)
|
||||||
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
|
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
|
||||||
val blockHeight = (place.height - topInset - bottomInset)
|
val blockHeight = (place.height - topInset - bottomInset)
|
||||||
@@ -1925,7 +1775,7 @@ private fun rememberDraftSurface(
|
|||||||
block = block,
|
block = block,
|
||||||
zone = zone,
|
zone = zone,
|
||||||
pointerInRoot = pointer,
|
pointerInRoot = pointer,
|
||||||
blockInRoot = node + Offset(0f, block.renderedSpan().topMin / 60f * hour),
|
blockInRoot = node + Offset(0f, startMin / 60f * hour),
|
||||||
)
|
)
|
||||||
true
|
true
|
||||||
},
|
},
|
||||||
@@ -1973,10 +1823,8 @@ private fun WeekLoading(dayCount: Int) {
|
|||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
// Same scale resolution as the loaded timeline, so the skeleton's
|
// Same scale resolution as the loaded timeline, so the skeleton's
|
||||||
// columns don't resize the moment the real week arrives.
|
// columns don't resize the moment the real week arrives.
|
||||||
val fitHeight = dayFitHeight(
|
val fitHeight = maxHeight + SKELETON_HEADER_HEIGHT + SKELETON_HEADER_PADDING * 2 -
|
||||||
maxHeight + SKELETON_HEADER_HEIGHT + SKELETON_HEADER_PADDING * 2 -
|
headerHeight - TIMELINE_TOP_GAP
|
||||||
headerHeight - TIMELINE_TOP_GAP,
|
|
||||||
)
|
|
||||||
val hourHeight = scale.hourHeight(fitHeight)
|
val hourHeight = scale.hourHeight(fitHeight)
|
||||||
val totalHeight = hourHeight * 24
|
val totalHeight = hourHeight * 24
|
||||||
// The skeleton wears the loaded columns' own ground, so the arrival
|
// The skeleton wears the loaded columns' own ground, so the arrival
|
||||||
@@ -1989,8 +1837,7 @@ private fun WeekLoading(dayCount: Int) {
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState),
|
||||||
.padding(bottom = FabClearance),
|
|
||||||
) {
|
) {
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
repeat(dayCount) {
|
repeat(dayCount) {
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import kotlinx.datetime.LocalDate
|
|||||||
* minimum tap-target height)
|
* minimum tap-target height)
|
||||||
* @param lane 0-based column within [laneCount]
|
* @param lane 0-based column within [laneCount]
|
||||||
* @param laneCount number of columns the event's overlap-cluster needs
|
* @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(
|
data class TimedBlock(
|
||||||
val event: EventInstance,
|
val event: EventInstance,
|
||||||
@@ -24,7 +22,6 @@ data class TimedBlock(
|
|||||||
val endMin: Int,
|
val endMin: Int,
|
||||||
val lane: Int,
|
val lane: Int,
|
||||||
val laneCount: Int,
|
val laneCount: Int,
|
||||||
val continuesAfter: Boolean = false,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.week
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.dayRange
|
import de.jeanlucmakiola.calendula.ui.common.dayRange
|
||||||
import de.jeanlucmakiola.calendula.ui.common.renderedSpan
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
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.data.time.stateFollowingDays
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
|
||||||
import de.jeanlucmakiola.calendula.domain.displayPriority
|
import de.jeanlucmakiola.calendula.domain.displayPriority
|
||||||
import de.jeanlucmakiola.calendula.domain.displayStart
|
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
|
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
|
||||||
@@ -209,7 +206,8 @@ abstract class PagedTimelineViewModel(
|
|||||||
): WeekUiState {
|
): WeekUiState {
|
||||||
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
|
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
|
||||||
val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
|
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(
|
return WeekUiState.Success(
|
||||||
weekStart = span.start,
|
weekStart = span.start,
|
||||||
today = today,
|
today = today,
|
||||||
@@ -294,21 +292,20 @@ internal fun layoutAllDay(
|
|||||||
days: List<LocalDate>,
|
days: List<LocalDate>,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
): List<AllDaySpan> {
|
): 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
|
val raw = events
|
||||||
.mapNotNull { ev ->
|
.mapNotNull { ev ->
|
||||||
val covered = days.indices.filter { ev.coversDay(days[it], zone) }
|
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
|
// Declined bars are packed after every other one, so on any day they
|
||||||
// cover they land in a lane below it (#230).
|
// cover they land in a lane below it (#230).
|
||||||
.sortedWith(
|
.sortedWith(
|
||||||
compareBy<Raw> { it.priority }
|
compareBy<Raw> { it.event.displayPriority(zone) }
|
||||||
.thenBy { it.startCol }
|
.thenBy { it.startCol }
|
||||||
.thenBy { it.endCol }
|
.thenBy { it.endCol }
|
||||||
.thenBy { it.event.displayStart(zone) }
|
.thenBy { it.event.start }
|
||||||
.thenBy { it.event.title }
|
|
||||||
.thenBy { it.event.instanceId },
|
.thenBy { it.event.instanceId },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -429,21 +426,12 @@ internal fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean {
|
|||||||
return coversDay(secondDay, zone)
|
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
|
* 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
|
* side-by-side. Lane count is computed per overlap-cluster (a maximal run of
|
||||||
* chained-overlapping events), matching the common phone week-view behaviour.
|
* 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(
|
internal fun layoutDay(
|
||||||
events: List<EventInstance>,
|
events: List<EventInstance>,
|
||||||
@@ -453,53 +441,55 @@ internal fun layoutDay(
|
|||||||
val dayStart = day.atStartOfDayIn(zone)
|
val dayStart = day.atStartOfDayIn(zone)
|
||||||
val dayEnd = day.plus(1, DateTimeUnit.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()
|
val raw = events.asSequence()
|
||||||
|
.filterNot { it.isAllDay }
|
||||||
.mapNotNull { ev ->
|
.mapNotNull { ev ->
|
||||||
if (ev.start == ev.end) {
|
if (ev.start == ev.end) {
|
||||||
// Instant event: keep only if the point falls inside this day.
|
// Instant event: keep only if the point falls inside this day.
|
||||||
if (ev.start < dayStart || ev.start >= dayEnd) return@mapNotNull null
|
if (ev.start < dayStart || ev.start >= dayEnd) return@mapNotNull null
|
||||||
val m = wallClockMinutes(ev.start, day, zone)
|
val m = wallClockMinutes(ev.start, day, zone)
|
||||||
TimedBlock(ev, m, m, lane = 0, laneCount = 1)
|
Raw(ev, m, m)
|
||||||
} else {
|
} else {
|
||||||
val s = maxOf(ev.start, dayStart)
|
val s = maxOf(ev.start, dayStart)
|
||||||
val e = minOf(ev.end, dayEnd)
|
val e = minOf(ev.end, dayEnd)
|
||||||
if (e <= s) return@mapNotNull null
|
if (e <= s) return@mapNotNull null
|
||||||
val startMin = wallClockMinutes(s, day, zone)
|
val startMin = wallClockMinutes(s, day, zone)
|
||||||
val endMin = wallClockEndMinutes(startMin, s, e, day, zone).coerceAtMost(MINUTES_PER_DAY)
|
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.startMin }, { it.endMin }))
|
||||||
.sortedWith(compareBy({ it.second.topMin }, { it.second.bottomMin }))
|
|
||||||
.toList()
|
.toList()
|
||||||
|
|
||||||
val result = ArrayList<TimedBlock>(raw.size)
|
val result = ArrayList<TimedBlock>(raw.size)
|
||||||
var i = 0
|
var i = 0
|
||||||
while (i < raw.size) {
|
while (i < raw.size) {
|
||||||
// Grow a cluster of chained-overlapping events.
|
// Grow a cluster of chained-overlapping events.
|
||||||
var clusterEnd = raw[i].second.bottomMin
|
var clusterEnd = raw[i].endMin
|
||||||
var j = i + 1
|
var j = i + 1
|
||||||
while (j < raw.size && raw[j].second.topMin < clusterEnd) {
|
while (j < raw.size && raw[j].startMin < clusterEnd) {
|
||||||
clusterEnd = maxOf(clusterEnd, raw[j].second.bottomMin)
|
clusterEnd = maxOf(clusterEnd, raw[j].endMin)
|
||||||
j++
|
j++
|
||||||
}
|
}
|
||||||
val cluster = raw.subList(i, j)
|
val cluster = raw.subList(i, j)
|
||||||
// Greedy first-fit column assignment (= max overlap depth in the cluster).
|
// Greedy first-fit column assignment (= max overlap depth in the cluster).
|
||||||
val laneEnd = ArrayList<Float>()
|
val laneEnd = ArrayList<Int>()
|
||||||
val lanes = IntArray(cluster.size)
|
val lanes = IntArray(cluster.size)
|
||||||
cluster.forEachIndexed { k, (_, span) ->
|
cluster.forEachIndexed { k, r ->
|
||||||
var placed = laneEnd.indexOfFirst { it <= span.topMin }
|
var placed = laneEnd.indexOfFirst { it <= r.startMin }
|
||||||
if (placed == -1) {
|
if (placed == -1) {
|
||||||
laneEnd.add(span.bottomMin)
|
laneEnd.add(r.endMin)
|
||||||
placed = laneEnd.size - 1
|
placed = laneEnd.size - 1
|
||||||
} else {
|
} else {
|
||||||
laneEnd[placed] = span.bottomMin
|
laneEnd[placed] = r.endMin
|
||||||
}
|
}
|
||||||
lanes[k] = placed
|
lanes[k] = placed
|
||||||
}
|
}
|
||||||
val laneCount = laneEnd.size
|
val laneCount = laneEnd.size
|
||||||
cluster.forEachIndexed { k, (block, _) ->
|
cluster.forEachIndexed { k, r ->
|
||||||
result.add(block.copy(lane = lanes[k], laneCount = laneCount))
|
result.add(TimedBlock(r.event, r.startMin, r.endMin, lanes[k], laneCount))
|
||||||
}
|
}
|
||||||
i = j
|
i = j
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ internal fun WeekViewPreview(
|
|||||||
onCreateAt = { _, _ -> },
|
onCreateAt = { _, _ -> },
|
||||||
onDrop = {},
|
onDrop = {},
|
||||||
onResize = {},
|
onResize = {},
|
||||||
onStripDrop = {},
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -76,12 +75,12 @@ private fun sampleWeekState(
|
|||||||
): WeekUiState.Success {
|
): WeekUiState.Success {
|
||||||
val start = if (dayCount == 7) today.startOfWeek(weekStart) else today
|
val start = if (dayCount == 7) today.startOfWeek(weekStart) else today
|
||||||
val days = (0 until dayCount).map { start.plus(it, DateTimeUnit.DAY) }
|
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(
|
return WeekUiState.Success(
|
||||||
weekStart = start,
|
weekStart = start,
|
||||||
today = today,
|
today = today,
|
||||||
days = days,
|
days = days,
|
||||||
allDaySpans = layoutAllDay(allDay, days, zone),
|
allDaySpans = layoutAllDay(events.filter { it.isAllDay }, days, zone),
|
||||||
timedByDay = days.associateWith { layoutDay(timed, it, zone) },
|
timedByDay = days.associateWith { layoutDay(events, it, zone) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -27,14 +27,6 @@ abstract class CalendulaWidgetReceiver : GlanceAppWidgetReceiver() {
|
|||||||
syncWidgetRollover(context)
|
syncWidgetRollover(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
|
||||||
super.onDeleted(context, appWidgetIds)
|
|
||||||
evictMonthSources(appWidgetIds)
|
|
||||||
WidgetKind.entries.firstOrNull { it.receiver == this::class.java }?.let { kind ->
|
|
||||||
appWidgetIds.forEach { WidgetTicks.cancel(context, kind, it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
|
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
|
||||||
restoreState(context, oldWidgetIds, newWidgetIds)
|
restoreState(context, oldWidgetIds, newWidgetIds)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import android.content.Context
|
|||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
@@ -171,7 +170,7 @@ internal suspend fun Context.loadAgendaWidgetData(
|
|||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
val anchor = today(zone)
|
val anchor = today(zone)
|
||||||
val ep = widgetEntryPoint()
|
val ep = widgetEntryPoint()
|
||||||
val weekStart = settings.weekStart.resolveFirstDay(widgetLocale())
|
val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
|
||||||
val dayCount = options.range.dayCount(anchor, weekStart)
|
val dayCount = options.range.dayCount(anchor, weekStart)
|
||||||
val window = agendaRange(anchor, dayCount - 1, zone)
|
val window = agendaRange(anchor, dayCount - 1, zone)
|
||||||
val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY)
|
val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY)
|
||||||
@@ -190,8 +189,7 @@ internal suspend fun Context.loadAgendaWidgetData(
|
|||||||
|
|
||||||
internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.ordinal
|
internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.ordinal
|
||||||
|
|
||||||
internal fun firstDayOfMonthIndex(index: Int): LocalDate =
|
private fun firstDayOfMonthIndex(index: Int): LocalDate = LocalDate(index / 12, index % 12 + 1, 1)
|
||||||
LocalDate(Math.floorDiv(index, 12), Math.floorMod(index, 12) + 1, 1)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The month the widget loads its window around while [index] is shown. Snapped
|
* The month the widget loads its window around while [index] is shown. Snapped
|
||||||
@@ -209,50 +207,28 @@ internal fun monthWindowCenter(index: Int): Int = Math.floorDiv(index, 3) * 3 +
|
|||||||
private object MonthSourceCache {
|
private object MonthSourceCache {
|
||||||
private class Entry(val center: Int, val stamp: Long, val source: Themed<MonthWidgetSource.Ready>)
|
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<GlanceId, Entry>()
|
||||||
private val entries = ConcurrentHashMap<Int, 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()) }
|
entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.content.today == today(systemZone()) }
|
||||||
?.source
|
?.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
|
val ready = source.content as? MonthWidgetSource.Ready ?: return
|
||||||
entries[id] = Entry(center, stamp, Themed(ready, source.appTheme))
|
entries[id] = Entry(center, stamp, Themed(ready, source.appTheme))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(id: Int) {
|
|
||||||
entries.remove(id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drops the cached windows of removed widgets [appWidgetIds]. */
|
/** [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */
|
||||||
internal fun evictMonthSources(appWidgetIds: IntArray) = appWidgetIds.forEach(MonthSourceCache::remove)
|
internal suspend fun Context.loadCachedMonthSource(id: GlanceId, centerIndex: Int, stamp: Long): Themed<MonthWidgetSource> =
|
||||||
|
MonthSourceCache.get(id, centerIndex, stamp)
|
||||||
/**
|
?: loadThemedMonthSource(centerIndex, widgetState(id).hiddenCalendars)
|
||||||
* [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds.
|
.also { MonthSourceCache.put(id, centerIndex, stamp, it) }
|
||||||
* A [load] already read for this pass saves reading the settings and widget state again.
|
|
||||||
*/
|
|
||||||
internal suspend fun Context.loadCachedMonthSource(
|
|
||||||
id: GlanceId,
|
|
||||||
centerIndex: Int,
|
|
||||||
stamp: Long,
|
|
||||||
load: WidgetLoadContext? = null,
|
|
||||||
): Themed<MonthWidgetSource> {
|
|
||||||
val key = GlanceAppWidgetManager(this).getAppWidgetId(id)
|
|
||||||
return MonthSourceCache.get(key, centerIndex, stamp)
|
|
||||||
?: loadThemedMonthSource(centerIndex, load?.hiddenCalendars ?: widgetState(id).hiddenCalendars, load?.settings)
|
|
||||||
.also { MonthSourceCache.put(key, centerIndex, stamp, it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */
|
/** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */
|
||||||
internal suspend fun Context.loadThemedMonthSource(
|
internal suspend fun Context.loadThemedMonthSource(centerIndex: Int, hiddenCalendars: Set<Long>): Themed<MonthWidgetSource> {
|
||||||
centerIndex: Int,
|
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||||
hiddenCalendars: Set<Long>,
|
return Themed(queryMonthSource(centerIndex, settings, hiddenCalendars), settings.themeMode)
|
||||||
settings: WidgetSettings? = null,
|
|
||||||
): Themed<MonthWidgetSource> {
|
|
||||||
val resolved = settings ?: widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
|
||||||
return Themed(queryMonthSource(centerIndex, resolved, hiddenCalendars), resolved.themeMode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun Context.queryMonthSource(
|
private suspend fun Context.queryMonthSource(
|
||||||
@@ -274,7 +250,7 @@ private suspend fun Context.queryMonthSource(
|
|||||||
.toInstant(zone)
|
.toInstant(zone)
|
||||||
return MonthWidgetSource.Ready(
|
return MonthWidgetSource.Ready(
|
||||||
today = today(zone),
|
today = today(zone),
|
||||||
weekStart = settings.weekStart.resolveFirstDay(widgetLocale()),
|
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
|
||||||
soften = settings.soften,
|
soften = settings.soften,
|
||||||
instances = ep.calendarRepository().instances(from..to).first().withoutCalendars(hiddenCalendars),
|
instances = ep.calendarRepository().instances(from..to).first().withoutCalendars(hiddenCalendars),
|
||||||
months = months,
|
months = months,
|
||||||
@@ -322,7 +298,7 @@ internal suspend fun Context.loadDays(
|
|||||||
if (!hasCalendarPermission()) return Themed(DaysWidgetData.NeedsPermission, settings.themeMode)
|
if (!hasCalendarPermission()) return Themed(DaysWidgetData.NeedsPermission, settings.themeMode)
|
||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
val anchor = today(zone)
|
val anchor = today(zone)
|
||||||
val weekStart = settings.weekStart.resolveFirstDay(widgetLocale())
|
val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
|
||||||
val range = days(anchor, weekStart)
|
val range = days(anchor, weekStart)
|
||||||
val window = range.start.atStartOfDayIn(zone)..range.endInclusive.atTime(23, 59, 59).toInstant(zone)
|
val window = range.start.atStartOfDayIn(zone)..range.endInclusive.atTime(23, 59, 59).toInstant(zone)
|
||||||
val instances = widgetEntryPoint().calendarRepository().instances(window).first().withoutCalendars(load.hiddenCalendars)
|
val instances = widgetEntryPoint().calendarRepository().instances(window).first().withoutCalendars(load.hiddenCalendars)
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ import androidx.glance.text.TextStyle
|
|||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
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.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
@@ -31,16 +32,6 @@ import de.jeanlucmakiola.floret.components.Position
|
|||||||
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
|
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
|
||||||
import de.jeanlucmakiola.floret.glance.themedColor
|
import de.jeanlucmakiola.floret.glance.themedColor
|
||||||
|
|
||||||
/** A past event's fill when dimmed: fainter than the app's whole-row dim, so its text can stay readable (#90). */
|
|
||||||
internal const val WidgetDimFillAlpha = 0.3f
|
|
||||||
|
|
||||||
/** A dimmed event's ink, held above its fill. */
|
|
||||||
private const val WidgetDimInkAlpha = 0.65f
|
|
||||||
|
|
||||||
/** Ink for an event on [fill], picked against the opaque tone and faded when the fill is dimmed. */
|
|
||||||
internal fun widgetEventInk(fill: Color, alpha: Float): Color =
|
|
||||||
eventInk(fill.copy(alpha = 1f), if (fill.alpha < 1f) alpha * WidgetDimInkAlpha else alpha)
|
|
||||||
|
|
||||||
/** Text and row sizes of a compact event list, from a widget's [WidgetSize]. */
|
/** 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)
|
internal data class EventRowMetrics(val title: TextUnit, val time: TextUnit, val rowHeight: Dp, val message: TextUnit)
|
||||||
|
|
||||||
@@ -72,9 +63,11 @@ internal fun WidgetEventRow(
|
|||||||
quiet: Boolean = false,
|
quiet: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
fun tone(dark: Boolean): Color = eventFill(event.color, dark, soften).let { if (dimmed) it.copy(alpha = WidgetDimFillAlpha) else it }
|
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 day = tone(dark = false)
|
||||||
val night = tone(dark = true)
|
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()
|
val card = widgetCardColor()
|
||||||
WidgetGroupedRow(position = position, color = if (quiet) card else themedColor(day = day, night = night)) {
|
WidgetGroupedRow(position = position, color = if (quiet) card else themedColor(day = day, night = night)) {
|
||||||
Column(
|
Column(
|
||||||
@@ -98,12 +91,12 @@ internal fun WidgetEventRow(
|
|||||||
val titleInk = if (quiet) {
|
val titleInk = if (quiet) {
|
||||||
GlanceTheme.colors.onSurface
|
GlanceTheme.colors.onSurface
|
||||||
} else {
|
} else {
|
||||||
themedColor(day = widgetEventInk(day, 1f), night = widgetEventInk(night, 1f))
|
themedColor(day = ink(day, 1f), night = ink(night, 1f))
|
||||||
}
|
}
|
||||||
val timeInk = if (quiet) {
|
val timeInk = if (quiet) {
|
||||||
GlanceTheme.colors.onSurfaceVariant
|
GlanceTheme.colors.onSurfaceVariant
|
||||||
} else {
|
} else {
|
||||||
themedColor(day = widgetEventInk(day, SECONDARY_INK_ALPHA), night = widgetEventInk(night, SECONDARY_INK_ALPHA))
|
themedColor(day = ink(day, SECONDARY_INK_ALPHA), night = ink(night, SECONDARY_INK_ALPHA))
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||||
@@ -112,7 +105,7 @@ internal fun WidgetEventRow(
|
|||||||
color = titleInk,
|
color = titleInk,
|
||||||
fontSize = metrics.title,
|
fontSize = metrics.title,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
textDecoration = glanceDeclinedDecoration(event.isCrossedOut),
|
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
Text(text = secondary, maxLines = 1, style = TextStyle(color = timeInk, fontSize = metrics.time))
|
Text(text = secondary, maxLines = 1, style = TextStyle(color = timeInk, fontSize = metrics.time))
|
||||||
|
|||||||
@@ -95,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) {
|
fun widget(): GlanceAppWidget = when (this) {
|
||||||
AGENDA -> AgendaWidget()
|
AGENDA -> AgendaWidget()
|
||||||
MONTH -> MonthWidget()
|
MONTH -> MonthWidget()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import kotlinx.datetime.plus
|
|||||||
import kotlinx.datetime.toJavaDayOfWeek
|
import kotlinx.datetime.toJavaDayOfWeek
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import java.util.Locale
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
|
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
|
||||||
@@ -38,20 +39,15 @@ internal fun widgetTimeLabel(context: Context, event: EventInstance, day: LocalD
|
|||||||
|
|
||||||
internal fun formatWidgetTime(instant: Instant, is24Hour: Boolean): String {
|
internal fun formatWidgetTime(instant: Instant, is24Hour: Boolean): String {
|
||||||
val t = instant.toLocalDateTime(systemZone()).time
|
val t = instant.toLocalDateTime(systemZone()).time
|
||||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, widgetLocale())
|
return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
|
||||||
}
|
|
||||||
|
|
||||||
/** "Today" or "Tomorrow" for those two days, else null. */
|
|
||||||
internal fun relativeDayWord(context: Context, date: LocalDate, today: LocalDate): String? = when (date) {
|
|
||||||
today -> context.getString(R.string.agenda_header_today)
|
|
||||||
today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
|
|
||||||
else -> null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Today", "Tomorrow", or the short weekday within the coming week, else the short date. */
|
/** "Today", "Tomorrow", or the short weekday within the coming week, else the short date. */
|
||||||
internal fun relativeDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
|
internal fun relativeDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
|
||||||
val locale = widgetLocale()
|
val locale = Locale.getDefault()
|
||||||
return relativeDayWord(context, date, today) ?: when {
|
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 < today.plus(7, DateTimeUnit.DAY) && date > today ->
|
||||||
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, locale)
|
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, locale)
|
||||||
else -> localizedDateFormatter(locale, "dMMM").format(date.toJavaLocalDate())
|
else -> localizedDateFormatter(locale, "dMMM").format(date.toJavaLocalDate())
|
||||||
|
|||||||
@@ -40,29 +40,15 @@ import kotlinx.datetime.atStartOfDayIn
|
|||||||
import kotlinx.datetime.atTime
|
import kotlinx.datetime.atTime
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
|
import java.util.Locale
|
||||||
import kotlin.time.Duration.Companion.hours
|
import kotlin.time.Duration.Companion.hours
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
/**
|
/** Republishes the generated picker previews (Android 15+) after an app update or a language change. */
|
||||||
* Republishes the generated picker previews (Android 15+) when anything they
|
|
||||||
* draw with has changed: the app version, language, today's date, or the
|
|
||||||
* theme, week start, time format and colour softening.
|
|
||||||
*/
|
|
||||||
internal suspend fun publishCalendulaWidgetPreviews(context: Context) {
|
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(
|
publishWidgetPreviews(
|
||||||
context,
|
context,
|
||||||
version = listOf(
|
version = "${context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime}-${Locale.getDefault().toLanguageTag()}",
|
||||||
updated,
|
|
||||||
widgetLocale().toLanguageTag(),
|
|
||||||
today(systemZone()),
|
|
||||||
settings.themeMode,
|
|
||||||
settings.weekStart,
|
|
||||||
is24Hour,
|
|
||||||
settings.soften,
|
|
||||||
).joinToString("-"),
|
|
||||||
receivers = listOf(
|
receivers = listOf(
|
||||||
AgendaWidgetReceiver::class,
|
AgendaWidgetReceiver::class,
|
||||||
MonthWidgetReceiver::class,
|
MonthWidgetReceiver::class,
|
||||||
@@ -87,7 +73,7 @@ internal suspend fun GlanceAppWidget.provideSamplePreview(context: Context, kind
|
|||||||
val today = today(zone)
|
val today = today(zone)
|
||||||
val now = today.atTime(8, 0).toInstant(zone)
|
val now = today.atTime(8, 0).toInstant(zone)
|
||||||
val instances = context.sampleInstances(today, 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 is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||||
val options = settings.agendaDefaults
|
val options = settings.agendaDefaults
|
||||||
val appearance = WidgetAppearance()
|
val appearance = WidgetAppearance()
|
||||||
|
|||||||
@@ -6,22 +6,18 @@ import android.content.ComponentName
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.util.Log
|
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
|
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
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.WidgetRefresher
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||||
import de.jeanlucmakiola.floret.glance.updatePlaced
|
import de.jeanlucmakiola.floret.glance.updatePlaced
|
||||||
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.CoroutineStart
|
import kotlinx.coroutines.CoroutineStart
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.drop
|
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). */
|
/** The app's own midnight wake-up (#228). */
|
||||||
internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
|
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. */
|
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
|
||||||
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
|
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
|
* Reloads and redraws every placed widget, a running session included: the
|
||||||
* refresh stamp moves, and every widget keys its data load on it (#420).
|
* refresh stamp moves, and every widget keys its data load on it (#420).
|
||||||
*/
|
*/
|
||||||
internal suspend fun redrawWidgets(context: Context, kinds: List<WidgetKind> = WidgetKind.entries) {
|
internal suspend fun redrawWidgets(context: Context) {
|
||||||
// One kind failing, say a widget removed mid-pass, mustn't stop the others
|
WidgetKind.entries.forEach { redrawWidgets(context, it) }
|
||||||
// or escape the broadcast's coroutine.
|
|
||||||
kinds.forEach { kind ->
|
|
||||||
try {
|
|
||||||
redrawWidgets(context, kind)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Redrawing $kind widgets failed", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val TAG = "WidgetRedraw"
|
|
||||||
|
|
||||||
internal suspend fun redrawWidgets(context: Context, kind: WidgetKind) =
|
internal suspend fun redrawWidgets(context: Context, kind: WidgetKind) =
|
||||||
kind.widget().updatePlaced(context, kind.receiver, bumpRefreshStamp = true)
|
kind.widget().updatePlaced(context, kind.receiver, bumpRefreshStamp = true)
|
||||||
|
|
||||||
@@ -96,14 +66,8 @@ private suspend fun redrawUnconfiguredWidgets(context: Context) {
|
|||||||
val glanceManager = GlanceAppWidgetManager(appContext)
|
val glanceManager = GlanceAppWidgetManager(appContext)
|
||||||
WidgetKind.entries.filter { it.hasContentOptions }.forEach { kind ->
|
WidgetKind.entries.filter { it.hasContentOptions }.forEach { kind ->
|
||||||
manager.getAppWidgetIds(ComponentName(appContext, kind.receiver)).forEach { appWidgetId ->
|
manager.getAppWidgetIds(ComponentName(appContext, kind.receiver)).forEach { appWidgetId ->
|
||||||
try {
|
val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
|
||||||
val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
|
if (!appContext.widgetState(glanceId).hasAgendaOptions) kind.widget().updateWithRefresh(appContext, glanceId)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,32 +77,9 @@ private val lastLocalChange = AtomicLong(0L)
|
|||||||
|
|
||||||
private const val PROVIDER_ECHO_WINDOW_MILLIS = 5_000L
|
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. */
|
internal fun isProviderEcho(): Boolean {
|
||||||
private val echoRedrawQueued = AtomicBoolean(false)
|
|
||||||
|
|
||||||
private fun providerEchoRemainingMillis(): Long {
|
|
||||||
val last = lastLocalChange.get()
|
val last = lastLocalChange.get()
|
||||||
if (last == 0L) return 0L
|
return last != 0L && SystemClock.elapsedRealtime() - last < PROVIDER_ECHO_WINDOW_MILLIS
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,28 +102,23 @@ class WidgetRefreshCoordinator @Inject constructor(
|
|||||||
/** Subscribes before returning, so a change made right after is never taken for the starting value. */
|
/** Subscribes before returning, so a change made right after is never taken for the starting value. */
|
||||||
fun start() {
|
fun start() {
|
||||||
if (!started.compareAndSet(false, true)) return
|
if (!started.compareAndSet(false, true)) return
|
||||||
val refreshers = WidgetKind.entries.associateWith { kind -> WidgetRefresher(scope) { redrawWidgets(context, kind) } }
|
val all = WidgetKind.entries.map { kind -> WidgetRefresher(scope) { redrawWidgets(context, kind) } }
|
||||||
val all = refreshers.values
|
|
||||||
val unconfigured = WidgetRefresher(scope) { redrawUnconfiguredWidgets(context) }
|
val unconfigured = WidgetRefresher(scope) { redrawUnconfiguredWidgets(context) }
|
||||||
watch(repository.localChanges) {
|
watch(repository.localChanges) {
|
||||||
lastLocalChange.set(SystemClock.elapsedRealtime())
|
lastLocalChange.set(SystemClock.elapsedRealtime())
|
||||||
EVENT_WIDGET_KINDS.forEach { refreshers.getValue(it).request() }
|
all.forEach { it.request() }
|
||||||
}
|
|
||||||
watch(repository.seriesSplits) { split ->
|
|
||||||
scope.launch(Dispatchers.IO) { repointCountdowns(context, split.oldEventId, split.newEventId) }
|
|
||||||
}
|
}
|
||||||
watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) {
|
watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) {
|
||||||
all.forEach { it.request() }
|
all.forEach { it.request() }
|
||||||
scope.launch(Dispatchers.IO) { publishCalendulaWidgetPreviews(context) }
|
|
||||||
}
|
}
|
||||||
watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) {
|
watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) {
|
||||||
unconfigured.request()
|
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) {
|
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
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
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 androidx.glance.text.TextDecoration
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
|
||||||
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
|
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetSurface
|
import de.jeanlucmakiola.floret.glance.WidgetSurface
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
|
||||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,22 +37,5 @@ internal fun CalendulaWidgetRoot(appearance: WidgetAppearance, appTheme: ThemeMo
|
|||||||
* Glance's counterpart to the app's `declinedDecoration`: a declined invitation
|
* 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).
|
* reads the same on the home screen as it does inside the app (#180).
|
||||||
*/
|
*/
|
||||||
fun glanceDeclinedDecoration(isCrossedOut: Boolean): TextDecoration =
|
fun glanceDeclinedDecoration(isDeclined: Boolean): TextDecoration =
|
||||||
if (isCrossedOut) TextDecoration.LineThrough else TextDecoration.None
|
if (isDeclined) 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)
|
|
||||||
}
|
|
||||||
@@ -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.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import de.jeanlucmakiola.floret.glance.FloretWidgetUpdateReceiver
|
import de.jeanlucmakiola.floret.glance.FloretWidgetUpdateReceiver
|
||||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
|
||||||
import kotlin.time.Duration.Companion.minutes
|
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 midnight rollover armed. `PROVIDER_CHANGED` covers changes made outside
|
||||||
* the app, and only redraws the widgets that draw events; the app's own writes
|
* the app; the app's own writes go through [WidgetRefreshCoordinator]. A data
|
||||||
* go through [WidgetRefreshCoordinator]. A data change also re-arms, at most
|
* change also re-arms, at most every 15 minutes: the inexact alarm can be
|
||||||
* every 15 minutes: the inexact alarm can be dropped in a restricted standby
|
* dropped in a restricted standby bucket, and a calendar change is the one
|
||||||
* bucket, and a calendar change is the one signal that keeps arriving. A
|
* signal that keeps arriving. A `PROVIDER_CHANGED` right after one of the
|
||||||
* `PROVIDER_CHANGED` right after one of the app's own changes waits out the
|
* app's own changes only echoes it, so it doesn't redraw a second time.
|
||||||
* echo window and redraws once ([claimProviderRedraw]).
|
|
||||||
*/
|
*/
|
||||||
class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() {
|
class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() {
|
||||||
override val rolloverAction = ACTION_WIDGET_ROLLOVER
|
override val rolloverAction = ACTION_WIDGET_ROLLOVER
|
||||||
@@ -23,20 +21,9 @@ class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() {
|
|||||||
override val extraHandledActions = setOf(Intent.ACTION_PROVIDER_CHANGED)
|
override val extraHandledActions = setOf(Intent.ACTION_PROVIDER_CHANGED)
|
||||||
override val extraRearmInterval = 15.minutes
|
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 rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context)
|
||||||
|
|
||||||
override fun skipRedraw(action: String): Boolean {
|
override fun skipRedraw(action: String) = action == Intent.ACTION_PROVIDER_CHANGED && isProviderEcho()
|
||||||
this.action = action
|
|
||||||
return action == Intent.ACTION_PROVIDER_CHANGED && !claimProviderRedraw()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun redraw(context: Context) {
|
override suspend fun redraw(context: Context) = redrawWidgets(context)
|
||||||
val dataChange = action == Intent.ACTION_PROVIDER_CHANGED
|
|
||||||
if (dataChange) awaitProviderEcho()
|
|
||||||
AppLanguage.applyDefault(context)
|
|
||||||
redrawWidgets(context, if (dataChange) EVENT_WIDGET_KINDS else WidgetKind.entries)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.agenda
|
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.WidgetKind
|
||||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
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 de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -53,11 +50,11 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
|
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetDimFillAlpha
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
import de.jeanlucmakiola.calendula.widget.widgetEventInk
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||||
@@ -84,6 +81,7 @@ import kotlinx.datetime.DateTimeUnit
|
|||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toJavaDayOfWeek
|
import kotlinx.datetime.toJavaDayOfWeek
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
||||||
@@ -112,13 +110,11 @@ class AgendaWidget : GlanceAppWidget() {
|
|||||||
// A running session never re-runs this preamble, so every redraw
|
// A running session never re-runs this preamble, so every redraw
|
||||||
// moves the stamp and the data reloads here instead (#420).
|
// moves the stamp and the data reloads here instead (#420).
|
||||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadAgendaWidget(id) }
|
val loaded = widgetData.current(currentRefreshStamp()) { context.loadAgendaWidget(id) }
|
||||||
InAppLanguage {
|
AgendaWidgetContent(
|
||||||
AgendaWidgetContent(
|
data = loaded.content,
|
||||||
data = loaded.content,
|
appearance = currentState<Preferences>().widgetAppearance,
|
||||||
appearance = currentState<Preferences>().widgetAppearance,
|
appTheme = loaded.appTheme,
|
||||||
appTheme = loaded.appTheme,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -396,7 +392,7 @@ private data class EventFill(val day: Color, val night: Color)
|
|||||||
private fun AgendaRow.Event.fill(data: AgendaWidgetData.Ready): EventFill {
|
private fun AgendaRow.Event.fill(data: AgendaWidgetData.Ready): EventFill {
|
||||||
val dimmed = data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now)
|
val dimmed = data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now)
|
||||||
fun tone(dark: Boolean) = eventFill(event.color, dark, data.soften).let {
|
fun tone(dark: Boolean) = eventFill(event.color, dark, data.soften).let {
|
||||||
if (dimmed) it.copy(alpha = WidgetDimFillAlpha) else it
|
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||||
}
|
}
|
||||||
return EventFill(day = tone(dark = false), night = tone(dark = true))
|
return EventFill(day = tone(dark = false), night = tone(dark = true))
|
||||||
}
|
}
|
||||||
@@ -411,10 +407,12 @@ private fun EventRow(
|
|||||||
) {
|
) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
val titleInk = themedColor(day = widgetEventInk(fill.day, 1f), night = widgetEventInk(fill.night, 1f))
|
// 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(
|
val timeInk = themedColor(
|
||||||
day = widgetEventInk(fill.day, SECONDARY_INK_ALPHA),
|
day = ink(fill.day, SECONDARY_INK_ALPHA),
|
||||||
night = widgetEventInk(fill.night, SECONDARY_INK_ALPHA),
|
night = ink(fill.night, SECONDARY_INK_ALPHA),
|
||||||
)
|
)
|
||||||
Column(
|
Column(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
@@ -441,7 +439,7 @@ private fun EventRow(
|
|||||||
color = titleInk,
|
color = titleInk,
|
||||||
fontSize = metrics.eventTitle,
|
fontSize = metrics.eventTitle,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
textDecoration = glanceDeclinedDecoration(event.isCrossedOut),
|
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
@@ -469,8 +467,12 @@ private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
|
|||||||
|
|
||||||
/** "Today · Wed, 17 Jun" — relative word for today/tomorrow, else the date. */
|
/** "Today · Wed, 17 Jun" — relative word for today/tomorrow, else the date. */
|
||||||
private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
|
private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
|
||||||
val relative = relativeDayWord(context, date, today)
|
val relative = when (date) {
|
||||||
val locale = widgetLocale()
|
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)
|
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
|
// Weekday + date in the locale's own field order (compact: no year), rather
|
||||||
// than a hardcoded day-month layout.
|
// than a hardcoded day-month layout.
|
||||||
@@ -480,13 +482,13 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
|||||||
|
|
||||||
private fun monthName(date: LocalDate, today: LocalDate): String = formatCalendarTitle(
|
private fun monthName(date: LocalDate, today: LocalDate): String = formatCalendarTitle(
|
||||||
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, 1),
|
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, 1),
|
||||||
locale = widgetLocale(),
|
locale = Locale.getDefault(),
|
||||||
currentYear = today.year,
|
currentYear = today.year,
|
||||||
skeleton = "LLLL",
|
skeleton = "LLLL",
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun weekdayShort(date: LocalDate): String =
|
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(
|
private fun eventTimeSummary(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
|||||||
+1
-2
@@ -25,7 +25,6 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.domain.dateZone
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.PickerSearchField
|
import de.jeanlucmakiola.calendula.ui.common.PickerSearchField
|
||||||
@@ -149,7 +148,7 @@ private fun CountdownEventPicker(
|
|||||||
val formatter = remember(locale) { localizedDateFormatter(locale, "EEEdMMMy") }
|
val formatter = remember(locale) { localizedDateFormatter(locale, "EEEdMMMy") }
|
||||||
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
|
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
|
||||||
itemsIndexed(matches, key = { _, it -> it.event.eventId }) { index, candidate ->
|
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(
|
GroupedRow(
|
||||||
title = candidate.event.title.ifBlank { stringResource(R.string.event_untitled) },
|
title = candidate.event.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||||
summary = if (candidate.repeats) stringResource(R.string.widget_config_countdown_repeats, date) else date,
|
summary = if (candidate.repeats) stringResource(R.string.widget_config_countdown_repeats, date) else date,
|
||||||
|
|||||||
+6
-28
@@ -15,7 +15,6 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
import de.jeanlucmakiola.calendula.widget.widgetLocale
|
|
||||||
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
|
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
|
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSettings
|
import de.jeanlucmakiola.calendula.widget.WidgetSettings
|
||||||
@@ -24,7 +23,6 @@ import de.jeanlucmakiola.calendula.widget.weekOf
|
|||||||
import de.jeanlucmakiola.calendula.widget.upcoming.loadUpcoming
|
import de.jeanlucmakiola.calendula.widget.upcoming.loadUpcoming
|
||||||
import de.jeanlucmakiola.calendula.widget.today.loadTodayList
|
import de.jeanlucmakiola.calendula.widget.today.loadTodayList
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
|
||||||
import de.jeanlucmakiola.calendula.widget.countdown.CountdownData
|
import de.jeanlucmakiola.calendula.widget.countdown.CountdownData
|
||||||
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
|
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
|
||||||
import de.jeanlucmakiola.calendula.widget.countdown.countdownOptions
|
import de.jeanlucmakiola.calendula.widget.countdown.countdownOptions
|
||||||
@@ -57,7 +55,6 @@ import de.jeanlucmakiola.calendula.widget.widgetState
|
|||||||
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
@@ -71,6 +68,7 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/** What the preview draws, loaded with the draft's options. */
|
/** What the preview draws, loaded with the draft's options. */
|
||||||
sealed interface WidgetPreviewData {
|
sealed interface WidgetPreviewData {
|
||||||
@@ -189,7 +187,7 @@ class WidgetConfigViewModel @Inject constructor(
|
|||||||
),
|
),
|
||||||
calendars = if (WidgetOption.CALENDARS in kind.options) shownCalendars() else emptyList(),
|
calendars = if (WidgetOption.CALENDARS in kind.options) shownCalendars() else emptyList(),
|
||||||
appTheme = settings.themeMode,
|
appTheme = settings.themeMode,
|
||||||
weekStart = settings.weekStart.resolveFirstDay(widgetLocale()),
|
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
|
||||||
placedSize = placedSize(kind),
|
placedSize = placedSize(kind),
|
||||||
preview = null,
|
preview = null,
|
||||||
)
|
)
|
||||||
@@ -239,31 +237,11 @@ class WidgetConfigViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val candidates = try {
|
val candidates = try {
|
||||||
val now = Clock.System.now()
|
val now = Clock.System.now()
|
||||||
val zone = TimeZone.currentSystemDefault()
|
val occurrences = repository.instances(now..now + 365.days).first().filter { it.end > now }
|
||||||
// A year, a quarter at a time, keeping only each event's first
|
val perEvent = occurrences.groupBy { it.eventId }
|
||||||
// occurrence so busy series don't hold the whole year in memory.
|
|
||||||
val first = LinkedHashMap<Long, EventInstance>()
|
|
||||||
val repeats = HashSet<Long>()
|
|
||||||
for (quarter in 0 until 4) {
|
|
||||||
val from = now + (quarter * 91).days
|
|
||||||
val to = if (quarter == 3) now + 365.days else now + ((quarter + 1) * 91).days
|
|
||||||
repository.instances(from..to).first()
|
|
||||||
.filter { !it.hasEnded(now, zone) }
|
|
||||||
.forEach { occurrence ->
|
|
||||||
val known = first[occurrence.eventId]
|
|
||||||
when {
|
|
||||||
known == null -> first[occurrence.eventId] = occurrence
|
|
||||||
known.start == occurrence.start -> Unit
|
|
||||||
else -> {
|
|
||||||
repeats += occurrence.eventId
|
|
||||||
if (occurrence.start < known.start) first[occurrence.eventId] = occurrence
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CountdownCandidates.Ready(
|
CountdownCandidates.Ready(
|
||||||
first.values
|
perEvent.values
|
||||||
.map { CountdownCandidate(it, repeats = it.eventId in repeats) }
|
.map { CountdownCandidate(it.minBy { o -> o.start }, repeats = it.size > 1) }
|
||||||
.sortedBy { it.event.start },
|
.sortedBy { it.event.start },
|
||||||
)
|
)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.countdown
|
package de.jeanlucmakiola.calendula.widget.countdown
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
|
||||||
import de.jeanlucmakiola.calendula.domain.isCrossedOut
|
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
@@ -47,7 +45,7 @@ fun countdownLabel(event: EventInstance, now: Instant, zone: TimeZone): Countdow
|
|||||||
return when {
|
return when {
|
||||||
delta < 1.minutes -> CountdownLabel(running, CountdownUnit.NOW, 0)
|
delta < 1.minutes -> CountdownLabel(running, CountdownUnit.NOW, 0)
|
||||||
delta < 1.hours -> CountdownLabel(running, CountdownUnit.MINUTES, ceil(delta / 1.minutes).toInt())
|
delta < 1.hours -> CountdownLabel(running, CountdownUnit.MINUTES, ceil(delta / 1.minutes).toInt())
|
||||||
delta < 24.hours -> CountdownLabel(running, CountdownUnit.HOURS, (delta / 1.hours).roundToInt().coerceIn(1, 23))
|
delta < 24.hours -> CountdownLabel(running, CountdownUnit.HOURS, (delta / 1.hours).roundToInt().coerceAtLeast(1))
|
||||||
else -> {
|
else -> {
|
||||||
val days = now.toLocalDateTime(zone).date.daysUntil(target.toLocalDateTime(zone).date)
|
val days = now.toLocalDateTime(zone).date.daysUntil(target.toLocalDateTime(zone).date)
|
||||||
CountdownLabel(running, CountdownUnit.DAYS, days.coerceAtLeast(1))
|
CountdownLabel(running, CountdownUnit.DAYS, days.coerceAtLeast(1))
|
||||||
@@ -76,18 +74,17 @@ fun nextLabelChange(event: EventInstance, now: Instant, zone: TimeZone): Instant
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The next-event mode's pick: timed and not filling whole days (#296), not
|
* The next-event mode's pick: timed, not declined, not over; of those, the one
|
||||||
* declined or cancelled, not over; of those, the one whose countdown changes
|
* whose countdown changes soonest — a running event's end, else a start.
|
||||||
* soonest — a running event's end, else a start.
|
|
||||||
*/
|
*/
|
||||||
fun pickNextEvent(instances: List<EventInstance>, now: Instant, zone: TimeZone): EventInstance? =
|
fun pickNextEvent(instances: List<EventInstance>, now: Instant): EventInstance? =
|
||||||
instances
|
instances
|
||||||
.filter { !it.coversWholeDays(zone) && !it.isCrossedOut && it.end > now }
|
.filter { !it.isAllDay && !it.isDeclined && it.end > now }
|
||||||
.minByOrNull { target(it, now) }
|
.minByOrNull { target(it, now) }
|
||||||
|
|
||||||
/** A pinned event's occurrence to count to: the running or next one that is not cancelled (#388). */
|
/** A pinned event's occurrence to count to: the running or next one. */
|
||||||
fun pickPinnedOccurrence(occurrences: List<EventInstance>, now: Instant, zone: TimeZone): EventInstance? =
|
fun pickPinnedOccurrence(occurrences: List<EventInstance>, now: Instant, zone: TimeZone): EventInstance? =
|
||||||
occurrences.filter { it.status != EventStatus.Cancelled && it.counted(zone).end > now }.minByOrNull { it.start }
|
occurrences.filter { it.counted(zone).end > now }.minByOrNull { it.start }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The span a countdown counts over. An all-day event is stored on UTC
|
* The span a countdown counts over. An all-day event is stored on UTC
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.countdown
|
package de.jeanlucmakiola.calendula.widget.countdown
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.datastore.preferences.core.MutablePreferences
|
import androidx.datastore.preferences.core.MutablePreferences
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
|
||||||
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.widget.Themed
|
import de.jeanlucmakiola.calendula.widget.Themed
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
|
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetTicks
|
|
||||||
import de.jeanlucmakiola.calendula.widget.hasCalendarPermission
|
import de.jeanlucmakiola.calendula.widget.hasCalendarPermission
|
||||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
import de.jeanlucmakiola.calendula.widget.today
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
@@ -23,6 +25,8 @@ import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
|||||||
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
|
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
|
||||||
import de.jeanlucmakiola.calendula.widget.widgetState
|
import de.jeanlucmakiola.calendula.widget.widgetState
|
||||||
import de.jeanlucmakiola.calendula.widget.withoutCalendars
|
import de.jeanlucmakiola.calendula.widget.withoutCalendars
|
||||||
|
import de.jeanlucmakiola.floret.android.goAsync
|
||||||
|
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||||
import de.jeanlucmakiola.floret.prefs.toEnum
|
import de.jeanlucmakiola.floret.prefs.toEnum
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -75,11 +79,7 @@ internal suspend fun Context.loadCountdown(
|
|||||||
val repository = widgetEntryPoint().calendarRepository()
|
val repository = widgetEntryPoint().calendarRepository()
|
||||||
val event = when (options.mode) {
|
val event = when (options.mode) {
|
||||||
CountdownMode.NEXT ->
|
CountdownMode.NEXT ->
|
||||||
pickNextEvent(
|
pickNextEvent(repository.instances(now..now + NextEventHorizon).first().withoutCalendars(load.hiddenCalendars), now)
|
||||||
repository.instances(now..now + NextEventHorizon).first().withoutCalendars(load.hiddenCalendars),
|
|
||||||
now,
|
|
||||||
systemZone(),
|
|
||||||
)
|
|
||||||
CountdownMode.PINNED -> options.eventId?.let { id ->
|
CountdownMode.PINNED -> options.eventId?.let { id ->
|
||||||
pickPinnedOccurrence(repository.eventOccurrences(id, now - 1.days..now + PinnedHorizon), now, systemZone())
|
pickPinnedOccurrence(repository.eventOccurrences(id, now - 1.days..now + PinnedHorizon), now, systemZone())
|
||||||
}
|
}
|
||||||
@@ -104,39 +104,64 @@ internal suspend fun Context.loadPlacedCountdown(id: GlanceId): Themed<Countdown
|
|||||||
val loaded = loadCountdown(widgetLoadContext(id), widgetState(id).countdownOptions, appWidgetId)
|
val loaded = loadCountdown(widgetLoadContext(id), widgetState(id).countdownOptions, appWidgetId)
|
||||||
val event = (loaded.content as? CountdownData.Ready)?.event
|
val event = (loaded.content as? CountdownData.Ready)?.event
|
||||||
if (event == null) {
|
if (event == null) {
|
||||||
WidgetTicks.cancel(this, WidgetKind.COUNTDOWN, appWidgetId)
|
CountdownTicks.cancel(this, appWidgetId)
|
||||||
} else {
|
} else {
|
||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
WidgetTicks.schedule(
|
CountdownTicks.schedule(this, appWidgetId, nextLabelChange(event.counted(zone), (loaded.content as CountdownData.Ready).now, zone))
|
||||||
this,
|
|
||||||
WidgetKind.COUNTDOWN,
|
|
||||||
appWidgetId,
|
|
||||||
nextLabelChange(event.counted(zone), (loaded.content as CountdownData.Ready).now, zone),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return loaded
|
return loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Moves countdowns pinned to [oldEventId] on to [newEventId] after the app
|
* One alarm per countdown widget, for the moment its label changes. Not a
|
||||||
* split that series, so they keep counting to the part that continues it.
|
* 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 suspend fun repointCountdowns(context: Context, oldEventId: Long, newEventId: Long) {
|
internal object CountdownTicks {
|
||||||
val manager = GlanceAppWidgetManager(context)
|
private const val ACTION_TICK = "de.jeanlucmakiola.calendula.widget.COUNTDOWN_TICK"
|
||||||
manager.getGlanceIds(CountdownWidget::class.java).forEach { id ->
|
|
||||||
try {
|
fun schedule(context: Context, appWidgetId: Int, at: Instant) {
|
||||||
var moved = false
|
val alarms = context.getSystemService(AlarmManager::class.java) ?: return
|
||||||
updateAppWidgetState(context, id) { prefs ->
|
val intent = pendingIntent(context, appWidgetId)
|
||||||
if (prefs[EventKey] == oldEventId) {
|
val millis = at.toEpochMilliseconds()
|
||||||
prefs[EventKey] = newEventId
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()) {
|
||||||
moved = true
|
alarms.setExact(AlarmManager.RTC, millis, intent)
|
||||||
}
|
} else {
|
||||||
|
alarms.setWindow(AlarmManager.RTC, millis, 60_000L, intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(context: Context, appWidgetId: Int) {
|
||||||
|
context.getSystemService(AlarmManager::class.java)?.cancel(pendingIntent(context, appWidgetId))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pendingIntent(context: Context, appWidgetId: Int): PendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
Intent(context, CountdownTickReceiver::class.java)
|
||||||
|
.setAction(ACTION_TICK)
|
||||||
|
// Keys the PendingIntent per widget.
|
||||||
|
.setData("calendula://countdown/$appWidgetId".toUri())
|
||||||
|
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reloads the countdown widget whose label just changed; the load arms the next tick. */
|
||||||
|
class CountdownTickReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
|
||||||
|
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
goAsync {
|
||||||
|
try {
|
||||||
|
val glanceId = GlanceAppWidgetManager(appContext).getGlanceIdBy(appWidgetId)
|
||||||
|
CountdownWidget().updateWithRefresh(appContext, glanceId)
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
// The widget is gone.
|
||||||
|
CountdownTicks.cancel(appContext, appWidgetId)
|
||||||
}
|
}
|
||||||
if (moved) CountdownWidget().updateWithRefresh(context, id)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// Gone mid-pass; nothing left to point anywhere.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.countdown
|
package de.jeanlucmakiola.calendula.widget.countdown
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.widget.InAppLanguage
|
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||||
@@ -40,7 +39,6 @@ import androidx.glance.text.TextStyle
|
|||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||||
@@ -51,7 +49,9 @@ import de.jeanlucmakiola.calendula.widget.formatWidgetTime
|
|||||||
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
|
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
|
||||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||||
import de.jeanlucmakiola.floret.glance.wallpaperBacking
|
import de.jeanlucmakiola.calendula.widget.widgetCardColor
|
||||||
|
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
||||||
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
||||||
@@ -65,7 +65,7 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
/**
|
/**
|
||||||
* Countdown (#439): to the next timed event, or to a chosen event or series
|
* Countdown (#439): to the next timed event, or to a chosen event or series
|
||||||
* ("Vacation · in 12 days"). Redraws when its label changes, see
|
* ("Vacation · in 12 days"). Redraws when its label changes, see
|
||||||
* [nextLabelChange] and [WidgetTicks]. Tapping opens the event.
|
* [nextLabelChange] and [CountdownTicks]. Tapping opens the event.
|
||||||
*/
|
*/
|
||||||
class CountdownWidget : GlanceAppWidget() {
|
class CountdownWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
@@ -82,15 +82,18 @@ class CountdownWidget : GlanceAppWidget() {
|
|||||||
val widgetData = WidgetData(context.loadPlacedCountdown(id), refreshStamp(context, id))
|
val widgetData = WidgetData(context.loadPlacedCountdown(id), refreshStamp(context, id))
|
||||||
provideContent {
|
provideContent {
|
||||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadPlacedCountdown(id) }
|
val loaded = widgetData.current(currentRefreshStamp()) { context.loadPlacedCountdown(id) }
|
||||||
InAppLanguage {
|
CountdownWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||||
CountdownWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class CountdownWidgetReceiver : CalendulaWidgetReceiver() {
|
class CountdownWidgetReceiver : CalendulaWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = CountdownWidget()
|
override val glanceAppWidget: GlanceAppWidget = CountdownWidget()
|
||||||
|
|
||||||
|
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||||
|
super.onDeleted(context, appWidgetIds)
|
||||||
|
appWidgetIds.forEach { CountdownTicks.cancel(context, it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -141,7 +144,11 @@ private fun EventCountdown(event: EventInstance, data: CountdownData.Ready) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Over a wallpaper the text sits on its own card, as every other widget's rows do.
|
// Over a wallpaper the text sits on its own card, as every other widget's rows do.
|
||||||
val card = GlanceModifier.wallpaperBacking(radius = 16.dp)
|
val card = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
|
||||||
|
GlanceModifier.cornerRadius(16.dp).background(widgetCardColor())
|
||||||
|
} else {
|
||||||
|
GlanceModifier
|
||||||
|
}
|
||||||
Box(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) {
|
Box(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) {
|
||||||
Row(
|
Row(
|
||||||
modifier = card.fillMaxSize().clickable(open).padding(horizontal = 10.dp, vertical = 8.dp),
|
modifier = card.fillMaxSize().clickable(open).padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
@@ -211,13 +218,12 @@ private fun whenText(context: Context, event: EventInstance, data: CountdownData
|
|||||||
val at = if (running) counted.end else counted.start
|
val at = if (running) counted.end else counted.start
|
||||||
val date = at.toLocalDateTime(systemZone()).date
|
val date = at.toLocalDateTime(systemZone()).date
|
||||||
val day = relativeDayLabel(context, date, data.today)
|
val day = relativeDayLabel(context, date, data.today)
|
||||||
val wholeDays = event.coversWholeDays(systemZone())
|
|
||||||
return when {
|
return when {
|
||||||
wholeDays && running -> context.getString(
|
event.isAllDay && running -> context.getString(
|
||||||
R.string.widget_countdown_until,
|
R.string.widget_countdown_until,
|
||||||
relativeDayLabel(context, date.minus(1, DateTimeUnit.DAY), data.today),
|
relativeDayLabel(context, date.minus(1, DateTimeUnit.DAY), data.today),
|
||||||
)
|
)
|
||||||
wholeDays -> "$day · ${context.getString(R.string.event_detail_all_day)}"
|
event.isAllDay -> "$day · ${context.getString(R.string.event_detail_all_day)}"
|
||||||
running -> context.getString(R.string.agenda_span_ends, "$day · ${formatWidgetTime(at, data.is24Hour)}")
|
running -> context.getString(R.string.agenda_span_ends, "$day · ${formatWidgetTime(at, data.is24Hour)}")
|
||||||
else -> "$day · ${formatWidgetTime(at, data.is24Hour)}"
|
else -> "$day · ${formatWidgetTime(at, data.is24Hour)}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.date
|
package de.jeanlucmakiola.calendula.widget.date
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.widget.InAppLanguage
|
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||||
@@ -25,6 +24,7 @@ import androidx.glance.layout.Column
|
|||||||
import androidx.glance.layout.Spacer
|
import androidx.glance.layout.Spacer
|
||||||
import androidx.glance.layout.fillMaxSize
|
import androidx.glance.layout.fillMaxSize
|
||||||
import androidx.glance.layout.height
|
import androidx.glance.layout.height
|
||||||
|
import androidx.glance.layout.padding
|
||||||
import androidx.glance.semantics.contentDescription
|
import androidx.glance.semantics.contentDescription
|
||||||
import androidx.glance.semantics.semantics
|
import androidx.glance.semantics.semantics
|
||||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
@@ -40,6 +40,8 @@ import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
|||||||
import de.jeanlucmakiola.calendula.widget.loadToday
|
import de.jeanlucmakiola.calendula.widget.loadToday
|
||||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||||
import de.jeanlucmakiola.calendula.widget.widgetLocale
|
import de.jeanlucmakiola.calendula.widget.widgetLocale
|
||||||
|
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetShape
|
import de.jeanlucmakiola.floret.glance.WidgetShape
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetShapedBox
|
import de.jeanlucmakiola.floret.glance.WidgetShapedBox
|
||||||
@@ -69,9 +71,7 @@ class DateWidget : GlanceAppWidget() {
|
|||||||
val widgetData = WidgetData(context.loadToday(), refreshStamp(context, id))
|
val widgetData = WidgetData(context.loadToday(), refreshStamp(context, id))
|
||||||
provideContent {
|
provideContent {
|
||||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadToday() }
|
val loaded = widgetData.current(currentRefreshStamp()) { context.loadToday() }
|
||||||
InAppLanguage {
|
DateWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||||
DateWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,11 @@ internal fun DateWidgetContent(today: LocalDate, appearance: WidgetAppearance, a
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
val name = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, widgetLocale())
|
val name = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, widgetLocale())
|
||||||
val backing = GlanceModifier.wallpaperBacking(radius = 8.dp, horizontalPadding = 6.dp)
|
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
|
||||||
|
GlanceModifier.wallpaperBacking(radius = 8.dp).padding(horizontal = 6.dp)
|
||||||
|
} else {
|
||||||
|
GlanceModifier
|
||||||
|
}
|
||||||
Text(text = name.uppercase(widgetLocale()), maxLines = 1, style = weekday, modifier = backing)
|
Text(text = name.uppercase(widgetLocale()), maxLines = 1, style = weekday, modifier = backing)
|
||||||
Spacer(GlanceModifier.height(2.dp))
|
Spacer(GlanceModifier.height(2.dp))
|
||||||
WidgetShapedBox(shape = WidgetShape.COOKIE, color = GlanceTheme.colors.primary, size = side) {
|
WidgetShapedBox(shape = WidgetShape.COOKIE, color = GlanceTheme.colors.primary, size = side) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
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.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
@@ -129,7 +129,7 @@ private fun bar(context: Context, event: EventInstance, cols: Int, soften: Boole
|
|||||||
views.setImageTint(context, R.id.bar_bg, mode.remoteColor(dayFill, nightFill))
|
views.setImageTint(context, R.id.bar_bg, mode.remoteColor(dayFill, nightFill))
|
||||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
// A span, like Glance's own decoration: paint flags would drop the text's defaults.
|
// A span, like Glance's own decoration: paint flags would drop the text's defaults.
|
||||||
views.setTextViewText(R.id.bar_title, if (event.isCrossedOut) span(title, StrikethroughSpan()) else title)
|
views.setTextViewText(R.id.bar_title, if (event.isDeclined) span(title, StrikethroughSpan()) else title)
|
||||||
views.setTextColor(context, R.id.bar_title, mode.remoteColor(eventInk(dayFill), eventInk(nightFill)))
|
views.setTextColor(context, R.id.bar_title, mode.remoteColor(eventInk(dayFill), eventInk(nightFill)))
|
||||||
return views
|
return views
|
||||||
}
|
}
|
||||||
@@ -147,9 +147,9 @@ private fun openDayIntent(context: Context, date: LocalDate): PendingIntent = Pe
|
|||||||
)
|
)
|
||||||
|
|
||||||
private val STRIP_IDS = intArrayOf(R.id.strip_0, R.id.strip_1, R.id.strip_2, R.id.strip_3, R.id.strip_4, R.id.strip_5, R.id.strip_6)
|
private val STRIP_IDS = intArrayOf(R.id.strip_0, R.id.strip_1, R.id.strip_2, R.id.strip_3, R.id.strip_4, R.id.strip_5, R.id.strip_6)
|
||||||
/** The shade over a day outside the month: a dark cell needs more of it to read as dimmed. Mirrored by `widget_preview_outside_shade`. */
|
/** The shade over a day outside the month: a dark cell needs more of it to read as dimmed. */
|
||||||
private val OUTSIDE_SHADE_DAY = Color.Black.copy(alpha = 0.11f)
|
private val OUTSIDE_SHADE_DAY = Color.Black.copy(alpha = 0.18f)
|
||||||
private val OUTSIDE_SHADE_NIGHT = Color.Black.copy(alpha = 0.30f)
|
private val OUTSIDE_SHADE_NIGHT = Color.Black.copy(alpha = 0.50f)
|
||||||
|
|
||||||
private val CELL_DIM_IDS = intArrayOf(
|
private val CELL_DIM_IDS = intArrayOf(
|
||||||
R.id.cell_dim_0, R.id.cell_dim_1, R.id.cell_dim_2, R.id.cell_dim_3, R.id.cell_dim_4, R.id.cell_dim_5, R.id.cell_dim_6,
|
R.id.cell_dim_0, R.id.cell_dim_1, R.id.cell_dim_2, R.id.cell_dim_3, R.id.cell_dim_4, R.id.cell_dim_5, R.id.cell_dim_6,
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.month
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.widget.InAppLanguage
|
|
||||||
import de.jeanlucmakiola.calendula.widget.widgetLocale
|
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||||
@@ -71,7 +69,6 @@ import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
|||||||
import de.jeanlucmakiola.floret.glance.wallpaperBacking
|
import de.jeanlucmakiola.floret.glance.wallpaperBacking
|
||||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||||
import de.jeanlucmakiola.calendula.widget.firstDayOfMonthIndex
|
|
||||||
import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource
|
import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource
|
||||||
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
|
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||||
@@ -88,6 +85,7 @@ import kotlinx.datetime.Month
|
|||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */
|
/** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */
|
||||||
private val MONTH_INDEX_KEY = intPreferencesKey("month_index")
|
private val MONTH_INDEX_KEY = intPreferencesKey("month_index")
|
||||||
@@ -105,7 +103,7 @@ private fun gridChromeHeight(fontScale: Float): Dp =
|
|||||||
private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone))
|
private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone))
|
||||||
|
|
||||||
private fun yearMonthOf(index: Int): YearMonth =
|
private fun yearMonthOf(index: Int): YearMonth =
|
||||||
firstDayOfMonthIndex(index).let { YearMonth(it.year, it.month) }
|
YearMonth(index / 12, Month(index % 12 + 1))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Month-grid widget: a 6×7 calendar with today highlighted, connected multi-day
|
* Month-grid widget: a 6×7 calendar with today highlighted, connected multi-day
|
||||||
@@ -171,14 +169,12 @@ class MonthWidget : GlanceAppWidget() {
|
|||||||
else -> index.coerceIn(source.months)
|
else -> index.coerceIn(source.months)
|
||||||
}
|
}
|
||||||
SideEffect { lastShown = shown }
|
SideEffect { lastShown = shown }
|
||||||
InAppLanguage {
|
MonthWidgetContent(
|
||||||
MonthWidgetContent(
|
source = source,
|
||||||
source = source,
|
index = shown,
|
||||||
index = shown,
|
appearance = currentState<Preferences>().widgetAppearance,
|
||||||
appearance = currentState<Preferences>().widgetAppearance,
|
appTheme = loaded.appTheme,
|
||||||
appTheme = loaded.appTheme,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,7 +354,7 @@ private fun monthCellRole(): WidgetColorRole = monthCellRole(LocalWidgetBackgrou
|
|||||||
/** Narrow weekday initials starting at [weekStart], in the device locale.
|
/** Narrow weekday initials starting at [weekStart], in the device locale.
|
||||||
* Computed outside the composable so the locale read stays observable-safe. */
|
* Computed outside the composable so the locale read stays observable-safe. */
|
||||||
private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
|
private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
|
||||||
val locale = widgetLocale()
|
val locale = Locale.getDefault()
|
||||||
return (0 until 7).map { i ->
|
return (0 until 7).map { i ->
|
||||||
java.time.DayOfWeek.of((weekStart.ordinal + i) % 7 + 1)
|
java.time.DayOfWeek.of((weekStart.ordinal + i) % 7 + 1)
|
||||||
.getDisplayName(JavaTextStyle.NARROW, locale)
|
.getDisplayName(JavaTextStyle.NARROW, locale)
|
||||||
@@ -384,10 +380,13 @@ private fun PermissionMessage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Locale.getDefault() rather than currentLocale(): Glance has no
|
||||||
|
// LocalConfiguration, and the widget is re-rendered on a configuration change
|
||||||
|
// anyway (same convention as AgendaWidget).
|
||||||
private fun monthLabel(month: YearMonth, currentYear: Int): String =
|
private fun monthLabel(month: YearMonth, currentYear: Int): String =
|
||||||
formatCalendarTitle(
|
formatCalendarTitle(
|
||||||
date = java.time.LocalDate.of(month.year, month.month.ordinal + 1, 1),
|
date = java.time.LocalDate.of(month.year, month.month.ordinal + 1, 1),
|
||||||
locale = widgetLocale(),
|
locale = Locale.getDefault(),
|
||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
skeleton = "LLLL",
|
skeleton = "LLLL",
|
||||||
)
|
)
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.quickadd
|
package de.jeanlucmakiola.calendula.widget.quickadd
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.widget.InAppLanguage
|
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||||
@@ -60,9 +59,7 @@ class QuickAddWidget : GlanceAppWidget() {
|
|||||||
provideContent {
|
provideContent {
|
||||||
// The rollover moves the stamp, so the editor opens on the new day.
|
// The rollover moves the stamp, so the editor opens on the new day.
|
||||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadToday() }
|
val loaded = widgetData.current(currentRefreshStamp()) { context.loadToday() }
|
||||||
InAppLanguage {
|
QuickAddWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||||
QuickAddWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.split
|
package de.jeanlucmakiola.calendula.widget.split
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.isCrossedOut
|
|
||||||
import de.jeanlucmakiola.calendula.widget.InAppLanguage
|
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||||
@@ -66,10 +64,8 @@ import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
|||||||
import de.jeanlucmakiola.calendula.widget.Themed
|
import de.jeanlucmakiola.calendula.widget.Themed
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetEventRow
|
import de.jeanlucmakiola.calendula.widget.WidgetEventRow
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSettings
|
|
||||||
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
|
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
|
||||||
import de.jeanlucmakiola.calendula.widget.compactRowMetrics
|
import de.jeanlucmakiola.calendula.widget.compactRowMetrics
|
||||||
import de.jeanlucmakiola.calendula.widget.firstDayOfMonthIndex
|
|
||||||
import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource
|
import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource
|
||||||
import de.jeanlucmakiola.calendula.widget.monthIndexOf
|
import de.jeanlucmakiola.calendula.widget.monthIndexOf
|
||||||
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
|
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
|
||||||
@@ -88,7 +84,9 @@ import kotlinx.datetime.toJavaLocalDate
|
|||||||
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
|
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
|
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetHeader
|
import de.jeanlucmakiola.floret.glance.WidgetHeader
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
|
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
|
||||||
@@ -125,13 +123,9 @@ sealed interface MonthDayData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** [loaded]'s month window with what the day list draws by. */
|
/** [loaded]'s month window with what the day list draws by. */
|
||||||
internal suspend fun Context.loadMonthDay(
|
internal suspend fun Context.loadMonthDay(loaded: Themed<MonthWidgetSource>, options: AgendaWidgetOptions): Themed<MonthDayData> {
|
||||||
loaded: Themed<MonthWidgetSource>,
|
|
||||||
options: AgendaWidgetOptions,
|
|
||||||
settings: WidgetSettings? = null,
|
|
||||||
): Themed<MonthDayData> {
|
|
||||||
val ready = loaded.content as? MonthWidgetSource.Ready ?: return Themed(MonthDayData.NeedsPermission, loaded.appTheme)
|
val ready = loaded.content as? MonthWidgetSource.Ready ?: return Themed(MonthDayData.NeedsPermission, loaded.appTheme)
|
||||||
val settings = settings ?: widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||||
return Themed(
|
return Themed(
|
||||||
MonthDayData.Ready(
|
MonthDayData.Ready(
|
||||||
source = ready,
|
source = ready,
|
||||||
@@ -165,8 +159,7 @@ class MonthDayWidget : GlanceAppWidget() {
|
|||||||
val shown = context.widgetState(id)[MONTH_INDEX_KEY] ?: monthIndexOf(today(systemZone()))
|
val shown = context.widgetState(id)[MONTH_INDEX_KEY] ?: monthIndexOf(today(systemZone()))
|
||||||
val center = monthWindowCenter(shown)
|
val center = monthWindowCenter(shown)
|
||||||
val load: suspend (Int, Long) -> Themed<MonthDayData> = { c, s ->
|
val load: suspend (Int, Long) -> Themed<MonthDayData> = { c, s ->
|
||||||
val ctx = context.widgetLoadContext(id)
|
context.loadMonthDay(context.loadCachedMonthSource(id, c, s), context.widgetLoadContext(id).options)
|
||||||
context.loadMonthDay(context.loadCachedMonthSource(id, c, s, ctx), ctx.options, ctx.settings)
|
|
||||||
}
|
}
|
||||||
val widgetData = WidgetData(load(center, stamp), center, stamp)
|
val widgetData = WidgetData(load(center, stamp), center, stamp)
|
||||||
provideContent {
|
provideContent {
|
||||||
@@ -175,9 +168,7 @@ class MonthDayWidget : GlanceAppWidget() {
|
|||||||
val currentStamp = currentRefreshStamp()
|
val currentStamp = currentRefreshStamp()
|
||||||
val loaded = widgetData.current(currentCenter, currentStamp) { load(currentCenter, currentStamp) }
|
val loaded = widgetData.current(currentCenter, currentStamp) { load(currentCenter, currentStamp) }
|
||||||
val selected = currentState(SELECTED_KEY)?.let(LocalDate::parse)
|
val selected = currentState(SELECTED_KEY)?.let(LocalDate::parse)
|
||||||
InAppLanguage {
|
MonthDayWidgetContent(loaded.content, index, selected, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||||
MonthDayWidgetContent(loaded.content, index, selected, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,17 +197,9 @@ class ShiftMonthDayAction : ActionCallback {
|
|||||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
val delta = parameters[deltaKey] ?: 0
|
val delta = parameters[deltaKey] ?: 0
|
||||||
val current = monthIndexOf(today(systemZone()))
|
val current = monthIndexOf(today(systemZone()))
|
||||||
// The selection moves with the page — to today on this month, else the
|
|
||||||
// 1st — so the list never shows a day outside the loaded window.
|
|
||||||
updateAppWidgetState(context, glanceId) { prefs ->
|
updateAppWidgetState(context, glanceId) { prefs ->
|
||||||
val next = (prefs[MONTH_INDEX_KEY] ?: current) + delta
|
val next = (prefs[MONTH_INDEX_KEY] ?: current) + delta
|
||||||
if (next == current) {
|
if (next == current) prefs.remove(MONTH_INDEX_KEY) else prefs[MONTH_INDEX_KEY] = next
|
||||||
prefs.remove(MONTH_INDEX_KEY)
|
|
||||||
prefs.remove(SELECTED_KEY)
|
|
||||||
} else {
|
|
||||||
prefs[MONTH_INDEX_KEY] = next
|
|
||||||
prefs[SELECTED_KEY] = firstDayOfMonthIndex(next).toString()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
MonthDayWidget().update(context.applicationContext, glanceId)
|
MonthDayWidget().update(context.applicationContext, glanceId)
|
||||||
}
|
}
|
||||||
@@ -239,7 +222,7 @@ class ResetMonthDayAction : ActionCallback {
|
|||||||
|
|
||||||
/** The grid's days: whole weeks from [weekStart] covering [index]'s month. */
|
/** The grid's days: whole weeks from [weekStart] covering [index]'s month. */
|
||||||
internal fun monthGridDays(index: Int, weekStart: DayOfWeek): List<LocalDate> {
|
internal fun monthGridDays(index: Int, weekStart: DayOfWeek): List<LocalDate> {
|
||||||
val first = firstDayOfMonthIndex(index)
|
val first = LocalDate(index / 12, index % 12 + 1, 1)
|
||||||
val start = weekOf(first, weekStart).start
|
val start = weekOf(first, weekStart).start
|
||||||
val last = first.plus(1, DateTimeUnit.MONTH).plus(-1, DateTimeUnit.DAY)
|
val last = first.plus(1, DateTimeUnit.MONTH).plus(-1, DateTimeUnit.DAY)
|
||||||
val weeks = (start.daysUntilInclusive(last) + 6) / 7
|
val weeks = (start.daysUntilInclusive(last) + 6) / 7
|
||||||
@@ -269,9 +252,7 @@ internal fun MonthDayWidgetContent(
|
|||||||
val day = selected ?: source.today
|
val day = selected ?: source.today
|
||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
val days = monthGridDays(index, source.weekStart)
|
val days = monthGridDays(index, source.weekStart)
|
||||||
// Declined and cancelled events are no plan for the day, as on the week strip (#388).
|
val busy = groupAgendaDays(days.first(), days.last(), source.instances, zone).map { it.date }.toSet()
|
||||||
val planned = source.instances.filterNot { it.isCrossedOut }
|
|
||||||
val busy = groupAgendaDays(days.first(), days.last(), planned, zone).map { it.date }.toSet()
|
|
||||||
Column(modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 6.dp)) {
|
Column(modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 6.dp)) {
|
||||||
Header(index, source.today)
|
Header(index, source.today)
|
||||||
Spacer(GlanceModifier.height(4.dp))
|
Spacer(GlanceModifier.height(4.dp))
|
||||||
@@ -296,7 +277,7 @@ private fun Header(index: Int, today: LocalDate) {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
WidgetHeader(
|
WidgetHeader(
|
||||||
title = formatCalendarTitle(
|
title = formatCalendarTitle(
|
||||||
date = firstDayOfMonthIndex(index).toJavaLocalDate(),
|
date = java.time.LocalDate.of(index / 12, index % 12 + 1, 1),
|
||||||
locale = widgetLocale(),
|
locale = widgetLocale(),
|
||||||
currentYear = today.year,
|
currentYear = today.year,
|
||||||
skeleton = "LLLL",
|
skeleton = "LLLL",
|
||||||
@@ -349,8 +330,12 @@ private fun Grid(
|
|||||||
width: Dp,
|
width: Dp,
|
||||||
) {
|
) {
|
||||||
val locale = widgetLocale()
|
val locale = widgetLocale()
|
||||||
val month = firstDayOfMonthIndex(index).month.ordinal + 1
|
val month = index % 12 + 1
|
||||||
val backing = GlanceModifier.wallpaperBacking(radius = 14.dp)
|
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
|
||||||
|
GlanceModifier.cornerRadius(14.dp).background(widgetCardColor())
|
||||||
|
} else {
|
||||||
|
GlanceModifier
|
||||||
|
}
|
||||||
Column(modifier = backing.fillMaxSize().padding(2.dp)) {
|
Column(modifier = backing.fillMaxSize().padding(2.dp)) {
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
for (i in 0 until 7) {
|
for (i in 0 until 7) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.today
|
package de.jeanlucmakiola.calendula.widget.today
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.widget.InAppLanguage
|
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||||
@@ -51,11 +50,6 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
|||||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||||
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
|
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.Themed
|
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetTicks
|
|
||||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
|
||||||
import de.jeanlucmakiola.calendula.domain.localEnd
|
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
|
||||||
import de.jeanlucmakiola.calendula.widget.EventRowMetrics
|
import de.jeanlucmakiola.calendula.widget.EventRowMetrics
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetEventRow
|
import de.jeanlucmakiola.calendula.widget.WidgetEventRow
|
||||||
@@ -68,6 +62,8 @@ import de.jeanlucmakiola.calendula.widget.widgetLocale
|
|||||||
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
|
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
|
||||||
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
|
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
||||||
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
||||||
@@ -100,12 +96,10 @@ class TodayWidget : GlanceAppWidget() {
|
|||||||
provideSamplePreview(context, WidgetKind.TODAY)
|
provideSamplePreview(context, WidgetKind.TODAY)
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val widgetData = WidgetData(context.loadPlacedToday(id), refreshStamp(context, id))
|
val widgetData = WidgetData(context.loadTodayList(context.widgetLoadContext(id)), refreshStamp(context, id))
|
||||||
provideContent {
|
provideContent {
|
||||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadPlacedToday(id) }
|
val loaded = widgetData.current(currentRefreshStamp()) { context.loadTodayList(context.widgetLoadContext(id)) }
|
||||||
InAppLanguage {
|
TodayWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||||
TodayWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,21 +112,6 @@ internal suspend fun Context.loadTodayList(load: WidgetLoadContext) = loadDays(l
|
|||||||
today..today.plus(1, DateTimeUnit.DAY)
|
today..today.plus(1, DateTimeUnit.DAY)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** [loadTodayList] for placed widget [id], arming its redraw for when the next of today's events ends. */
|
|
||||||
private suspend fun Context.loadPlacedToday(id: GlanceId): Themed<DaysWidgetData> {
|
|
||||||
val loaded = loadTodayList(widgetLoadContext(id))
|
|
||||||
val appWidgetId = GlanceAppWidgetManager(this).getAppWidgetId(id)
|
|
||||||
val data = loaded.content as? DaysWidgetData.Ready
|
|
||||||
val zone = systemZone()
|
|
||||||
val nextEnd = data?.eventsOn(data.today)?.map { it.localEnd(zone) }?.filter { it > data.now }?.minOrNull()
|
|
||||||
if (nextEnd == null) {
|
|
||||||
WidgetTicks.cancel(this, WidgetKind.TODAY, appWidgetId)
|
|
||||||
} else {
|
|
||||||
WidgetTicks.schedule(this, WidgetKind.TODAY, appWidgetId, nextEnd)
|
|
||||||
}
|
|
||||||
return loaded
|
|
||||||
}
|
|
||||||
|
|
||||||
private val PillWidth = 44.dp
|
private val PillWidth = 44.dp
|
||||||
private val RowGap = 2.dp
|
private val RowGap = 2.dp
|
||||||
|
|
||||||
@@ -210,7 +189,11 @@ private fun NothingLeft(tomorrow: List<EventInstance>, data: DaysWidgetData.Read
|
|||||||
val room = LocalSize.current.height - 12.dp - lineHeight
|
val room = LocalSize.current.height - 12.dp - lineHeight
|
||||||
val fits = ((room + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceIn(0, 2)
|
val fits = ((room + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceIn(0, 2)
|
||||||
val ahead = tomorrow.take(fits)
|
val ahead = tomorrow.take(fits)
|
||||||
val backing = GlanceModifier.wallpaperBacking(radius = 8.dp, horizontalPadding = 8.dp, filledHorizontalPadding = 4.dp)
|
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
|
||||||
|
GlanceModifier.wallpaperBacking(radius = 8.dp).padding(horizontal = 8.dp)
|
||||||
|
} else {
|
||||||
|
GlanceModifier.padding(horizontal = 4.dp)
|
||||||
|
}
|
||||||
Column(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
Column(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
text = context.getString(R.string.widget_nothing_left_today),
|
text = context.getString(R.string.widget_nothing_left_today),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.upcoming
|
package de.jeanlucmakiola.calendula.widget.upcoming
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.widget.InAppLanguage
|
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||||
@@ -55,6 +54,8 @@ import de.jeanlucmakiola.calendula.widget.widgetLocale
|
|||||||
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
|
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
|
||||||
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
|
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
||||||
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
||||||
@@ -88,9 +89,7 @@ class UpcomingWidget : GlanceAppWidget() {
|
|||||||
val widgetData = WidgetData(context.loadUpcoming(context.widgetLoadContext(id)), refreshStamp(context, id))
|
val widgetData = WidgetData(context.loadUpcoming(context.widgetLoadContext(id)), refreshStamp(context, id))
|
||||||
provideContent {
|
provideContent {
|
||||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadUpcoming(context.widgetLoadContext(id)) }
|
val loaded = widgetData.current(currentRefreshStamp()) { context.loadUpcoming(context.widgetLoadContext(id)) }
|
||||||
InAppLanguage {
|
UpcomingWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||||
UpcomingWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,7 +155,11 @@ internal fun UpcomingWidgetContent(data: DaysWidgetData, appearance: WidgetAppea
|
|||||||
private fun DateHeader(today: LocalDate, height: Dp) {
|
private fun DateHeader(today: LocalDate, height: Dp) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val locale = widgetLocale()
|
val locale = widgetLocale()
|
||||||
val backing = GlanceModifier.wallpaperBacking(radius = 12.dp, horizontalPadding = 8.dp, filledHorizontalPadding = 4.dp)
|
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
|
||||||
|
GlanceModifier.wallpaperBacking(radius = 12.dp).padding(horizontal = 8.dp)
|
||||||
|
} else {
|
||||||
|
GlanceModifier.padding(horizontal = 4.dp)
|
||||||
|
}
|
||||||
Box(modifier = GlanceModifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
|
Box(modifier = GlanceModifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
|
||||||
Row(
|
Row(
|
||||||
modifier = backing.clickable(actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Agenda))),
|
modifier = backing.clickable(actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Agenda))),
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.week
|
package de.jeanlucmakiola.calendula.widget.week
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.isCrossedOut
|
|
||||||
import de.jeanlucmakiola.calendula.widget.InAppLanguage
|
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||||
@@ -93,9 +91,7 @@ class WeekStripWidget : GlanceAppWidget() {
|
|||||||
val widgetData = WidgetData(context.loadWeek(id), refreshStamp(context, id))
|
val widgetData = WidgetData(context.loadWeek(id), refreshStamp(context, id))
|
||||||
provideContent {
|
provideContent {
|
||||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadWeek(id) }
|
val loaded = widgetData.current(currentRefreshStamp()) { context.loadWeek(id) }
|
||||||
InAppLanguage {
|
WeekStripWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||||
WeekStripWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,15 +114,13 @@ internal fun WeekStripWidgetContent(data: DaysWidgetData, appearance: WidgetAppe
|
|||||||
when (data) {
|
when (data) {
|
||||||
DaysWidgetData.NeedsPermission ->
|
DaysWidgetData.NeedsPermission ->
|
||||||
WidgetMessage(LocalContext.current.getString(R.string.widget_needs_permission))
|
WidgetMessage(LocalContext.current.getString(R.string.widget_needs_permission))
|
||||||
// The gap is padding on each tile rather than a Spacer between them:
|
|
||||||
// Glance drops a row's children past the tenth, and 7 tiles plus 6
|
|
||||||
// spacers would lose the weekend.
|
|
||||||
is DaysWidgetData.Ready -> Row(
|
is DaysWidgetData.Ready -> Row(
|
||||||
modifier = GlanceModifier.fillMaxSize().padding(horizontal = 6.dp - DayGap / 2, vertical = 6.dp),
|
modifier = GlanceModifier.fillMaxSize().padding(6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
for (i in 0 until 7) {
|
for (i in 0 until 7) {
|
||||||
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight().padding(horizontal = DayGap / 2)) {
|
if (i > 0) Spacer(GlanceModifier.width(DayGap))
|
||||||
|
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) {
|
||||||
DayTile(data.firstDay.plus(i, DateTimeUnit.DAY), data)
|
DayTile(data.firstDay.plus(i, DateTimeUnit.DAY), data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,9 +140,7 @@ private fun DayTile(date: LocalDate, data: DaysWidgetData.Ready) {
|
|||||||
else -> GlanceTheme.colors.onSurface
|
else -> GlanceTheme.colors.onSurface
|
||||||
}
|
}
|
||||||
val locale = widgetLocale()
|
val locale = widgetLocale()
|
||||||
// A declined or cancelled event is no plan for the day, so it adds no dot and no count (#388).
|
val colors = data.eventsOn(date).map { it.color }.distinct()
|
||||||
val events = data.eventsOn(date).filterNot { it.isCrossedOut }
|
|
||||||
val colors = events.map { it.color }.distinct()
|
|
||||||
val label = localizedDateFormatter(locale, "EEEEdMMMM").format(date.toJavaLocalDate())
|
val label = localizedDateFormatter(locale, "EEEEdMMMM").format(date.toJavaLocalDate())
|
||||||
Column(
|
Column(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
@@ -158,7 +150,7 @@ private fun DayTile(date: LocalDate, data: DaysWidgetData.Ready) {
|
|||||||
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Week)))
|
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Week)))
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = if (colors.isEmpty()) label else context.resources.getQuantityString(
|
contentDescription = if (colors.isEmpty()) label else context.resources.getQuantityString(
|
||||||
R.plurals.widget_day_with_events, events.size, label, events.size,
|
R.plurals.widget_day_with_events, data.eventsOn(date).size, label, data.eventsOn(date).size,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- A day outside the month: its cell under the widget's shade (MonthGridViews). -->
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
android:shape="rectangle">
|
||||||
<item android:drawable="@drawable/preview_widget_cell_bg" />
|
<solid android:color="@color/widget_preview_outside" />
|
||||||
<item>
|
<corners android:radius="12dp" />
|
||||||
<shape android:shape="rectangle">
|
</shape>
|
||||||
<solid android:color="@color/widget_preview_outside_shade" />
|
|
||||||
<corners android:radius="12dp" />
|
|
||||||
</shape>
|
|
||||||
</item>
|
|
||||||
</layer-list>
|
|
||||||
@@ -9,9 +9,9 @@
|
|||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:padding="12dp"
|
android:padding="12dp"
|
||||||
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessLeaf,UselessParent">
|
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent">
|
||||||
|
|
||||||
<FrameLayout
|
<View
|
||||||
android:layout_width="4dp"
|
android:layout_width="4dp"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="@drawable/preview_stripe"
|
android:background="@drawable/preview_stripe"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
android:background="@drawable/preview_widget_bg"
|
android:background="@drawable/preview_widget_bg"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:padding="6dp"
|
android:padding="6dp"
|
||||||
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessLeaf,UselessParent">
|
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
style="@style/WidgetPreviewWeekDay"
|
style="@style/WidgetPreviewWeekDay"
|
||||||
@@ -32,19 +32,19 @@
|
|||||||
android:layout_height="6dp"
|
android:layout_height="6dp"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal">
|
||||||
<FrameLayout
|
<View
|
||||||
android:layout_width="4dp"
|
android:layout_width="4dp"
|
||||||
android:layout_height="4dp"
|
android:layout_height="4dp"
|
||||||
android:layout_marginHorizontal="1dp"
|
android:layout_marginHorizontal="1dp"
|
||||||
android:background="@drawable/preview_today_circle"
|
android:background="@drawable/preview_today_circle"
|
||||||
android:backgroundTint="@color/widget_preview_event_blue" />
|
android:backgroundTint="@color/widget_preview_event_blue" />
|
||||||
<FrameLayout
|
<View
|
||||||
android:layout_width="4dp"
|
android:layout_width="4dp"
|
||||||
android:layout_height="4dp"
|
android:layout_height="4dp"
|
||||||
android:layout_marginHorizontal="1dp"
|
android:layout_marginHorizontal="1dp"
|
||||||
android:background="@drawable/preview_today_circle"
|
android:background="@drawable/preview_today_circle"
|
||||||
android:backgroundTint="@color/widget_preview_event_green" />
|
android:backgroundTint="@color/widget_preview_event_green" />
|
||||||
<FrameLayout
|
<View
|
||||||
android:layout_width="4dp"
|
android:layout_width="4dp"
|
||||||
android:layout_height="4dp"
|
android:layout_height="4dp"
|
||||||
android:layout_marginHorizontal="1dp"
|
android:layout_marginHorizontal="1dp"
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
android:layout_height="6dp"
|
android:layout_height="6dp"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal">
|
||||||
<FrameLayout
|
<View
|
||||||
android:layout_width="4dp"
|
android:layout_width="4dp"
|
||||||
android:layout_height="4dp"
|
android:layout_height="4dp"
|
||||||
android:layout_marginHorizontal="1dp"
|
android:layout_marginHorizontal="1dp"
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
android:layout_height="6dp"
|
android:layout_height="6dp"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal">
|
||||||
<FrameLayout
|
<View
|
||||||
android:layout_width="4dp"
|
android:layout_width="4dp"
|
||||||
android:layout_height="4dp"
|
android:layout_height="4dp"
|
||||||
android:layout_marginHorizontal="1dp"
|
android:layout_marginHorizontal="1dp"
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
android:textColor="@color/widget_preview_on_surface"
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
android:textSize="16sp"
|
android:textSize="16sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
<FrameLayout
|
<Space
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="6dp" />
|
android:layout_height="6dp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
android:textColor="@color/widget_preview_on_surface"
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
android:textSize="16sp"
|
android:textSize="16sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
<FrameLayout
|
<Space
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="6dp" />
|
android:layout_height="6dp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
android:layout_height="6dp"
|
android:layout_height="6dp"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal">
|
||||||
<FrameLayout
|
<View
|
||||||
android:layout_width="4dp"
|
android:layout_width="4dp"
|
||||||
android:layout_height="4dp"
|
android:layout_height="4dp"
|
||||||
android:layout_marginHorizontal="1dp"
|
android:layout_marginHorizontal="1dp"
|
||||||
@@ -195,7 +195,7 @@
|
|||||||
android:textColor="@color/widget_preview_on_surface"
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
android:textSize="16sp"
|
android:textSize="16sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
<FrameLayout
|
<Space
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="6dp" />
|
android:layout_height="6dp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -456,7 +456,7 @@
|
|||||||
<string name="calendars_restore_action">استعادة من ملف .ics</string>
|
<string name="calendars_restore_action">استعادة من ملف .ics</string>
|
||||||
<string name="calendars_restore_hint">استيراد الأحداث من نسخة احتياطية أو تطبيق تقويم آخر.</string>
|
<string name="calendars_restore_hint">استيراد الأحداث من نسخة احتياطية أو تطبيق تقويم آخر.</string>
|
||||||
<string name="calendars_auto_backup">النسخ الاحتياطي التلقائي</string>
|
<string name="calendars_auto_backup">النسخ الاحتياطي التلقائي</string>
|
||||||
<string name="calendars_auto_backup_hint">تصدير تقويماتك المحلية بشكل دوري إلى مجلد كـ ملف .ics</string>
|
<string name="calendars_auto_backup_hint">قم بتصدير تقويماتك المحلية بشكل دوري إلى مجلد كـ ملف .ics.</string>
|
||||||
<string name="calendars_auto_backup_folder">مجلد النسخ الاحتياطي</string>
|
<string name="calendars_auto_backup_folder">مجلد النسخ الاحتياطي</string>
|
||||||
<string name="calendars_auto_backup_folder_unset">اضغط لاختيار مجلد</string>
|
<string name="calendars_auto_backup_folder_unset">اضغط لاختيار مجلد</string>
|
||||||
<string name="calendars_auto_backup_every">كل %1$s</string>
|
<string name="calendars_auto_backup_every">كل %1$s</string>
|
||||||
@@ -638,7 +638,7 @@
|
|||||||
<item quantity="many">يتم استيراد %d حدث</item>
|
<item quantity="many">يتم استيراد %d حدث</item>
|
||||||
<item quantity="other">يتم استيراد %d حدث</item>
|
<item quantity="other">يتم استيراد %d حدث</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="event_move_recurring_title">نقل الحدث المتكرّر</string>
|
<string name="event_move_recurring_title">نقل الحدث المتكرر</string>
|
||||||
<string name="event_edit_conflict_overwrite_hint">فقط الحقول التي عدلتها هي التي ستحل محل التغيير الخارجي</string>
|
<string name="event_edit_conflict_overwrite_hint">فقط الحقول التي عدلتها هي التي ستحل محل التغيير الخارجي</string>
|
||||||
<string name="reorder_drag_handle">اسحب لإعادة الترتيب</string>
|
<string name="reorder_drag_handle">اسحب لإعادة الترتيب</string>
|
||||||
<string name="settings_drawer_order_hint">اسحب لإعادة ترتيب طرق العرض المدرجة في قائمة التنقل.</string>
|
<string name="settings_drawer_order_hint">اسحب لإعادة ترتيب طرق العرض المدرجة في قائمة التنقل.</string>
|
||||||
@@ -665,7 +665,7 @@
|
|||||||
<string name="duration_hours_minutes">%1$s %2$s</string>
|
<string name="duration_hours_minutes">%1$s %2$s</string>
|
||||||
<string name="settings_widgets_hint">إعدادات لأدوات الشاشة الرئيسية وعنصر الإعدادات السريعة. أضف أداة عن طريق الضغط لفترة طويلة على شاشتك الرئيسية.</string>
|
<string name="settings_widgets_hint">إعدادات لأدوات الشاشة الرئيسية وعنصر الإعدادات السريعة. أضف أداة عن طريق الضغط لفترة طويلة على شاشتك الرئيسية.</string>
|
||||||
<string name="import_warning_attendees">لم يتم استيراد قوائم الضيوف.</string>
|
<string name="import_warning_attendees">لم يتم استيراد قوائم الضيوف.</string>
|
||||||
<string name="settings_reliable_delivery_exempt">معفى من تحسين البطارية — تصل التذكيرات في الوقت المحدّد.</string>
|
<string name="settings_reliable_delivery_exempt">معفى من تحسين البطارية — تصل التذكيرات في الوقت المحدد.</string>
|
||||||
<string name="settings_reliable_delivery">توصيل موثوق</string>
|
<string name="settings_reliable_delivery">توصيل موثوق</string>
|
||||||
<string name="settings_allday_reminder_time_hint">تنطلق التذكيرات لأحداث التي تستمر طوال اليوم عند %1$s</string>
|
<string name="settings_allday_reminder_time_hint">تنطلق التذكيرات لأحداث التي تستمر طوال اليوم عند %1$s</string>
|
||||||
<string name="agenda_range_override_hint">فقط للآن — يعيد التعيين للمدى المحفوظ خاصتك عند إعادة فتح Calendula.</string>
|
<string name="agenda_range_override_hint">فقط للآن — يعيد التعيين للمدى المحفوظ خاصتك عند إعادة فتح Calendula.</string>
|
||||||
@@ -804,30 +804,4 @@
|
|||||||
<string name="reminder_day_today">اليوم</string>
|
<string name="reminder_day_today">اليوم</string>
|
||||||
<string name="event_detail_open_in_maps">فتح في الخرائط</string>
|
<string name="event_detail_open_in_maps">فتح في الخرائط</string>
|
||||||
<string name="link_open_failed">لا تطبيق يمكنه فتح هذا الرابط</string>
|
<string name="link_open_failed">لا تطبيق يمكنه فتح هذا الرابط</string>
|
||||||
<string name="settings_support_kofi">Ko-fi</string>
|
|
||||||
<string name="settings_support_liberapay">Liberapay</string>
|
|
||||||
<string name="settings_support_liberapay_hint">التبرعات المتكررة</string>
|
|
||||||
<string name="settings_support_kofi_hint">الإكراميات لمرة واحدة</string>
|
|
||||||
<string name="settings_theme_black">أسود</string>
|
|
||||||
<string name="settings_theme_black_summary">داكن مع خلفيات سوداء نقية، لشاشات AMOLED</string>
|
|
||||||
<string name="event_resize_done">تم التغيير إلى %1$s</string>
|
|
||||||
<string name="event_resize_recurring_title">تغيير الحدث المتكرّر</string>
|
|
||||||
<string name="event_selection_actions">إجراءات الحدث</string>
|
|
||||||
<string name="event_selection_hide">إخفاء إجراءات الحدث</string>
|
|
||||||
<string name="event_selection_close">إلغاء تحديد الحدث</string>
|
|
||||||
<string name="event_draft_create">إنشاء حدث</string>
|
|
||||||
<string name="event_draft_discard">تجاهل الحدث الجديد</string>
|
|
||||||
<string name="event_action_load_failed">تعذّر فتح الحدث</string>
|
|
||||||
<string name="event_edit_more_actions">المزيد من الإجراءات</string>
|
|
||||||
<string name="event_edit_unsaved_title">تغييرات غير محفوظة</string>
|
|
||||||
<string name="event_edit_unsaved_save">حفظ التغييرات</string>
|
|
||||||
<string name="event_edit_unsaved_discard">تجاهل التغييرات</string>
|
|
||||||
<string name="event_edit_create">إنشاء</string>
|
|
||||||
<string name="settings_tap_opens_details">التفاصيل</string>
|
|
||||||
<string name="settings_tap_opens_editor">نموذج التعديل</string>
|
|
||||||
<string name="process_text_create_event">إنشاء حدث</string>
|
|
||||||
<string name="settings_events_header">الأحداث</string>
|
|
||||||
<string name="settings_tap_opens">النقر بحدث يفتح</string>
|
|
||||||
<string name="settings_tap_opens_hint">الأحداث التي لا يمكنك تعديلها وإشعارات التذكير دائمًا تفتح التفاصيل.</string>
|
|
||||||
<string name="event_edit_unsaved_keep">متابعة التعديل</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -708,37 +708,4 @@
|
|||||||
<string name="onboarding_hidden_failed">Гэты не атрымалася ўключыць. У мэню Налады → Календары знаходзяцца такія самыя перамыкачы.</string>
|
<string name="onboarding_hidden_failed">Гэты не атрымалася ўключыць. У мэню Налады → Календары знаходзяцца такія самыя перамыкачы.</string>
|
||||||
<string name="onboarding_calendars_create_failed">Не атрымалася стварыць каляндар з-за адмовы сховішча вашай прылады.</string>
|
<string name="onboarding_calendars_create_failed">Не атрымалася стварыць каляндар з-за адмовы сховішча вашай прылады.</string>
|
||||||
<string name="reminder_day_today">Сёньня</string>
|
<string name="reminder_day_today">Сёньня</string>
|
||||||
<string name="event_resize_done">Зьменена на %1$s</string>
|
|
||||||
<string name="event_resize_recurring_title">Зьмяніць пэрыядычную падзею</string>
|
|
||||||
<string name="event_selection_actions">Дзеяньні падзеі</string>
|
|
||||||
<string name="event_selection_hide">Схаваць дзеяньні падзеі</string>
|
|
||||||
<string name="event_selection_close">Адмяніць выбар падзеі</string>
|
|
||||||
<string name="event_draft_create">Стварыць падзею</string>
|
|
||||||
<string name="event_draft_discard">Адкінуць новую падзею</string>
|
|
||||||
<string name="event_action_load_failed">Не атрымалася адкрыць падзею</string>
|
|
||||||
<string name="event_edit_more_actions">Іншыя дзеяньні</string>
|
|
||||||
<string name="event_edit_unsaved_title">Незахаваныя зьмены</string>
|
|
||||||
<string name="event_edit_unsaved_save">Захаваць зьмены</string>
|
|
||||||
<string name="event_edit_unsaved_discard">Адкінуць зьмены</string>
|
|
||||||
<string name="event_edit_unsaved_keep">Працягнуць рэдагаваньне</string>
|
|
||||||
<string name="event_edit_create">Стварыць</string>
|
|
||||||
<plurals name="view_multi_day">
|
|
||||||
<item quantity="one">%d суткі</item>
|
|
||||||
<item quantity="few">%d сутак</item>
|
|
||||||
<item quantity="many">%d сутак</item>
|
|
||||||
<item quantity="other">%d сутак</item>
|
|
||||||
</plurals>
|
|
||||||
<string name="settings_theme_black">Чорная</string>
|
|
||||||
<string name="settings_theme_black_summary">Цёмная з чыста чорным тлом для экранаў AMOLED</string>
|
|
||||||
<string name="settings_multi_day_count">Паказ некалькіх сутак</string>
|
|
||||||
<string name="settings_multi_day_count_hint">Сколькі сутак бачныя ў рэжыме паказу некалькіх сутак. Сёньня адкрываецца ў першым слупку.</string>
|
|
||||||
<string name="settings_timeline_scale_by_visible_hours">Задаецца праз бачныя гадзіны</string>
|
|
||||||
<string name="settings_visible_hours">Бачныя гадзіны</string>
|
|
||||||
<string name="settings_visible_hours_summary">Упісваць дыяпазон гадзін на экране пры праглядзе тыдня, некалькіх сутак або адных сутак</string>
|
|
||||||
<string name="settings_visible_hours_on_summary">Пры адкрыцьці прагляду на экране зьявіцца дыяпазон %1$s—%2$s</string>
|
|
||||||
<string name="settings_visible_hours_start">Ад</string>
|
|
||||||
<string name="settings_visible_hours_end">Да</string>
|
|
||||||
<string name="settings_visible_hours_start_hint">Гадзіна зьверху экрана ў рэжыме прагляду тыдня, некалькіх сутак або сутак. Каб пабачыць ранейшыя гадзіны, трэба прагортка.</string>
|
|
||||||
<string name="settings_visible_hours_end_hint">Апошняя гадзіна ўнізе экрану ў рэжыме прагляду. Каб пабачыць пазьнейшыя гадзіны трэба прагортка. Таксама зьвядзеньнем пальцаў па экране можна зьмяніць маштаб да наступнага адкрыцьця рэжыму.</string>
|
|
||||||
<string name="settings_events_header">Падзеі</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -471,7 +471,7 @@
|
|||||||
<string name="settings_reminders_hint">Vidíte připomínky dvakrát? Zobrazuje je i jiná aplikace kalendáře – v jedné z nich je vypněte.</string>
|
<string name="settings_reminders_hint">Vidíte připomínky dvakrát? Zobrazuje je i jiná aplikace kalendáře – v jedné z nich je vypněte.</string>
|
||||||
<string name="settings_default_reminder">Výchozí připomínka</string>
|
<string name="settings_default_reminder">Výchozí připomínka</string>
|
||||||
<string name="settings_default_reminder_allday">Celodenní události</string>
|
<string name="settings_default_reminder_allday">Celodenní události</string>
|
||||||
<string name="settings_allday_reminder_time">Čas připomínky celodenní události</string>
|
<string name="settings_allday_reminder_time">Čas celodenní připomínky</string>
|
||||||
<string name="settings_allday_reminder_time_hint">Připomínky na celodenní události se spouštějí v %1$s</string>
|
<string name="settings_allday_reminder_time_hint">Připomínky na celodenní události se spouštějí v %1$s</string>
|
||||||
<string name="settings_allday_reminder_fires_at">Upozorní v %1$s</string>
|
<string name="settings_allday_reminder_fires_at">Upozorní v %1$s</string>
|
||||||
<string name="reminder_none">Žádná</string>
|
<string name="reminder_none">Žádná</string>
|
||||||
@@ -489,7 +489,7 @@
|
|||||||
<string name="settings_snooze_duration">Doba odložení</string>
|
<string name="settings_snooze_duration">Doba odložení</string>
|
||||||
<string name="settings_section_calendars">Kalendáře</string>
|
<string name="settings_section_calendars">Kalendáře</string>
|
||||||
<string name="settings_manage_calendars">Správa kalendářů</string>
|
<string name="settings_manage_calendars">Správa kalendářů</string>
|
||||||
<string name="settings_manage_calendars_hint">Správa místních a synchronizovaných kalendářů</string>
|
<string name="settings_manage_calendars_hint">Vytvořte místní kalendáře; spravujte synchronizované kalendáře</string>
|
||||||
<string name="settings_section_language">Jazyk</string>
|
<string name="settings_section_language">Jazyk</string>
|
||||||
<string name="settings_language">Jazyk aplikace</string>
|
<string name="settings_language">Jazyk aplikace</string>
|
||||||
<string name="settings_language_auto">Výchozí nastavení systému</string>
|
<string name="settings_language_auto">Výchozí nastavení systému</string>
|
||||||
@@ -515,9 +515,9 @@
|
|||||||
<string name="settings_time_format_hint">Způsob zápisu času v celé aplikaci. Funkce „Automaticky“ se řídí nastavením vašeho systému.</string>
|
<string name="settings_time_format_hint">Způsob zápisu času v celé aplikaci. Funkce „Automaticky“ se řídí nastavením vašeho systému.</string>
|
||||||
<string name="settings_past_events_hint">Jak program zachází s událostmi, které již skončily.</string>
|
<string name="settings_past_events_hint">Jak program zachází s událostmi, které již skončily.</string>
|
||||||
<string name="settings_dynamic_color_summary">Aplikace převezme barvy z vaší tapety.</string>
|
<string name="settings_dynamic_color_summary">Aplikace převezme barvy z vaší tapety.</string>
|
||||||
<string name="settings_section_special_dates">Události kontaktů</string>
|
<string name="settings_section_special_dates">Speciální data kontaktů</string>
|
||||||
<string name="settings_special_dates_enable">Zobrazit události kontaktů</string>
|
<string name="settings_special_dates_enable">Zobrazit data kontaktů</string>
|
||||||
<string name="settings_special_dates_enable_hint">Zkopírujte data narozenin či dalších událostí svých kontaktů do místních kalendářů. Aplikace čte pouze kontakty v tomto zařízení – nikam se nic nenahrává, vaše kontakty zůstávají beze změny.</string>
|
<string name="settings_special_dates_enable_hint">Zkopírujte data narozenin a další data svých kontaktů do místních kalendářů. Aplikace čte pouze kontakty v tomto zařízení – nikam se nic nenahrává, vaše kontakty zůstávají beze změny.</string>
|
||||||
<string name="settings_special_dates_type_birthday">Narozeniny</string>
|
<string name="settings_special_dates_type_birthday">Narozeniny</string>
|
||||||
<string name="settings_special_dates_type_anniversary">Výročí</string>
|
<string name="settings_special_dates_type_anniversary">Výročí</string>
|
||||||
<string name="settings_special_dates_type_custom">Ostatní data</string>
|
<string name="settings_special_dates_type_custom">Ostatní data</string>
|
||||||
@@ -529,11 +529,11 @@
|
|||||||
<string name="settings_special_dates_never_synced">Zatím nesynchronizováno</string>
|
<string name="settings_special_dates_never_synced">Zatím nesynchronizováno</string>
|
||||||
<string name="settings_special_dates_last_synced">Naposledy synchronizováno %1$s</string>
|
<string name="settings_special_dates_last_synced">Naposledy synchronizováno %1$s</string>
|
||||||
<string name="settings_special_dates_calendar_hint">Barvu a viditelnost jednotlivých kalendářů nastavte v nastavení kalendářů.</string>
|
<string name="settings_special_dates_calendar_hint">Barvu a viditelnost jednotlivých kalendářů nastavte v nastavení kalendářů.</string>
|
||||||
<string name="settings_calendar_reminders_managed_hint">Nastavte v části Události kontaktů</string>
|
<string name="settings_calendar_reminders_managed_hint">Nastavte v části Speciální data kontaktu</string>
|
||||||
<string name="settings_special_dates_paused_title">Pozastaveno</string>
|
<string name="settings_special_dates_paused_title">Pozastaveno</string>
|
||||||
<string name="settings_special_dates_paused_hint">Calendula už nemá přístup k vašim kontaktům, tudíž zůstávají bez aktualizace.</string>
|
<string name="settings_special_dates_paused_hint">Calendula už nemá přístup k vašim kontaktům, tudíž zůstávají bez aktualizace.</string>
|
||||||
<string name="settings_special_dates_grant">Udělit přístup</string>
|
<string name="settings_special_dates_grant">Udělit přístup</string>
|
||||||
<string name="settings_special_dates_disable_title">Vypnout události kontaktů?</string>
|
<string name="settings_special_dates_disable_title">Vypnout data kontaktů?</string>
|
||||||
<string name="settings_special_dates_disable_type_message">Tímto se odstraní kalendář „%1$s“ a jeho události. Veškeré připomínky a poznámky, které jste do nich přidali, budou ztraceny.</string>
|
<string name="settings_special_dates_disable_type_message">Tímto se odstraní kalendář „%1$s“ a jeho události. Veškeré připomínky a poznámky, které jste do nich přidali, budou ztraceny.</string>
|
||||||
<string name="settings_special_dates_disable_confirm">Vypnout</string>
|
<string name="settings_special_dates_disable_confirm">Vypnout</string>
|
||||||
<string name="dialog_save">Uložit</string>
|
<string name="dialog_save">Uložit</string>
|
||||||
@@ -566,7 +566,7 @@
|
|||||||
<string name="calendars_state_read_only">Pouze pro čtení</string>
|
<string name="calendars_state_read_only">Pouze pro čtení</string>
|
||||||
<string name="calendars_state_not_synced">Není synchronizováno s tímto zařízením</string>
|
<string name="calendars_state_not_synced">Není synchronizováno s tímto zařízením</string>
|
||||||
<string name="calendars_state_managed">Vyplněno z vašich kontaktů</string>
|
<string name="calendars_state_managed">Vyplněno z vašich kontaktů</string>
|
||||||
<string name="calendars_managed_delete_locked">Tento kalendář se plní ze seznamu vašich kontaktů, takže jej aplikace Calendula při příští synchronizaci znovu vytvoří. Chcete-li jej odstranit, vypněte možnost událostí kontaktů v nabídce Nastavení → Události kontaktů.</string>
|
<string name="calendars_managed_delete_locked">Tento kalendář se plní z vašich kontaktů, takže jej aplikace Calendula při příští synchronizaci znovu vytvoří. Chcete-li jej odstranit, vypněte možnost speciálních dat v nabídce Nastavení → Speciální data.</string>
|
||||||
<string name="calendars_synced_header">Synchronizované kalendáře</string>
|
<string name="calendars_synced_header">Synchronizované kalendáře</string>
|
||||||
<string name="calendars_synced_hint">Tyto kontakty pocházejí z účtů ve vašem zařízení. Vytvářejte je a upravujte v příslušné aplikaci.</string>
|
<string name="calendars_synced_hint">Tyto kontakty pocházejí z účtů ve vašem zařízení. Vytvářejte je a upravujte v příslušné aplikaci.</string>
|
||||||
<string name="calendars_manage_in_app">Spravovat v aplikaci</string>
|
<string name="calendars_manage_in_app">Spravovat v aplikaci</string>
|
||||||
@@ -689,7 +689,7 @@
|
|||||||
<string name="crash_report_open_failed">Nepodařilo se otevřít systém pro hlášení problémů. Zpráva je zkopírována ve vaší schránce.</string>
|
<string name="crash_report_open_failed">Nepodařilo se otevřít systém pro hlášení problémů. Zpráva je zkopírována ve vaší schránce.</string>
|
||||||
<string name="special_dates_calendar_birthday">Narozeniny</string>
|
<string name="special_dates_calendar_birthday">Narozeniny</string>
|
||||||
<string name="special_dates_calendar_anniversary">Výročí</string>
|
<string name="special_dates_calendar_anniversary">Výročí</string>
|
||||||
<string name="special_dates_calendar_custom">Ostatní data</string>
|
<string name="special_dates_calendar_custom">Speciální data</string>
|
||||||
<string name="special_dates_default_title_birthday">Narozeniny má {name} ({year})</string>
|
<string name="special_dates_default_title_birthday">Narozeniny má {name} ({year})</string>
|
||||||
<string name="special_dates_default_title_anniversary">Výročí má {name} ({year})</string>
|
<string name="special_dates_default_title_anniversary">Výročí má {name} ({year})</string>
|
||||||
<string name="special_dates_default_title_custom">{name}</string>
|
<string name="special_dates_default_title_custom">{name}</string>
|
||||||
@@ -764,26 +764,4 @@
|
|||||||
<string name="settings_support_kofi_hint">Jednorázové příspěvky</string>
|
<string name="settings_support_kofi_hint">Jednorázové příspěvky</string>
|
||||||
<string name="settings_support_liberapay">Liberapay</string>
|
<string name="settings_support_liberapay">Liberapay</string>
|
||||||
<string name="settings_support_liberapay_hint">Pravidelné příspěvky</string>
|
<string name="settings_support_liberapay_hint">Pravidelné příspěvky</string>
|
||||||
<string name="event_resize_done">Změněno na %1$s</string>
|
|
||||||
<string name="event_resize_recurring_title">Změnit opakující se událost</string>
|
|
||||||
<string name="event_selection_actions">Akce událostí</string>
|
|
||||||
<string name="event_selection_hide">Skrýt akce událostí</string>
|
|
||||||
<string name="event_selection_close">Zrušit výběr události</string>
|
|
||||||
<string name="event_draft_create">Vytvořit událost</string>
|
|
||||||
<string name="event_draft_discard">Zahodit novou událost</string>
|
|
||||||
<string name="event_action_load_failed">Událost se nepodařilo otevřít</string>
|
|
||||||
<string name="event_edit_more_actions">Další akce</string>
|
|
||||||
<string name="event_edit_unsaved_title">Neuložené změny</string>
|
|
||||||
<string name="event_edit_unsaved_save">Uložit změny</string>
|
|
||||||
<string name="event_edit_unsaved_discard">Zahodit změny</string>
|
|
||||||
<string name="event_edit_unsaved_keep">Pokračovat v úpravách</string>
|
|
||||||
<string name="event_edit_create">Vytvořit</string>
|
|
||||||
<string name="settings_theme_black">Černý</string>
|
|
||||||
<string name="settings_theme_black_summary">Tmavý s čistě černým pozadím, pro AMOLED obrazovky</string>
|
|
||||||
<string name="settings_events_header">Události</string>
|
|
||||||
<string name="settings_tap_opens">Klepnutím na událost se otevře</string>
|
|
||||||
<string name="settings_tap_opens_details">Podrobnosti</string>
|
|
||||||
<string name="settings_tap_opens_editor">Formulář pro úpravu</string>
|
|
||||||
<string name="settings_tap_opens_hint">U upomínek a událostí, které nelze upravit, se vždy otevře stránka s podrobnostmi.</string>
|
|
||||||
<string name="process_text_create_event">Vytvořit událost</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -592,7 +592,7 @@
|
|||||||
<string name="settings_month_header">Monatsansicht</string>
|
<string name="settings_month_header">Monatsansicht</string>
|
||||||
<string name="settings_month_view_style">Stil der Monatsansicht</string>
|
<string name="settings_month_view_style">Stil der Monatsansicht</string>
|
||||||
<string name="month_style_paged">Seiten</string>
|
<string name="month_style_paged">Seiten</string>
|
||||||
<string name="month_style_paged_summary">Ein Monat füllt den Bildschirm. Wische nach links oder rechts, um den Monat zu wechseln, oder wische nach oben, um die Monatsagenda anzuzeigen.</string>
|
<string name="month_style_paged_summary">Ein Monat füllt den Bildschirm. Wische nach links oder rechts, um den Monat zu wechseln.</string>
|
||||||
<string name="month_style_continuous">Fortlaufende Monate</string>
|
<string name="month_style_continuous">Fortlaufende Monate</string>
|
||||||
<string name="month_style_continuous_summary">Jeder Monat steht unter seiner eigenen Überschrift, mit etwas Abstand zum nächsten.</string>
|
<string name="month_style_continuous_summary">Jeder Monat steht unter seiner eigenen Überschrift, mit etwas Abstand zum nächsten.</string>
|
||||||
<string name="month_style_dense">Nahtlose Wochen</string>
|
<string name="month_style_dense">Nahtlose Wochen</string>
|
||||||
@@ -727,40 +727,4 @@
|
|||||||
<string name="settings_visible_hours">Sichtbare Stunden</string>
|
<string name="settings_visible_hours">Sichtbare Stunden</string>
|
||||||
<string name="settings_visible_hours_start">Von</string>
|
<string name="settings_visible_hours_start">Von</string>
|
||||||
<string name="settings_visible_hours_end">Bis</string>
|
<string name="settings_visible_hours_end">Bis</string>
|
||||||
<string name="month_open_week">Woche %1$d öffnen</string>
|
|
||||||
<string name="month_split_clear_filter">Zeig die Agenda des ganzen Monats</string>
|
|
||||||
<string name="event_resize_done">Zu %1$s geändert</string>
|
|
||||||
<string name="event_selection_actions">Termin Aktionen</string>
|
|
||||||
<string name="event_selection_hide">Termin Aktionen verstecken</string>
|
|
||||||
<string name="event_selection_close">Termin abwählen</string>
|
|
||||||
<string name="event_draft_discard">Neuen Termin verwerfen</string>
|
|
||||||
<string name="event_action_load_failed">Termin konnte nicht geöffnet werden</string>
|
|
||||||
<string name="event_edit_unsaved_title">Ungespeicherte Änderungen</string>
|
|
||||||
<string name="event_edit_unsaved_save">Änderungen speichern</string>
|
|
||||||
<string name="event_edit_unsaved_discard">Änderungen verwerfen</string>
|
|
||||||
<string name="event_edit_create">Erstellen</string>
|
|
||||||
<string name="event_detail_open_in_maps">In Maps öffnen</string>
|
|
||||||
<string name="settings_theme_black_summary">Dunkel mit schwarzem Hintergrund, für AMOLED Bildschirme</string>
|
|
||||||
<string name="settings_multi_day_count">Mehrtagesansicht</string>
|
|
||||||
<string name="settings_multi_day_count_hint">Bestimmt, wie viele Tage in der Mehrtagesansicht angezeigt werden sollen. Die erste Spalte ist immer der aktuelle Tag.</string>
|
|
||||||
<string name="settings_timeline_scale_by_visible_hours">Durch sichtbares Zeitfenster bestimmt</string>
|
|
||||||
<string name="settings_visible_hours_summary">Zeigt in Wochen-, Mehrtages- und Tagesansicht anstatt aller Stunden ein bestimmtes Zeitfenster an</string>
|
|
||||||
<string name="settings_visible_hours_on_summary">Ansicht zeigt nur Zeit von %1$s bis %2$s</string>
|
|
||||||
<string name="settings_visible_hours_start_hint">Die Stunde am oberen Bildschirmrand, wenn die Wochen-, Mehrtages- oder Tagesansicht geöffnet wird. Frühere Stunden können durch Scrollen erreicht werden.</string>
|
|
||||||
<string name="settings_visible_hours_end_hint">Die Stunde am unteren Bildschirmrand, wenn eine Ansicht geöffnet wird. Spätere Stunden erreichst du weiterhin durch Scrollen, und das Zoomen per Pinch-Geste bleibt bestehen, bis die Ansicht erneut geöffnet wird.</string>
|
|
||||||
<string name="settings_events_header">Termine</string>
|
|
||||||
<string name="settings_tap_opens">Aktion beim Tippen auf einen Termin</string>
|
|
||||||
<string name="settings_tap_opens_details">Details</string>
|
|
||||||
<string name="settings_tap_opens_editor">Termin-Formular</string>
|
|
||||||
<string name="settings_tap_opens_hint">Termine, die nicht bearbeitet werden können, und Erinnerungsbenachrichtigungen öffnen immer die Details.</string>
|
|
||||||
<string name="settings_support_kofi_hint">Einmalige Spenden</string>
|
|
||||||
<string name="settings_support_liberapay_hint">Regelmäßige Spenden</string>
|
|
||||||
<string name="process_text_create_event">Termin erstellen</string>
|
|
||||||
<string name="settings_support_kofi">Ko-fi</string>
|
|
||||||
<string name="settings_support_liberapay">Liberapay</string>
|
|
||||||
<string name="event_resize_recurring_title">Wiederkehrenden Termin verschieben</string>
|
|
||||||
<string name="event_draft_create">Neuen Termin erstellen</string>
|
|
||||||
<string name="event_edit_more_actions">Weitere Aktionen</string>
|
|
||||||
<string name="event_edit_unsaved_keep">Weiter bearbeiten</string>
|
|
||||||
<string name="settings_theme_black">Zurück</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
<string name="settings_time_format_12h">12 ore</string>
|
<string name="settings_time_format_12h">12 ore</string>
|
||||||
<string name="settings_time_format_24h">24 ore</string>
|
<string name="settings_time_format_24h">24 ore</string>
|
||||||
<string name="settings_hour_lines">Grglia oraria</string>
|
<string name="settings_hour_lines">Grglia oraria</string>
|
||||||
<string name="settings_hour_lines_summary">Mostra una linea di separazione per ogni ora in una cella a sé stante nella visualizzazione settimanale e giornaliera</string>
|
<string name="settings_hour_lines_summary">Mostra una riga separatrice per ogni ora nella vista Settimana e Giorno</string>
|
||||||
<string name="settings_dim_completed">Attenua gli eventi completati</string>
|
<string name="settings_dim_completed">Attenua gli eventi completati</string>
|
||||||
<string name="settings_dim_completed_summary">Attenua gli eventi conclusi nella vista Mese e Settimana</string>
|
<string name="settings_dim_completed_summary">Attenua gli eventi conclusi nella vista Mese e Settimana</string>
|
||||||
<string name="settings_past_events">Eventi passati</string>
|
<string name="settings_past_events">Eventi passati</string>
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
|
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
|
||||||
<string name="settings_section_views">Vista</string>
|
<string name="settings_section_views">Vista</string>
|
||||||
<string name="settings_quick_switch_header">Pulsante di cambio rapido</string>
|
<string name="settings_quick_switch_header">Pulsante di cambio rapido</string>
|
||||||
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere col pulsante in alto a destra, trascinale per riordinarle. Se sono attive meno di due schermate, il pulsante viene nascosto. Le viste disattivate rimangono accessibili dal menu di navigazione.</string>
|
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere col pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.</string>
|
||||||
<string name="settings_drawer_order_header">Menù di navigazione</string>
|
<string name="settings_drawer_order_header">Menù di navigazione</string>
|
||||||
<string name="settings_drawer_order_hint">Trascina per riordinare le viste elencate nel menù di navigazione.</string>
|
<string name="settings_drawer_order_hint">Trascina per riordinare le viste elencate nel menù di navigazione.</string>
|
||||||
<string name="reorder_drag_handle">Trascina per riordinare</string>
|
<string name="reorder_drag_handle">Trascina per riordinare</string>
|
||||||
@@ -551,14 +551,12 @@
|
|||||||
<string name="search_select_all">Seleziona tutti</string>
|
<string name="search_select_all">Seleziona tutti</string>
|
||||||
<string name="search_delete_selected">Elimina i selezionati</string>
|
<string name="search_delete_selected">Elimina i selezionati</string>
|
||||||
<plurals name="search_delete_title">
|
<plurals name="search_delete_title">
|
||||||
<item quantity="one">Eliminare %d l\'evento?</item>
|
<item quantity="one">Eliminare l\'evento?</item>
|
||||||
<item quantity="many">Eliminare %d gli eventi?</item>
|
<item quantity="other">Eliminare l\'evento?</item>
|
||||||
<item quantity="other">Eliminare %d eventi?</item>
|
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="search_delete_done">
|
<plurals name="search_delete_done">
|
||||||
<item quantity="one">%d evento eliminato</item>
|
<item quantity="one">Evento eliminato</item>
|
||||||
<item quantity="many">%d eventi eliminati</item>
|
<item quantity="other">Evento eliminato</item>
|
||||||
<item quantity="other">%d eventi eliminati</item>
|
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="search_delete_partial">%1$d eliminati, %2$d no</string>
|
<string name="search_delete_partial">%1$d eliminati, %2$d no</string>
|
||||||
<string name="search_delete_denied_partial">%1$d eliminati, poi permessi di scrittura negati</string>
|
<string name="search_delete_denied_partial">%1$d eliminati, poi permessi di scrittura negati</string>
|
||||||
@@ -600,7 +598,7 @@
|
|||||||
<string name="settings_month_header">Vista Mese</string>
|
<string name="settings_month_header">Vista Mese</string>
|
||||||
<string name="settings_month_view_style">Stile vista Mese</string>
|
<string name="settings_month_view_style">Stile vista Mese</string>
|
||||||
<string name="month_style_paged">Pagine</string>
|
<string name="month_style_paged">Pagine</string>
|
||||||
<string name="month_style_paged_summary">Viene mostrato un singolo mese. Scorri a destra o a sinistra per cambiare mese oppure scorri verso l\'alto per visualizzare l\'agenda mensile.</string>
|
<string name="month_style_paged_summary">Viene mostrato un singolo mese. Scorri a destra o a sinistra per cambiare mese.</string>
|
||||||
<string name="month_style_continuous">Scorrimento</string>
|
<string name="month_style_continuous">Scorrimento</string>
|
||||||
<string name="settings_past_events_hint">Ciò che fa l\'Agenda con eventi conclusi.</string>
|
<string name="settings_past_events_hint">Ciò che fa l\'Agenda con eventi conclusi.</string>
|
||||||
<string name="settings_dynamic_color_summary">Deduci il colore dell\'app dallo sfondo.</string>
|
<string name="settings_dynamic_color_summary">Deduci il colore dell\'app dallo sfondo.</string>
|
||||||
@@ -698,9 +696,9 @@
|
|||||||
<string name="onboarding_calendars_create_failed">Impossibile creare il calendario. La memoria del calendario del tuo telefono non lo ha accettato.</string>
|
<string name="onboarding_calendars_create_failed">Impossibile creare il calendario. La memoria del calendario del tuo telefono non lo ha accettato.</string>
|
||||||
<string name="reminder_day_today">Oggi</string>
|
<string name="reminder_day_today">Oggi</string>
|
||||||
<plurals name="view_multi_day">
|
<plurals name="view_multi_day">
|
||||||
<item quantity="one">%d giorno</item>
|
<item quantity="one">Giorno</item>
|
||||||
<item quantity="many">%d giorni</item>
|
<item quantity="many">Giorni</item>
|
||||||
<item quantity="other">%d giorni</item>
|
<item quantity="other">Giorno</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="settings_multi_day_count">Visualizzazione su più giorni</string>
|
<string name="settings_multi_day_count">Visualizzazione su più giorni</string>
|
||||||
<string name="settings_multi_day_count_hint">La vista più giorni mostra, affiancati, i giorni. La data odierna è visualizzata nella prima colonna.</string>
|
<string name="settings_multi_day_count_hint">La vista più giorni mostra, affiancati, i giorni. La data odierna è visualizzata nella prima colonna.</string>
|
||||||
@@ -738,30 +736,4 @@
|
|||||||
<item quantity="many">Non è stato possibile aggiungere gli eventi %d.</item>
|
<item quantity="many">Non è stato possibile aggiungere gli eventi %d.</item>
|
||||||
<item quantity="other">Non è stato possibile aggiungere l\'evento %d.</item>
|
<item quantity="other">Non è stato possibile aggiungere l\'evento %d.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="event_draft_create">Crea evento</string>
|
|
||||||
<string name="event_draft_discard">Scarta nuovo evento</string>
|
|
||||||
<string name="event_action_load_failed">Impossibile aprire l\'evento</string>
|
|
||||||
<string name="event_selection_close">Deseleziona evento</string>
|
|
||||||
<string name="event_edit_more_actions">Più azioni</string>
|
|
||||||
<string name="event_edit_unsaved_title">Modifiche non salvate</string>
|
|
||||||
<string name="event_edit_unsaved_save">Salva modifiche</string>
|
|
||||||
<string name="event_edit_unsaved_discard">Scarta modifiche</string>
|
|
||||||
<string name="event_edit_unsaved_keep">Continua a modificare</string>
|
|
||||||
<string name="event_edit_create">Crea</string>
|
|
||||||
<string name="event_resize_done">Modificato in %1$s</string>
|
|
||||||
<string name="event_resize_recurring_title">Modifica evento ricorrente</string>
|
|
||||||
<string name="event_selection_actions">Azioni evento</string>
|
|
||||||
<string name="event_selection_hide">Nascondi azioni evento</string>
|
|
||||||
<string name="settings_theme_black">Nero</string>
|
|
||||||
<string name="settings_theme_black_summary">Scuro con sfondi completamente neri, per schermi AMOLED</string>
|
|
||||||
<string name="settings_events_header">Eventi</string>
|
|
||||||
<string name="settings_tap_opens">Cliccando su un evento si apre</string>
|
|
||||||
<string name="settings_tap_opens_details">Dettagli</string>
|
|
||||||
<string name="settings_tap_opens_editor">Modifica modulo</string>
|
|
||||||
<string name="settings_tap_opens_hint">Gli eventi che non è possibile modificare e le notifiche di promemoria, aprono sempre la pagina dei dettagli.</string>
|
|
||||||
<string name="settings_support_kofi">Ko-fi</string>
|
|
||||||
<string name="settings_support_kofi_hint">Contributi una tantum</string>
|
|
||||||
<string name="settings_support_liberapay">Liberapay</string>
|
|
||||||
<string name="settings_support_liberapay_hint">Donazioni ricorrenti</string>
|
|
||||||
<string name="process_text_create_event">Crea evento</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
Loaded 100 of 186 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user