Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f0b60f369 | ||
|
|
5afb332fa7 | ||
|
|
8987573295 | ||
|
|
1807f228e7 | ||
|
|
b794c35d8e | ||
|
|
43ef4027f5 | ||
|
|
2a55bc3baa | ||
|
|
14c10c6d0b | ||
|
|
f5e28eae8d | ||
|
|
b6cec2f95e | ||
|
|
d012948de6 | ||
|
|
8ff8a2de1f |
No files matched your search
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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]).
|
||||
|
||||
### 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]).
|
||||
|
||||
### 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]).
|
||||
|
||||
## [2.23.0] — 2026-10-21
|
||||
|
||||
### Added
|
||||
@@ -1889,3 +1906,17 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#178]: https://codeberg.org/jlmakiola/calendula/issues/178
|
||||
[#116]: https://codeberg.org/jlmakiola/calendula/issues/116
|
||||
[#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
|
||||
[#441]: https://codeberg.org/jlmakiola/calendula/issues/441
|
||||
@@ -32,8 +32,8 @@ android {
|
||||
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
|
||||
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
|
||||
// See docs/RELEASING.md.
|
||||
versionCode = 2230099
|
||||
versionName = "2.23.0"
|
||||
versionCode = 2240002
|
||||
versionName = "2.24.0-beta.2"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -426,7 +426,7 @@
|
||||
|
||||
<!-- A countdown widget's redraw for when its label changes (#439). -->
|
||||
<receiver
|
||||
android:name=".widget.countdown.CountdownTickReceiver"
|
||||
android:name=".widget.WidgetTickReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
@@ -450,6 +450,12 @@
|
||||
move the boundary, boot / package-replace wipe the alarm, and
|
||||
LOCALE_CHANGED redraws the labels in the new language.
|
||||
Exported: the system broadcasts arrive from outside the app. -->
|
||||
<!-- The app's own redraw request (language change); not exported so
|
||||
other apps can't trigger it. -->
|
||||
<receiver
|
||||
android:name=".widget.WidgetRedrawReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".widget.WidgetUpdateReceiver"
|
||||
android:exported="true">
|
||||
|
||||
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
|
||||
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -45,6 +46,9 @@ class CalendulaApp : Application() {
|
||||
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
||||
),
|
||||
)
|
||||
// Below API 33 only AppCompat activities get the app language; a process
|
||||
// woken for a widget formats in it too (#90).
|
||||
AppLanguage.applyDefault(this)
|
||||
// Before anything that writes, so the widgets see every change (#420).
|
||||
widgetEntryPoint().widgetRefreshCoordinator().start()
|
||||
reconcileAutoBackup()
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.isDark
|
||||
import de.jeanlucmakiola.floret.prefs.isPureBlack
|
||||
@@ -278,6 +279,7 @@ class MainActivity : AppCompatActivity() {
|
||||
super.onResume()
|
||||
// Reaching a running UI means startup succeeded; reset the loop trail.
|
||||
CrashReporter.markHealthy(this)
|
||||
AppLanguage.syncMirror(this)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
@@ -593,7 +595,9 @@ class MainActivity : AppCompatActivity() {
|
||||
*/
|
||||
fun openDateIntent(context: Context, date: LocalDate, source: CalendarView): Intent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
data = "calendula://date/$date".toUri()
|
||||
// The source is part of the URI so widgets of different kinds
|
||||
// tapping the same date don't share one PendingIntent.
|
||||
data = "calendula://date/$date/${source.name}".toUri()
|
||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||
putExtra(EXTRA_SOURCE_VIEW, source.name)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
@@ -612,6 +616,7 @@ class MainActivity : AppCompatActivity() {
|
||||
endMillis: Long,
|
||||
source: CalendarView,
|
||||
): Intent = eventDetailIntent(context, eventId, beginMillis, endMillis).apply {
|
||||
data = "calendula://event/$eventId/$beginMillis/${source.name}".toUri()
|
||||
putExtra(EXTRA_SOURCE_VIEW, source.name)
|
||||
}
|
||||
|
||||
|
||||
@@ -571,7 +571,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
queryInstances(beginMillis, endMillis, null, null)
|
||||
|
||||
override fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||
queryInstances(beginMillis, endMillis, "${CalendarContract.Instances.EVENT_ID} = ?", arrayOf(eventId.toString()))
|
||||
// The series' own rows plus its exceptions, so an occurrence edited on
|
||||
// its own is still one of the series' occurrences.
|
||||
queryInstances(
|
||||
beginMillis,
|
||||
endMillis,
|
||||
"(${CalendarContract.Instances.EVENT_ID} = ? OR ${CalendarContract.Instances.ORIGINAL_ID} = ?)",
|
||||
arrayOf(eventId.toString(), eventId.toString()),
|
||||
)
|
||||
|
||||
private fun queryInstances(
|
||||
beginMillis: Long,
|
||||
|
||||
@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlin.time.Instant
|
||||
|
||||
interface CalendarRepository {
|
||||
@@ -108,6 +109,12 @@ interface CalendarRepository {
|
||||
form: EventForm,
|
||||
): Long
|
||||
|
||||
/**
|
||||
* Every series split the app makes ([updateEventFromOccurrence]), so what
|
||||
* follows a series by id can move on to the part that continues it.
|
||||
*/
|
||||
val seriesSplits: Flow<SeriesSplit> get() = emptyFlow()
|
||||
|
||||
/**
|
||||
* Change a recurring event from [beginMillis] onwards (series split);
|
||||
* returns the new event's `Events._ID`.
|
||||
@@ -135,3 +142,6 @@ class NoSuchEventException(eventId: Long) :
|
||||
/** A ContentResolver write affected no rows or returned no URI. */
|
||||
class WriteFailedException(operation: String) :
|
||||
RuntimeException("Calendar write failed: $operation")
|
||||
|
||||
/** A "this and following" edit: [oldEventId] now ends where [newEventId] picks up. */
|
||||
data class SeriesSplit(val oldEventId: Long, val newEventId: Long)
|
||||
+5
-1
@@ -351,7 +351,11 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
dataSource.updateEventFromOccurrence(
|
||||
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
|
||||
)
|
||||
}
|
||||
}.also { newId -> if (newId != eventId) splits.tryEmit(SeriesSplit(eventId, newId)) }
|
||||
|
||||
private val splits = MutableSharedFlow<SeriesSplit>(extraBufferCapacity = 8)
|
||||
|
||||
override val seriesSplits: Flow<SeriesSplit> = splits
|
||||
|
||||
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = write {
|
||||
dataSource.deleteOccurrence(eventId, beginMillis)
|
||||
|
||||
+13
-4
@@ -38,6 +38,8 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
ACTION_DISMISS -> notifier.cancel(alert)
|
||||
|
||||
ACTION_SNOOZE -> {
|
||||
// Tapped on a stale view: the reminder that replaced it stays put.
|
||||
if (notifier.isSuperseded(alert)) return
|
||||
// Cancel now so the notification doesn't linger until the alarm;
|
||||
// the snooze delay read is the only async work.
|
||||
notifier.cancel(alert)
|
||||
@@ -50,6 +52,8 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
|
||||
ACTION_SHOW -> {
|
||||
goAsync {
|
||||
// A later reminder posted while this one was snoozed has taken its place.
|
||||
if (notifier.isSuperseded(alert)) return@goAsync
|
||||
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
|
||||
notifier.post(alert)
|
||||
}
|
||||
@@ -82,12 +86,14 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
/**
|
||||
* An explicit intent to this receiver carrying [alert] as extras. The
|
||||
* data URI duplicates no information but is what keeps two reminders'
|
||||
* `PendingIntent`s apart — `filterEquals` never compares extras.
|
||||
* `PendingIntent`s apart — `filterEquals` never compares extras. The
|
||||
* snooze alarm is the exception: one per occurrence, so a later snooze
|
||||
* replaces an earlier one and a newer reminder can cancel it.
|
||||
*/
|
||||
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
||||
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||
this.action = action
|
||||
data = "calendula://reminder/${alert.key}".toUri()
|
||||
data = "calendula://reminder/${identity(alert, action)}".toUri()
|
||||
putExtra(EXTRA_ALERT_KEY, alert.key)
|
||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||
@@ -101,7 +107,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
/**
|
||||
* A stable request code per (alert, action), so one notification's
|
||||
* PendingIntents stay distinct. The shift keeps the action slot intact;
|
||||
* the top bits it drops are separated by [intent]'s per-reminder URI.
|
||||
* the top bits it drops are separated by [intent]'s URI.
|
||||
*/
|
||||
fun requestCode(alert: ReminderAlert, action: String): Int {
|
||||
val actionOffset = when (action) {
|
||||
@@ -111,9 +117,12 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
ACTION_OPEN -> 4
|
||||
else -> 0
|
||||
}
|
||||
return (alert.key.toInt() shl 3) + actionOffset
|
||||
return (identity(alert, action).toInt() shl 3) + actionOffset
|
||||
}
|
||||
|
||||
private fun identity(alert: ReminderAlert, action: String): Long =
|
||||
if (action == ACTION_SHOW) alert.occurrenceKey else alert.key
|
||||
|
||||
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
|
||||
return ReminderAlert(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.occurrenceKey
|
||||
|
||||
/**
|
||||
* One reminder as the notification layer needs it: what to show, and the stable
|
||||
@@ -20,6 +21,13 @@ data class ReminderAlert(
|
||||
val isAllDay: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* The occurrence [ReminderAlert] belongs to. Keys the notification and the
|
||||
* snooze alarm, so a later reminder for the same occurrence replaces the earlier
|
||||
* one instead of stacking beside it (#463).
|
||||
*/
|
||||
val ReminderAlert.occurrenceKey: Long get() = occurrenceKey(eventId, beginMillis)
|
||||
|
||||
fun PlannedReminder.toAlert(): ReminderAlert = ReminderAlert(
|
||||
key = key,
|
||||
eventId = instance.eventId,
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
@@ -28,13 +29,13 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Posts one notification per due reminder on a dedicated channel. Tapping opens
|
||||
* Posts one notification per occurrence on a dedicated channel. Tapping opens
|
||||
* the event's detail screen.
|
||||
*
|
||||
* The tag is the reminder's stable key, so a scan that posts the same reminder
|
||||
* again — a catch-up pass overlapping the alarm that already fired — replaces
|
||||
* its notification silently ([NotificationCompat.Builder.setOnlyAlertOnce])
|
||||
* instead of stacking a second one.
|
||||
* The tag is the [occurrenceKey], so each reminder for an occurrence replaces
|
||||
* the one before it in place (#463). A different reminder alerts afresh; the
|
||||
* same one posted again — a catch-up pass overlapping the alarm that already
|
||||
* fired — replaces its notification silently.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderNotifier @Inject constructor(
|
||||
@@ -65,7 +66,7 @@ class ReminderNotifier @Inject constructor(
|
||||
suspend fun post(alert: ReminderAlert) {
|
||||
if (isSilenced(alert.calendarId)) return
|
||||
ensureChannel()
|
||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
val title = displayTitle(alert)
|
||||
val is24Hour = settingsPrefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
val zone = ZoneId.systemDefault()
|
||||
@@ -96,7 +97,8 @@ class ReminderNotifier @Inject constructor(
|
||||
.setCategory(NotificationCompat.CATEGORY_EVENT)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setOnlyAlertOnce(shownKey(alert) == alert.key)
|
||||
.addExtras(Bundle().apply { putLong(EXTRA_REMINDER_KEY, alert.key) })
|
||||
.setContentIntent(detailIntent(alert))
|
||||
.addAction(
|
||||
R.drawable.ic_notification_snooze,
|
||||
@@ -109,20 +111,58 @@ class ReminderNotifier @Inject constructor(
|
||||
actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
|
||||
)
|
||||
.build()
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
cancelLegacy(alert)
|
||||
try {
|
||||
NotificationManagerCompat.from(context)
|
||||
.notify(alert.key.toString(), NOTIFICATION_ID, notification)
|
||||
manager.notify(alert.occurrenceKey.toString(), NOTIFICATION_ID, notification)
|
||||
} catch (e: SecurityException) {
|
||||
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
||||
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
||||
/**
|
||||
* Remove [alert]'s notification (snooze re-shows it later; dismiss is final),
|
||||
* unless a later reminder has replaced it — an action tapped on a stale view
|
||||
* mustn't take the newer one with it.
|
||||
*/
|
||||
fun cancel(alert: ReminderAlert) {
|
||||
NotificationManagerCompat.from(context).cancel(alert.key.toString(), NOTIFICATION_ID)
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
if (shownKey(alert) == alert.key) manager.cancel(alert.occurrenceKey.toString(), NOTIFICATION_ID)
|
||||
// Posted before reminders shared a notification, under the reminder's own key.
|
||||
manager.cancel(alert.key.toString(), NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
/** Whether a different reminder for [alert]'s occurrence is on screen now. */
|
||||
fun isSuperseded(alert: ReminderAlert): Boolean = shownKey(alert).let { it != null && it != alert.key }
|
||||
|
||||
/** The key of the reminder showing for [alert]'s occurrence, if any. */
|
||||
private fun shownKey(alert: ReminderAlert): Long? {
|
||||
val tag = alert.occurrenceKey.toString()
|
||||
return NotificationManagerCompat.from(context).activeNotifications
|
||||
.firstOrNull { it.tag == tag && it.id == NOTIFICATION_ID }
|
||||
?.notification?.extras
|
||||
?.takeIf { it.containsKey(EXTRA_REMINDER_KEY) }
|
||||
?.getLong(EXTRA_REMINDER_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a reminder for the same occurrence posted before reminders shared a
|
||||
* notification. It carries no reminder key; its time and title identify it.
|
||||
*/
|
||||
private fun cancelLegacy(alert: ReminderAlert) {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
manager.activeNotifications
|
||||
.filter { it.id == NOTIFICATION_ID && it.notification.channelId == CHANNEL_ID }
|
||||
.filter { !it.notification.extras.containsKey(EXTRA_REMINDER_KEY) }
|
||||
.filter { it.notification.`when` == alert.beginMillis }
|
||||
.filter { it.notification.extras.getCharSequence(NotificationCompat.EXTRA_TITLE)?.toString() == displayTitle(alert) }
|
||||
.forEach { manager.cancel(it.tag, it.id) }
|
||||
}
|
||||
|
||||
private fun displayTitle(alert: ReminderAlert): String =
|
||||
alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
|
||||
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
@@ -157,8 +197,10 @@ class ReminderNotifier @Inject constructor(
|
||||
const val TAG = "ReminderNotifier"
|
||||
const val CHANNEL_ID = "reminders"
|
||||
|
||||
// One id, distinct tags: the tag (alert id) already keys the
|
||||
// One id, distinct tags: the tag (occurrence) already keys the
|
||||
// notification, so a fixed id keeps cancellation/replacement simple.
|
||||
const val NOTIFICATION_ID = 1
|
||||
|
||||
const val EXTRA_REMINDER_KEY = "de.jeanlucmakiola.calendula.reminder_key"
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ class ReminderScanner @Inject constructor(
|
||||
private val source: ReminderInstanceSource,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
private val notifier: ReminderNotifier,
|
||||
private val snoozes: ReminderSnoozeScheduler,
|
||||
private val alarms: ReminderAlarmScheduler,
|
||||
private val state: ReminderStatePrefs,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
@@ -104,7 +105,11 @@ class ReminderScanner @Inject constructor(
|
||||
)
|
||||
|
||||
if (notifier.canPost()) {
|
||||
schedule.due.forEach { notifier.post(it.toAlert()) }
|
||||
schedule.due.forEach {
|
||||
val alert = it.toAlert()
|
||||
snoozes.cancel(alert)
|
||||
notifier.post(alert)
|
||||
}
|
||||
}
|
||||
// Advance even when nothing could be posted, so muting notifications
|
||||
// doesn't build a backlog.
|
||||
|
||||
+12
@@ -33,4 +33,16 @@ class ReminderSnoozeScheduler @Inject constructor(
|
||||
)
|
||||
alarmManager.setWakeup(triggerAtMillis, pendingIntent)
|
||||
}
|
||||
|
||||
/** Drop a snooze still pending for [alert]'s occurrence, so it can't re-show over a newer reminder. */
|
||||
fun cancel(alert: ReminderAlert) {
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
ReminderActionReceiver.requestCode(alert, ReminderActionReceiver.ACTION_SHOW),
|
||||
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
|
||||
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE,
|
||||
) ?: return
|
||||
context.getSystemService<AlarmManager>()?.cancel(pendingIntent)
|
||||
pendingIntent.cancel()
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,8 @@ import java.util.Locale
|
||||
* - all-day, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end)
|
||||
*
|
||||
* All-day instances store UTC midnights with an exclusive end, so they are read
|
||||
* in UTC and the end day is the last *covered* day (issue #308).
|
||||
* in UTC and the end day is the last *covered* day (issue #308). A timed event
|
||||
* from local midnight to local midnight reads like one too, in [zone] (#296).
|
||||
*/
|
||||
fun reminderTimeText(
|
||||
beginMillis: Long,
|
||||
@@ -47,11 +48,14 @@ fun reminderTimeText(
|
||||
tomorrowLabel: String,
|
||||
yesterdayLabel: String,
|
||||
): String {
|
||||
if (isAllDay) {
|
||||
val wholeDays = !isAllDay && endMillis > beginMillis &&
|
||||
isStartOfDay(beginMillis, zone) && isStartOfDay(endMillis, zone)
|
||||
if (isAllDay || wholeDays) {
|
||||
val dayZone = if (isAllDay) ZoneOffset.UTC else zone
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
// (atZone().toLocalDate() instead of LocalDate.ofInstant — API 34+)
|
||||
val firstDay = Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
val firstDay = Instant.ofEpochMilli(beginMillis).atZone(dayZone).toLocalDate()
|
||||
val lastDay = Instant.ofEpochMilli(endMillis).atZone(dayZone).toLocalDate()
|
||||
.minusDays(1)
|
||||
.coerceAtLeast(firstDay)
|
||||
val start = relativeDayPrefix(
|
||||
@@ -134,3 +138,8 @@ private fun LocalDate.previousOrSame(target: DayOfWeek): LocalDate {
|
||||
}
|
||||
|
||||
private const val RANGE = " – "
|
||||
|
||||
private fun isStartOfDay(millis: Long, zone: ZoneId): Boolean {
|
||||
val at = Instant.ofEpochMilli(millis).atZone(zone)
|
||||
return at.toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli() == millis
|
||||
}
|
||||
@@ -6,13 +6,20 @@ import kotlinx.datetime.TimeZone
|
||||
fun EventInstance.displayPriority(zone: TimeZone): Int = when {
|
||||
isDeclined -> 3
|
||||
spansMultipleDays(zone) -> 0
|
||||
isAllDay -> 1
|
||||
coversWholeDays(zone) -> 1
|
||||
else -> 2
|
||||
}
|
||||
|
||||
/** Shared order for agenda and split day lists. Grid bars add their lane geometry. */
|
||||
fun eventDisplayComparator(zone: TimeZone): Comparator<EventInstance> =
|
||||
compareBy<EventInstance> { it.displayPriority(zone) }
|
||||
.thenBy { it.start }
|
||||
.thenBy { it.title }
|
||||
.thenBy { it.instanceId }
|
||||
/**
|
||||
* Shared order for agenda and split day lists. Grid bars add their lane geometry.
|
||||
* Each event's priority is worked out once, not on every comparison.
|
||||
*/
|
||||
fun List<EventInstance>.sortedForDisplay(zone: TimeZone): List<EventInstance> =
|
||||
map { it to it.displayPriority(zone) }
|
||||
.sortedWith(
|
||||
compareBy<Pair<EventInstance, Int>> { it.second }
|
||||
.thenBy { it.first.start }
|
||||
.thenBy { it.first.title }
|
||||
.thenBy { it.first.instanceId },
|
||||
)
|
||||
.map { it.first }
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
@@ -100,10 +101,16 @@ val EventInstance.isDeclined: Boolean get() = response == EventResponse.Declined
|
||||
/**
|
||||
* Whether this event has finished relative to [now] — its end is at or before
|
||||
* the current instant. An in-progress event (already started but not yet ended)
|
||||
* is *not* considered ended. All-day events end at the exclusive next-midnight,
|
||||
* so they only count as ended once their day is fully over.
|
||||
* is *not* considered ended. All-day events end at the exclusive next-midnight
|
||||
* in [zone] rather than the UTC midnight they are stored on, so they only count
|
||||
* as ended once their local day is fully over.
|
||||
*/
|
||||
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
||||
fun EventInstance.hasEnded(now: Instant, zone: TimeZone = TimeZone.currentSystemDefault()): Boolean =
|
||||
localEnd(zone) <= now
|
||||
|
||||
/** [EventInstance.end], with an all-day event's UTC midnight moved to local midnight in [zone]. */
|
||||
fun EventInstance.localEnd(zone: TimeZone): Instant =
|
||||
if (isAllDay) end.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone) else end
|
||||
|
||||
/**
|
||||
* The zone this event's calendar dates live in: the device [zone] for timed
|
||||
@@ -130,6 +137,17 @@ fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
|
||||
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
|
||||
spanFirstDay(zone) != spanLastDay(zone)
|
||||
|
||||
/**
|
||||
* Whether this event fills whole days in [zone], so the grids and lists lay it
|
||||
* out with the all-day events: an all-day event, or a timed one running from
|
||||
* local midnight to local midnight (#296). The event itself stays timed.
|
||||
*/
|
||||
fun EventInstance.coversWholeDays(zone: TimeZone): Boolean =
|
||||
isAllDay || (end > start && start.isStartOfDay(zone) && end.isStartOfDay(zone))
|
||||
|
||||
private fun Instant.isStartOfDay(zone: TimeZone): Boolean =
|
||||
this == toLocalDateTime(zone).date.atStartOfDayIn(zone)
|
||||
|
||||
data class EventDetail(
|
||||
val instance: EventInstance,
|
||||
val description: String?,
|
||||
|
||||
@@ -32,19 +32,26 @@ data class PlannedReminder(
|
||||
val minutes: Int,
|
||||
val alarmMillis: Long,
|
||||
) {
|
||||
/** The occurrence this reminder belongs to; it shares one notification with its siblings (#463). */
|
||||
val occurrenceKey: Long = occurrenceKey(instance.eventId, instance.beginMillis)
|
||||
|
||||
/**
|
||||
* Stable identity, keying the notification tag and the snooze/dismiss
|
||||
* Stable identity of this one reminder, keying the snooze/dismiss
|
||||
* `PendingIntent`s. Survives reboot, re-scan and reinstall.
|
||||
*/
|
||||
val key: Long = key(instance.eventId, instance.beginMillis, minutes)
|
||||
val key: Long = mix64(occurrenceKey + minutes)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
fun key(eventId: Long, beginMillis: Long, minutes: Int): Long {
|
||||
var h = eventId * 1_000_003L
|
||||
h = (h xor beginMillis) * 31L
|
||||
return h + minutes
|
||||
}
|
||||
}
|
||||
/** Stable identity of one occurrence, shared by every reminder on it. */
|
||||
fun occurrenceKey(eventId: Long, beginMillis: Long): Long =
|
||||
mix64(mix64(eventId) + beginMillis)
|
||||
|
||||
/** SplitMix64's finaliser: spreads close inputs across all 64 bits. */
|
||||
private fun mix64(value: Long): Long {
|
||||
var z = value + -0x61c8864680b583ebL
|
||||
z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L
|
||||
z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L
|
||||
return z xor (z ushr 31)
|
||||
}
|
||||
|
||||
/** What one scan concluded: post these now, and wake up again at [nextAlarmMillis]. */
|
||||
@@ -146,7 +153,8 @@ private fun allDayAlarmMillis(
|
||||
*
|
||||
* Due means the fire instant falls in `(lastFiredMillis, nowMillis]`, so a scan
|
||||
* running twice cannot post twice while a late one still catches up. Reminders
|
||||
* whose event has ended are dropped ([isStillRelevant]). [nextAlarmMillis] is
|
||||
* whose event has ended are dropped ([isStillRelevant]), and of several due on
|
||||
* one occurrence only the latest is kept — they'd replace each other anyway (#463). [nextAlarmMillis] is
|
||||
* capped at [horizonMillis] so the lookahead window keeps rolling forward.
|
||||
*/
|
||||
fun scheduleReminders(
|
||||
@@ -158,7 +166,8 @@ fun scheduleReminders(
|
||||
val due = planned
|
||||
.filter { it.alarmMillis in (lastFiredMillis + 1)..nowMillis }
|
||||
.filter { it.instance.isStillRelevant(nowMillis) }
|
||||
.distinctBy { it.key }
|
||||
.groupBy { it.occurrenceKey }
|
||||
.values.map { siblings -> siblings.maxBy { it.alarmMillis } }
|
||||
.sortedWith(compareBy({ it.instance.beginMillis }, { it.key }))
|
||||
val nextPending = planned
|
||||
.filter { it.alarmMillis > nowMillis }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
||||
import de.jeanlucmakiola.calendula.domain.sortedForDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
||||
@@ -18,7 +19,7 @@ import kotlin.time.Instant
|
||||
* surface only formats the instants into its own locale/24h string.
|
||||
*/
|
||||
sealed interface AgendaTimeLabel {
|
||||
/** An all-day event, or a whole in-between day of a multi-day span. */
|
||||
/** An event filling whole days (#296), or a whole in-between day of a multi-day span. */
|
||||
data object AllDay : AgendaTimeLabel
|
||||
/** The first day of a multi-day timed event: when it begins. */
|
||||
data class Starts(val start: Instant) : AgendaTimeLabel
|
||||
@@ -30,7 +31,7 @@ sealed interface AgendaTimeLabel {
|
||||
|
||||
/** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */
|
||||
fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel {
|
||||
if (event.isAllDay) return AgendaTimeLabel.AllDay
|
||||
if (event.coversWholeDays(zone)) return AgendaTimeLabel.AllDay
|
||||
val firstDay = event.spanFirstDay(zone)
|
||||
val lastDay = event.spanLastDay(zone)
|
||||
return when {
|
||||
@@ -87,7 +88,7 @@ fun groupAgendaDays(
|
||||
return byDay.map { (date, dayEvents) ->
|
||||
AgendaDay(
|
||||
date = date,
|
||||
events = dayEvents.sortedWith(eventDisplayComparator(zone)),
|
||||
events = dayEvents.sortedForDisplay(zone),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ fun CalendarDatePickerDialog(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val state = rememberDatePickerState(
|
||||
initialSelectedDateMillis = initial.toEpochDays() * MILLIS_PER_DAY,
|
||||
initialSelectedDateMillis = initial.coerceIn(CalendarSpan.FIRST_DAY, CalendarSpan.LAST_DAY)
|
||||
.toEpochDays() * MILLIS_PER_DAY,
|
||||
yearRange = CalendarSpan.years,
|
||||
)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* The dates every view can show and every date picker offers. One span for all
|
||||
* of them, so no view can hand another a date it has no page for (#457).
|
||||
*/
|
||||
object CalendarSpan {
|
||||
val FIRST_DAY: LocalDate = LocalDate(1600, 1, 1)
|
||||
val LAST_DAY: LocalDate = LocalDate(2399, 12, 31)
|
||||
|
||||
val years: IntRange get() = FIRST_DAY.year..LAST_DAY.year
|
||||
}
|
||||
@@ -1,22 +1,26 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* One shared state per pager page — a week, a day, a month — so a page and the
|
||||
* screen's anchor read the same query, and the pages either side stay loaded
|
||||
* across a swipe.
|
||||
*
|
||||
* Every entry shares on its own job, cancelled when [keep] lets it go: a
|
||||
* `stateIn` on the ViewModel's scope would stay running, holding its last state,
|
||||
* until the ViewModel itself was cleared. Main thread only.
|
||||
* An entry queries only while something collects it, stopping [STOP_TIMEOUT_MILLIS]
|
||||
* after the last collector leaves, and starts again when one comes back. Letting
|
||||
* an entry go only forgets it: a page still holding it keeps a working state
|
||||
* however far the pager composes or prefetches, and however long the screen sat
|
||||
* stopped (#440, #454). A forgotten entry nobody collects holds no coroutine, so
|
||||
* paging through a year doesn't keep a year of queries around. Main thread only.
|
||||
*
|
||||
* @param keep whether a cached key is still worth holding once [get] asks for another.
|
||||
*/
|
||||
@@ -26,28 +30,54 @@ class PageStateCache<K, S>(
|
||||
private val keep: (cached: K, requested: K) -> Boolean,
|
||||
private val load: (K) -> Flow<S>,
|
||||
) {
|
||||
private class Entry<S>(val state: StateFlow<S>, val job: Job)
|
||||
|
||||
private val entries = HashMap<K, Entry<S>>()
|
||||
private val entries = HashMap<K, Entry>()
|
||||
|
||||
/** The number of entries held, for tests. */
|
||||
internal val size: Int get() = entries.size
|
||||
|
||||
fun get(key: K): StateFlow<S> {
|
||||
val iterator = entries.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val (cached, entry) = iterator.next()
|
||||
if (cached != key && !keep(cached, key)) {
|
||||
entry.job.cancel()
|
||||
iterator.remove()
|
||||
// One still collected stays, so the anchor asking for it again shares its query.
|
||||
entries.entries.removeAll { (cached, entry) ->
|
||||
cached != key && !keep(cached, key) && !entry.isCollected
|
||||
}
|
||||
return entries.getOrPut(key) { Entry(key) }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForInheritanceCoroutinesApi::class)
|
||||
private inner class Entry(private val key: K) : StateFlow<S> {
|
||||
private val state = MutableStateFlow(initial)
|
||||
private var collectors = 0
|
||||
private var loading: Job? = null
|
||||
private var stopping: Job? = null
|
||||
|
||||
val isCollected: Boolean get() = collectors > 0
|
||||
|
||||
override val value: S get() = state.value
|
||||
override val replayCache: List<S> get() = state.replayCache
|
||||
|
||||
override suspend fun collect(collector: FlowCollector<S>): Nothing {
|
||||
collectors++
|
||||
stopping?.cancel()
|
||||
stopping = null
|
||||
if (loading?.isActive != true) {
|
||||
loading = scope.launch { load(key).collect { state.value = it } }
|
||||
}
|
||||
try {
|
||||
state.collect(collector)
|
||||
} finally {
|
||||
if (--collectors == 0) {
|
||||
stopping = scope.launch {
|
||||
delay(STOP_TIMEOUT_MILLIS)
|
||||
loading?.cancel()
|
||||
loading = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries.getOrPut(key) {
|
||||
val job = SupervisorJob(scope.coroutineContext[Job])
|
||||
Entry(
|
||||
state = load(key).stateWhileSubscribed(scope + job, initial),
|
||||
job = job,
|
||||
)
|
||||
}.state
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Long enough to ride out a configuration change, as `stateWhileSubscribed` does. */
|
||||
const val STOP_TIMEOUT_MILLIS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ fun EventInstance.isPastOn(
|
||||
day: LocalDate,
|
||||
now: Instant,
|
||||
zone: TimeZone = TimeZone.currentSystemDefault(),
|
||||
): Boolean = hasEnded(now) || day < now.toLocalDateTime(zone).date
|
||||
): Boolean = hasEnded(now, zone) || day < now.toLocalDateTime(zone).date
|
||||
|
||||
/** Opacity applied to a completed/past event chip when it is dimmed. */
|
||||
const val EventDimAlpha = 0.4f
|
||||
|
||||
@@ -755,12 +755,21 @@ class TimelineDragController {
|
||||
|
||||
/** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */
|
||||
private fun settle(landed: TimelineDrag, end: Instant? = null) {
|
||||
// Landed on midnight to midnight, the event moves to the all-day strip
|
||||
// (#296): no block will seat its copy or carry the selection.
|
||||
val leavesGrid = landed.eventSpanMin > 0 &&
|
||||
landed.eventStartMin.mod(MINUTES_PER_DAY) == 0 &&
|
||||
landed.eventSpanMin % MINUTES_PER_DAY == 0
|
||||
if (leavesGrid && !landed.event.isDraft && selection?.event?.instanceId == landed.event.instanceId) {
|
||||
selection = null
|
||||
selectionCovered = false
|
||||
}
|
||||
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
|
||||
// An edge piece is a hint for the finger, not a preview of the landing
|
||||
// slot: the event belongs to a day this timeline has no column for, so
|
||||
// [noteGrid] can never seat it and it would sit at the top of today for
|
||||
// the whole settle timeout, long after the event moved off-screen.
|
||||
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
|
||||
settling = if (landed.edgeOnly || leavesGrid) landed.copy(pieces = emptyList()) else landed
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledOrigin = landed.event.eventId to landed.event.start
|
||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth
|
||||
import kotlinx.datetime.TimeZone
|
||||
@@ -11,8 +12,8 @@ import java.util.Locale
|
||||
|
||||
/**
|
||||
* The start time a chip shows before its title (#219), or null when it has none
|
||||
* to show: an all-day chip never carries a time, and neither does a bar carried
|
||||
* in from an earlier week, whose start is not in this segment.
|
||||
* to show: a chip filling whole days never carries a time (#296), and neither
|
||||
* does a bar carried in from an earlier week, whose start is not in this segment.
|
||||
*/
|
||||
internal fun monthChipTime(
|
||||
event: EventInstance,
|
||||
@@ -21,7 +22,7 @@ internal fun monthChipTime(
|
||||
is24Hour: Boolean,
|
||||
locale: Locale,
|
||||
): String? {
|
||||
if (event.isAllDay || continuesLeft) return null
|
||||
if (event.coversWholeDays(zone) || continuesLeft) return null
|
||||
return formatTimeOfDay(event.start, zone, is24Hour, locale)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaDays
|
||||
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
|
||||
@@ -278,6 +277,7 @@ fun MonthScreen(
|
||||
val selectedDate by viewModel.selectedDate.collectAsStateWithLifecycle()
|
||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||
val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle()
|
||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||
// The instant before which an event counts as completed, or null when dimming
|
||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||
// screen while the setting is off (it stays null regardless of the tick).
|
||||
@@ -363,7 +363,7 @@ fun MonthScreen(
|
||||
if (!scrolling) return@LaunchedEffect
|
||||
snapshotFlow {
|
||||
// Empty before the first layout pass — and reporting a default of 0
|
||||
// for it would claim January 1900 is on screen and drag the whole
|
||||
// for it would claim the span's first month is on screen and drag the whole
|
||||
// window back there.
|
||||
val visible = listState.layoutInfo.visibleItemsInfo
|
||||
when {
|
||||
@@ -448,7 +448,8 @@ fun MonthScreen(
|
||||
}
|
||||
}
|
||||
// Drawer jump-to-date.
|
||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||
val jumpToDate: (LocalDate) -> Unit = { date ->
|
||||
val target = coerceToMonthRange(date)
|
||||
if (scrolling) {
|
||||
val targetMonth = target.yearMonth
|
||||
pendingScrollMonth = targetMonth
|
||||
@@ -543,6 +544,7 @@ fun MonthScreen(
|
||||
weekStart = weekStart,
|
||||
monthState = viewModel::month,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
showToday = showToday,
|
||||
onPaneSwipeNext = { viewModel.stepSplitPane(1) },
|
||||
onPaneSwipePrev = { viewModel.stepSplitPane(-1) },
|
||||
onClearSelection = viewModel::clearSelection,
|
||||
@@ -551,8 +553,10 @@ fun MonthScreen(
|
||||
// month, pager and all — straight away, or a swipe
|
||||
// still settling would land and take the tap back.
|
||||
onSelectDay = { date ->
|
||||
viewModel.selectDate(date)
|
||||
jumpToMonth(date.yearMonth)
|
||||
if (isInMonthRange(date)) {
|
||||
viewModel.selectDate(date)
|
||||
jumpToMonth(date.yearMonth)
|
||||
}
|
||||
},
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
@@ -1198,6 +1202,7 @@ private fun SplitMonthContent(
|
||||
weekStart: DayOfWeek,
|
||||
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
|
||||
showWeekNumbers: Boolean,
|
||||
showToday: Boolean,
|
||||
onPaneSwipeNext: () -> Unit,
|
||||
onPaneSwipePrev: () -> Unit,
|
||||
onClearSelection: () -> Unit,
|
||||
@@ -1336,6 +1341,7 @@ private fun SplitMonthContent(
|
||||
paneReady = paneReady,
|
||||
selected = pageSelected,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
showToday = showToday,
|
||||
expandDrag = expandDrag,
|
||||
onSelectDay = onSelectDay,
|
||||
onPaneSwipeNext = {
|
||||
@@ -1378,6 +1384,7 @@ private fun SplitMonthCollapsed(
|
||||
paneReady: Boolean,
|
||||
selected: LocalDate?,
|
||||
showWeekNumbers: Boolean,
|
||||
showToday: Boolean,
|
||||
expandDrag: Modifier,
|
||||
onSelectDay: (LocalDate) -> Unit,
|
||||
onPaneSwipeNext: () -> Unit,
|
||||
@@ -1410,6 +1417,7 @@ private fun SplitMonthCollapsed(
|
||||
Box(Modifier.weight(1f).fillMaxWidth().then(paneModifier)) {
|
||||
SplitMonthAgendaPane(
|
||||
state = state,
|
||||
showToday = showToday,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
@@ -1859,22 +1867,33 @@ private fun SplitDots(
|
||||
@Composable
|
||||
private fun SplitMonthAgendaPane(
|
||||
state: MonthUiState.Success,
|
||||
showToday: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateEvent: (LocalDate) -> Unit,
|
||||
) {
|
||||
val start = agendaStartForMonth(state.month, state.today)
|
||||
val days = state.instancesByDay.keys
|
||||
.filter { it >= start && it.year == state.month.year && it.month == state.month.month }
|
||||
.filter { state.instancesByDay[it].orEmpty().isNotEmpty() }
|
||||
.toMutableList()
|
||||
.apply { if (start !in this) add(start) }
|
||||
.sorted()
|
||||
val days = remember(state.month, state.today, state.instancesByDay, showToday) {
|
||||
splitAgendaDays(state.month, state.today, state.instancesByDay, showToday)
|
||||
}
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val listState = key(state.month) { rememberLazyListState() }
|
||||
LazyColumn(state = listState, contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
|
||||
LazyColumn(state = listState, contentPadding = PaddingValues(bottom = 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(8.dp)) }
|
||||
if (days.isEmpty()) {
|
||||
val start = agendaStartForMonth(state.month, state.today)
|
||||
item(key = "empty") {
|
||||
AgendaEmptyDayRow(
|
||||
text = stringResource(
|
||||
if (start == state.today) R.string.month_split_no_more_events else R.string.month_split_no_events_month,
|
||||
),
|
||||
onClick = { onCreateEvent(start) },
|
||||
)
|
||||
}
|
||||
}
|
||||
agendaDays(
|
||||
days = days.map { AgendaDay(it, state.instancesByDay[it].orEmpty()) },
|
||||
days = days,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
emptyText = R.string.month_split_no_events,
|
||||
|
||||
@@ -13,11 +13,15 @@ import de.jeanlucmakiola.calendula.data.time.currentDate
|
||||
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarSpan
|
||||
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||
import de.jeanlucmakiola.calendula.domain.sortedForDisplay
|
||||
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
|
||||
@@ -81,6 +85,13 @@ class MonthViewModel @Inject constructor(
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
/** Whether the split agenda keeps a free today, as the Agenda view does (#35). */
|
||||
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
initialValue = true,
|
||||
)
|
||||
|
||||
/** How the grid is laid out and navigated (#38, #53). */
|
||||
val viewStyle: StateFlow<MonthViewStyle> = settingsPrefs.monthViewStyle
|
||||
.stateWhileSubscribed(
|
||||
@@ -95,7 +106,7 @@ class MonthViewModel @Inject constructor(
|
||||
val today: StateFlow<LocalDate> =
|
||||
todaySource.today.stateFollowingDays(viewModelScope, todayDate, ::followToday)
|
||||
|
||||
private val _month = MutableStateFlow(todayDate.yearMonth)
|
||||
private val _month = MutableStateFlow(yearMonthForIndex(monthIndexOf(todayDate.yearMonth)))
|
||||
val month: StateFlow<YearMonth> = _month
|
||||
|
||||
/**
|
||||
@@ -149,7 +160,7 @@ class MonthViewModel @Inject constructor(
|
||||
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
|
||||
fun onPageSettled(page: Int) {
|
||||
if (monthIndexOf(_month.value) == page) return
|
||||
_month.value = yearMonthForIndex(page)
|
||||
setMonth(yearMonthForIndex(page))
|
||||
// Moving the grid to another month restores its full agenda.
|
||||
_selectedDate.value = null
|
||||
}
|
||||
@@ -298,9 +309,11 @@ class MonthViewModel @Inject constructor(
|
||||
* silently list a date the grid isn't pointing at.
|
||||
*/
|
||||
fun selectDate(date: LocalDate) {
|
||||
// A greyed-out day past either end of the grid has no month to follow to.
|
||||
if (!isInMonthRange(date)) return
|
||||
val target = date.yearMonth
|
||||
_selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date
|
||||
if (target != _month.value) _month.value = target
|
||||
if (target != _month.value) setMonth(target)
|
||||
}
|
||||
|
||||
fun clearSelection() { _selectedDate.value = null }
|
||||
@@ -309,16 +322,16 @@ class MonthViewModel @Inject constructor(
|
||||
fun stepSplitPane(days: Int) {
|
||||
val selected = _selectedDate.value
|
||||
if (selected == null) {
|
||||
_month.value = yearMonthForIndex(monthIndexOf(_month.value) + days)
|
||||
setMonth(yearMonthForIndex(monthIndexOf(_month.value) + days))
|
||||
} else {
|
||||
val next = selected.plus(days, DateTimeUnit.DAY)
|
||||
val next = coerceToMonthRange(selected.plus(days, DateTimeUnit.DAY))
|
||||
_selectedDate.value = next
|
||||
_month.value = next.yearMonth
|
||||
setMonth(next.yearMonth)
|
||||
}
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
_month.value = todayDate.yearMonth
|
||||
setMonth(todayDate.yearMonth)
|
||||
_selectedDate.value = null
|
||||
}
|
||||
|
||||
@@ -330,7 +343,7 @@ class MonthViewModel @Inject constructor(
|
||||
private fun followToday(old: LocalDate, new: LocalDate) {
|
||||
if (viewStyle.value.isScrolling) return
|
||||
if (_month.value != old.yearMonth) return
|
||||
_month.value = new.yearMonth
|
||||
setMonth(new.yearMonth)
|
||||
_selectedDate.value = null
|
||||
}
|
||||
|
||||
@@ -344,14 +357,24 @@ class MonthViewModel @Inject constructor(
|
||||
*/
|
||||
fun syncScrollMonth(ym: YearMonth) {
|
||||
if (_month.value == ym) return
|
||||
_month.value = ym
|
||||
setMonth(ym)
|
||||
_selectedDate.value = null
|
||||
}
|
||||
|
||||
/** Jump to the month containing [date] (drawer jump-to-date). */
|
||||
/**
|
||||
* Jump to the month containing [date] (drawer jump-to-date, or the focus
|
||||
* handed over by another view). The focus itself keeps a date outside the
|
||||
* grid's span, so the day and week views return to it unchanged.
|
||||
*/
|
||||
fun goToDate(date: LocalDate) {
|
||||
_month.value = date.yearMonth
|
||||
_selectedDate.value = date
|
||||
val target = coerceToMonthRange(date)
|
||||
setMonth(target.yearMonth)
|
||||
_selectedDate.value = target
|
||||
}
|
||||
|
||||
/** Every write to [_month] goes through here, so it can't leave the grid's span (#457). */
|
||||
private fun setMonth(ym: YearMonth) {
|
||||
_month.value = yearMonthForIndex(monthIndexOf(ym))
|
||||
}
|
||||
|
||||
private fun buildState(
|
||||
@@ -469,7 +492,7 @@ internal fun layoutCalendarWeek(
|
||||
// crossing midnight into the next week row covers a single day in each, and
|
||||
// counting per row drew it as two unrelated pills (#253).
|
||||
val (bars, singles) = weekEvents.partition { ev ->
|
||||
ev.isAllDay || ev.spansMultipleDays(zone)
|
||||
ev.coversWholeDays(zone) || ev.spansMultipleDays(zone)
|
||||
}
|
||||
val spans = layoutAllDay(bars, days, zone).map { s ->
|
||||
MonthSpan(
|
||||
@@ -509,7 +532,7 @@ internal fun instancesByDay(
|
||||
days.associateWith { day ->
|
||||
instances
|
||||
.filter { it.coversDay(day, zone) }
|
||||
.sortedWith(eventDisplayComparator(zone))
|
||||
.sortedForDisplay(zone)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -556,6 +579,25 @@ internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate
|
||||
LocalDate(month.year, month.month, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The days the split agenda lists for [month]: those from [agendaStartForMonth]
|
||||
* on that have events, plus a free today when [showToday] is set, as in the
|
||||
* Agenda view (#142).
|
||||
*/
|
||||
internal fun splitAgendaDays(
|
||||
month: YearMonth,
|
||||
today: LocalDate,
|
||||
instancesByDay: Map<LocalDate, List<EventInstance>>,
|
||||
showToday: Boolean,
|
||||
): List<AgendaDay> {
|
||||
val start = agendaStartForMonth(month, today)
|
||||
val days = instancesByDay.keys
|
||||
.filter { it >= start && it.yearMonth == month && instancesByDay.getValue(it).isNotEmpty() }
|
||||
.sorted()
|
||||
.map { AgendaDay(it, instancesByDay.getValue(it)) }
|
||||
return anchorTodayIfMissing(days, today, enabled = showToday && start == today)
|
||||
}
|
||||
|
||||
/**
|
||||
* The window to load for a visible range, or null to keep the current one.
|
||||
*
|
||||
@@ -578,24 +620,39 @@ internal fun nextLoadWindow(
|
||||
/**
|
||||
* The continuous grid addresses months by an absolute index, so the list has one
|
||||
* stable, gap-free coordinate space to scroll through and key its items by.
|
||||
* Index 0 is January 1900; the list runs to [continuousMonthCount].
|
||||
* Index 0 is the first month of [CalendarSpan]; the list runs to [continuousMonthCount].
|
||||
*
|
||||
* Unlike the week indexing this replaced, the coordinate space doesn't depend on
|
||||
* the week-start preference — changing it reflows the rows *inside* a month
|
||||
* block but never moves the block, so a scroll position stays put.
|
||||
*/
|
||||
private const val MONTH_INDEX_EPOCH_YEAR = 1900
|
||||
private const val MONTH_INDEX_END_YEAR = 2100
|
||||
private val MONTH_INDEX_EPOCH_YEAR = CalendarSpan.FIRST_DAY.year
|
||||
private val MONTH_INDEX_END_YEAR = CalendarSpan.LAST_DAY.year
|
||||
|
||||
/** The first and last day the month and Dense grids can show. */
|
||||
private val GRID_FIRST_DAY = LocalDate(MONTH_INDEX_EPOCH_YEAR, 1, 1)
|
||||
private val GRID_LAST_DAY = LocalDate(MONTH_INDEX_END_YEAR, 12, 31)
|
||||
|
||||
internal fun monthIndexOf(ym: YearMonth): Int =
|
||||
(ym.year - MONTH_INDEX_EPOCH_YEAR) * 12 + ym.month.ordinal
|
||||
|
||||
internal fun yearMonthForIndex(index: Int): YearMonth = YearMonth(
|
||||
MONTH_INDEX_EPOCH_YEAR + index / 12,
|
||||
Month.entries[index % 12],
|
||||
)
|
||||
/** The month at [index], held to the grid's span, so a stray index can't throw (#457). */
|
||||
internal fun yearMonthForIndex(index: Int): YearMonth {
|
||||
val held = clampMonthIndex(index)
|
||||
return YearMonth(MONTH_INDEX_EPOCH_YEAR + held / 12, Month.entries[held % 12])
|
||||
}
|
||||
|
||||
/** Total months the continuous grid scrolls through (1900 → 2100). */
|
||||
internal fun clampMonthIndex(index: Int): Int = index.coerceIn(0, continuousMonthCount() - 1)
|
||||
|
||||
/**
|
||||
* Hold [date] inside the span the month grid can show. Every view shares
|
||||
* [CalendarSpan], but another app can open Calendula on any date (#457).
|
||||
*/
|
||||
internal fun coerceToMonthRange(date: LocalDate): LocalDate = date.coerceIn(GRID_FIRST_DAY, GRID_LAST_DAY)
|
||||
|
||||
internal fun isInMonthRange(date: LocalDate): Boolean = date in GRID_FIRST_DAY..GRID_LAST_DAY
|
||||
|
||||
/** Total months the continuous grid scrolls through. */
|
||||
internal fun continuousMonthCount(): Int =
|
||||
(MONTH_INDEX_END_YEAR - MONTH_INDEX_EPOCH_YEAR + 1) * 12
|
||||
|
||||
@@ -611,25 +668,23 @@ internal fun monthIndexForItem(itemIndex: Int): Int = itemIndex / 2
|
||||
/**
|
||||
* Hold a load window inside the months that actually exist. The pad
|
||||
* [nextLoadWindow] adds can otherwise push an index past either end of the
|
||||
* 1900–2100 span, and [yearMonthForIndex] has no month to hand back for one.
|
||||
* span, and [yearMonthForIndex] has no month to hand back for one.
|
||||
*/
|
||||
internal fun clampMonthWindow(window: IntRange): IntRange {
|
||||
val last = continuousMonthCount() - 1
|
||||
return window.first.coerceIn(0, last)..window.last.coerceIn(0, last)
|
||||
}
|
||||
internal fun clampMonthWindow(window: IntRange): IntRange =
|
||||
clampMonthIndex(window.first)..clampMonthIndex(window.last)
|
||||
|
||||
/**
|
||||
* The Dense style addresses *weeks* by absolute index instead: it has no month
|
||||
* blocks to key by, and a row's identity has to survive the months around it
|
||||
* scrolling past. Index 0 is the first week of 1900 under the active week-start,
|
||||
* scrolling past. Index 0 is the span's first week under the active week-start,
|
||||
* so — like the month space — every index is non-negative and the LazyColumn's
|
||||
* item indices and week indices are the same number.
|
||||
*
|
||||
* Unlike month indices these *do* shift with the week-start preference, which is
|
||||
* why the list state is keyed on it.
|
||||
*/
|
||||
private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1)
|
||||
private val WEEK_INDEX_END = LocalDate(2100, 12, 31)
|
||||
private val WEEK_INDEX_EPOCH = GRID_FIRST_DAY
|
||||
private val WEEK_INDEX_END = GRID_LAST_DAY
|
||||
|
||||
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
|
||||
val base = WEEK_INDEX_EPOCH.startOfWeek(weekStart)
|
||||
@@ -639,7 +694,7 @@ internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
|
||||
internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate =
|
||||
WEEK_INDEX_EPOCH.startOfWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
|
||||
|
||||
/** Total weeks the Dense grid scrolls through (1900 → 2100). */
|
||||
/** Total weeks the Dense grid scrolls through. */
|
||||
internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
|
||||
weekIndexOf(WEEK_INDEX_END, weekStart) + 1
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.LanguagePickerRow
|
||||
import de.jeanlucmakiola.calendula.widget.requestWidgetRedraw
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
@@ -258,15 +259,16 @@ private fun SettingsHub(
|
||||
leading = { CategoryIcon(Icons.Default.Widgets, ChipAccent.Tertiary) },
|
||||
onClick = { onOpenSection(SettingsSection.Widgets) },
|
||||
)
|
||||
val context = LocalContext.current
|
||||
LanguagePickerRow(
|
||||
position = Position.Middle,
|
||||
title = stringResource(R.string.settings_language),
|
||||
autoLabel = stringResource(R.string.settings_language_auto),
|
||||
localesConfig = R.xml.locales_config,
|
||||
predictiveBack = true,
|
||||
onChange = { requestWidgetRedraw(context) },
|
||||
// Invite contributions right where users pick their language.
|
||||
header = {
|
||||
val context = LocalContext.current
|
||||
val translateUrl = stringResource(R.string.about_translate_url)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_translate),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarSpan
|
||||
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -7,16 +8,7 @@ import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.plus
|
||||
|
||||
/** Pages the week pager spans — a little under four centuries. */
|
||||
internal const val WEEK_PAGE_COUNT: Int = 20_000
|
||||
|
||||
/** Pages the multi-day pager spans — over three centuries at the widest page. */
|
||||
internal const val DAYS_PAGE_COUNT: Int = 40_000
|
||||
|
||||
/** Pages the day pager spans — a little under four centuries. */
|
||||
internal const val DAY_PAGE_COUNT: Int = 140_000
|
||||
|
||||
/** A fixed day every grid's pages are counted from, at its middle page. */
|
||||
/** A fixed day every grid's pages are counted from. */
|
||||
private val DAYS_EPOCH = LocalDate(2000, 1, 1)
|
||||
|
||||
/** The week the week pager's middle page shows; any date works, it only has to stay fixed. */
|
||||
@@ -24,13 +16,19 @@ private val WEEK_EPOCH = LocalDate(2000, 1, 3)
|
||||
|
||||
/**
|
||||
* How a timeline pager cuts the calendar: runs of [dayCount] days, offset
|
||||
* [phase] days from a fixed epoch, over [pageCount] pages centred on it. The
|
||||
* multi-day view's phase (#193) stays put while paging and only changes when a
|
||||
* jump moves another date to the first column.
|
||||
* [phase] days from a fixed epoch, over as many pages as cover [CalendarSpan].
|
||||
* The multi-day view's phase (#193) stays put while paging and only changes when
|
||||
* a jump moves another date to the first column.
|
||||
*/
|
||||
data class DaysGrid(val dayCount: Int, val phase: Int, val pageCount: Int = DAYS_PAGE_COUNT) {
|
||||
data class DaysGrid(val dayCount: Int, val phase: Int) {
|
||||
|
||||
private val epochPage: Int get() = pageCount / 2
|
||||
/** The page showing the epoch; page 0 is the one holding [CalendarSpan.FIRST_DAY]. */
|
||||
private val epochPage: Int get() = -pagesFromEpoch(CalendarSpan.FIRST_DAY)
|
||||
|
||||
val pageCount: Int get() = epochPage + pagesFromEpoch(CalendarSpan.LAST_DAY) + 1
|
||||
|
||||
private fun pagesFromEpoch(date: LocalDate): Int =
|
||||
Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount)
|
||||
|
||||
/** First day shown on pager [page]. */
|
||||
fun startForPage(page: Int): LocalDate =
|
||||
@@ -38,18 +36,16 @@ data class DaysGrid(val dayCount: Int, val phase: Int, val pageCount: Int = DAYS
|
||||
|
||||
/** The pager page showing [date]. */
|
||||
fun pageFor(date: LocalDate): Int =
|
||||
(epochPage + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount))
|
||||
.coerceIn(0, pageCount - 1)
|
||||
(epochPage + pagesFromEpoch(date)).coerceIn(0, pageCount - 1)
|
||||
|
||||
companion object {
|
||||
/** Single days: every date starts a page of its own. */
|
||||
val days: DaysGrid = DaysGrid(dayCount = 1, phase = 0, pageCount = DAY_PAGE_COUNT)
|
||||
val days: DaysGrid = DaysGrid(dayCount = 1, phase = 0)
|
||||
|
||||
/** Whole weeks, starting on [firstDay]. */
|
||||
fun weeks(firstDay: DayOfWeek): DaysGrid = DaysGrid(
|
||||
dayCount = 7,
|
||||
phase = DAYS_EPOCH.daysUntil(WEEK_EPOCH.startOfWeek(firstDay)),
|
||||
pageCount = WEEK_PAGE_COUNT,
|
||||
)
|
||||
|
||||
/** The grid of [dayCount]-day pages on which [date] is a page's first day. */
|
||||
|
||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.data.time.currentDate
|
||||
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
||||
import de.jeanlucmakiola.calendula.domain.displayPriority
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
@@ -206,8 +207,7 @@ abstract class PagedTimelineViewModel(
|
||||
): WeekUiState {
|
||||
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
|
||||
val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
|
||||
val allDay = instances.filter { it.isAllDay }
|
||||
val timed = instances.filterNot { it.isAllDay }
|
||||
val (allDay, timed) = instances.partition { it.coversWholeDays(zone) }
|
||||
return WeekUiState.Success(
|
||||
weekStart = span.start,
|
||||
today = today,
|
||||
@@ -292,17 +292,17 @@ internal fun layoutAllDay(
|
||||
days: List<LocalDate>,
|
||||
zone: TimeZone,
|
||||
): List<AllDaySpan> {
|
||||
data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int)
|
||||
data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int, val priority: Int)
|
||||
|
||||
val raw = events
|
||||
.mapNotNull { ev ->
|
||||
val covered = days.indices.filter { ev.coversDay(days[it], zone) }
|
||||
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last())
|
||||
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last(), ev.displayPriority(zone))
|
||||
}
|
||||
// Declined bars are packed after every other one, so on any day they
|
||||
// cover they land in a lane below it (#230).
|
||||
.sortedWith(
|
||||
compareBy<Raw> { it.event.displayPriority(zone) }
|
||||
compareBy<Raw> { it.priority }
|
||||
.thenBy { it.startCol }
|
||||
.thenBy { it.endCol }
|
||||
.thenBy { it.event.start }
|
||||
@@ -444,7 +444,7 @@ internal fun layoutDay(
|
||||
data class Raw(val event: EventInstance, val startMin: Int, val endMin: Int)
|
||||
|
||||
val raw = events.asSequence()
|
||||
.filterNot { it.isAllDay }
|
||||
.filterNot { it.coversWholeDays(zone) }
|
||||
.mapNotNull { ev ->
|
||||
if (ev.start == ev.end) {
|
||||
// Instant event: keep only if the point falls inside this day.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
|
||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -80,7 +81,7 @@ private fun sampleWeekState(
|
||||
weekStart = start,
|
||||
today = today,
|
||||
days = days,
|
||||
allDaySpans = layoutAllDay(events.filter { it.isAllDay }, days, zone),
|
||||
allDaySpans = layoutAllDay(events.filter { it.coversWholeDays(zone) }, days, zone),
|
||||
timedByDay = days.associateWith { layoutDay(events, it, zone) },
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,14 @@ abstract class CalendulaWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
syncWidgetRollover(context)
|
||||
}
|
||||
|
||||
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||
super.onDeleted(context, appWidgetIds)
|
||||
evictMonthSources(appWidgetIds)
|
||||
WidgetKind.entries.firstOrNull { it.receiver == this::class.java }?.let { kind ->
|
||||
appWidgetIds.forEach { WidgetTicks.cancel(context, kind, it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
|
||||
restoreState(context, oldWidgetIds, newWidgetIds)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
@@ -170,7 +171,7 @@ internal suspend fun Context.loadAgendaWidgetData(
|
||||
val zone = systemZone()
|
||||
val anchor = today(zone)
|
||||
val ep = widgetEntryPoint()
|
||||
val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
|
||||
val weekStart = settings.weekStart.resolveFirstDay(widgetLocale())
|
||||
val dayCount = options.range.dayCount(anchor, weekStart)
|
||||
val window = agendaRange(anchor, dayCount - 1, zone)
|
||||
val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY)
|
||||
@@ -189,7 +190,8 @@ internal suspend fun Context.loadAgendaWidgetData(
|
||||
|
||||
internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.ordinal
|
||||
|
||||
private fun firstDayOfMonthIndex(index: Int): LocalDate = LocalDate(index / 12, index % 12 + 1, 1)
|
||||
internal fun firstDayOfMonthIndex(index: Int): LocalDate =
|
||||
LocalDate(Math.floorDiv(index, 12), Math.floorMod(index, 12) + 1, 1)
|
||||
|
||||
/**
|
||||
* The month the widget loads its window around while [index] is shown. Snapped
|
||||
@@ -207,28 +209,50 @@ internal fun monthWindowCenter(index: Int): Int = Math.floorDiv(index, 3) * 3 +
|
||||
private object MonthSourceCache {
|
||||
private class Entry(val center: Int, val stamp: Long, val source: Themed<MonthWidgetSource.Ready>)
|
||||
|
||||
private val entries = ConcurrentHashMap<GlanceId, Entry>()
|
||||
// Keyed by app-widget id, which a removed widget can still be named by.
|
||||
private val entries = ConcurrentHashMap<Int, Entry>()
|
||||
|
||||
fun get(id: GlanceId, center: Int, stamp: Long): Themed<MonthWidgetSource>? =
|
||||
fun get(id: Int, center: Int, stamp: Long): Themed<MonthWidgetSource>? =
|
||||
entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.content.today == today(systemZone()) }
|
||||
?.source
|
||||
|
||||
fun put(id: GlanceId, center: Int, stamp: Long, source: Themed<MonthWidgetSource>) {
|
||||
fun put(id: Int, center: Int, stamp: Long, source: Themed<MonthWidgetSource>) {
|
||||
val ready = source.content as? MonthWidgetSource.Ready ?: return
|
||||
entries[id] = Entry(center, stamp, Themed(ready, source.appTheme))
|
||||
}
|
||||
|
||||
fun remove(id: Int) {
|
||||
entries.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
/** [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */
|
||||
internal suspend fun Context.loadCachedMonthSource(id: GlanceId, centerIndex: Int, stamp: Long): Themed<MonthWidgetSource> =
|
||||
MonthSourceCache.get(id, centerIndex, stamp)
|
||||
?: loadThemedMonthSource(centerIndex, widgetState(id).hiddenCalendars)
|
||||
.also { MonthSourceCache.put(id, centerIndex, stamp, it) }
|
||||
/** Drops the cached windows of removed widgets [appWidgetIds]. */
|
||||
internal fun evictMonthSources(appWidgetIds: IntArray) = appWidgetIds.forEach(MonthSourceCache::remove)
|
||||
|
||||
/**
|
||||
* [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds.
|
||||
* A [load] already read for this pass saves reading the settings and widget state again.
|
||||
*/
|
||||
internal suspend fun Context.loadCachedMonthSource(
|
||||
id: GlanceId,
|
||||
centerIndex: Int,
|
||||
stamp: Long,
|
||||
load: WidgetLoadContext? = null,
|
||||
): Themed<MonthWidgetSource> {
|
||||
val key = GlanceAppWidgetManager(this).getAppWidgetId(id)
|
||||
return MonthSourceCache.get(key, centerIndex, stamp)
|
||||
?: loadThemedMonthSource(centerIndex, load?.hiddenCalendars ?: widgetState(id).hiddenCalendars, load?.settings)
|
||||
.also { MonthSourceCache.put(key, centerIndex, stamp, it) }
|
||||
}
|
||||
|
||||
/** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */
|
||||
internal suspend fun Context.loadThemedMonthSource(centerIndex: Int, hiddenCalendars: Set<Long>): Themed<MonthWidgetSource> {
|
||||
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||
return Themed(queryMonthSource(centerIndex, settings, hiddenCalendars), settings.themeMode)
|
||||
internal suspend fun Context.loadThemedMonthSource(
|
||||
centerIndex: Int,
|
||||
hiddenCalendars: Set<Long>,
|
||||
settings: WidgetSettings? = null,
|
||||
): Themed<MonthWidgetSource> {
|
||||
val resolved = settings ?: widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||
return Themed(queryMonthSource(centerIndex, resolved, hiddenCalendars), resolved.themeMode)
|
||||
}
|
||||
|
||||
private suspend fun Context.queryMonthSource(
|
||||
@@ -250,7 +274,7 @@ private suspend fun Context.queryMonthSource(
|
||||
.toInstant(zone)
|
||||
return MonthWidgetSource.Ready(
|
||||
today = today(zone),
|
||||
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
|
||||
weekStart = settings.weekStart.resolveFirstDay(widgetLocale()),
|
||||
soften = settings.soften,
|
||||
instances = ep.calendarRepository().instances(from..to).first().withoutCalendars(hiddenCalendars),
|
||||
months = months,
|
||||
@@ -298,7 +322,7 @@ internal suspend fun Context.loadDays(
|
||||
if (!hasCalendarPermission()) return Themed(DaysWidgetData.NeedsPermission, settings.themeMode)
|
||||
val zone = systemZone()
|
||||
val anchor = today(zone)
|
||||
val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
|
||||
val weekStart = settings.weekStart.resolveFirstDay(widgetLocale())
|
||||
val range = days(anchor, weekStart)
|
||||
val window = range.start.atStartOfDayIn(zone)..range.endInclusive.atTime(23, 59, 59).toInstant(zone)
|
||||
val instances = widgetEntryPoint().calendarRepository().instances(window).first().withoutCalendars(load.hiddenCalendars)
|
||||
|
||||
@@ -23,7 +23,6 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
@@ -32,6 +31,16 @@ import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
|
||||
import de.jeanlucmakiola.floret.glance.themedColor
|
||||
|
||||
/** A past event's fill when dimmed: fainter than the app's whole-row dim, so its text can stay readable (#90). */
|
||||
internal const val WidgetDimFillAlpha = 0.3f
|
||||
|
||||
/** A dimmed event's ink, held above its fill. */
|
||||
private const val WidgetDimInkAlpha = 0.65f
|
||||
|
||||
/** Ink for an event on [fill], picked against the opaque tone and faded when the fill is dimmed. */
|
||||
internal fun widgetEventInk(fill: Color, alpha: Float): Color =
|
||||
eventInk(fill.copy(alpha = 1f), if (fill.alpha < 1f) alpha * WidgetDimInkAlpha else alpha)
|
||||
|
||||
/** Text and row sizes of a compact event list, from a widget's [WidgetSize]. */
|
||||
internal data class EventRowMetrics(val title: TextUnit, val time: TextUnit, val rowHeight: Dp, val message: TextUnit)
|
||||
|
||||
@@ -63,11 +72,9 @@ internal fun WidgetEventRow(
|
||||
quiet: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
fun tone(dark: Boolean): Color = eventFill(event.color, dark, soften).let { if (dimmed) it.copy(alpha = EventDimAlpha) else it }
|
||||
fun tone(dark: Boolean): Color = eventFill(event.color, dark, soften).let { if (dimmed) it.copy(alpha = WidgetDimFillAlpha) else it }
|
||||
val day = tone(dark = false)
|
||||
val night = tone(dark = true)
|
||||
// Ink is picked against the opaque fill, then carries the dim along with it.
|
||||
fun ink(fill: Color, alpha: Float) = eventInk(fill.copy(alpha = 1f), alpha * fill.alpha)
|
||||
val card = widgetCardColor()
|
||||
WidgetGroupedRow(position = position, color = if (quiet) card else themedColor(day = day, night = night)) {
|
||||
Column(
|
||||
@@ -91,12 +98,12 @@ internal fun WidgetEventRow(
|
||||
val titleInk = if (quiet) {
|
||||
GlanceTheme.colors.onSurface
|
||||
} else {
|
||||
themedColor(day = ink(day, 1f), night = ink(night, 1f))
|
||||
themedColor(day = widgetEventInk(day, 1f), night = widgetEventInk(night, 1f))
|
||||
}
|
||||
val timeInk = if (quiet) {
|
||||
GlanceTheme.colors.onSurfaceVariant
|
||||
} else {
|
||||
themedColor(day = ink(day, SECONDARY_INK_ALPHA), night = ink(night, SECONDARY_INK_ALPHA))
|
||||
themedColor(day = widgetEventInk(day, SECONDARY_INK_ALPHA), night = widgetEventInk(night, SECONDARY_INK_ALPHA))
|
||||
}
|
||||
Text(
|
||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||
|
||||
@@ -95,6 +95,9 @@ enum class WidgetKind(
|
||||
),
|
||||
;
|
||||
|
||||
/** Whether a calendar change can change what it draws; Date and Quick add draw no events. */
|
||||
val drawsEvents: Boolean get() = this != QUICK_ADD && this != DATE
|
||||
|
||||
fun widget(): GlanceAppWidget = when (this) {
|
||||
AGENDA -> AgendaWidget()
|
||||
MONTH -> MonthWidget()
|
||||
|
||||
@@ -18,7 +18,6 @@ import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toJavaDayOfWeek
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.util.Locale
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
|
||||
@@ -39,15 +38,20 @@ internal fun widgetTimeLabel(context: Context, event: EventInstance, day: LocalD
|
||||
|
||||
internal fun formatWidgetTime(instant: Instant, is24Hour: Boolean): String {
|
||||
val t = instant.toLocalDateTime(systemZone()).time
|
||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
|
||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, widgetLocale())
|
||||
}
|
||||
|
||||
/** "Today" or "Tomorrow" for those two days, else null. */
|
||||
internal fun relativeDayWord(context: Context, date: LocalDate, today: LocalDate): String? = when (date) {
|
||||
today -> context.getString(R.string.agenda_header_today)
|
||||
today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** "Today", "Tomorrow", or the short weekday within the coming week, else the short date. */
|
||||
internal fun relativeDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
return when {
|
||||
date == today -> context.getString(R.string.agenda_header_today)
|
||||
date == today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
|
||||
val locale = widgetLocale()
|
||||
return relativeDayWord(context, date, today) ?: when {
|
||||
date < today.plus(7, DateTimeUnit.DAY) && date > today ->
|
||||
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, locale)
|
||||
else -> localizedDateFormatter(locale, "dMMM").format(date.toJavaLocalDate())
|
||||
|
||||
@@ -40,15 +40,29 @@ import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/** Republishes the generated picker previews (Android 15+) 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) {
|
||||
val settings = context.widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||
val updated = context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime
|
||||
val is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
publishWidgetPreviews(
|
||||
context,
|
||||
version = "${context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime}-${Locale.getDefault().toLanguageTag()}",
|
||||
version = listOf(
|
||||
updated,
|
||||
widgetLocale().toLanguageTag(),
|
||||
today(systemZone()),
|
||||
settings.themeMode,
|
||||
settings.weekStart,
|
||||
is24Hour,
|
||||
settings.soften,
|
||||
).joinToString("-"),
|
||||
receivers = listOf(
|
||||
AgendaWidgetReceiver::class,
|
||||
MonthWidgetReceiver::class,
|
||||
@@ -73,7 +87,7 @@ internal suspend fun GlanceAppWidget.provideSamplePreview(context: Context, kind
|
||||
val today = today(zone)
|
||||
val now = today.atTime(8, 0).toInstant(zone)
|
||||
val instances = context.sampleInstances(today, zone)
|
||||
val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
|
||||
val weekStart = settings.weekStart.resolveFirstDay(widgetLocale())
|
||||
val is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
val options = settings.agendaDefaults
|
||||
val appearance = WidgetAppearance()
|
||||
|
||||
@@ -6,18 +6,22 @@ import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.repointCountdowns
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRefresher
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||
import de.jeanlucmakiola.floret.glance.updatePlaced
|
||||
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
@@ -34,6 +38,20 @@ internal val WIDGET_RECEIVER_CLASSES: List<Class<*>> = WidgetKind.entries.map {
|
||||
/** The app's own midnight wake-up (#228). */
|
||||
internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
|
||||
|
||||
/** Redraws every widget, for app changes they draw with that no other signal reaches, like its language (#90). */
|
||||
internal const val ACTION_WIDGET_REDRAW = "de.jeanlucmakiola.calendula.widget.REDRAW"
|
||||
|
||||
/**
|
||||
* Asks [WidgetRedrawReceiver] to redraw every widget. A broadcast rather than a
|
||||
* coroutine, so it outlives the activity a language change recreates.
|
||||
*/
|
||||
fun requestWidgetRedraw(context: Context) {
|
||||
context.sendBroadcast(Intent(context, WidgetRedrawReceiver::class.java).setAction(ACTION_WIDGET_REDRAW))
|
||||
}
|
||||
|
||||
/** The widgets a calendar change can affect. */
|
||||
internal val EVENT_WIDGET_KINDS: List<WidgetKind> = WidgetKind.entries.filter { it.drawsEvents }
|
||||
|
||||
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
|
||||
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
|
||||
|
||||
@@ -52,10 +70,22 @@ internal fun syncWidgetRollover(context: Context) =
|
||||
* Reloads and redraws every placed widget, a running session included: the
|
||||
* refresh stamp moves, and every widget keys its data load on it (#420).
|
||||
*/
|
||||
internal suspend fun redrawWidgets(context: Context) {
|
||||
WidgetKind.entries.forEach { redrawWidgets(context, it) }
|
||||
internal suspend fun redrawWidgets(context: Context, kinds: List<WidgetKind> = WidgetKind.entries) {
|
||||
// One kind failing, say a widget removed mid-pass, mustn't stop the others
|
||||
// or escape the broadcast's coroutine.
|
||||
kinds.forEach { kind ->
|
||||
try {
|
||||
redrawWidgets(context, kind)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Redrawing $kind widgets failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "WidgetRedraw"
|
||||
|
||||
internal suspend fun redrawWidgets(context: Context, kind: WidgetKind) =
|
||||
kind.widget().updatePlaced(context, kind.receiver, bumpRefreshStamp = true)
|
||||
|
||||
@@ -66,8 +96,14 @@ private suspend fun redrawUnconfiguredWidgets(context: Context) {
|
||||
val glanceManager = GlanceAppWidgetManager(appContext)
|
||||
WidgetKind.entries.filter { it.hasContentOptions }.forEach { kind ->
|
||||
manager.getAppWidgetIds(ComponentName(appContext, kind.receiver)).forEach { appWidgetId ->
|
||||
val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
|
||||
if (!appContext.widgetState(glanceId).hasAgendaOptions) kind.widget().updateWithRefresh(appContext, glanceId)
|
||||
try {
|
||||
val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
|
||||
if (!appContext.widgetState(glanceId).hasAgendaOptions) kind.widget().updateWithRefresh(appContext, glanceId)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Redrawing widget $appWidgetId failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,9 +113,32 @@ private val lastLocalChange = AtomicLong(0L)
|
||||
|
||||
private const val PROVIDER_ECHO_WINDOW_MILLIS = 5_000L
|
||||
|
||||
internal fun isProviderEcho(): Boolean {
|
||||
/** Set while a `PROVIDER_CHANGED` redraw waits out the echo window, so a burst of them redraws once. */
|
||||
private val echoRedrawQueued = AtomicBoolean(false)
|
||||
|
||||
private fun providerEchoRemainingMillis(): Long {
|
||||
val last = lastLocalChange.get()
|
||||
return last != 0L && SystemClock.elapsedRealtime() - last < PROVIDER_ECHO_WINDOW_MILLIS
|
||||
if (last == 0L) return 0L
|
||||
return PROVIDER_ECHO_WINDOW_MILLIS - (SystemClock.elapsedRealtime() - last)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `PROVIDER_CHANGED` should redraw. Within the echo window of one of
|
||||
* the app's own changes it may only echo that change, or be a sync landing
|
||||
* right after it, so it isn't dropped: the first one redraws once the window
|
||||
* has passed ([awaitProviderEcho]) and the rest of the burst ride along.
|
||||
*/
|
||||
internal fun claimProviderRedraw(): Boolean =
|
||||
providerEchoRemainingMillis() <= 0L || echoRedrawQueued.compareAndSet(false, true)
|
||||
|
||||
/** Waits out the echo window, if one is open, before a `PROVIDER_CHANGED` redraw. */
|
||||
internal suspend fun awaitProviderEcho() {
|
||||
try {
|
||||
val wait = providerEchoRemainingMillis()
|
||||
if (wait > 0L) delay(wait)
|
||||
} finally {
|
||||
echoRedrawQueued.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,23 +161,28 @@ class WidgetRefreshCoordinator @Inject constructor(
|
||||
/** Subscribes before returning, so a change made right after is never taken for the starting value. */
|
||||
fun start() {
|
||||
if (!started.compareAndSet(false, true)) return
|
||||
val all = WidgetKind.entries.map { kind -> WidgetRefresher(scope) { redrawWidgets(context, kind) } }
|
||||
val refreshers = WidgetKind.entries.associateWith { kind -> WidgetRefresher(scope) { redrawWidgets(context, kind) } }
|
||||
val all = refreshers.values
|
||||
val unconfigured = WidgetRefresher(scope) { redrawUnconfiguredWidgets(context) }
|
||||
watch(repository.localChanges) {
|
||||
lastLocalChange.set(SystemClock.elapsedRealtime())
|
||||
all.forEach { it.request() }
|
||||
EVENT_WIDGET_KINDS.forEach { refreshers.getValue(it).request() }
|
||||
}
|
||||
watch(repository.seriesSplits) { split ->
|
||||
scope.launch(Dispatchers.IO) { repointCountdowns(context, split.oldEventId, split.newEventId) }
|
||||
}
|
||||
watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) {
|
||||
all.forEach { it.request() }
|
||||
scope.launch(Dispatchers.IO) { publishCalendulaWidgetPreviews(context) }
|
||||
}
|
||||
watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) {
|
||||
unconfigured.request()
|
||||
}
|
||||
}
|
||||
|
||||
private fun watch(changes: Flow<*>, onChange: () -> Unit) {
|
||||
private fun <T> watch(changes: Flow<T>, onChange: (T) -> Unit) {
|
||||
scope.launch(Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) {
|
||||
changes.collect { onChange() }
|
||||
changes.collect { onChange(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import de.jeanlucmakiola.floret.android.goAsync
|
||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||
|
||||
/**
|
||||
* Takes [requestWidgetRedraw]'s in-app redraw. Not exported, unlike
|
||||
* [WidgetUpdateReceiver], so no other app can make every widget reload.
|
||||
*/
|
||||
class WidgetRedrawReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != ACTION_WIDGET_REDRAW) return
|
||||
val appContext = context.applicationContext
|
||||
goAsync {
|
||||
AppLanguage.applyDefault(appContext)
|
||||
redrawWidgets(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.text.TextDecoration
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
|
||||
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
|
||||
import de.jeanlucmakiola.floret.glance.WidgetSurface
|
||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
|
||||
/**
|
||||
@@ -39,3 +44,20 @@ internal fun CalendulaWidgetRoot(appearance: WidgetAppearance, appTheme: ThemeMo
|
||||
*/
|
||||
fun glanceDeclinedDecoration(isDeclined: Boolean): TextDecoration =
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.net.toUri
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import de.jeanlucmakiola.floret.android.goAsync
|
||||
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* One alarm per placed widget, for the moment what it shows next changes: a
|
||||
* countdown's label, the Today list's last event ending. Not a wake-up alarm:
|
||||
* with the screen off nobody sees the widget, so the redraw waits for the
|
||||
* device to wake. Exact where allowed (the app holds the exact alarm
|
||||
* permission for reminders), else within a minute.
|
||||
*/
|
||||
internal object WidgetTicks {
|
||||
private const val ACTION_TICK = "de.jeanlucmakiola.calendula.widget.TICK"
|
||||
internal const val EXTRA_KIND = "de.jeanlucmakiola.calendula.widget.KIND"
|
||||
|
||||
fun schedule(context: Context, kind: WidgetKind, appWidgetId: Int, at: Instant) {
|
||||
val alarms = context.getSystemService(AlarmManager::class.java) ?: return
|
||||
val intent = pendingIntent(context, kind, appWidgetId)
|
||||
val millis = at.toEpochMilliseconds()
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()) {
|
||||
alarms.setExact(AlarmManager.RTC, millis, intent)
|
||||
} else {
|
||||
alarms.setWindow(AlarmManager.RTC, millis, 60_000L, intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel(context: Context, kind: WidgetKind, appWidgetId: Int) {
|
||||
context.getSystemService(AlarmManager::class.java)?.cancel(pendingIntent(context, kind, appWidgetId))
|
||||
}
|
||||
|
||||
private fun pendingIntent(context: Context, kind: WidgetKind, appWidgetId: Int): PendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
Intent(context, WidgetTickReceiver::class.java)
|
||||
.setAction(ACTION_TICK)
|
||||
// Keys the PendingIntent per widget.
|
||||
.setData("calendula://tick/${kind.name}/$appWidgetId".toUri())
|
||||
.putExtra(EXTRA_KIND, kind.name)
|
||||
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
/** Reloads the widget whose content just changed; the load arms its next tick. */
|
||||
class WidgetTickReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
|
||||
val kind = intent.getStringExtra(WidgetTicks.EXTRA_KIND)
|
||||
?.let { name -> WidgetKind.entries.firstOrNull { it.name == name } }
|
||||
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID || kind == null) return
|
||||
val appContext = context.applicationContext
|
||||
goAsync {
|
||||
try {
|
||||
val glanceId = GlanceAppWidgetManager(appContext).getGlanceIdBy(appWidgetId)
|
||||
kind.widget().updateWithRefresh(appContext, glanceId)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// The widget is gone.
|
||||
WidgetTicks.cancel(appContext, kind, appWidgetId)
|
||||
} catch (_: Exception) {
|
||||
// A failed redraw leaves the last frame; the next signal retries.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,18 @@ import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import de.jeanlucmakiola.floret.glance.FloretWidgetUpdateReceiver
|
||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* Redraws both widgets when their data, day or language goes stale, and keeps
|
||||
* Redraws the widgets when their data, day or language goes stale, and keeps
|
||||
* the midnight rollover armed. `PROVIDER_CHANGED` covers changes made outside
|
||||
* the app; the app's own writes go through [WidgetRefreshCoordinator]. A data
|
||||
* change also re-arms, at most every 15 minutes: the inexact alarm can be
|
||||
* dropped in a restricted standby bucket, and a calendar change is the one
|
||||
* signal that keeps arriving. A `PROVIDER_CHANGED` right after one of the
|
||||
* app's own changes only echoes it, so it doesn't redraw a second time.
|
||||
* the app, and only redraws the widgets that draw events; the app's own writes
|
||||
* go through [WidgetRefreshCoordinator]. A data change also re-arms, at most
|
||||
* every 15 minutes: the inexact alarm can be dropped in a restricted standby
|
||||
* bucket, and a calendar change is the one signal that keeps arriving. A
|
||||
* `PROVIDER_CHANGED` right after one of the app's own changes waits out the
|
||||
* echo window and redraws once ([claimProviderRedraw]).
|
||||
*/
|
||||
class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() {
|
||||
override val rolloverAction = ACTION_WIDGET_ROLLOVER
|
||||
@@ -21,9 +23,20 @@ class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() {
|
||||
override val extraHandledActions = setOf(Intent.ACTION_PROVIDER_CHANGED)
|
||||
override val extraRearmInterval = 15.minutes
|
||||
|
||||
/** This broadcast's action; a receiver instance handles exactly one. */
|
||||
private var action: String? = null
|
||||
|
||||
override fun rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context)
|
||||
|
||||
override fun skipRedraw(action: String) = action == Intent.ACTION_PROVIDER_CHANGED && isProviderEcho()
|
||||
override fun skipRedraw(action: String): Boolean {
|
||||
this.action = action
|
||||
return action == Intent.ACTION_PROVIDER_CHANGED && !claimProviderRedraw()
|
||||
}
|
||||
|
||||
override suspend fun redraw(context: Context) = redrawWidgets(context)
|
||||
override suspend fun redraw(context: Context) {
|
||||
val dataChange = action == Intent.ACTION_PROVIDER_CHANGED
|
||||
if (dataChange) awaitProviderEcho()
|
||||
AppLanguage.applyDefault(context)
|
||||
redrawWidgets(context, if (dataChange) EVENT_WIDGET_KINDS else WidgetKind.entries)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package de.jeanlucmakiola.calendula.widget.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.InAppLanguage
|
||||
import de.jeanlucmakiola.calendula.widget.relativeDayWord
|
||||
import de.jeanlucmakiola.calendula.widget.widgetLocale
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||
@@ -50,11 +53,11 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetDimFillAlpha
|
||||
import de.jeanlucmakiola.calendula.widget.widgetEventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
@@ -81,7 +84,6 @@ import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toJavaDayOfWeek
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
||||
@@ -110,11 +112,13 @@ class AgendaWidget : GlanceAppWidget() {
|
||||
// A running session never re-runs this preamble, so every redraw
|
||||
// moves the stamp and the data reloads here instead (#420).
|
||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadAgendaWidget(id) }
|
||||
AgendaWidgetContent(
|
||||
data = loaded.content,
|
||||
appearance = currentState<Preferences>().widgetAppearance,
|
||||
appTheme = loaded.appTheme,
|
||||
)
|
||||
InAppLanguage {
|
||||
AgendaWidgetContent(
|
||||
data = loaded.content,
|
||||
appearance = currentState<Preferences>().widgetAppearance,
|
||||
appTheme = loaded.appTheme,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -392,7 +396,7 @@ private data class EventFill(val day: Color, val night: Color)
|
||||
private fun AgendaRow.Event.fill(data: AgendaWidgetData.Ready): EventFill {
|
||||
val dimmed = data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now)
|
||||
fun tone(dark: Boolean) = eventFill(event.color, dark, data.soften).let {
|
||||
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||
if (dimmed) it.copy(alpha = WidgetDimFillAlpha) else it
|
||||
}
|
||||
return EventFill(day = tone(dark = false), night = tone(dark = true))
|
||||
}
|
||||
@@ -407,12 +411,10 @@ private fun EventRow(
|
||||
) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
// Ink is picked against the opaque fill, then carries the dim along with it.
|
||||
fun ink(fill: Color, alpha: Float) = eventInk(fill.copy(alpha = 1f), alpha * fill.alpha)
|
||||
val titleInk = themedColor(day = ink(fill.day, 1f), night = ink(fill.night, 1f))
|
||||
val titleInk = themedColor(day = widgetEventInk(fill.day, 1f), night = widgetEventInk(fill.night, 1f))
|
||||
val timeInk = themedColor(
|
||||
day = ink(fill.day, SECONDARY_INK_ALPHA),
|
||||
night = ink(fill.night, SECONDARY_INK_ALPHA),
|
||||
day = widgetEventInk(fill.day, SECONDARY_INK_ALPHA),
|
||||
night = widgetEventInk(fill.night, SECONDARY_INK_ALPHA),
|
||||
)
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
@@ -467,12 +469,8 @@ private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
|
||||
|
||||
/** "Today · Wed, 17 Jun" — relative word for today/tomorrow, else the date. */
|
||||
private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
|
||||
val relative = when (date) {
|
||||
today -> context.getString(R.string.agenda_header_today)
|
||||
today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
|
||||
else -> null
|
||||
}
|
||||
val locale = Locale.getDefault()
|
||||
val relative = relativeDayWord(context, date, today)
|
||||
val locale = widgetLocale()
|
||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
// Weekday + date in the locale's own field order (compact: no year), rather
|
||||
// than a hardcoded day-month layout.
|
||||
@@ -482,13 +480,13 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
||||
|
||||
private fun monthName(date: LocalDate, today: LocalDate): String = formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, 1),
|
||||
locale = Locale.getDefault(),
|
||||
locale = widgetLocale(),
|
||||
currentYear = today.year,
|
||||
skeleton = "LLLL",
|
||||
)
|
||||
|
||||
private fun weekdayShort(date: LocalDate): String =
|
||||
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, Locale.getDefault())
|
||||
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, widgetLocale())
|
||||
|
||||
private fun eventTimeSummary(
|
||||
context: Context,
|
||||
|
||||
+2
-1
@@ -25,6 +25,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.domain.dateZone
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerSearchField
|
||||
@@ -148,7 +149,7 @@ private fun CountdownEventPicker(
|
||||
val formatter = remember(locale) { localizedDateFormatter(locale, "EEEdMMMy") }
|
||||
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
|
||||
itemsIndexed(matches, key = { _, it -> it.event.eventId }) { index, candidate ->
|
||||
val date = formatter.format(candidate.event.start.toLocalDateTime(zone).date.toJavaLocalDate())
|
||||
val date = formatter.format(candidate.event.start.toLocalDateTime(candidate.event.dateZone(zone)).date.toJavaLocalDate())
|
||||
GroupedRow(
|
||||
title = candidate.event.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||
summary = if (candidate.repeats) stringResource(R.string.widget_config_countdown_repeats, date) else date,
|
||||
|
||||
+28
-6
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.widgetLocale
|
||||
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSettings
|
||||
@@ -23,6 +24,7 @@ import de.jeanlucmakiola.calendula.widget.weekOf
|
||||
import de.jeanlucmakiola.calendula.widget.upcoming.loadUpcoming
|
||||
import de.jeanlucmakiola.calendula.widget.today.loadTodayList
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.CountdownData
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.countdownOptions
|
||||
@@ -55,6 +57,7 @@ import de.jeanlucmakiola.calendula.widget.widgetState
|
||||
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import javax.inject.Inject
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -68,7 +71,6 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import java.util.Locale
|
||||
|
||||
/** What the preview draws, loaded with the draft's options. */
|
||||
sealed interface WidgetPreviewData {
|
||||
@@ -187,7 +189,7 @@ class WidgetConfigViewModel @Inject constructor(
|
||||
),
|
||||
calendars = if (WidgetOption.CALENDARS in kind.options) shownCalendars() else emptyList(),
|
||||
appTheme = settings.themeMode,
|
||||
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
|
||||
weekStart = settings.weekStart.resolveFirstDay(widgetLocale()),
|
||||
placedSize = placedSize(kind),
|
||||
preview = null,
|
||||
)
|
||||
@@ -237,11 +239,31 @@ class WidgetConfigViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
val candidates = try {
|
||||
val now = Clock.System.now()
|
||||
val occurrences = repository.instances(now..now + 365.days).first().filter { it.end > now }
|
||||
val perEvent = occurrences.groupBy { it.eventId }
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
// A year, a quarter at a time, keeping only each event's first
|
||||
// occurrence so busy series don't hold the whole year in memory.
|
||||
val first = LinkedHashMap<Long, EventInstance>()
|
||||
val repeats = HashSet<Long>()
|
||||
for (quarter in 0 until 4) {
|
||||
val from = now + (quarter * 91).days
|
||||
val to = if (quarter == 3) now + 365.days else now + ((quarter + 1) * 91).days
|
||||
repository.instances(from..to).first()
|
||||
.filter { !it.hasEnded(now, zone) }
|
||||
.forEach { occurrence ->
|
||||
val known = first[occurrence.eventId]
|
||||
when {
|
||||
known == null -> first[occurrence.eventId] = occurrence
|
||||
known.start == occurrence.start -> Unit
|
||||
else -> {
|
||||
repeats += occurrence.eventId
|
||||
if (occurrence.start < known.start) first[occurrence.eventId] = occurrence
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CountdownCandidates.Ready(
|
||||
perEvent.values
|
||||
.map { CountdownCandidate(it.minBy { o -> o.start }, repeats = it.size > 1) }
|
||||
first.values
|
||||
.map { CountdownCandidate(it, repeats = it.eventId in repeats) }
|
||||
.sortedBy { it.event.start },
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.widget.countdown
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.TimeZone
|
||||
@@ -45,7 +46,7 @@ fun countdownLabel(event: EventInstance, now: Instant, zone: TimeZone): Countdow
|
||||
return when {
|
||||
delta < 1.minutes -> CountdownLabel(running, CountdownUnit.NOW, 0)
|
||||
delta < 1.hours -> CountdownLabel(running, CountdownUnit.MINUTES, ceil(delta / 1.minutes).toInt())
|
||||
delta < 24.hours -> CountdownLabel(running, CountdownUnit.HOURS, (delta / 1.hours).roundToInt().coerceAtLeast(1))
|
||||
delta < 24.hours -> CountdownLabel(running, CountdownUnit.HOURS, (delta / 1.hours).roundToInt().coerceIn(1, 23))
|
||||
else -> {
|
||||
val days = now.toLocalDateTime(zone).date.daysUntil(target.toLocalDateTime(zone).date)
|
||||
CountdownLabel(running, CountdownUnit.DAYS, days.coerceAtLeast(1))
|
||||
@@ -74,12 +75,13 @@ fun nextLabelChange(event: EventInstance, now: Instant, zone: TimeZone): Instant
|
||||
}
|
||||
|
||||
/**
|
||||
* The next-event mode's pick: timed, not declined, not over; of those, the one
|
||||
* whose countdown changes soonest — a running event's end, else a start.
|
||||
* The next-event mode's pick: timed and not filling whole days (#296), not
|
||||
* declined, not over; of those, the one whose countdown changes soonest — a
|
||||
* running event's end, else a start.
|
||||
*/
|
||||
fun pickNextEvent(instances: List<EventInstance>, now: Instant): EventInstance? =
|
||||
fun pickNextEvent(instances: List<EventInstance>, now: Instant, zone: TimeZone): EventInstance? =
|
||||
instances
|
||||
.filter { !it.isAllDay && !it.isDeclined && it.end > now }
|
||||
.filter { !it.coversWholeDays(zone) && !it.isDeclined && it.end > now }
|
||||
.minByOrNull { target(it, now) }
|
||||
|
||||
/** A pinned event's occurrence to count to: the running or next one. */
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
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.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.net.toUri
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.widget.Themed
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetTicks
|
||||
import de.jeanlucmakiola.calendula.widget.hasCalendarPermission
|
||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||
import de.jeanlucmakiola.calendula.widget.today
|
||||
@@ -25,8 +23,6 @@ import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
||||
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
|
||||
import de.jeanlucmakiola.calendula.widget.widgetState
|
||||
import de.jeanlucmakiola.calendula.widget.withoutCalendars
|
||||
import de.jeanlucmakiola.floret.android.goAsync
|
||||
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||
import de.jeanlucmakiola.floret.prefs.toEnum
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -79,7 +75,11 @@ internal suspend fun Context.loadCountdown(
|
||||
val repository = widgetEntryPoint().calendarRepository()
|
||||
val event = when (options.mode) {
|
||||
CountdownMode.NEXT ->
|
||||
pickNextEvent(repository.instances(now..now + NextEventHorizon).first().withoutCalendars(load.hiddenCalendars), now)
|
||||
pickNextEvent(
|
||||
repository.instances(now..now + NextEventHorizon).first().withoutCalendars(load.hiddenCalendars),
|
||||
now,
|
||||
systemZone(),
|
||||
)
|
||||
CountdownMode.PINNED -> options.eventId?.let { id ->
|
||||
pickPinnedOccurrence(repository.eventOccurrences(id, now - 1.days..now + PinnedHorizon), now, systemZone())
|
||||
}
|
||||
@@ -104,64 +104,39 @@ internal suspend fun Context.loadPlacedCountdown(id: GlanceId): Themed<Countdown
|
||||
val loaded = loadCountdown(widgetLoadContext(id), widgetState(id).countdownOptions, appWidgetId)
|
||||
val event = (loaded.content as? CountdownData.Ready)?.event
|
||||
if (event == null) {
|
||||
CountdownTicks.cancel(this, appWidgetId)
|
||||
WidgetTicks.cancel(this, WidgetKind.COUNTDOWN, appWidgetId)
|
||||
} else {
|
||||
val zone = systemZone()
|
||||
CountdownTicks.schedule(this, appWidgetId, nextLabelChange(event.counted(zone), (loaded.content as CountdownData.Ready).now, zone))
|
||||
WidgetTicks.schedule(
|
||||
this,
|
||||
WidgetKind.COUNTDOWN,
|
||||
appWidgetId,
|
||||
nextLabelChange(event.counted(zone), (loaded.content as CountdownData.Ready).now, zone),
|
||||
)
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
|
||||
/**
|
||||
* One alarm per countdown widget, for the moment its label changes. 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.
|
||||
* Moves countdowns pinned to [oldEventId] on to [newEventId] after the app
|
||||
* split that series, so they keep counting to the part that continues it.
|
||||
*/
|
||||
internal object CountdownTicks {
|
||||
private const val ACTION_TICK = "de.jeanlucmakiola.calendula.widget.COUNTDOWN_TICK"
|
||||
|
||||
fun schedule(context: Context, appWidgetId: Int, at: Instant) {
|
||||
val alarms = context.getSystemService(AlarmManager::class.java) ?: return
|
||||
val intent = pendingIntent(context, 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, 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)
|
||||
internal suspend fun repointCountdowns(context: Context, oldEventId: Long, newEventId: Long) {
|
||||
val manager = GlanceAppWidgetManager(context)
|
||||
manager.getGlanceIds(CountdownWidget::class.java).forEach { id ->
|
||||
try {
|
||||
var moved = false
|
||||
updateAppWidgetState(context, id) { prefs ->
|
||||
if (prefs[EventKey] == oldEventId) {
|
||||
prefs[EventKey] = newEventId
|
||||
moved = true
|
||||
}
|
||||
}
|
||||
if (moved) CountdownWidget().updateWithRefresh(context, id)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
// Gone mid-pass; nothing left to point anywhere.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.widget.countdown
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.InAppLanguage
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||
@@ -39,6 +40,7 @@ import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||
@@ -49,9 +51,7 @@ import de.jeanlucmakiola.calendula.widget.formatWidgetTime
|
||||
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
|
||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.widgetCardColor
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.wallpaperBacking
|
||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
||||
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
|
||||
* ("Vacation · in 12 days"). Redraws when its label changes, see
|
||||
* [nextLabelChange] and [CountdownTicks]. Tapping opens the event.
|
||||
* [nextLabelChange] and [WidgetTicks]. Tapping opens the event.
|
||||
*/
|
||||
class CountdownWidget : GlanceAppWidget() {
|
||||
|
||||
@@ -82,18 +82,15 @@ class CountdownWidget : GlanceAppWidget() {
|
||||
val widgetData = WidgetData(context.loadPlacedCountdown(id), refreshStamp(context, id))
|
||||
provideContent {
|
||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadPlacedCountdown(id) }
|
||||
CountdownWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
InAppLanguage {
|
||||
CountdownWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CountdownWidgetReceiver : CalendulaWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = CountdownWidget()
|
||||
|
||||
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||
super.onDeleted(context, appWidgetIds)
|
||||
appWidgetIds.forEach { CountdownTicks.cancel(context, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -144,11 +141,7 @@ 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.
|
||||
val card = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
|
||||
GlanceModifier.cornerRadius(16.dp).background(widgetCardColor())
|
||||
} else {
|
||||
GlanceModifier
|
||||
}
|
||||
val card = GlanceModifier.wallpaperBacking(radius = 16.dp)
|
||||
Box(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) {
|
||||
Row(
|
||||
modifier = card.fillMaxSize().clickable(open).padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
@@ -218,12 +211,13 @@ private fun whenText(context: Context, event: EventInstance, data: CountdownData
|
||||
val at = if (running) counted.end else counted.start
|
||||
val date = at.toLocalDateTime(systemZone()).date
|
||||
val day = relativeDayLabel(context, date, data.today)
|
||||
val wholeDays = event.coversWholeDays(systemZone())
|
||||
return when {
|
||||
event.isAllDay && running -> context.getString(
|
||||
wholeDays && running -> context.getString(
|
||||
R.string.widget_countdown_until,
|
||||
relativeDayLabel(context, date.minus(1, DateTimeUnit.DAY), data.today),
|
||||
)
|
||||
event.isAllDay -> "$day · ${context.getString(R.string.event_detail_all_day)}"
|
||||
wholeDays -> "$day · ${context.getString(R.string.event_detail_all_day)}"
|
||||
running -> context.getString(R.string.agenda_span_ends, "$day · ${formatWidgetTime(at, data.is24Hour)}")
|
||||
else -> "$day · ${formatWidgetTime(at, data.is24Hour)}"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.widget.date
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.InAppLanguage
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||
@@ -24,7 +25,6 @@ import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.semantics.contentDescription
|
||||
import androidx.glance.semantics.semantics
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
@@ -40,8 +40,6 @@ import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.loadToday
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.widgetLocale
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||
import de.jeanlucmakiola.floret.glance.WidgetShape
|
||||
import de.jeanlucmakiola.floret.glance.WidgetShapedBox
|
||||
@@ -71,7 +69,9 @@ class DateWidget : GlanceAppWidget() {
|
||||
val widgetData = WidgetData(context.loadToday(), refreshStamp(context, id))
|
||||
provideContent {
|
||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadToday() }
|
||||
DateWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
InAppLanguage {
|
||||
DateWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,11 +105,7 @@ internal fun DateWidgetContent(today: LocalDate, appearance: WidgetAppearance, a
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val name = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, widgetLocale())
|
||||
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
|
||||
GlanceModifier.wallpaperBacking(radius = 8.dp).padding(horizontal = 6.dp)
|
||||
} else {
|
||||
GlanceModifier
|
||||
}
|
||||
val backing = GlanceModifier.wallpaperBacking(radius = 8.dp, horizontalPadding = 6.dp)
|
||||
Text(text = name.uppercase(widgetLocale()), maxLines = 1, style = weekday, modifier = backing)
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
WidgetShapedBox(shape = WidgetShape.COOKIE, color = GlanceTheme.colors.primary, size = side) {
|
||||
|
||||
@@ -147,9 +147,9 @@ private fun openDayIntent(context: Context, date: LocalDate): PendingIntent = Pe
|
||||
)
|
||||
|
||||
private val STRIP_IDS = intArrayOf(R.id.strip_0, R.id.strip_1, R.id.strip_2, R.id.strip_3, R.id.strip_4, R.id.strip_5, R.id.strip_6)
|
||||
/** The shade over a day outside the month: a dark cell needs more of it to read as dimmed. */
|
||||
private val OUTSIDE_SHADE_DAY = Color.Black.copy(alpha = 0.18f)
|
||||
private val OUTSIDE_SHADE_NIGHT = Color.Black.copy(alpha = 0.50f)
|
||||
/** The shade over a day outside the month: a dark cell needs more of it to read as dimmed. Mirrored by `widget_preview_outside_shade`. */
|
||||
private val OUTSIDE_SHADE_DAY = Color.Black.copy(alpha = 0.11f)
|
||||
private val OUTSIDE_SHADE_NIGHT = Color.Black.copy(alpha = 0.30f)
|
||||
|
||||
private val CELL_DIM_IDS = intArrayOf(
|
||||
R.id.cell_dim_0, R.id.cell_dim_1, R.id.cell_dim_2, R.id.cell_dim_3, R.id.cell_dim_4, R.id.cell_dim_5, R.id.cell_dim_6,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.InAppLanguage
|
||||
import de.jeanlucmakiola.calendula.widget.widgetLocale
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||
@@ -69,6 +71,7 @@ import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.floret.glance.wallpaperBacking
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||
import de.jeanlucmakiola.calendula.widget.firstDayOfMonthIndex
|
||||
import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource
|
||||
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
|
||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||
@@ -85,7 +88,6 @@ import kotlinx.datetime.Month
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
/** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */
|
||||
private val MONTH_INDEX_KEY = intPreferencesKey("month_index")
|
||||
@@ -103,7 +105,7 @@ private fun gridChromeHeight(fontScale: Float): Dp =
|
||||
private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone))
|
||||
|
||||
private fun yearMonthOf(index: Int): YearMonth =
|
||||
YearMonth(index / 12, Month(index % 12 + 1))
|
||||
firstDayOfMonthIndex(index).let { YearMonth(it.year, it.month) }
|
||||
|
||||
/**
|
||||
* Month-grid widget: a 6×7 calendar with today highlighted, connected multi-day
|
||||
@@ -169,12 +171,14 @@ class MonthWidget : GlanceAppWidget() {
|
||||
else -> index.coerceIn(source.months)
|
||||
}
|
||||
SideEffect { lastShown = shown }
|
||||
MonthWidgetContent(
|
||||
source = source,
|
||||
index = shown,
|
||||
appearance = currentState<Preferences>().widgetAppearance,
|
||||
appTheme = loaded.appTheme,
|
||||
)
|
||||
InAppLanguage {
|
||||
MonthWidgetContent(
|
||||
source = source,
|
||||
index = shown,
|
||||
appearance = currentState<Preferences>().widgetAppearance,
|
||||
appTheme = loaded.appTheme,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -354,7 +358,7 @@ private fun monthCellRole(): WidgetColorRole = monthCellRole(LocalWidgetBackgrou
|
||||
/** Narrow weekday initials starting at [weekStart], in the device locale.
|
||||
* Computed outside the composable so the locale read stays observable-safe. */
|
||||
private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
|
||||
val locale = Locale.getDefault()
|
||||
val locale = widgetLocale()
|
||||
return (0 until 7).map { i ->
|
||||
java.time.DayOfWeek.of((weekStart.ordinal + i) % 7 + 1)
|
||||
.getDisplayName(JavaTextStyle.NARROW, locale)
|
||||
@@ -380,13 +384,10 @@ private fun PermissionMessage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Locale.getDefault() rather than currentLocale(): Glance has no
|
||||
// LocalConfiguration, and the widget is re-rendered on a configuration change
|
||||
// anyway (same convention as AgendaWidget).
|
||||
private fun monthLabel(month: YearMonth, currentYear: Int): String =
|
||||
formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(month.year, month.month.ordinal + 1, 1),
|
||||
locale = Locale.getDefault(),
|
||||
locale = widgetLocale(),
|
||||
currentYear = currentYear,
|
||||
skeleton = "LLLL",
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.widget.quickadd
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.InAppLanguage
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||
@@ -59,7 +60,9 @@ class QuickAddWidget : GlanceAppWidget() {
|
||||
provideContent {
|
||||
// The rollover moves the stamp, so the editor opens on the new day.
|
||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadToday() }
|
||||
QuickAddWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
InAppLanguage {
|
||||
QuickAddWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.widget.split
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.InAppLanguage
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||
@@ -64,8 +65,10 @@ import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||
import de.jeanlucmakiola.calendula.widget.Themed
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetEventRow
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSettings
|
||||
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
|
||||
import de.jeanlucmakiola.calendula.widget.compactRowMetrics
|
||||
import de.jeanlucmakiola.calendula.widget.firstDayOfMonthIndex
|
||||
import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource
|
||||
import de.jeanlucmakiola.calendula.widget.monthIndexOf
|
||||
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
|
||||
@@ -84,9 +87,7 @@ import kotlinx.datetime.toJavaLocalDate
|
||||
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeader
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
|
||||
@@ -123,9 +124,13 @@ sealed interface MonthDayData {
|
||||
}
|
||||
|
||||
/** [loaded]'s month window with what the day list draws by. */
|
||||
internal suspend fun Context.loadMonthDay(loaded: Themed<MonthWidgetSource>, options: AgendaWidgetOptions): Themed<MonthDayData> {
|
||||
internal suspend fun Context.loadMonthDay(
|
||||
loaded: Themed<MonthWidgetSource>,
|
||||
options: AgendaWidgetOptions,
|
||||
settings: WidgetSettings? = null,
|
||||
): Themed<MonthDayData> {
|
||||
val ready = loaded.content as? MonthWidgetSource.Ready ?: return Themed(MonthDayData.NeedsPermission, loaded.appTheme)
|
||||
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||
val settings = settings ?: widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||
return Themed(
|
||||
MonthDayData.Ready(
|
||||
source = ready,
|
||||
@@ -159,7 +164,8 @@ class MonthDayWidget : GlanceAppWidget() {
|
||||
val shown = context.widgetState(id)[MONTH_INDEX_KEY] ?: monthIndexOf(today(systemZone()))
|
||||
val center = monthWindowCenter(shown)
|
||||
val load: suspend (Int, Long) -> Themed<MonthDayData> = { c, s ->
|
||||
context.loadMonthDay(context.loadCachedMonthSource(id, c, s), context.widgetLoadContext(id).options)
|
||||
val ctx = context.widgetLoadContext(id)
|
||||
context.loadMonthDay(context.loadCachedMonthSource(id, c, s, ctx), ctx.options, ctx.settings)
|
||||
}
|
||||
val widgetData = WidgetData(load(center, stamp), center, stamp)
|
||||
provideContent {
|
||||
@@ -168,7 +174,9 @@ class MonthDayWidget : GlanceAppWidget() {
|
||||
val currentStamp = currentRefreshStamp()
|
||||
val loaded = widgetData.current(currentCenter, currentStamp) { load(currentCenter, currentStamp) }
|
||||
val selected = currentState(SELECTED_KEY)?.let(LocalDate::parse)
|
||||
MonthDayWidgetContent(loaded.content, index, selected, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
InAppLanguage {
|
||||
MonthDayWidgetContent(loaded.content, index, selected, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,9 +205,17 @@ class ShiftMonthDayAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
val delta = parameters[deltaKey] ?: 0
|
||||
val current = monthIndexOf(today(systemZone()))
|
||||
// The selection moves with the page — to today on this month, else the
|
||||
// 1st — so the list never shows a day outside the loaded window.
|
||||
updateAppWidgetState(context, glanceId) { prefs ->
|
||||
val next = (prefs[MONTH_INDEX_KEY] ?: current) + delta
|
||||
if (next == current) prefs.remove(MONTH_INDEX_KEY) else prefs[MONTH_INDEX_KEY] = next
|
||||
if (next == current) {
|
||||
prefs.remove(MONTH_INDEX_KEY)
|
||||
prefs.remove(SELECTED_KEY)
|
||||
} else {
|
||||
prefs[MONTH_INDEX_KEY] = next
|
||||
prefs[SELECTED_KEY] = firstDayOfMonthIndex(next).toString()
|
||||
}
|
||||
}
|
||||
MonthDayWidget().update(context.applicationContext, glanceId)
|
||||
}
|
||||
@@ -222,7 +238,7 @@ class ResetMonthDayAction : ActionCallback {
|
||||
|
||||
/** The grid's days: whole weeks from [weekStart] covering [index]'s month. */
|
||||
internal fun monthGridDays(index: Int, weekStart: DayOfWeek): List<LocalDate> {
|
||||
val first = LocalDate(index / 12, index % 12 + 1, 1)
|
||||
val first = firstDayOfMonthIndex(index)
|
||||
val start = weekOf(first, weekStart).start
|
||||
val last = first.plus(1, DateTimeUnit.MONTH).plus(-1, DateTimeUnit.DAY)
|
||||
val weeks = (start.daysUntilInclusive(last) + 6) / 7
|
||||
@@ -277,7 +293,7 @@ private fun Header(index: Int, today: LocalDate) {
|
||||
val context = LocalContext.current
|
||||
WidgetHeader(
|
||||
title = formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(index / 12, index % 12 + 1, 1),
|
||||
date = firstDayOfMonthIndex(index).toJavaLocalDate(),
|
||||
locale = widgetLocale(),
|
||||
currentYear = today.year,
|
||||
skeleton = "LLLL",
|
||||
@@ -330,12 +346,8 @@ private fun Grid(
|
||||
width: Dp,
|
||||
) {
|
||||
val locale = widgetLocale()
|
||||
val month = index % 12 + 1
|
||||
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
|
||||
GlanceModifier.cornerRadius(14.dp).background(widgetCardColor())
|
||||
} else {
|
||||
GlanceModifier
|
||||
}
|
||||
val month = firstDayOfMonthIndex(index).month.ordinal + 1
|
||||
val backing = GlanceModifier.wallpaperBacking(radius = 14.dp)
|
||||
Column(modifier = backing.fillMaxSize().padding(2.dp)) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
for (i in 0 until 7) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.widget.today
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.InAppLanguage
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||
@@ -50,6 +51,11 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.Themed
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetTicks
|
||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||
import de.jeanlucmakiola.calendula.domain.localEnd
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import de.jeanlucmakiola.calendula.widget.EventRowMetrics
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetEventRow
|
||||
@@ -62,8 +68,6 @@ import de.jeanlucmakiola.calendula.widget.widgetLocale
|
||||
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
|
||||
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
||||
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
||||
@@ -96,10 +100,12 @@ class TodayWidget : GlanceAppWidget() {
|
||||
provideSamplePreview(context, WidgetKind.TODAY)
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val widgetData = WidgetData(context.loadTodayList(context.widgetLoadContext(id)), refreshStamp(context, id))
|
||||
val widgetData = WidgetData(context.loadPlacedToday(id), refreshStamp(context, id))
|
||||
provideContent {
|
||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadTodayList(context.widgetLoadContext(id)) }
|
||||
TodayWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadPlacedToday(id) }
|
||||
InAppLanguage {
|
||||
TodayWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,6 +118,21 @@ internal suspend fun Context.loadTodayList(load: WidgetLoadContext) = loadDays(l
|
||||
today..today.plus(1, DateTimeUnit.DAY)
|
||||
}
|
||||
|
||||
/** [loadTodayList] for placed widget [id], arming its redraw for when the next of today's events ends. */
|
||||
private suspend fun Context.loadPlacedToday(id: GlanceId): Themed<DaysWidgetData> {
|
||||
val loaded = loadTodayList(widgetLoadContext(id))
|
||||
val appWidgetId = GlanceAppWidgetManager(this).getAppWidgetId(id)
|
||||
val data = loaded.content as? DaysWidgetData.Ready
|
||||
val zone = systemZone()
|
||||
val nextEnd = data?.eventsOn(data.today)?.map { it.localEnd(zone) }?.filter { it > data.now }?.minOrNull()
|
||||
if (nextEnd == null) {
|
||||
WidgetTicks.cancel(this, WidgetKind.TODAY, appWidgetId)
|
||||
} else {
|
||||
WidgetTicks.schedule(this, WidgetKind.TODAY, appWidgetId, nextEnd)
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
|
||||
private val PillWidth = 44.dp
|
||||
private val RowGap = 2.dp
|
||||
|
||||
@@ -189,11 +210,7 @@ private fun NothingLeft(tomorrow: List<EventInstance>, data: DaysWidgetData.Read
|
||||
val room = LocalSize.current.height - 12.dp - lineHeight
|
||||
val fits = ((room + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceIn(0, 2)
|
||||
val ahead = tomorrow.take(fits)
|
||||
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
|
||||
GlanceModifier.wallpaperBacking(radius = 8.dp).padding(horizontal = 8.dp)
|
||||
} else {
|
||||
GlanceModifier.padding(horizontal = 4.dp)
|
||||
}
|
||||
val backing = GlanceModifier.wallpaperBacking(radius = 8.dp, horizontalPadding = 8.dp, filledHorizontalPadding = 4.dp)
|
||||
Column(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = context.getString(R.string.widget_nothing_left_today),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.widget.upcoming
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.InAppLanguage
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||
@@ -54,8 +55,6 @@ import de.jeanlucmakiola.calendula.widget.widgetLocale
|
||||
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
|
||||
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
||||
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
||||
@@ -89,7 +88,9 @@ class UpcomingWidget : GlanceAppWidget() {
|
||||
val widgetData = WidgetData(context.loadUpcoming(context.widgetLoadContext(id)), refreshStamp(context, id))
|
||||
provideContent {
|
||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadUpcoming(context.widgetLoadContext(id)) }
|
||||
UpcomingWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
InAppLanguage {
|
||||
UpcomingWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,11 +156,7 @@ internal fun UpcomingWidgetContent(data: DaysWidgetData, appearance: WidgetAppea
|
||||
private fun DateHeader(today: LocalDate, height: Dp) {
|
||||
val context = LocalContext.current
|
||||
val locale = widgetLocale()
|
||||
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
|
||||
GlanceModifier.wallpaperBacking(radius = 12.dp).padding(horizontal = 8.dp)
|
||||
} else {
|
||||
GlanceModifier.padding(horizontal = 4.dp)
|
||||
}
|
||||
val backing = GlanceModifier.wallpaperBacking(radius = 12.dp, horizontalPadding = 8.dp, filledHorizontalPadding = 4.dp)
|
||||
Box(modifier = GlanceModifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
|
||||
Row(
|
||||
modifier = backing.clickable(actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Agenda))),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.widget.week
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.InAppLanguage
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||
@@ -91,7 +92,9 @@ class WeekStripWidget : GlanceAppWidget() {
|
||||
val widgetData = WidgetData(context.loadWeek(id), refreshStamp(context, id))
|
||||
provideContent {
|
||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadWeek(id) }
|
||||
WeekStripWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
InAppLanguage {
|
||||
WeekStripWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,13 +117,15 @@ internal fun WeekStripWidgetContent(data: DaysWidgetData, appearance: WidgetAppe
|
||||
when (data) {
|
||||
DaysWidgetData.NeedsPermission ->
|
||||
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(
|
||||
modifier = GlanceModifier.fillMaxSize().padding(6.dp),
|
||||
modifier = GlanceModifier.fillMaxSize().padding(horizontal = 6.dp - DayGap / 2, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
for (i in 0 until 7) {
|
||||
if (i > 0) Spacer(GlanceModifier.width(DayGap))
|
||||
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) {
|
||||
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight().padding(horizontal = DayGap / 2)) {
|
||||
DayTile(data.firstDay.plus(i, DateTimeUnit.DAY), data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/widget_preview_outside" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
<!-- A day outside the month: its cell under the widget's shade (MonthGridViews). -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@drawable/preview_widget_cell_bg" />
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<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:orientation="horizontal"
|
||||
android:padding="12dp"
|
||||
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent">
|
||||
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessLeaf,UselessParent">
|
||||
|
||||
<View
|
||||
<FrameLayout
|
||||
android:layout_width="4dp"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/preview_stripe"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
android:background="@drawable/preview_widget_bg"
|
||||
android:orientation="horizontal"
|
||||
android:padding="6dp"
|
||||
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent">
|
||||
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessLeaf,UselessParent">
|
||||
|
||||
<LinearLayout
|
||||
style="@style/WidgetPreviewWeekDay"
|
||||
@@ -32,19 +32,19 @@
|
||||
android:layout_height="6dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
<View
|
||||
<FrameLayout
|
||||
android:layout_width="4dp"
|
||||
android:layout_height="4dp"
|
||||
android:layout_marginHorizontal="1dp"
|
||||
android:background="@drawable/preview_today_circle"
|
||||
android:backgroundTint="@color/widget_preview_event_blue" />
|
||||
<View
|
||||
<FrameLayout
|
||||
android:layout_width="4dp"
|
||||
android:layout_height="4dp"
|
||||
android:layout_marginHorizontal="1dp"
|
||||
android:background="@drawable/preview_today_circle"
|
||||
android:backgroundTint="@color/widget_preview_event_green" />
|
||||
<View
|
||||
<FrameLayout
|
||||
android:layout_width="4dp"
|
||||
android:layout_height="4dp"
|
||||
android:layout_marginHorizontal="1dp"
|
||||
@@ -73,7 +73,7 @@
|
||||
android:layout_height="6dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
<View
|
||||
<FrameLayout
|
||||
android:layout_width="4dp"
|
||||
android:layout_height="4dp"
|
||||
android:layout_marginHorizontal="1dp"
|
||||
@@ -102,7 +102,7 @@
|
||||
android:layout_height="6dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
<View
|
||||
<FrameLayout
|
||||
android:layout_width="4dp"
|
||||
android:layout_height="4dp"
|
||||
android:layout_marginHorizontal="1dp"
|
||||
@@ -126,7 +126,7 @@
|
||||
android:textColor="@color/widget_preview_on_surface"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
<Space
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="6dp" />
|
||||
</LinearLayout>
|
||||
@@ -146,7 +146,7 @@
|
||||
android:textColor="@color/widget_preview_on_surface"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
<Space
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="6dp" />
|
||||
</LinearLayout>
|
||||
@@ -171,7 +171,7 @@
|
||||
android:layout_height="6dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
<View
|
||||
<FrameLayout
|
||||
android:layout_width="4dp"
|
||||
android:layout_height="4dp"
|
||||
android:layout_marginHorizontal="1dp"
|
||||
@@ -195,7 +195,7 @@
|
||||
android:textColor="@color/widget_preview_on_surface"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
<Space
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="6dp" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<resources>
|
||||
<color name="widget_preview_surface">@android:color/system_accent2_700</color>
|
||||
<color name="widget_preview_cell">@android:color/system_accent2_900</color>
|
||||
<color name="widget_preview_outside">@android:color/system_neutral2_600</color>
|
||||
<color name="widget_preview_on_surface">@android:color/system_neutral1_50</color>
|
||||
<color name="widget_preview_variant">@android:color/system_neutral2_200</color>
|
||||
<color name="widget_preview_primary">@android:color/system_accent1_200</color>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<resources>
|
||||
<color name="widget_preview_surface">#2B3742</color>
|
||||
<color name="widget_preview_cell">#1C252D</color>
|
||||
<color name="widget_preview_outside">#3A4651</color>
|
||||
<color name="widget_preview_outside_shade">#4D000000</color>
|
||||
<color name="widget_preview_on_surface">#E1E3E6</color>
|
||||
<color name="widget_preview_variant">#A8ADB2</color>
|
||||
<color name="widget_preview_primary">#A3CBE2</color>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<resources>
|
||||
<color name="widget_preview_surface">@android:color/system_accent2_100</color>
|
||||
<color name="widget_preview_cell">@android:color/system_accent2_10</color>
|
||||
<color name="widget_preview_outside">@android:color/system_neutral2_300</color>
|
||||
<color name="widget_preview_on_surface">@android:color/system_neutral1_900</color>
|
||||
<color name="widget_preview_variant">@android:color/system_neutral2_700</color>
|
||||
<color name="widget_preview_primary">@android:color/system_accent1_600</color>
|
||||
|
||||
@@ -433,21 +433,29 @@
|
||||
|
||||
<!-- Home-screen widgets -->
|
||||
<string name="widget_agenda_title">Upcoming</string>
|
||||
<string name="widget_agenda_label">Calendula agenda</string>
|
||||
<string name="widget_month_label">Calendula month</string>
|
||||
<string name="widget_quick_add_label">Calendula quick add</string>
|
||||
<string name="widget_date_label">Calendula date</string>
|
||||
<string name="widget_week_strip_label">Calendula week strip</string>
|
||||
<string name="widget_agenda_label">Agenda</string>
|
||||
<string name="widget_agenda_description">Your next events, grouped by day</string>
|
||||
<string name="widget_month_label">Month</string>
|
||||
<string name="widget_month_description">The month at a glance, with your events</string>
|
||||
<string name="widget_quick_add_label">Quick add</string>
|
||||
<string name="widget_quick_add_description">Start a new event in one tap</string>
|
||||
<string name="widget_date_label">Date</string>
|
||||
<string name="widget_date_description">Today\'s date</string>
|
||||
<string name="widget_week_strip_label">Week strip</string>
|
||||
<string name="widget_week_strip_description">This week, with the days that have events</string>
|
||||
<!-- A day in the week strip widget, for screen readers: "Wednesday, 7 October, 2 events" -->
|
||||
<plurals name="widget_day_with_events">
|
||||
<item quantity="one">%1$s, %2$d event</item>
|
||||
<item quantity="other">%1$s, %2$d events</item>
|
||||
</plurals>
|
||||
<string name="widget_upcoming_label">Calendula upcoming</string>
|
||||
<string name="widget_upcoming_label">Upcoming</string>
|
||||
<string name="widget_upcoming_description">Today\'s date over your next events</string>
|
||||
<string name="widget_upcoming_empty">No upcoming events</string>
|
||||
<string name="widget_today_label">Calendula today</string>
|
||||
<string name="widget_today_label">Today</string>
|
||||
<string name="widget_today_description">Today\'s events, then tomorrow\'s</string>
|
||||
<string name="widget_nothing_left_today">Nothing left today</string>
|
||||
<string name="widget_countdown_label">Calendula countdown</string>
|
||||
<string name="widget_countdown_label">Countdown</string>
|
||||
<string name="widget_countdown_description">Time until an event, or the next one</string>
|
||||
<string name="widget_countdown_now">Now</string>
|
||||
<string name="widget_countdown_ending_now">Ending now</string>
|
||||
<string name="widget_countdown_tomorrow">Tomorrow</string>
|
||||
@@ -492,7 +500,8 @@
|
||||
<string name="widget_config_countdown_load_failed">Couldn\'t load your events.</string>
|
||||
<!-- A repeating event in the countdown's event list: "Next: Fri, 24 Oct · repeats" -->
|
||||
<string name="widget_config_countdown_repeats">%1$s · repeats</string>
|
||||
<string name="widget_month_day_label">Calendula month and day</string>
|
||||
<string name="widget_month_day_label">Month and day</string>
|
||||
<string name="widget_month_day_description">The month and the events of the day you pick</string>
|
||||
<string name="widget_day_empty">No events</string>
|
||||
<string name="widget_refresh">Refresh</string>
|
||||
<string name="widget_new_event">New event</string>
|
||||
@@ -628,10 +637,10 @@
|
||||
<string name="settings_agenda_range">Agenda range</string>
|
||||
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
|
||||
<string name="settings_agenda_widget_range">Agenda widget range</string>
|
||||
<string name="settings_agenda_widget_range_hint">How far ahead the agenda home-screen widget lists events.</string>
|
||||
<string name="settings_agenda_widget_range_hint">How far ahead this widget lists events.</string>
|
||||
|
||||
<string name="settings_widget_size">Agenda widget size</string>
|
||||
<string name="settings_widget_size_hint">How large the agenda home-screen widget draws its text. The month widget has no setting — its grid always fits itself to the space you give it.</string>
|
||||
<string name="settings_widget_size_hint">How large this widget draws its text.</string>
|
||||
<string name="settings_widget_size_small">Small</string>
|
||||
<string name="settings_widget_size_medium">Medium</string>
|
||||
<string name="settings_widget_size_large">Large</string>
|
||||
@@ -668,6 +677,8 @@
|
||||
<string name="month_style_split">Split</string>
|
||||
<string name="month_style_split_summary">A compact grid marks the days that have events, and the day you tap is listed underneath.</string>
|
||||
<string name="month_split_no_events">Nothing scheduled</string>
|
||||
<string name="month_split_no_events_month">Nothing scheduled this month</string>
|
||||
<string name="month_split_no_more_events">Nothing else scheduled this month</string>
|
||||
<string name="month_split_expand">Show the whole month</string>
|
||||
<string name="month_split_collapse">Show the day\'s events</string>
|
||||
<string name="settings_quick_switch_header">Quick-switch button</string>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<resources>
|
||||
<color name="widget_preview_surface">#D6E4F0</color>
|
||||
<color name="widget_preview_cell">#EEF3F8</color>
|
||||
<color name="widget_preview_outside">#C2CCD6</color>
|
||||
<color name="widget_preview_outside_shade">#1C000000</color>
|
||||
<color name="widget_preview_on_surface">#191C1F</color>
|
||||
<color name="widget_preview_variant">#6E7479</color>
|
||||
<color name="widget_preview_primary">#3B5364</color>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
android:widgetCategory="home_screen"
|
||||
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable"
|
||||
android:description="@string/widget_agenda_label"
|
||||
android:description="@string/widget_agenda_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:previewLayout="@layout/widget_preview_agenda"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
@@ -10,7 +10,7 @@
|
||||
android:widgetCategory="home_screen"
|
||||
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable"
|
||||
android:description="@string/widget_countdown_label"
|
||||
android:description="@string/widget_countdown_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:previewLayout="@layout/widget_preview_countdown"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
@@ -8,7 +8,7 @@
|
||||
android:widgetCategory="home_screen"
|
||||
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable"
|
||||
android:description="@string/widget_date_label"
|
||||
android:description="@string/widget_date_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:previewLayout="@layout/widget_preview_date"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
@@ -10,7 +10,7 @@
|
||||
android:widgetCategory="home_screen"
|
||||
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable"
|
||||
android:description="@string/widget_month_label"
|
||||
android:description="@string/widget_month_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:previewLayout="@layout/widget_preview_month"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
@@ -10,7 +10,7 @@
|
||||
android:widgetCategory="home_screen"
|
||||
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable"
|
||||
android:description="@string/widget_month_day_label"
|
||||
android:description="@string/widget_month_day_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:previewLayout="@layout/widget_preview_month_day"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
@@ -8,7 +8,7 @@
|
||||
android:widgetCategory="home_screen"
|
||||
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable"
|
||||
android:description="@string/widget_quick_add_label"
|
||||
android:description="@string/widget_quick_add_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:previewLayout="@layout/widget_preview_quick_add"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
@@ -12,7 +12,7 @@
|
||||
android:widgetCategory="home_screen"
|
||||
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable"
|
||||
android:description="@string/widget_today_label"
|
||||
android:description="@string/widget_today_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:previewLayout="@layout/widget_preview_today"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
@@ -10,7 +10,7 @@
|
||||
android:widgetCategory="home_screen"
|
||||
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable"
|
||||
android:description="@string/widget_upcoming_label"
|
||||
android:description="@string/widget_upcoming_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:previewLayout="@layout/widget_preview_upcoming"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
@@ -10,7 +10,7 @@
|
||||
android:widgetCategory="home_screen"
|
||||
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable"
|
||||
android:description="@string/widget_week_strip_label"
|
||||
android:description="@string/widget_week_strip_description"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:previewLayout="@layout/widget_preview_week_strip"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ReminderAlertTest {
|
||||
|
||||
private fun instance(eventId: Long = 1L, beginMillis: Long = 1_800_000_000_000L) = ReminderEventInstance(
|
||||
eventId = eventId,
|
||||
calendarId = 1L,
|
||||
beginMillis = beginMillis,
|
||||
endMillis = beginMillis + 3_600_000L,
|
||||
title = "Dentist",
|
||||
location = null,
|
||||
isAllDay = false,
|
||||
)
|
||||
|
||||
private fun alert(instance: ReminderEventInstance, minutes: Int) =
|
||||
PlannedReminder(instance, minutes, instance.beginMillis - minutes * 60_000L).toAlert()
|
||||
|
||||
@Test
|
||||
fun `two reminders on one occurrence share a notification`() {
|
||||
val dayBefore = alert(instance(), 1_440)
|
||||
val hourBefore = alert(instance(), 60)
|
||||
|
||||
assertThat(dayBefore.key).isNotEqualTo(hourBefore.key)
|
||||
assertThat(dayBefore.occurrenceKey).isEqualTo(hourBefore.occurrenceKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `occurrences of one series keep their own notifications`() {
|
||||
val first = alert(instance(beginMillis = 1_800_000_000_000L), 60)
|
||||
val second = alert(instance(beginMillis = 1_800_086_400_000L), 60)
|
||||
|
||||
assertThat(first.occurrenceKey).isNotEqualTo(second.occurrenceKey)
|
||||
}
|
||||
}
|
||||
@@ -212,4 +212,30 @@ class ReminderTimeTextTest {
|
||||
)
|
||||
assertThat(text).isEqualTo("Today")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed event from local midnight to midnight reads like an all-day one`() {
|
||||
val oneDay = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 0), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 13, 0, 0), berlin),
|
||||
today = LocalDate.of(2026, 6, 11),
|
||||
)
|
||||
assertThat(oneDay).isEqualTo("Tomorrow")
|
||||
val twoDays = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 0, 0), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 13, 0, 0), berlin),
|
||||
)
|
||||
assertThat(twoDays).startsWith("Today – ")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `midnight is read in the device zone`() {
|
||||
// Midnight in Berlin is 22:00 the day before in UTC, so it is timed there.
|
||||
val text = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 0), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 13, 0, 0), berlin),
|
||||
zone = ZoneOffset.UTC,
|
||||
)
|
||||
assertThat(text).contains("22:00")
|
||||
}
|
||||
}
|
||||
@@ -82,4 +82,39 @@ class EventInstanceSpanTest {
|
||||
val timed = instance(utc(2026, 7, 19, 12), utc(2026, 7, 19, 13), isAllDay = false)
|
||||
assertThat(timed.dateZone(newYork)).isEqualTo(newYork)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed event from local midnight to local midnight covers whole days`() {
|
||||
// Stored as 22:00Z, the shape ICSx5 gives a date-only feed event (#296).
|
||||
val oneDay = instance(utc(2026, 7, 18, 22), utc(2026, 7, 19, 22), isAllDay = false)
|
||||
val twoDays = instance(utc(2026, 7, 18, 22), utc(2026, 7, 20, 22), isAllDay = false)
|
||||
assertThat(oneDay.coversWholeDays(berlin)).isTrue()
|
||||
assertThat(twoDays.coversWholeDays(berlin)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local midnight is read in the device zone`() {
|
||||
val event = instance(utc(2026, 7, 18, 22), utc(2026, 7, 19, 22), isAllDay = false)
|
||||
assertThat(event.coversWholeDays(newYork)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed event off midnight at either end does not cover whole days`() {
|
||||
assertThat(instance(utc(2026, 7, 19), utc(2026, 7, 19, 23), isAllDay = false).coversWholeDays(TimeZone.UTC))
|
||||
.isFalse()
|
||||
assertThat(instance(utc(2026, 7, 19, 1), utc(2026, 7, 20), isAllDay = false).coversWholeDays(TimeZone.UTC))
|
||||
.isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero-length event at midnight does not cover whole days`() {
|
||||
assertThat(instance(utc(2026, 7, 19), utc(2026, 7, 19), isAllDay = false).coversWholeDays(TimeZone.UTC))
|
||||
.isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day event covers whole days in any zone`() {
|
||||
assertThat(allDayJul19().coversWholeDays(newYork)).isTrue()
|
||||
assertThat(allDayJul19().coversWholeDays(berlin)).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.testEvent
|
||||
import kotlinx.datetime.TimeZone
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
class HasEndedTest {
|
||||
|
||||
private val newYork = TimeZone.of("America/New_York")
|
||||
|
||||
// All-day Oct 28, stored as UTC midnights.
|
||||
private val holiday = testEvent(
|
||||
title = "Holiday",
|
||||
start = Instant.parse("2026-10-28T00:00:00Z"),
|
||||
end = Instant.parse("2026-10-29T00:00:00Z"),
|
||||
isAllDay = true,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `an all-day event runs until local midnight west of UTC`() {
|
||||
// 20:00 in New York, past the stored UTC end.
|
||||
assertThat(holiday.hasEnded(Instant.parse("2026-10-29T00:00:00Z"), newYork)).isFalse()
|
||||
assertThat(holiday.hasEnded(Instant.parse("2026-10-29T04:00:00Z"), newYork)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day event ends at local midnight east of UTC`() {
|
||||
val berlin = TimeZone.of("Europe/Berlin")
|
||||
assertThat(holiday.hasEnded(Instant.parse("2026-10-28T23:30:00Z"), berlin)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed event ends at its own end`() {
|
||||
val call = holiday.copy(isAllDay = false, end = Instant.parse("2026-10-28T15:00:00Z"))
|
||||
assertThat(call.hasEnded(Instant.parse("2026-10-28T15:00:00Z"), newYork)).isTrue()
|
||||
assertThat(call.hasEnded(Instant.parse("2026-10-28T14:59:00Z"), newYork)).isFalse()
|
||||
}
|
||||
}
|
||||
@@ -419,6 +419,30 @@ class ReminderPlanTest {
|
||||
assertThat(schedule.due).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `of several reminders due on one occurrence only the latest is posted`() {
|
||||
val planned = plan(
|
||||
instances = listOf(instance(1L, beginMillis = now + 20 * minute)),
|
||||
minutesByEvent = mapOf(1L to listOf(60, 30)),
|
||||
)
|
||||
|
||||
val schedule = scheduleReminders(
|
||||
planned, lastFiredMillis = now - day, nowMillis = now, horizonMillis = now + day,
|
||||
)
|
||||
|
||||
assertThat(schedule.due.map { it.minutes }).containsExactly(30)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two reminders on one occurrence share an occurrence key`() {
|
||||
val planned = plan(
|
||||
instances = listOf(instance(1L)),
|
||||
minutesByEvent = mapOf(1L to listOf(10, 30)),
|
||||
)
|
||||
|
||||
assertThat(planned[0].occurrenceKey).isEqualTo(planned[1].occurrenceKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `due reminders come out in occurrence order`() {
|
||||
val planned = plan(
|
||||
|
||||
@@ -65,4 +65,14 @@ class AgendaTimeLabelTest {
|
||||
assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.Ends(e.end))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed event from midnight to midnight is all-day`() {
|
||||
val oneDay = event(at(2026, 7, 2), at(2026, 7, 3))
|
||||
val twoDays = event(at(2026, 7, 2), at(2026, 7, 4))
|
||||
|
||||
assertThat(labelOn(2026, 7, 2, oneDay)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
assertThat(labelOn(2026, 7, 2, twoDays)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
assertThat(labelOn(2026, 7, 3, twoDays)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.math.abs
|
||||
@@ -13,16 +15,10 @@ import kotlin.math.abs
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PageStateCacheTest {
|
||||
|
||||
@Test
|
||||
fun `the same key shares one state`() = runTest {
|
||||
val cache = PageStateCache<Int, Int>(backgroundScope, 0, { a, b -> abs(a - b) <= 1 }) { flow { emit(it) } }
|
||||
assertThat(cache.get(5)).isSameInstanceAs(cache.get(5))
|
||||
}
|
||||
private var running = 0
|
||||
|
||||
@Test
|
||||
fun `a dropped entry stops loading`() = runTest(UnconfinedTestDispatcher()) {
|
||||
var running = 0
|
||||
val cache = PageStateCache<Int, Int>(backgroundScope, 0, { a, b -> abs(a - b) <= 1 }) { key ->
|
||||
private fun TestScope.trackingCache() =
|
||||
PageStateCache<Int, Int>(backgroundScope, -1, { a, b -> abs(a - b) <= 1 }) { key ->
|
||||
flow {
|
||||
running++
|
||||
try {
|
||||
@@ -33,13 +29,11 @@ class PageStateCacheTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
backgroundScope.launch { cache.get(0).collect {} }
|
||||
assertThat(running).isEqualTo(1)
|
||||
|
||||
// Far enough away that 0 is let go, even though it is still collected.
|
||||
cache.get(10)
|
||||
assertThat(running).isEqualTo(0)
|
||||
assertThat(cache.size).isEqualTo(1)
|
||||
@Test
|
||||
fun `the same key shares one state`() = runTest {
|
||||
val cache = trackingCache()
|
||||
assertThat(cache.get(5)).isSameInstanceAs(cache.get(5))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -50,4 +44,63 @@ class PageStateCacheTest {
|
||||
cache.get(6)
|
||||
assertThat(cache.size).isEqualTo(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an entry loads only while collected`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val cache = trackingCache()
|
||||
val state = cache.get(0)
|
||||
assertThat(running).isEqualTo(0)
|
||||
|
||||
val page = backgroundScope.launch { state.collect {} }
|
||||
assertThat(running).isEqualTo(1)
|
||||
assertThat(state.value).isEqualTo(0)
|
||||
|
||||
page.cancel()
|
||||
advanceTimeBy(5_001)
|
||||
assertThat(running).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unwatched entry far away is forgotten`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val cache = trackingCache()
|
||||
cache.get(0)
|
||||
cache.get(10)
|
||||
assertThat(cache.size).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an entry a page still collects is kept however far away`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val cache = trackingCache()
|
||||
backgroundScope.launch { cache.get(0).collect {} }
|
||||
|
||||
cache.get(10)
|
||||
assertThat(running).isEqualTo(1)
|
||||
assertThat(cache.size).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a forgotten entry a page holds still loads once collected`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val cache = trackingCache()
|
||||
// The page took its state but hasn't started collecting when 0 is let go.
|
||||
val held = cache.get(0)
|
||||
cache.get(10)
|
||||
|
||||
val values = mutableListOf<Int>()
|
||||
backgroundScope.launch { held.collect { values += it } }
|
||||
assertThat(running).isEqualTo(1)
|
||||
assertThat(values.last()).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an entry restarts after the screen comes back`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val cache = trackingCache()
|
||||
val held = cache.get(0)
|
||||
backgroundScope.launch { held.collect {} }.cancel()
|
||||
advanceTimeBy(5_001)
|
||||
cache.get(10)
|
||||
assertThat(running).isEqualTo(0)
|
||||
|
||||
backgroundScope.launch { held.collect {} }
|
||||
assertThat(running).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.testEvent
|
||||
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
@@ -10,6 +12,7 @@ import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.yearMonth
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
@@ -34,22 +37,22 @@ class ContinuousMonthIndexTest {
|
||||
@Test
|
||||
fun `index round-trips back to its month`() {
|
||||
listOf(
|
||||
YearMonth(1900, Month.JANUARY),
|
||||
YearMonth(1600, Month.JANUARY),
|
||||
jun26,
|
||||
YearMonth(2026, Month.DECEMBER),
|
||||
YearMonth(2100, Month.DECEMBER),
|
||||
YearMonth(2399, Month.DECEMBER),
|
||||
).forEach { ym ->
|
||||
assertThat(yearMonthForIndex(monthIndexOf(ym))).isEqualTo(ym)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `indices are non-negative and span 1900 through 2100`() {
|
||||
assertThat(monthIndexOf(YearMonth(1900, Month.JANUARY))).isEqualTo(0)
|
||||
fun `indices are non-negative and span 1600 through 2399`() {
|
||||
assertThat(monthIndexOf(YearMonth(1600, Month.JANUARY))).isEqualTo(0)
|
||||
assertThat(monthIndexOf(jun26)).isGreaterThan(0)
|
||||
assertThat(monthIndexOf(YearMonth(2100, Month.DECEMBER)))
|
||||
assertThat(monthIndexOf(YearMonth(2399, Month.DECEMBER)))
|
||||
.isEqualTo(continuousMonthCount() - 1)
|
||||
assertThat(continuousMonthCount()).isEqualTo(201 * 12)
|
||||
assertThat(continuousMonthCount()).isEqualTo(800 * 12)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -154,9 +157,9 @@ class ContinuousMonthIndexTest {
|
||||
DayOfWeek.entries.forEach { ws ->
|
||||
val index = weekIndexOf(wed, ws)
|
||||
assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfWeek(ws))
|
||||
assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0)
|
||||
assertThat(weekIndexOf(LocalDate(1600, 1, 1), ws)).isAtLeast(0)
|
||||
assertThat(index).isLessThan(continuousWeekCount(ws))
|
||||
assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws))
|
||||
assertThat(weekIndexOf(LocalDate(2399, 12, 31), ws))
|
||||
.isLessThan(continuousWeekCount(ws))
|
||||
}
|
||||
}
|
||||
@@ -236,4 +239,68 @@ class ContinuousMonthIndexTest {
|
||||
assertThat(agendaStartForMonth(YearMonth(2025, Month.JUNE), today))
|
||||
.isEqualTo(LocalDate(2025, 6, 1))
|
||||
}
|
||||
|
||||
private fun days(month: YearMonth, today: LocalDate, byDay: Map<LocalDate, List<EventInstance>>, showToday: Boolean = true) =
|
||||
splitAgendaDays(month, today, byDay, showToday).map { it.date }
|
||||
|
||||
@Test
|
||||
fun `the split agenda skips a free first day of another month`() {
|
||||
val today = LocalDate(2026, 6, 10)
|
||||
val byDay = mapOf(
|
||||
LocalDate(2026, 7, 1) to emptyList(),
|
||||
LocalDate(2026, 7, 3) to listOf(testEvent()),
|
||||
)
|
||||
assertThat(days(YearMonth(2026, Month.JULY), today, byDay)).containsExactly(LocalDate(2026, 7, 3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the split agenda keeps a free today`() {
|
||||
val today = LocalDate(2026, 6, 10)
|
||||
val byDay = mapOf(LocalDate(2026, 6, 12) to listOf(testEvent()))
|
||||
assertThat(days(jun26, today, byDay)).containsExactly(today, LocalDate(2026, 6, 12)).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the split agenda drops a free today when the agenda would`() {
|
||||
val today = LocalDate(2026, 6, 10)
|
||||
val byDay = mapOf(LocalDate(2026, 6, 12) to listOf(testEvent()))
|
||||
assertThat(days(jun26, today, byDay, showToday = false)).containsExactly(LocalDate(2026, 6, 12))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the split agenda leaves out past days and the grid's other months`() {
|
||||
val today = LocalDate(2026, 6, 10)
|
||||
val byDay = mapOf(
|
||||
LocalDate(2026, 5, 31) to listOf(testEvent()),
|
||||
LocalDate(2026, 6, 9) to listOf(testEvent()),
|
||||
LocalDate(2026, 6, 10) to listOf(testEvent()),
|
||||
LocalDate(2026, 7, 1) to listOf(testEvent()),
|
||||
)
|
||||
assertThat(days(jun26, today, byDay)).containsExactly(today)
|
||||
assertThat(days(YearMonth(2026, Month.JULY), today, byDay)).containsExactly(LocalDate(2026, 7, 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty month lists no days`() {
|
||||
assertThat(days(YearMonth(2026, Month.JULY), LocalDate(2026, 6, 10), emptyMap())).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a date before 1600 is held at the first day of the grid`() {
|
||||
val held = coerceToMonthRange(LocalDate(1599, 12, 31))
|
||||
assertThat(held).isEqualTo(LocalDate(1600, 1, 1))
|
||||
assertThat(monthIndexOf(held.yearMonth)).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a date after 2399 is held at the last day of the grid`() {
|
||||
val held = coerceToMonthRange(LocalDate(2400, 1, 1))
|
||||
assertThat(held).isEqualTo(LocalDate(2399, 12, 31))
|
||||
assertThat(monthIndexOf(held.yearMonth)).isEqualTo(continuousMonthCount() - 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a date inside the span is left alone`() {
|
||||
assertThat(coerceToMonthRange(LocalDate(2026, 6, 10))).isEqualTo(LocalDate(2026, 6, 10))
|
||||
}
|
||||
}
|
||||
@@ -321,4 +321,18 @@ class MonthLayoutTest {
|
||||
private fun declinedTimed(date: LocalDate) =
|
||||
timed(date, 7, 8, id = 4L, title = "Declined early")
|
||||
.copy(response = EventResponse.Declined)
|
||||
|
||||
@Test
|
||||
fun `a timed event from midnight to midnight is a bar, not a timed chip`() {
|
||||
val ev = timedEvent(
|
||||
LocalDate(2026, 6, 10), 0, 0, zone = zone, id = 3L,
|
||||
endDate = LocalDate(2026, 6, 11),
|
||||
)
|
||||
val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone)
|
||||
|
||||
assertThat(week.spans.map { it.event }).containsExactly(ev)
|
||||
assertThat(week.spans.single().startCol).isEqualTo(2)
|
||||
assertThat(week.spans.single().endCol).isEqualTo(2)
|
||||
assertThat(week.timedByDay.values.flatten()).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.MainDispatcherExtension
|
||||
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||
import de.jeanlucmakiola.calendula.testPrefs
|
||||
import de.jeanlucmakiola.calendula.testRepository
|
||||
import de.jeanlucmakiola.calendula.testSettings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.Month
|
||||
import kotlinx.datetime.YearMonth
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.RegisterExtension
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
/** The month view never leaves the span its pager can show (#457). */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MonthViewModelRangeTest {
|
||||
|
||||
@JvmField @RegisterExtension
|
||||
val mainDispatcher = MainDispatcherExtension()
|
||||
private val dispatcher = mainDispatcher.dispatcher
|
||||
|
||||
private fun viewModel(tempDir: Path): MonthViewModel {
|
||||
val settings = testSettings(tempDir, CoroutineScope(dispatcher))
|
||||
val repo = testRepository(
|
||||
FakeCalendarDataSource(), tempDir, dispatcher,
|
||||
prefs = testPrefs(tempDir, CoroutineScope(dispatcher)),
|
||||
settings = settings,
|
||||
)
|
||||
val today = object : TodaySource {
|
||||
override val today = flowOf(LocalDate(2026, 11, 2))
|
||||
}
|
||||
return MonthViewModel(repo, settings, today, dispatcher)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a focus before 1600 opens the first month`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
val vm = viewModel(tempDir)
|
||||
vm.goToDate(LocalDate(1599, 12, 31))
|
||||
assertThat(vm.month.value).isEqualTo(YearMonth(1600, Month.JANUARY))
|
||||
assertThat(vm.selectedDate.value).isEqualTo(LocalDate(1600, 1, 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a focus after 2399 opens the last month`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
val vm = viewModel(tempDir)
|
||||
vm.goToDate(LocalDate(2400, 1, 1))
|
||||
assertThat(vm.month.value).isEqualTo(YearMonth(2399, Month.DECEMBER))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tap on a day past the span is ignored`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
val vm = viewModel(tempDir)
|
||||
vm.goToDate(LocalDate(1600, 1, 1))
|
||||
vm.selectDate(LocalDate(1599, 12, 31))
|
||||
assertThat(vm.month.value).isEqualTo(YearMonth(1600, Month.JANUARY))
|
||||
assertThat(vm.selectedDate.value).isEqualTo(LocalDate(1600, 1, 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the split pane stops at the first month`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
val vm = viewModel(tempDir)
|
||||
vm.goToDate(LocalDate(1600, 1, 15))
|
||||
vm.clearSelection()
|
||||
vm.stepSplitPane(-1)
|
||||
assertThat(vm.month.value).isEqualTo(YearMonth(1600, Month.JANUARY))
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,9 @@ import kotlinx.datetime.plus
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Pins each grid's page numbering to the formulas it shipped with: a pager
|
||||
* restores its page by number, so a change here moves the user to another date.
|
||||
* Pins each grid's page numbering: a pager restores its page by number, so a
|
||||
* change here moves the user to another date. Page 0 holds the first day of
|
||||
* the shared span, and the last page holds its last day (#457).
|
||||
*/
|
||||
class PageNumberingTest {
|
||||
|
||||
@@ -26,6 +27,7 @@ class PageNumberingTest {
|
||||
add(e)
|
||||
e = e.plus(1, DateTimeUnit.DAY)
|
||||
}
|
||||
addAll(listOf(FIRST, FIRST.minus(1, DateTimeUnit.DAY), LAST, LAST.plus(1, DateTimeUnit.DAY)))
|
||||
}
|
||||
|
||||
private fun pages(count: Int): List<Int> =
|
||||
@@ -36,13 +38,14 @@ class PageNumberingTest {
|
||||
fun `week pages keep their numbering for every first day`() {
|
||||
DayOfWeek.entries.forEach { firstDay ->
|
||||
val grid = DaysGrid.weeks(firstDay)
|
||||
val phase = DAYS_EPOCH.daysUntil(WEEK_EPOCH.weekStart(firstDay))
|
||||
assertThat(grid.dayCount).isEqualTo(7)
|
||||
assertThat(grid.pageCount).isEqualTo(LEGACY_WEEK_COUNT)
|
||||
pages(LEGACY_WEEK_COUNT).forEach { page ->
|
||||
assertThat(grid.startForPage(page)).isEqualTo(legacyWeekStart(page, firstDay))
|
||||
assertThat(grid.pageCount).isEqualTo(expectedCount(7, phase))
|
||||
pages(grid.pageCount).forEach { page ->
|
||||
assertThat(grid.startForPage(page)).isEqualTo(expectedStart(page, 7, phase))
|
||||
}
|
||||
dates.forEach { date ->
|
||||
assertThat(grid.pageFor(date)).isEqualTo(legacyWeekPage(date, firstDay))
|
||||
assertThat(grid.pageFor(date)).isEqualTo(expectedPage(date, 7, phase))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,12 +55,12 @@ class PageNumberingTest {
|
||||
(1..7).forEach { count ->
|
||||
(-count..count * 2).forEach { phase ->
|
||||
val grid = DaysGrid(count, phase)
|
||||
assertThat(grid.pageCount).isEqualTo(LEGACY_DAYS_COUNT)
|
||||
pages(LEGACY_DAYS_COUNT).forEach { page ->
|
||||
assertThat(grid.startForPage(page)).isEqualTo(legacyDaysStart(page, count, phase))
|
||||
assertThat(grid.pageCount).isEqualTo(expectedCount(count, phase))
|
||||
pages(grid.pageCount).forEach { page ->
|
||||
assertThat(grid.startForPage(page)).isEqualTo(expectedStart(page, count, phase))
|
||||
}
|
||||
dates.forEach { date ->
|
||||
assertThat(grid.pageFor(date)).isEqualTo(legacyDaysPage(date, count, phase))
|
||||
assertThat(grid.pageFor(date)).isEqualTo(expectedPage(date, count, phase))
|
||||
}
|
||||
}
|
||||
dates.filter { it.year in 1950..2050 }.forEach { date ->
|
||||
@@ -71,12 +74,21 @@ class PageNumberingTest {
|
||||
fun `day pages keep their numbering`() {
|
||||
val grid = DaysGrid.days
|
||||
assertThat(grid.dayCount).isEqualTo(1)
|
||||
assertThat(grid.pageCount).isEqualTo(LEGACY_DAY_COUNT)
|
||||
pages(LEGACY_DAY_COUNT).forEach { page ->
|
||||
assertThat(grid.startForPage(page)).isEqualTo(legacyDayStart(page))
|
||||
assertThat(grid.pageCount).isEqualTo(FIRST.daysUntil(LAST) + 1)
|
||||
pages(grid.pageCount).forEach { page ->
|
||||
assertThat(grid.startForPage(page)).isEqualTo(FIRST.plus(page, DateTimeUnit.DAY))
|
||||
}
|
||||
dates.forEach { date ->
|
||||
assertThat(grid.pageFor(date)).isEqualTo(legacyDayPage(date))
|
||||
assertThat(grid.pageFor(date)).isEqualTo(FIRST.daysUntil(date).coerceIn(0, grid.pageCount - 1))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every grid reaches both ends of the span`() {
|
||||
(DayOfWeek.entries.map { DaysGrid.weeks(it) } + DaysGrid.days + (1..7).map { DaysGrid(it, 0) }).forEach { grid ->
|
||||
assertThat(grid.startForPage(grid.pageFor(FIRST))).isAtMost(FIRST)
|
||||
val lastStart = grid.startForPage(grid.pageFor(LAST))
|
||||
assertThat(lastStart.plus(grid.dayCount - 1, DateTimeUnit.DAY)).isAtLeast(LAST)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,34 +116,28 @@ class PageNumberingTest {
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LEGACY_WEEK_COUNT = 20_000
|
||||
const val LEGACY_DAYS_COUNT = 40_000
|
||||
const val LEGACY_DAY_COUNT = 140_000
|
||||
val FIRST = LocalDate(1600, 1, 1)
|
||||
val LAST = LocalDate(2399, 12, 31)
|
||||
val WEEK_EPOCH = LocalDate(2000, 1, 3)
|
||||
val DAYS_EPOCH = LocalDate(2000, 1, 1)
|
||||
|
||||
fun LocalDate.weekStart(firstDay: DayOfWeek): LocalDate =
|
||||
minus(((dayOfWeek.ordinal - firstDay.ordinal) + 7) % 7, DateTimeUnit.DAY)
|
||||
|
||||
fun legacyWeekStart(page: Int, firstDay: DayOfWeek): LocalDate =
|
||||
WEEK_EPOCH.weekStart(firstDay).plus((page - LEGACY_WEEK_COUNT / 2) * 7, DateTimeUnit.DAY)
|
||||
|
||||
fun legacyWeekPage(date: LocalDate, firstDay: DayOfWeek): Int {
|
||||
val days = WEEK_EPOCH.weekStart(firstDay).daysUntil(date.weekStart(firstDay))
|
||||
return (LEGACY_WEEK_COUNT / 2 + days / 7).coerceIn(0, LEGACY_WEEK_COUNT - 1)
|
||||
/** Page 0 starts on the page boundary at or before [FIRST]. */
|
||||
fun firstStart(count: Int, phase: Int): LocalDate {
|
||||
val origin = DAYS_EPOCH.plus(phase, DateTimeUnit.DAY)
|
||||
return origin.plus(Math.floorDiv(origin.daysUntil(FIRST), count) * count, DateTimeUnit.DAY)
|
||||
}
|
||||
|
||||
fun legacyDaysStart(page: Int, count: Int, phase: Int): LocalDate =
|
||||
DAYS_EPOCH.plus(phase + (page - LEGACY_DAYS_COUNT / 2) * count, DateTimeUnit.DAY)
|
||||
fun expectedStart(page: Int, count: Int, phase: Int): LocalDate =
|
||||
firstStart(count, phase).plus(page * count, DateTimeUnit.DAY)
|
||||
|
||||
fun legacyDaysPage(date: LocalDate, count: Int, phase: Int): Int =
|
||||
(LEGACY_DAYS_COUNT / 2 + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, count))
|
||||
.coerceIn(0, LEGACY_DAYS_COUNT - 1)
|
||||
fun expectedCount(count: Int, phase: Int): Int =
|
||||
Math.floorDiv(firstStart(count, phase).daysUntil(LAST), count) + 1
|
||||
|
||||
fun legacyDayStart(page: Int): LocalDate =
|
||||
DAYS_EPOCH.plus(page - LEGACY_DAY_COUNT / 2, DateTimeUnit.DAY)
|
||||
|
||||
fun legacyDayPage(date: LocalDate): Int =
|
||||
(LEGACY_DAY_COUNT / 2 + DAYS_EPOCH.daysUntil(date)).coerceIn(0, LEGACY_DAY_COUNT - 1)
|
||||
fun expectedPage(date: LocalDate, count: Int, phase: Int): Int =
|
||||
Math.floorDiv(firstStart(count, phase).daysUntil(date), count)
|
||||
.coerceIn(0, expectedCount(count, phase) - 1)
|
||||
}
|
||||
}
|
||||
@@ -300,4 +300,11 @@ class WeekLayoutTest {
|
||||
val second = event(Instant.parse("2026-10-25T01:10:00Z"), Instant.parse("2026-10-25T01:40:00Z"))
|
||||
assertThat(layoutDay(listOf(second), day, brussels).single().endMin).isEqualTo(2 * 60 + 40)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed event from midnight to midnight stays out of the time grid`() {
|
||||
val ev = testEvent(start = at(wed, 0), end = at(wed.plus(1, DateTimeUnit.DAY), 0))
|
||||
|
||||
assertThat(layoutDay(listOf(ev), wed, zone)).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,11 @@ class CountdownTest {
|
||||
start = start, end = end, isAllDay = allDay, color = 0, location = null, response = response,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the last half hour before the day mark still reads 23 h`() {
|
||||
assertThat(countdownLabel(event(now + 23.hours + 40.minutes), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.HOURS, 23))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `labels step from days to hours to minutes to now`() {
|
||||
assertThat(countdownLabel(event(now + 30.seconds), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.NOW, 0))
|
||||
@@ -63,9 +68,16 @@ class CountdownTest {
|
||||
val over = event(now - 2.hours, now - 1.hours, id = 3)
|
||||
val later = event(now + 3.hours, id = 4)
|
||||
val running = event(now - 1.hours, now + 2.hours, id = 5)
|
||||
assertThat(pickNextEvent(listOf(allDay, declined, over, later), now)?.eventId).isEqualTo(4)
|
||||
assertThat(pickNextEvent(listOf(allDay, declined, over, later), now, zone)?.eventId).isEqualTo(4)
|
||||
// A running event ending before the next one starts comes first.
|
||||
assertThat(pickNextEvent(listOf(later, running), now)?.eventId).isEqualTo(5)
|
||||
assertThat(pickNextEvent(listOf(later, running), now, zone)?.eventId).isEqualTo(5)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next-event mode skips a timed event filling whole days`() {
|
||||
val wholeDay = event(Instant.parse("2026-10-22T00:00:00Z"), Instant.parse("2026-10-23T00:00:00Z"), id = 1)
|
||||
val later = event(now + 30.hours, id = 2)
|
||||
assertThat(pickNextEvent(listOf(wholeDay, later), now, zone)?.eventId).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -333,7 +333,7 @@ sequenceDiagram
|
||||
Src-->>Sc: Instances ⋈ Reminders
|
||||
Sc->>P: planReminders / scheduleReminders(watermark, now)
|
||||
P-->>Sc: due + next alarm
|
||||
Sc->>N: post(alert) — tag = reminder key
|
||||
Sc->>N: post(alert) — tag = occurrence, so a later reminder replaces it
|
||||
Sc->>A: scheduleScan(next)
|
||||
```
|
||||
|
||||
|
||||
+1
-1
Submodule floret-kit updated: 6b5ee409b3...aef99a119f.
Reference in new issue
Block a user