Compare commits

..
Author SHA1 Message Date
makiolaj b6cec2f95e 2.24.0-beta.1
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 8m50s
2026-10-26 20:32:16 +01:00
makiolaj d012948de6 Pin floret-kit to main after #22 2026-10-26 20:28:37 +01:00
makiolaj 8ff8a2de1f Merge the widget refactor and new widgets into 2.24.0 2026-10-26 20:16:34 +01:00
makiolaj 3f81bb9ea5 Widget picker: generated previews on Android 15+, XML mock-ups redrawn to match the widgets 2026-10-26 20:08:18 +01:00
makiolaj a08ad15c4a Month and day widget: clear the fill of previously tapped days (#441) 2026-10-26 20:08:11 +01:00
makiolaj c9cc40ff60 Month and day widget: compact month grid beside the selected day's events (#441) 2026-10-23 18:51:49 +02:00
makiolaj 343a8904ea Countdown widget: next timed event or a chosen event/series, redrawn when its label changes (#439) 2026-10-23 18:51:49 +02:00
makiolaj 075940d29e Today list widget: today's events, then tomorrow's first ones once the day is done (#438) 2026-10-23 18:51:49 +02:00
makiolaj 670cdb226a Upcoming widget: today's date over the next events, rows fitted to the height (#437) 2026-10-23 18:51:49 +02:00
makiolaj a8da8bc7a3 Week strip widget: this week's days with event-colour dots, today filled (#436) 2026-10-23 18:51:49 +02:00
makiolaj cfcba283f1 Date widget: 1×1 weekday and day number on a cookie shape, opens today (#376) 2026-10-23 18:51:49 +02:00
makiolaj 85d4dc535c Quick add widget: 1×1 shaped + opening the editor on today (#435) 2026-10-23 18:51:49 +02:00
makiolaj edf80fe8b8 Widgets: one kind registry and receiver base, per-widget calendar choice (#292) 2026-10-23 18:51:49 +02:00
makiolaj 0ecd6de27b Merge #71 widget redesign and #372 month grid into the widget refactor 2026-10-23 17:59:34 +02:00
70 changed files with 5527 additions and 459 deletions
+28
View File
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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.
### 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]).
- **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 ## [2.23.0] — 2026-10-21
### Added ### Added
@@ -1889,3 +1903,17 @@ automatically, with zero telemetry and no internet permission.
[#178]: https://codeberg.org/jlmakiola/calendula/issues/178 [#178]: https://codeberg.org/jlmakiola/calendula/issues/178
[#116]: https://codeberg.org/jlmakiola/calendula/issues/116 [#116]: https://codeberg.org/jlmakiola/calendula/issues/116
[#413]: https://codeberg.org/jlmakiola/calendula/issues/413 [#413]: https://codeberg.org/jlmakiola/calendula/issues/413
[#71]: https://codeberg.org/jlmakiola/calendula/issues/71
[#90]: https://codeberg.org/jlmakiola/calendula/issues/90
[#229]: https://codeberg.org/jlmakiola/calendula/issues/229
[#292]: https://codeberg.org/jlmakiola/calendula/issues/292
[#372]: https://codeberg.org/jlmakiola/calendula/issues/372
[#376]: https://codeberg.org/jlmakiola/calendula/issues/376
[#386]: https://codeberg.org/jlmakiola/calendula/issues/386
[#420]: https://codeberg.org/jlmakiola/calendula/issues/420
[#435]: https://codeberg.org/jlmakiola/calendula/issues/435
[#436]: https://codeberg.org/jlmakiola/calendula/issues/436
[#437]: https://codeberg.org/jlmakiola/calendula/issues/437
[#438]: https://codeberg.org/jlmakiola/calendula/issues/438
[#439]: https://codeberg.org/jlmakiola/calendula/issues/439
[#441]: https://codeberg.org/jlmakiola/calendula/issues/441
+2 -2
View File
@@ -32,8 +32,8 @@ android {
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001, // (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match. // 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md. // See docs/RELEASING.md.
versionCode = 2230099 versionCode = 2240001
versionName = "2.23.0" versionName = "2.24.0-beta.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
+89
View File
@@ -352,6 +352,95 @@
android:resource="@xml/appwidget_info_month" /> android:resource="@xml/appwidget_info_month" />
</receiver> </receiver>
<receiver
android:name=".widget.quickadd.QuickAddWidgetReceiver"
android:label="@string/widget_quick_add_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_quick_add" />
</receiver>
<receiver
android:name=".widget.date.DateWidgetReceiver"
android:label="@string/widget_date_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_date" />
</receiver>
<receiver
android:name=".widget.week.WeekStripWidgetReceiver"
android:label="@string/widget_week_strip_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_week_strip" />
</receiver>
<receiver
android:name=".widget.upcoming.UpcomingWidgetReceiver"
android:label="@string/widget_upcoming_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_upcoming" />
</receiver>
<receiver
android:name=".widget.today.TodayWidgetReceiver"
android:label="@string/widget_today_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_today" />
</receiver>
<receiver
android:name=".widget.countdown.CountdownWidgetReceiver"
android:label="@string/widget_countdown_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_countdown" />
</receiver>
<!-- A countdown widget's redraw for when its label changes (#439). -->
<receiver
android:name=".widget.countdown.CountdownTickReceiver"
android:exported="false" />
<receiver
android:name=".widget.split.MonthDayWidgetReceiver"
android:label="@string/widget_month_day_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_month_day" />
</receiver>
<!-- Keeps both widgets fresh: the calendar provider broadcasts <!-- Keeps both widgets fresh: the calendar provider broadcasts
PROVIDER_CHANGED on any data change (external sync; the app's own PROVIDER_CHANGED on any data change (external sync; the app's own
writes redraw directly). The day boundary arrives as the app's own writes redraw directly). The day boundary arrives as the app's own
@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
import de.jeanlucmakiola.calendula.widget.publishCalendulaWidgetPreviews
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashConfig
@@ -58,10 +59,12 @@ class CalendulaApp : Application() {
* (#228). Idempotent, and it covers what no broadcast reaches — an alarm * (#228). Idempotent, and it covers what no broadcast reaches — an alarm
* dropped by a force-stop is armed again the next time the app is opened. * dropped by a force-stop is armed again the next time the app is opened.
* Off the main thread: a handful of binder calls on every process start. * Off the main thread: a handful of binder calls on every process start.
* The picker previews are republished here too, once per update.
*/ */
private fun reconcileWidgetRollover() { private fun reconcileWidgetRollover() {
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
syncWidgetRollover(this@CalendulaApp) syncWidgetRollover(this@CalendulaApp)
publishCalendulaWidgetPreviews(this@CalendulaApp)
} }
} }
@@ -56,6 +56,10 @@ import javax.inject.Singleton
interface CalendarDataSource { interface CalendarDataSource {
fun calendars(): List<CalendarSource> fun calendars(): List<CalendarSource>
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
/** [instances] of event [eventId] alone, soonest first. */
fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List<EventInstance> =
instances(beginMillis, endMillis).filter { it.eventId == eventId }
/** /**
* [allDayReminderTimeMinutes]: the hour all-day reminders fire at, needed to * [allDayReminderTimeMinutes]: the hour all-day reminders fire at, needed to
* decode their stored offsets back to whole-day lead times (see * decode their stored offsets back to whole-day lead times (see
@@ -563,21 +567,33 @@ class AndroidCalendarDataSource @Inject constructor(
} }
} }
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> { override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
queryInstances(beginMillis, endMillis, null, null)
override fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List<EventInstance> =
queryInstances(beginMillis, endMillis, "${CalendarContract.Instances.EVENT_ID} = ?", arrayOf(eventId.toString()))
private fun queryInstances(
beginMillis: Long,
endMillis: Long,
extraSelection: String?,
args: Array<String>?,
): List<EventInstance> {
ensureObserversRegistered() ensureObserversRegistered()
// Hide cancelled occurrences: "delete only this event" writes a
// cancelled exception for the one instance (#47). A NULL status is a
// normal, un-cancelled event, so it must survive the filter — a bare
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
// DELETED = 0: a provider that soft-deletes may keep the row's
// instances until a purge (#364).
val visible = "(${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
"${CalendarContract.Events.DELETED} = 0"
return resolver.query( return resolver.query(
instancesUri(beginMillis, endMillis), instancesUri(beginMillis, endMillis),
InstanceProjection.COLUMNS, InstanceProjection.COLUMNS,
// Hide cancelled occurrences: "delete only this event" writes a if (extraSelection == null) visible else "$visible AND $extraSelection",
// cancelled exception for the one instance (#47). A NULL status is a args,
// normal, un-cancelled event, so it must survive the filter — a bare
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
// DELETED = 0: a provider that soft-deletes may keep the row's
// instances until a purge (#364).
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
"${CalendarContract.Events.DELETED} = 0",
null,
CalendarContract.Instances.BEGIN + " ASC", CalendarContract.Instances.BEGIN + " ASC",
)?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList() )?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList()
} }
@@ -16,6 +16,9 @@ interface CalendarRepository {
fun calendars(): Flow<List<CalendarSource>> fun calendars(): Flow<List<CalendarSource>>
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
/** One read of event [eventId]'s occurrences overlapping [range], soonest first, whatever calendar it is in. */
suspend fun eventOccurrences(eventId: Long, range: ClosedRange<Instant>): List<EventInstance>
/** Re-query every open [calendars] / [instances] flow. */ /** Re-query every open [calendars] / [instances] flow. */
fun refresh() fun refresh()
@@ -194,6 +194,11 @@ class CalendarRepositoryImpl @Inject constructor(
cachedCalendars cachedCalendars
} }
override suspend fun eventOccurrences(eventId: Long, range: ClosedRange<Instant>): List<EventInstance> =
withContext(io) {
dataSource.eventInstances(eventId, range.start.toEpochMillis(), range.endInclusive.toEpochMillis())
}
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) { override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
dataSource.eventDetail(eventId, allDayReminderTimeMinutes()) dataSource.eventDetail(eventId, allDayReminderTimeMinutes())
?: throw NoSuchEventException(eventId) ?: throw NoSuchEventException(eventId)
@@ -101,9 +101,10 @@ fun TimeZonePickerDialog(
onDismiss = onDismiss, onDismiss = onDismiss,
scrollable = false, scrollable = false,
) { ) {
SearchField( PickerSearchField(
query = query, query = query,
onQueryChange = { query = it }, onQueryChange = { query = it },
placeholder = stringResource(R.string.event_edit_timezone_search),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) )
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) { LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
@@ -181,9 +182,10 @@ fun TimeZonePickerDialog(
* which has a top bar to put it in) because the picker's bar is the title. * which has a top bar to put it in) because the picker's bar is the title.
*/ */
@Composable @Composable
private fun SearchField( internal fun PickerSearchField(
query: String, query: String,
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val keyboard = LocalSoftwareKeyboardController.current val keyboard = LocalSoftwareKeyboardController.current
@@ -206,7 +208,7 @@ private fun SearchField(
InlineTextField( InlineTextField(
value = query, value = query,
onValueChange = onQueryChange, onValueChange = onQueryChange,
placeholder = stringResource(R.string.event_edit_timezone_search), placeholder = placeholder,
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Search, imeAction = ImeAction.Search,
onImeAction = { keyboard?.hide() }, onImeAction = { keyboard?.hide() },
@@ -29,11 +29,10 @@ import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.widget.config.PlacedWidget import de.jeanlucmakiola.calendula.widget.config.PlacedWidget
import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity
import de.jeanlucmakiola.calendula.widget.config.WidgetKind import de.jeanlucmakiola.calendula.widget.WidgetKind
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import de.jeanlucmakiola.calendula.widget.config.backgroundLabel import de.jeanlucmakiola.calendula.widget.config.backgroundLabel
import de.jeanlucmakiola.calendula.widget.config.label
import de.jeanlucmakiola.calendula.widget.config.placedWidgets import de.jeanlucmakiola.calendula.widget.config.placedWidgets
import de.jeanlucmakiola.calendula.widget.config.themeLabel import de.jeanlucmakiola.calendula.widget.config.themeLabel
@@ -0,0 +1,44 @@
package de.jeanlucmakiola.calendula.widget
import android.annotation.SuppressLint
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
import de.jeanlucmakiola.floret.glance.restoreState
/**
* What every widget receiver shares: the midnight rollover follows whether any
* widget is placed (#228), a restore keeps each widget's settings (#292), and
* the system's `updatePeriodMillis` wake-up reloads the data and re-arms the
* alarm — anything that drops a pending alarm without a broadcast (force-stop,
* battery restriction, an OEM freeze) is repaired there rather than on the
* next app open.
*/
abstract class CalendulaWidgetReceiver : GlanceAppWidgetReceiver() {
override fun onEnabled(context: Context) {
super.onEnabled(context)
syncWidgetRollover(context)
}
override fun onDisabled(context: Context) {
super.onDisabled(context)
syncWidgetRollover(context)
}
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
restoreState(context, oldWidgetIds, newWidgetIds)
}
@SuppressLint("MissingSuperCall")
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
// In place of super, which only recomposes and would take the goAsync handle.
reloadOnUpdate(context, appWidgetIds)
syncWidgetRollover(context)
}
}
@@ -5,10 +5,12 @@ import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.glance.GlanceId import androidx.glance.GlanceId
import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.state.PreferencesGlanceStateDefinition
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.agenda.storageValue
@@ -50,6 +52,7 @@ private val AgendaRangeKey = stringPreferencesKey("cfg_agenda_range")
private val TextSizeKey = stringPreferencesKey("cfg_text_size") private val TextSizeKey = stringPreferencesKey("cfg_text_size")
private val ShowTodayKey = booleanPreferencesKey("cfg_show_today") private val ShowTodayKey = booleanPreferencesKey("cfg_show_today")
private val PastDisplayKey = stringPreferencesKey("cfg_past_display") private val PastDisplayKey = stringPreferencesKey("cfg_past_display")
private val HiddenCalendarsKey = stringSetPreferencesKey("cfg_hidden_calendars")
internal val Preferences.widgetAppearance: WidgetAppearance internal val Preferences.widgetAppearance: WidgetAppearance
get() = WidgetAppearance( get() = WidgetAppearance(
@@ -84,5 +87,19 @@ internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) {
this[PastDisplayKey] = options.pastDisplay.name this[PastDisplayKey] = options.pastDisplay.name
} }
/**
* Calendars this widget leaves out, on top of the ones hidden app-wide. Stored
* as the excluded set, so a calendar added later shows up by default.
*/
internal val Preferences.hiddenCalendars: Set<Long>
get() = this[HiddenCalendarsKey]?.mapNotNull { it.toLongOrNull() }?.toSet().orEmpty()
internal fun MutablePreferences.putHiddenCalendars(ids: Set<Long>) {
this[HiddenCalendarsKey] = ids.map(Long::toString).toSet()
}
internal fun List<EventInstance>.withoutCalendars(hidden: Set<Long>): List<EventInstance> =
if (hidden.isEmpty()) this else filterNot { it.calendarId in hidden }
internal suspend fun Context.widgetState(id: GlanceId): Preferences = internal suspend fun Context.widgetState(id: GlanceId): Preferences =
getAppWidgetState(this, PreferencesGlanceStateDefinition, id) getAppWidgetState(this, PreferencesGlanceStateDefinition, id)
@@ -96,6 +96,12 @@ internal val SettingsPrefs.widgetSettings: Flow<WidgetSettings>
internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault() internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
/**
* The locale widget text is written in. Glance has no LocalConfiguration, and a
* widget is redrawn on a locale change anyway (LOCALE_CHANGED), so a plain read is enough.
*/
internal fun widgetLocale(): Locale = Locale.getDefault()
internal fun today(zone: TimeZone): LocalDate = internal fun today(zone: TimeZone): LocalDate =
Clock.System.now().toLocalDateTime(zone).date Clock.System.now().toLocalDateTime(zone).date
@@ -145,8 +151,9 @@ sealed interface MonthWidgetSource {
/** [loadAgendaWidgetData] with agenda widget [id]'s own options. */ /** [loadAgendaWidgetData] with agenda widget [id]'s own options. */
internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed<AgendaWidgetData> { internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed<AgendaWidgetData> {
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first() val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
val options = widgetState(id).agendaWidgetOptions(settings.agendaDefaults) val state = widgetState(id)
return Themed(loadAgendaWidgetData(settings, options), settings.themeMode) val options = state.agendaWidgetOptions(settings.agendaDefaults)
return Themed(loadAgendaWidgetData(settings, options, state.hiddenCalendars), settings.themeMode)
} }
/** /**
@@ -157,6 +164,7 @@ internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed<AgendaWidget
internal suspend fun Context.loadAgendaWidgetData( internal suspend fun Context.loadAgendaWidgetData(
settings: WidgetSettings, settings: WidgetSettings,
options: AgendaWidgetOptions, options: AgendaWidgetOptions,
hiddenCalendars: Set<Long>,
): AgendaWidgetData { ): AgendaWidgetData {
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
val zone = systemZone() val zone = systemZone()
@@ -166,7 +174,7 @@ internal suspend fun Context.loadAgendaWidgetData(
val dayCount = options.range.dayCount(anchor, weekStart) val dayCount = options.range.dayCount(anchor, weekStart)
val window = agendaRange(anchor, dayCount - 1, zone) val window = agendaRange(anchor, dayCount - 1, zone)
val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY) val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY)
val instances = ep.calendarRepository().instances(window).first() val instances = ep.calendarRepository().instances(window).first().withoutCalendars(hiddenCalendars)
return AgendaWidgetData.Ready( return AgendaWidgetData.Ready(
today = anchor, today = anchor,
days = groupAgendaDays(anchor, windowEnd, instances, zone), days = groupAgendaDays(anchor, windowEnd, instances, zone),
@@ -214,15 +222,20 @@ private object MonthSourceCache {
/** [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */ /** [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */
internal suspend fun Context.loadCachedMonthSource(id: GlanceId, centerIndex: Int, stamp: Long): Themed<MonthWidgetSource> = internal suspend fun Context.loadCachedMonthSource(id: GlanceId, centerIndex: Int, stamp: Long): Themed<MonthWidgetSource> =
MonthSourceCache.get(id, centerIndex, stamp) MonthSourceCache.get(id, centerIndex, stamp)
?: loadThemedMonthSource(centerIndex).also { MonthSourceCache.put(id, centerIndex, stamp, it) } ?: loadThemedMonthSource(centerIndex, widgetState(id).hiddenCalendars)
.also { MonthSourceCache.put(id, centerIndex, stamp, it) }
/** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */ /** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */
internal suspend fun Context.loadThemedMonthSource(centerIndex: Int): Themed<MonthWidgetSource> { internal suspend fun Context.loadThemedMonthSource(centerIndex: Int, hiddenCalendars: Set<Long>): Themed<MonthWidgetSource> {
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first() val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
return Themed(queryMonthSource(centerIndex, settings), settings.themeMode) return Themed(queryMonthSource(centerIndex, settings, hiddenCalendars), settings.themeMode)
} }
private suspend fun Context.queryMonthSource(centerIndex: Int, settings: WidgetSettings): MonthWidgetSource { private suspend fun Context.queryMonthSource(
centerIndex: Int,
settings: WidgetSettings,
hiddenCalendars: Set<Long>,
): MonthWidgetSource {
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
val zone = systemZone() val zone = systemZone()
val ep = widgetEntryPoint() val ep = widgetEntryPoint()
@@ -239,7 +252,75 @@ private suspend fun Context.queryMonthSource(centerIndex: Int, settings: WidgetS
today = today(zone), today = today(zone),
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()), weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
soften = settings.soften, soften = settings.soften,
instances = ep.calendarRepository().instances(from..to).first(), instances = ep.calendarRepository().instances(from..to).first().withoutCalendars(hiddenCalendars),
months = months, months = months,
) )
} }
/** Today and the app theme, for a widget that draws no events. */
internal suspend fun Context.loadToday(): Themed<LocalDate> =
Themed(today(systemZone()), widgetEntryPoint().settingsPrefs().widgetSettings.first().themeMode)
/** What a widget drawing a run of days loads: those days' events, grouped like the agenda. */
sealed interface DaysWidgetData {
data object NeedsPermission : DaysWidgetData
data class Ready(
val today: LocalDate,
val firstDay: LocalDate,
val lastDay: LocalDate,
/** Only the days with events, in order. */
val days: List<AgendaDay>,
val weekStart: DayOfWeek,
val is24Hour: Boolean,
val soften: Boolean,
val options: AgendaWidgetOptions,
val now: Instant,
) : DaysWidgetData {
fun eventsOn(date: LocalDate): List<EventInstance> = days.firstOrNull { it.date == date }?.events.orEmpty()
}
}
/** Widget [id]'s stored options and calendars, with the app's values for what it has none of. */
internal class WidgetLoadContext(val settings: WidgetSettings, val options: AgendaWidgetOptions, val hiddenCalendars: Set<Long>)
internal suspend fun Context.widgetLoadContext(id: GlanceId): WidgetLoadContext {
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
val state = widgetState(id)
return WidgetLoadContext(settings, state.agendaWidgetOptions(settings.agendaDefaults), state.hiddenCalendars)
}
/** The events on the days [days] picks from today and the week start, for [load]'s widget. */
internal suspend fun Context.loadDays(
load: WidgetLoadContext,
days: (today: LocalDate, weekStart: DayOfWeek) -> ClosedRange<LocalDate>,
): Themed<DaysWidgetData> {
val settings = load.settings
if (!hasCalendarPermission()) return Themed(DaysWidgetData.NeedsPermission, settings.themeMode)
val zone = systemZone()
val anchor = today(zone)
val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
val range = days(anchor, weekStart)
val window = range.start.atStartOfDayIn(zone)..range.endInclusive.atTime(23, 59, 59).toInstant(zone)
val instances = widgetEntryPoint().calendarRepository().instances(window).first().withoutCalendars(load.hiddenCalendars)
return Themed(
DaysWidgetData.Ready(
today = anchor,
firstDay = range.start,
lastDay = range.endInclusive,
days = groupAgendaDays(range.start, range.endInclusive, instances, zone),
weekStart = weekStart,
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
soften = settings.soften,
options = load.options,
now = Clock.System.now(),
),
settings.themeMode,
)
}
/** The week [today] falls in, from [weekStart]. */
internal fun weekOf(today: LocalDate, weekStart: DayOfWeek): ClosedRange<LocalDate> {
val back = (today.dayOfWeek.ordinal - weekStart.ordinal + 7) % 7
val first = today.minus(back, DateTimeUnit.DAY)
return first..first.plus(6, DateTimeUnit.DAY)
}
@@ -0,0 +1,114 @@
package de.jeanlucmakiola.calendula.widget
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.action.clickable
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
import de.jeanlucmakiola.floret.glance.themedColor
/** Text and row sizes of a compact event list, from a widget's [WidgetSize]. */
internal data class EventRowMetrics(val title: TextUnit, val time: TextUnit, val rowHeight: Dp, val message: TextUnit)
/** The agenda widget's sizes with tighter rows, resolved against the system font scale. */
internal fun compactRowMetrics(size: WidgetSize, fontScale: Float): EventRowMetrics {
val agenda = metricsFor(size)
return EventRowMetrics(
title = agenda.eventTitle,
time = agenda.eventTime,
rowHeight = agenda.contentH * fontScale + agenda.rowVPad,
message = agenda.message,
)
}
/**
* One event as a two-line row in its calendar's fill, part of a grouped card
* at [position]; opens the occurrence. [quiet] draws it in the card colour
* instead, for events shown only as a look ahead.
*/
@Composable
internal fun WidgetEventRow(
event: EventInstance,
secondary: String,
soften: Boolean,
metrics: EventRowMetrics,
position: Position,
source: CalendarView,
dimmed: Boolean = false,
quiet: Boolean = false,
) {
val context = LocalContext.current
fun tone(dark: Boolean): Color = eventFill(event.color, dark, soften).let { if (dimmed) it.copy(alpha = EventDimAlpha) else it }
val day = tone(dark = false)
val night = tone(dark = true)
// Ink is picked against the opaque fill, then carries the dim along with it.
fun ink(fill: Color, alpha: Float) = eventInk(fill.copy(alpha = 1f), alpha * fill.alpha)
val card = widgetCardColor()
WidgetGroupedRow(position = position, color = if (quiet) card else themedColor(day = day, night = night)) {
Column(
modifier = GlanceModifier
.fillMaxWidth()
.height(metrics.rowHeight)
.padding(horizontal = 12.dp)
.clickable(
actionStartActivity(
MainActivity.openEventIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
source = source,
),
),
),
verticalAlignment = Alignment.CenterVertically,
) {
val titleInk = if (quiet) {
GlanceTheme.colors.onSurface
} else {
themedColor(day = ink(day, 1f), night = ink(night, 1f))
}
val timeInk = if (quiet) {
GlanceTheme.colors.onSurfaceVariant
} else {
themedColor(day = ink(day, SECONDARY_INK_ALPHA), night = ink(night, SECONDARY_INK_ALPHA))
}
Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1,
style = TextStyle(
color = titleInk,
fontSize = metrics.title,
fontWeight = FontWeight.Medium,
textDecoration = glanceDeclinedDecoration(event.isDeclined),
),
)
Text(text = secondary, maxLines = 1, style = TextStyle(color = timeInk, fontSize = metrics.time))
}
}
}
@@ -0,0 +1,120 @@
package de.jeanlucmakiola.calendula.widget
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.glance.appwidget.GlanceAppWidget
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidget
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetReceiver
import de.jeanlucmakiola.calendula.widget.date.DateWidget
import de.jeanlucmakiola.calendula.widget.date.DateWidgetReceiver
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidget
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetReceiver
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidget
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetReceiver
import de.jeanlucmakiola.calendula.widget.today.TodayWidget
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetReceiver
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidget
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetReceiver
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidget
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetReceiver
/** A content option a widget's config screen offers, besides background and theme. */
enum class WidgetOption { RANGE, TEXT_SIZE, PAST_DISPLAY, SHOW_TODAY, CALENDARS }
/**
* Every widget the app ships, told apart by their receiver: what redraws,
* the rollover and the config screen go through.
*/
enum class WidgetKind(
val receiver: Class<*>,
val label: Int,
val options: Set<WidgetOption>,
/** The size a config preview draws at before the launcher reports one. */
val defaultSize: DpSize,
) {
AGENDA(
AgendaWidgetReceiver::class.java,
R.string.widget_agenda_label,
setOf(WidgetOption.RANGE, WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.SHOW_TODAY, WidgetOption.CALENDARS),
DpSize(260.dp, 280.dp),
),
MONTH(
MonthWidgetReceiver::class.java,
R.string.widget_month_label,
setOf(WidgetOption.CALENDARS),
DpSize(320.dp, 340.dp),
),
QUICK_ADD(
QuickAddWidgetReceiver::class.java,
R.string.widget_quick_add_label,
emptySet(),
DpSize(76.dp, 76.dp),
),
DATE(
DateWidgetReceiver::class.java,
R.string.widget_date_label,
emptySet(),
DpSize(76.dp, 76.dp),
),
WEEK_STRIP(
WeekStripWidgetReceiver::class.java,
R.string.widget_week_strip_label,
setOf(WidgetOption.CALENDARS),
DpSize(300.dp, 90.dp),
),
UPCOMING(
UpcomingWidgetReceiver::class.java,
R.string.widget_upcoming_label,
setOf(WidgetOption.RANGE, WidgetOption.TEXT_SIZE, WidgetOption.CALENDARS),
DpSize(180.dp, 180.dp),
),
TODAY(
TodayWidgetReceiver::class.java,
R.string.widget_today_label,
setOf(WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.CALENDARS),
DpSize(300.dp, 100.dp),
),
COUNTDOWN(
CountdownWidgetReceiver::class.java,
R.string.widget_countdown_label,
setOf(WidgetOption.CALENDARS),
DpSize(200.dp, 100.dp),
),
MONTH_DAY(
MonthDayWidgetReceiver::class.java,
R.string.widget_month_day_label,
setOf(WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.CALENDARS),
DpSize(320.dp, 200.dp),
),
;
fun widget(): GlanceAppWidget = when (this) {
AGENDA -> AgendaWidget()
MONTH -> MonthWidget()
QUICK_ADD -> QuickAddWidget()
DATE -> DateWidget()
WEEK_STRIP -> WeekStripWidget()
UPCOMING -> UpcomingWidget()
TODAY -> TodayWidget()
COUNTDOWN -> CountdownWidget()
MONTH_DAY -> MonthDayWidget()
}
/** Whether this kind stores [AgendaWidgetOptions] of its own. */
val hasContentOptions: Boolean
get() = options.any { it != WidgetOption.CALENDARS }
companion object {
fun of(context: Context, appWidgetId: Int): WidgetKind? {
val provider = AppWidgetManager.getInstance(context)?.getAppWidgetInfo(appWidgetId)?.provider ?: return null
return entries.firstOrNull { it.receiver.name == provider.className }
}
}
}
@@ -0,0 +1,55 @@
package de.jeanlucmakiola.calendula.widget
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.glance.GlanceTheme
import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel
import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
import java.util.Locale
import kotlin.time.Instant
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
@Composable
internal fun widgetCardColor(): ColorProvider = when (LocalWidgetBackground.current) {
WidgetBackground.SOLID -> GlanceTheme.colors.surfaceVariant
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground
}
/** An event's time on [day]: "All day", "Starts 14:00", "Ends 9:00" or "14:00 – 15:30". */
internal fun widgetTimeLabel(context: Context, event: EventInstance, day: LocalDate, is24Hour: Boolean): String =
when (val label = agendaTimeLabel(event, day, systemZone())) {
AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day)
is AgendaTimeLabel.Starts -> context.getString(R.string.agenda_span_starts, formatWidgetTime(label.start, is24Hour))
is AgendaTimeLabel.Ends -> context.getString(R.string.agenda_span_ends, formatWidgetTime(label.end, is24Hour))
is AgendaTimeLabel.Range -> "${formatWidgetTime(label.start, is24Hour)} – ${formatWidgetTime(label.end, is24Hour)}"
}
internal fun formatWidgetTime(instant: Instant, is24Hour: Boolean): String {
val t = instant.toLocalDateTime(systemZone()).time
return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
}
/** "Today", "Tomorrow", or the short weekday within the coming week, else the short date. */
internal fun relativeDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
val locale = Locale.getDefault()
return when {
date == today -> context.getString(R.string.agenda_header_today)
date == today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
date < today.plus(7, DateTimeUnit.DAY) && date > today ->
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, locale)
else -> localizedDateFormatter(locale, "dMMM").format(date.toJavaLocalDate())
}
}
@@ -0,0 +1,177 @@
package de.jeanlucmakiola.calendula.widget
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.countdown.CountdownData
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetContent
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetReceiver
import de.jeanlucmakiola.calendula.widget.date.DateWidgetContent
import de.jeanlucmakiola.calendula.widget.date.DateWidgetReceiver
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetContent
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetReceiver
import de.jeanlucmakiola.calendula.widget.split.MonthDayData
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetContent
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetReceiver
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetContent
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetReceiver
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetContent
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetReceiver
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetContent
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetReceiver
import de.jeanlucmakiola.floret.glance.publishWidgetPreviews
import kotlinx.coroutines.flow.first
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import 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. */
internal suspend fun publishCalendulaWidgetPreviews(context: Context) {
publishWidgetPreviews(
context,
version = "${context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime}-${Locale.getDefault().toLanguageTag()}",
receivers = listOf(
AgendaWidgetReceiver::class,
MonthWidgetReceiver::class,
QuickAddWidgetReceiver::class,
DateWidgetReceiver::class,
WeekStripWidgetReceiver::class,
UpcomingWidgetReceiver::class,
TodayWidgetReceiver::class,
CountdownWidgetReceiver::class,
MonthDayWidgetReceiver::class,
),
)
}
/** The size [kind]'s picker preview is laid out at. */
internal fun previewSizeOf(kind: WidgetKind) = SizeMode.Responsive(setOf(kind.defaultSize))
/** [kind]'s picker preview: the real widget, drawn with sample events around today's morning. */
internal suspend fun GlanceAppWidget.provideSamplePreview(context: Context, kind: WidgetKind) {
val settings = context.widgetEntryPoint().settingsPrefs().widgetSettings.first()
val zone = systemZone()
val today = today(zone)
val now = today.atTime(8, 0).toInstant(zone)
val instances = context.sampleInstances(today, zone)
val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
val is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
val options = settings.agendaDefaults
val appearance = WidgetAppearance()
val index = monthIndexOf(today)
val month = MonthWidgetSource.Ready(today, weekStart, settings.soften, instances, index..index)
fun days(range: ClosedRange<LocalDate>) = DaysWidgetData.Ready(
today = today,
firstDay = range.start,
lastDay = range.endInclusive,
days = groupAgendaDays(range.start, range.endInclusive, instances, zone),
weekStart = weekStart,
is24Hour = is24Hour,
soften = settings.soften,
options = options,
now = now,
)
val theme = settings.themeMode
provideContent {
when (kind) {
WidgetKind.AGENDA -> AgendaWidgetContent(
AgendaWidgetData.Ready(
today = today,
days = groupAgendaDays(today, today.plus(6, DateTimeUnit.DAY), instances, zone),
is24Hour = is24Hour,
soften = settings.soften,
pastDisplay = options.pastDisplay,
showToday = options.showToday,
textSize = options.textSize,
now = now,
),
appearance,
theme,
preview = true,
)
WidgetKind.MONTH -> MonthWidgetContent(month, index, appearance, theme)
WidgetKind.MONTH_DAY -> MonthDayWidgetContent(
MonthDayData.Ready(month, options, is24Hour, now),
index,
selected = null,
appearance = appearance,
appTheme = theme,
preview = true,
)
WidgetKind.QUICK_ADD -> QuickAddWidgetContent(today, appearance, theme)
WidgetKind.DATE -> DateWidgetContent(today, appearance, theme)
WidgetKind.WEEK_STRIP -> WeekStripWidgetContent(days(weekOf(today, weekStart)), appearance, theme)
WidgetKind.UPCOMING -> UpcomingWidgetContent(days(today..today.plus(6, DateTimeUnit.DAY)), appearance, theme)
WidgetKind.TODAY -> TodayWidgetContent(days(today..today.plus(1, DateTimeUnit.DAY)), appearance, theme, preview = true)
WidgetKind.COUNTDOWN -> CountdownWidgetContent(
CountdownData.Ready(
options = CountdownOptions(),
event = instances.first { !it.isAllDay && it.start > now },
now = now,
today = today,
is24Hour = is24Hour,
soften = settings.soften,
appWidgetId = null,
),
appearance,
theme,
)
}
}
}
/** A plausible few weeks: a busy today, a trip tomorrow, and a scatter of days with something on. */
private fun Context.sampleInstances(today: LocalDate, zone: TimeZone): List<EventInstance> {
var id = 0L
fun timed(day: Int, at: LocalTime, length: kotlin.time.Duration, title: Int, color: Int): EventInstance {
val start = today.plus(day, DateTimeUnit.DAY).atTime(at).toInstant(zone)
id++
return EventInstance(id, id, 1, getString(title), start, start + length, false, color, null)
}
fun allDay(day: Int, title: Int, color: Int): EventInstance {
val date = today.plus(day, DateTimeUnit.DAY)
id++
return EventInstance(
id, id, 1, getString(title),
date.atStartOfDayIn(TimeZone.UTC), date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(TimeZone.UTC),
true, color, null,
)
}
return listOf(
timed(0, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue),
timed(0, LocalTime(12, 30), 1.hours, R.string.settings_past_events_sample_midday, Green),
timed(0, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple),
allDay(1, R.string.widget_preview_sample_trip, Orange),
timed(2, LocalTime(10, 0), 1.hours, R.string.widget_preview_sample_dentist, Red),
timed(-6, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue),
timed(-2, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple),
timed(5, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple),
timed(8, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue),
timed(12, LocalTime(12, 30), 1.hours, R.string.settings_past_events_sample_midday, Green),
).sortedBy { it.start }
}
private const val Blue = 0xFF039BE5.toInt()
private const val Green = 0xFF33B679.toInt()
private const val Purple = 0xFF8E24AA.toInt()
private const val Orange = 0xFFF4511E.toInt()
private const val Red = 0xFFD50000.toInt()
@@ -9,10 +9,6 @@ import android.os.SystemClock
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.ApplicationScope import de.jeanlucmakiola.calendula.data.di.ApplicationScope
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
import de.jeanlucmakiola.floret.glance.WidgetRefresher import de.jeanlucmakiola.floret.glance.WidgetRefresher
import de.jeanlucmakiola.floret.glance.WidgetRollover import de.jeanlucmakiola.floret.glance.WidgetRollover
import de.jeanlucmakiola.floret.glance.updatePlaced import de.jeanlucmakiola.floret.glance.updatePlaced
@@ -33,10 +29,7 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** Every widget receiver; the set [WidgetRollover] checks for placed instances. */ /** Every widget receiver; the set [WidgetRollover] checks for placed instances. */
internal val WIDGET_RECEIVER_CLASSES: List<Class<*>> = listOf( internal val WIDGET_RECEIVER_CLASSES: List<Class<*>> = WidgetKind.entries.map { it.receiver }
MonthWidgetReceiver::class.java,
AgendaWidgetReceiver::class.java,
)
/** The app's own midnight wake-up (#228). */ /** The app's own midnight wake-up (#228). */
internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER" internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
@@ -57,31 +50,28 @@ internal fun syncWidgetRollover(context: Context) =
/** /**
* Reloads and redraws every placed widget, a running session included: the * Reloads and redraws every placed widget, a running session included: the
* refresh stamp moves, and both widgets key their data load on it (#420). * refresh stamp moves, and every widget keys its data load on it (#420).
*/ */
internal suspend fun redrawWidgets(context: Context) { internal suspend fun redrawWidgets(context: Context) {
redrawAgendaWidgets(context) WidgetKind.entries.forEach { redrawWidgets(context, it) }
redrawMonthWidgets(context)
} }
private suspend fun redrawAgendaWidgets(context: Context) = internal suspend fun redrawWidgets(context: Context, kind: WidgetKind) =
AgendaWidget().updatePlaced(context, AgendaWidgetReceiver::class.java, bumpRefreshStamp = true) kind.widget().updatePlaced(context, kind.receiver, bumpRefreshStamp = true)
/** Redraws only the agenda widgets with no options of their own, which still follow the app's. */ /** Redraws only the widgets with no content options of their own, which still follow the app's. */
private suspend fun redrawUnconfiguredAgendaWidgets(context: Context) { private suspend fun redrawUnconfiguredWidgets(context: Context) {
val appContext = context.applicationContext val appContext = context.applicationContext
val ids = AppWidgetManager.getInstance(appContext) val manager = AppWidgetManager.getInstance(appContext) ?: return
?.getAppWidgetIds(ComponentName(appContext, AgendaWidgetReceiver::class.java)) ?: return
val glanceManager = GlanceAppWidgetManager(appContext) val glanceManager = GlanceAppWidgetManager(appContext)
ids.forEach { appWidgetId -> WidgetKind.entries.filter { it.hasContentOptions }.forEach { kind ->
val glanceId = glanceManager.getGlanceIdBy(appWidgetId) manager.getAppWidgetIds(ComponentName(appContext, kind.receiver)).forEach { appWidgetId ->
if (!appContext.widgetState(glanceId).hasAgendaOptions) AgendaWidget().updateWithRefresh(appContext, glanceId) val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
if (!appContext.widgetState(glanceId).hasAgendaOptions) kind.widget().updateWithRefresh(appContext, glanceId)
}
} }
} }
private suspend fun redrawMonthWidgets(context: Context) =
MonthWidget().updatePlaced(context, MonthWidgetReceiver::class.java, bumpRefreshStamp = true)
/** When the app last saw a calendar change of its own, for skipping the `PROVIDER_CHANGED` that echoes it. */ /** When the app last saw a calendar change of its own, for skipping the `PROVIDER_CHANGED` that echoes it. */
private val lastLocalChange = AtomicLong(0L) private val lastLocalChange = AtomicLong(0L)
@@ -97,7 +87,7 @@ internal fun isProviderEcho(): Boolean {
* the settings they draw with, so those don't wait for `PROVIDER_CHANGED`, * the settings they draw with, so those don't wait for `PROVIDER_CHANGED`,
* which arrives seconds late or not at all (#420, #229). Changes from outside * which arrives seconds late or not at all (#420, #229). Changes from outside
* the app are [WidgetUpdateReceiver]'s. The app's agenda options only redraw * the app are [WidgetUpdateReceiver]'s. The app's agenda options only redraw
* agenda widgets that have none of their own (#292). Started once from * widgets that have no content options of their own (#292). Started once from
* `CalendulaApp`. * `CalendulaApp`.
*/ */
@Singleton @Singleton
@@ -112,20 +102,17 @@ class WidgetRefreshCoordinator @Inject constructor(
/** Subscribes before returning, so a change made right after is never taken for the starting value. */ /** Subscribes before returning, so a change made right after is never taken for the starting value. */
fun start() { fun start() {
if (!started.compareAndSet(false, true)) return if (!started.compareAndSet(false, true)) return
val agenda = WidgetRefresher(scope) { redrawAgendaWidgets(context) } val all = WidgetKind.entries.map { kind -> WidgetRefresher(scope) { redrawWidgets(context, kind) } }
val unconfiguredAgenda = WidgetRefresher(scope) { redrawUnconfiguredAgendaWidgets(context) } val unconfigured = WidgetRefresher(scope) { redrawUnconfiguredWidgets(context) }
val month = WidgetRefresher(scope) { redrawMonthWidgets(context) }
watch(repository.localChanges) { watch(repository.localChanges) {
lastLocalChange.set(SystemClock.elapsedRealtime()) lastLocalChange.set(SystemClock.elapsedRealtime())
agenda.request() all.forEach { it.request() }
month.request()
} }
watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) { watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) {
agenda.request() all.forEach { it.request() }
month.request()
} }
watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) { watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) {
unconfiguredAgenda.request() unconfigured.request()
} }
} }
@@ -1,5 +1,8 @@
package de.jeanlucmakiola.calendula.widget.agenda package de.jeanlucmakiola.calendula.widget.agenda
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context import android.content.Context
@@ -45,13 +48,10 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel
import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.ui.common.eventInk
@@ -60,12 +60,12 @@ import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.WidgetSize import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.WidgetAppearance import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetCardColor
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
import de.jeanlucmakiola.calendula.widget.loadAgendaWidget import de.jeanlucmakiola.calendula.widget.loadAgendaWidget
import de.jeanlucmakiola.calendula.widget.widgetAppearance import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
import de.jeanlucmakiola.floret.glance.themedColor import de.jeanlucmakiola.floret.glance.themedColor
import de.jeanlucmakiola.floret.glance.wallpaperBacking import de.jeanlucmakiola.floret.glance.wallpaperBacking
@@ -79,11 +79,8 @@ import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today import de.jeanlucmakiola.calendula.widget.today
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import java.util.Locale import java.util.Locale
/** /**
@@ -101,6 +98,11 @@ class AgendaWidget : GlanceAppWidget() {
// the RemoteViews past the binder transaction limit. // the RemoteViews past the binder transaction limit.
override val sizeMode = SizeMode.Single override val sizeMode = SizeMode.Single
override val previewSizeMode = previewSizeOf(WidgetKind.AGENDA)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.AGENDA)
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val stamp = refreshStamp(context, id) val stamp = refreshStamp(context, id)
val widgetData = WidgetData(context.loadAgendaWidget(id), stamp) val widgetData = WidgetData(context.loadAgendaWidget(id), stamp)
@@ -217,7 +219,7 @@ private fun AgendaRows(
// Each day is one card of grouped rows (#71), with its date pill // Each day is one card of grouped rows (#71), with its date pill
// beside the first row. // beside the first row.
val positions = dayPositions(rows) val positions = dayPositions(rows)
val cardColor = agendaCardColor() val cardColor = widgetCardColor()
val item: @Composable (Int) -> Unit = { index -> val item: @Composable (Int) -> Unit = { index ->
val row = rows[index] val row = rows[index]
val position = positions[index] val position = positions[index]
@@ -305,13 +307,6 @@ private fun AgendaHeader() {
} }
} }
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
@Composable
private fun agendaCardColor(): ColorProvider = when (LocalWidgetBackground.current) {
WidgetBackground.SOLID -> GlanceTheme.colors.surfaceVariant
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground
}
/** The month name ("November", with its year outside the current one) above a new month's first day. */ /** The month name ("November", with its year outside the current one) above a new month's first day. */
@Composable @Composable
private fun MonthLabel(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) { private fun MonthLabel(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
@@ -470,8 +465,6 @@ private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
} }
} }
private fun zone(): TimeZone = systemZone()
/** "Today · Wed, 17 Jun" — relative word for today/tomorrow, else the date. */ /** "Today · Wed, 17 Jun" — relative word for today/tomorrow, else the date. */
private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String { private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
val relative = when (date) { val relative = when (date) {
@@ -503,20 +496,7 @@ private fun eventTimeSummary(
day: LocalDate, day: LocalDate,
is24Hour: Boolean, is24Hour: Boolean,
): String { ): String {
val time = when (val label = agendaTimeLabel(event, day, zone())) { val time = widgetTimeLabel(context, event, day, is24Hour)
AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day)
is AgendaTimeLabel.Starts ->
context.getString(R.string.agenda_span_starts, formatTime(label.start, is24Hour))
is AgendaTimeLabel.Ends ->
context.getString(R.string.agenda_span_ends, formatTime(label.end, is24Hour))
is AgendaTimeLabel.Range ->
"${formatTime(label.start, is24Hour)} – ${formatTime(label.end, is24Hour)}"
}
val location = event.location?.takeIf { it.isNotBlank() } val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time return if (location != null) "$time · $location" else time
} }
private fun formatTime(instant: Instant, is24Hour: Boolean): String {
val t = instant.toLocalDateTime(zone()).time
return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
}
@@ -1,54 +1,9 @@
package de.jeanlucmakiola.calendula.widget.agenda package de.jeanlucmakiola.calendula.widget.agenda
import android.annotation.SuppressLint
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
import de.jeanlucmakiola.floret.glance.restoreState
/** /** Host-facing receiver for the agenda widget; rendering is [AgendaWidget]'s. */
* Host-facing receiver for the agenda widget. Declared in the manifest with the class AgendaWidgetReceiver : CalendulaWidgetReceiver() {
* `appwidget_info_agenda` provider metadata; delegates all rendering to
* [AgendaWidget].
*/
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = AgendaWidget() override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
/** First agenda widget placed — start rolling "today" over at midnight (#228). */
override fun onEnabled(context: Context) {
super.onEnabled(context)
syncWidgetRollover(context)
}
/**
* Last agenda widget removed. [syncWidgetRollover] cancels only if
* no month widget is left either.
*/
override fun onDisabled(context: Context) {
super.onDisabled(context)
syncWidgetRollover(context)
}
/** A backup restore or device transfer renumbered the widgets: keep each one's settings (#292). */
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
restoreState(context, oldWidgetIds, newWidgetIds)
}
/**
* Self-heal on the system's own `updatePeriodMillis` wake-up — see
* [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
*/
@SuppressLint("MissingSuperCall")
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
// In place of super, which only recomposes and would take the goAsync handle.
reloadOnUpdate(context, appWidgetIds)
syncWidgetRollover(context)
}
} }
@@ -0,0 +1,178 @@
package de.jeanlucmakiola.calendula.widget.config
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.PickerSearchField
import de.jeanlucmakiola.calendula.widget.countdown.CountdownMode
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedChoiceRow
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
/** What the countdown widget counts to (#439): the next event, or one picked from a searchable list. */
@Composable
internal fun CountdownOptionsSection(
options: CountdownOptions,
candidates: CountdownCandidates,
onChange: (CountdownOptions) -> Unit,
onLoadCandidates: () -> Unit,
) {
var picking by rememberSaveable { mutableStateOf(false) }
GroupedSectionHeader(stringResource(R.string.widget_config_countdown))
GroupedChoiceRow(
title = stringResource(R.string.widget_config_countdown),
options = CountdownMode.entries,
selected = options.mode,
label = {
stringResource(
when (it) {
CountdownMode.NEXT -> R.string.widget_config_countdown_next
CountdownMode.PINNED -> R.string.widget_config_countdown_pinned
},
)
},
onSelect = { mode ->
onChange(options.copy(mode = mode))
if (mode == CountdownMode.PINNED && options.eventId == null) picking = true
},
position = if (options.mode == CountdownMode.PINNED) Position.Top else Position.Alone,
)
when (options.mode) {
CountdownMode.NEXT -> GroupedHint(stringResource(R.string.widget_config_countdown_next_hint), verticalPadding = 8.dp)
CountdownMode.PINNED -> GroupedRow(
title = stringResource(R.string.widget_config_countdown_event),
summary = options.title ?: stringResource(R.string.widget_config_countdown_event_none),
position = Position.Bottom,
onClick = { picking = true },
)
}
if (picking) {
LaunchedEffect(Unit) { onLoadCandidates() }
CountdownEventPicker(
candidates = candidates,
selectedEventId = options.eventId,
onRetry = onLoadCandidates,
onSelect = { candidate ->
onChange(options.copy(mode = CountdownMode.PINNED, eventId = candidate.event.eventId, title = candidate.event.title))
picking = false
},
onDismiss = { picking = false },
)
}
}
@Composable
private fun CountdownEventPicker(
candidates: CountdownCandidates,
selectedEventId: Long?,
onRetry: () -> Unit,
onSelect: (CountdownCandidate) -> Unit,
onDismiss: () -> Unit,
) {
var query by rememberSaveable { mutableStateOf("") }
FullScreenPicker(
title = stringResource(R.string.widget_config_countdown_event),
onDismiss = onDismiss,
predictiveBack = true,
scrollable = false,
) {
PickerSearchField(
query = query,
onQueryChange = { query = it },
placeholder = stringResource(R.string.widget_config_countdown_search),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
when (candidates) {
CountdownCandidates.Idle, CountdownCandidates.Loading -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
CountdownCandidates.Failed -> Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
PickerMessage(stringResource(R.string.widget_config_countdown_load_failed))
FilledTonalButton(onClick = onRetry, modifier = Modifier.padding(top = 16.dp)) {
Text(stringResource(R.string.state_retry))
}
}
is CountdownCandidates.Ready -> {
val locale = currentLocale()
val matches = remember(candidates, query) {
val needle = query.trim()
if (needle.isEmpty()) candidates.events
else candidates.events.filter { it.event.title.contains(needle, ignoreCase = true) }
}
if (matches.isEmpty()) {
PickerMessage(
stringResource(R.string.widget_config_countdown_empty),
Modifier.fillMaxWidth().padding(24.dp),
)
} else {
val zone = remember { TimeZone.currentSystemDefault() }
val formatter = remember(locale) { localizedDateFormatter(locale, "EEEdMMMy") }
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
itemsIndexed(matches, key = { _, it -> it.event.eventId }) { index, candidate ->
val date = formatter.format(candidate.event.start.toLocalDateTime(zone).date.toJavaLocalDate())
GroupedRow(
title = candidate.event.title.ifBlank { stringResource(R.string.event_untitled) },
summary = if (candidate.repeats) stringResource(R.string.widget_config_countdown_repeats, date) else date,
position = positionOf(index, matches.size),
selected = candidate.event.eventId == selectedEventId,
minHeight = 56.dp,
leading = { CalendarColorChip(candidate.event.color) },
onClick = { onSelect(candidate) },
)
}
}
}
}
}
}
}
@Composable
private fun PickerMessage(text: String, modifier: Modifier = Modifier) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = modifier,
)
}
@@ -5,6 +5,7 @@ import android.content.ComponentName
import android.content.Context import android.content.Context
import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.GlanceAppWidgetManager
import de.jeanlucmakiola.calendula.widget.WidgetAppearance import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.widgetAppearance import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetState import de.jeanlucmakiola.calendula.widget.widgetState
@@ -0,0 +1,60 @@
package de.jeanlucmakiola.calendula.widget.config
import androidx.compose.material3.Checkbox
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.positionOf
/** Which of the app's calendars one widget shows, grouped by account like the drawer's filter. */
@Composable
internal fun WidgetCalendarPicker(
calendars: List<CalendarSource>,
hidden: Set<Long>,
onSetShown: (Long, Boolean) -> Unit,
onDismiss: () -> Unit,
) {
FullScreenPicker(
title = stringResource(R.string.widget_config_calendars),
onDismiss = onDismiss,
predictiveBack = true,
) {
GroupedHint(stringResource(R.string.widget_config_calendars_hint), verticalPadding = 8.dp)
calendars.groupByAccount().forEach { group ->
GroupedSectionHeader(accountGroupTitle(group))
group.calendars.forEachIndexed { index, calendar ->
val shown = calendar.id !in hidden
GroupedRow(
title = calendar.displayName.orUnnamed(),
position = positionOf(index, group.calendars.size),
minHeight = 56.dp,
leading = { CalendarColorChip(calendar.color) },
trailing = { Checkbox(checked = shown, onCheckedChange = { onSetShown(calendar.id, it) }) },
onClick = { onSetShown(calendar.id, !shown) },
)
}
}
}
}
/** "All calendars", or how many of them the widget shows. */
@Composable
internal fun calendarsSummary(calendars: List<CalendarSource>, hidden: Set<Long>): String {
val shown = calendars.count { it.id !in hidden }
return if (shown == calendars.size) {
stringResource(R.string.widget_config_calendars_all)
} else {
pluralStringResource(R.plurals.widget_config_calendars_some, calendars.size, shown, calendars.size)
}
}
@@ -113,7 +113,10 @@ class WidgetConfigActivity : ComponentActivity() {
onSave = viewModel::save, onSave = viewModel::save,
onRetry = viewModel::retry, onRetry = viewModel::retry,
onAppearance = viewModel::setAppearance, onAppearance = viewModel::setAppearance,
onAgendaOptions = viewModel::setAgendaOptions, onContent = viewModel::setContent,
onCalendarShown = viewModel::setCalendarShown,
onCountdown = viewModel::setCountdown,
onLoadCountdownCandidates = viewModel::loadCountdownCandidates,
) )
} }
} }
@@ -42,11 +42,21 @@ import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.settings.pastEventDisplayLabelRes import de.jeanlucmakiola.calendula.ui.settings.pastEventDisplayLabelRes
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
import de.jeanlucmakiola.calendula.widget.WidgetAppearance import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.WidgetOption
import de.jeanlucmakiola.calendula.widget.WidgetSize import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.WidgetTheme import de.jeanlucmakiola.calendula.widget.WidgetTheme
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetContent
import de.jeanlucmakiola.calendula.widget.date.DateWidgetContent
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetContent
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetContent
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetContent
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetContent
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetContent
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.floret.components.GroupedChoiceRow import de.jeanlucmakiola.floret.components.GroupedChoiceRow
import de.jeanlucmakiola.floret.components.GroupedHint import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
@@ -54,6 +64,7 @@ import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.GroupedSwitchRow import de.jeanlucmakiola.floret.components.GroupedSwitchRow
import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.glance.GlanceWidgetPreview import de.jeanlucmakiola.floret.glance.GlanceWidgetPreview
import de.jeanlucmakiola.floret.glance.WallpaperWindow import de.jeanlucmakiola.floret.glance.WallpaperWindow
import de.jeanlucmakiola.floret.glance.WidgetBackground import de.jeanlucmakiola.floret.glance.WidgetBackground
@@ -69,7 +80,10 @@ internal fun WidgetConfigScreen(
onSave: () -> Unit, onSave: () -> Unit,
onRetry: () -> Unit, onRetry: () -> Unit,
onAppearance: (WidgetAppearance) -> Unit, onAppearance: (WidgetAppearance) -> Unit,
onAgendaOptions: (AgendaWidgetOptions) -> Unit, onContent: (AgendaWidgetOptions) -> Unit,
onCalendarShown: (Long, Boolean) -> Unit,
onCountdown: (CountdownOptions) -> Unit,
onLoadCountdownCandidates: () -> Unit,
) { ) {
val ready = state as? WidgetConfigUiState.Ready val ready = state as? WidgetConfigUiState.Ready
val surface = MaterialTheme.colorScheme.surface val surface = MaterialTheme.colorScheme.surface
@@ -143,6 +157,27 @@ internal fun WidgetConfigScreen(
AgendaWidgetContent(preview.data, state.appearance, state.appTheme, preview = true) AgendaWidgetContent(preview.data, state.appearance, state.appTheme, preview = true)
is WidgetPreviewData.Month -> is WidgetPreviewData.Month ->
MonthWidgetContent(preview.source, preview.index, state.appearance, state.appTheme) MonthWidgetContent(preview.source, preview.index, state.appearance, state.appTheme)
is WidgetPreviewData.MonthDay -> MonthDayWidgetContent(
preview.data,
preview.index,
selected = null,
appearance = state.appearance,
appTheme = state.appTheme,
preview = true,
)
is WidgetPreviewData.Countdown ->
CountdownWidgetContent(preview.data, state.appearance, state.appTheme)
is WidgetPreviewData.Days -> when (state.kind) {
WidgetKind.TODAY ->
TodayWidgetContent(preview.data, state.appearance, state.appTheme, preview = true)
WidgetKind.UPCOMING -> UpcomingWidgetContent(preview.data, state.appearance, state.appTheme)
else -> WeekStripWidgetContent(preview.data, state.appearance, state.appTheme)
}
is WidgetPreviewData.Today -> if (state.kind == WidgetKind.DATE) {
DateWidgetContent(preview.today, state.appearance, state.appTheme)
} else {
QuickAddWidgetContent(preview.today, state.appearance, state.appTheme)
}
} }
} }
} }
@@ -150,7 +185,10 @@ internal fun WidgetConfigScreen(
ConfigOptions( ConfigOptions(
state = state, state = state,
onAppearance = onAppearance, onAppearance = onAppearance,
onAgendaOptions = onAgendaOptions, onContent = onContent,
onCalendarShown = onCalendarShown,
onCountdown = onCountdown,
onLoadCountdownCandidates = onLoadCountdownCandidates,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.background(surface), .background(surface),
@@ -173,17 +211,14 @@ private fun PreviewFailed() {
) )
} }
internal val WidgetKind.label: Int
get() = when (this) {
WidgetKind.AGENDA -> R.string.widget_agenda_label
WidgetKind.MONTH -> R.string.widget_month_label
}
@Composable @Composable
private fun ConfigOptions( private fun ConfigOptions(
state: WidgetConfigUiState.Ready, state: WidgetConfigUiState.Ready,
onAppearance: (WidgetAppearance) -> Unit, onAppearance: (WidgetAppearance) -> Unit,
onAgendaOptions: (AgendaWidgetOptions) -> Unit, onContent: (AgendaWidgetOptions) -> Unit,
onCalendarShown: (Long, Boolean) -> Unit,
onCountdown: (CountdownOptions) -> Unit,
onLoadCountdownCandidates: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@@ -209,47 +244,80 @@ private fun ConfigOptions(
onSelect = { onAppearance(state.appearance.copy(theme = it)) }, onSelect = { onAppearance(state.appearance.copy(theme = it)) },
position = Position.Bottom, position = Position.Bottom,
) )
state.agenda?.let { options -> if (state.kind == WidgetKind.COUNTDOWN) {
AgendaOptions(options = options, state = state, onChange = onAgendaOptions) CountdownOptionsSection(
options = state.draft.countdown,
candidates = state.candidates,
onChange = onCountdown,
onLoadCandidates = onLoadCountdownCandidates,
)
}
if (state.kind.options.isNotEmpty()) {
ContentOptions(state = state, onChange = onContent, onCalendarShown = onCalendarShown)
} }
} }
} }
@Composable @Composable
private fun AgendaOptions( private fun ContentOptions(
options: AgendaWidgetOptions,
state: WidgetConfigUiState.Ready, state: WidgetConfigUiState.Ready,
onChange: (AgendaWidgetOptions) -> Unit, onChange: (AgendaWidgetOptions) -> Unit,
onCalendarShown: (Long, Boolean) -> Unit,
) { ) {
val options = state.draft.content
var showRange by rememberSaveable { mutableStateOf(false) } var showRange by rememberSaveable { mutableStateOf(false) }
var showSize by rememberSaveable { mutableStateOf(false) } var showSize by rememberSaveable { mutableStateOf(false) }
var showPast by rememberSaveable { mutableStateOf(false) } var showPast by rememberSaveable { mutableStateOf(false) }
var showCalendars by rememberSaveable { mutableStateOf(false) }
// Rows in WidgetOption order, each shaped by where it lands among the kind's own.
val rows = WidgetOption.entries.filter { it in state.kind.options }
fun position(option: WidgetOption) = positionOf(rows.indexOf(option), rows.size)
GroupedSectionHeader(stringResource(R.string.widget_config_content)) GroupedSectionHeader(stringResource(R.string.widget_config_content))
GroupedRow( rows.forEach { option ->
title = stringResource(R.string.widget_config_range), when (option) {
summary = agendaRangeLabel(options.range), WidgetOption.RANGE -> GroupedRow(
position = Position.Top, title = stringResource(R.string.widget_config_range),
onClick = { showRange = true }, summary = agendaRangeLabel(options.range),
) position = position(option),
GroupedRow( onClick = { showRange = true },
title = stringResource(R.string.widget_config_text_size), )
summary = widgetSizeLabel(options.textSize), WidgetOption.TEXT_SIZE -> GroupedRow(
position = Position.Middle, title = stringResource(R.string.widget_config_text_size),
onClick = { showSize = true }, summary = widgetSizeLabel(options.textSize),
) position = position(option),
GroupedRow( onClick = { showSize = true },
title = stringResource(R.string.settings_past_events), )
summary = stringResource(pastEventDisplayLabelRes(options.pastDisplay)), WidgetOption.PAST_DISPLAY -> GroupedRow(
position = Position.Middle, title = stringResource(R.string.settings_past_events),
onClick = { showPast = true }, summary = stringResource(pastEventDisplayLabelRes(options.pastDisplay)),
) position = position(option),
GroupedSwitchRow( onClick = { showPast = true },
title = stringResource(R.string.settings_agenda_show_today), )
checked = options.showToday, WidgetOption.SHOW_TODAY -> GroupedSwitchRow(
onCheckedChange = { onChange(options.copy(showToday = it)) }, title = stringResource(R.string.settings_agenda_show_today),
position = Position.Bottom, checked = options.showToday,
) onCheckedChange = { onChange(options.copy(showToday = it)) },
position = position(option),
)
WidgetOption.CALENDARS -> GroupedRow(
title = stringResource(R.string.widget_config_calendars),
summary = calendarsSummary(state.calendars, state.draft.hiddenCalendars),
position = position(option),
onClick = { showCalendars = true },
)
}
}
if (showCalendars) {
WidgetCalendarPicker(
calendars = state.calendars,
hidden = state.draft.hiddenCalendars,
onSetShown = onCalendarShown,
onDismiss = { showCalendars = false },
)
}
if (showRange) { if (showRange) {
AgendaRangePicker( AgendaRangePicker(
@@ -4,25 +4,44 @@ import android.appwidget.AppWidgetManager
import android.content.Context import android.content.Context
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
import de.jeanlucmakiola.calendula.widget.WidgetSettings
import de.jeanlucmakiola.calendula.widget.loadDays
import de.jeanlucmakiola.calendula.widget.weekOf
import de.jeanlucmakiola.calendula.widget.upcoming.loadUpcoming
import de.jeanlucmakiola.calendula.widget.today.loadTodayList
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.widget.countdown.CountdownData
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
import de.jeanlucmakiola.calendula.widget.countdown.countdownOptions
import de.jeanlucmakiola.calendula.widget.countdown.loadCountdown
import de.jeanlucmakiola.calendula.widget.countdown.putCountdownOptions
import de.jeanlucmakiola.calendula.widget.split.MonthDayData
import de.jeanlucmakiola.calendula.widget.split.loadMonthDay
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import de.jeanlucmakiola.calendula.widget.WidgetOption
import de.jeanlucmakiola.calendula.widget.hiddenCalendars
import de.jeanlucmakiola.calendula.widget.putHiddenCalendars
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
import de.jeanlucmakiola.calendula.widget.WidgetAppearance import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
import de.jeanlucmakiola.calendula.widget.loadThemedMonthSource import de.jeanlucmakiola.calendula.widget.loadThemedMonthSource
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
import de.jeanlucmakiola.calendula.widget.monthIndexOf import de.jeanlucmakiola.calendula.widget.monthIndexOf
import de.jeanlucmakiola.calendula.widget.monthWindowCenter import de.jeanlucmakiola.calendula.widget.monthWindowCenter
import de.jeanlucmakiola.calendula.widget.agendaWidgetOptions import de.jeanlucmakiola.calendula.widget.agendaWidgetOptions
@@ -48,33 +67,41 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import java.util.Locale import java.util.Locale
/** The widgets a config screen can edit, told apart by their receiver. */
enum class WidgetKind(val receiver: Class<*>) {
AGENDA(AgendaWidgetReceiver::class.java),
MONTH(MonthWidgetReceiver::class.java),
;
fun widget(): GlanceAppWidget = when (this) {
AGENDA -> AgendaWidget()
MONTH -> MonthWidget()
}
companion object {
fun of(context: Context, appWidgetId: Int): WidgetKind? {
val provider = AppWidgetManager.getInstance(context)?.getAppWidgetInfo(appWidgetId)?.provider ?: return null
return entries.firstOrNull { it.receiver.name == provider.className }
}
}
}
/** What the preview draws, loaded with the draft's options. */ /** What the preview draws, loaded with the draft's options. */
sealed interface WidgetPreviewData { sealed interface WidgetPreviewData {
data class Agenda(val data: AgendaWidgetData) : WidgetPreviewData data class Agenda(val data: AgendaWidgetData) : WidgetPreviewData
data class Month(val source: MonthWidgetSource, val index: Int) : WidgetPreviewData data class Month(val source: MonthWidgetSource, val index: Int) : WidgetPreviewData
data class Today(val today: LocalDate) : WidgetPreviewData
data class Days(val data: DaysWidgetData) : WidgetPreviewData
data class Countdown(val data: CountdownData) : WidgetPreviewData
data class MonthDay(val data: MonthDayData, val index: Int) : WidgetPreviewData
} }
/** The options being edited, applied to the preview as they change and written on save. */
data class WidgetDraft(
val appearance: WidgetAppearance,
/** The content options; stored only for a kind that [WidgetKind.hasContentOptions]. */
val content: AgendaWidgetOptions,
val hiddenCalendars: Set<Long>,
val countdown: CountdownOptions = CountdownOptions(),
)
/** One event or series the countdown can be pinned to, at its next occurrence. */
data class CountdownCandidate(val event: EventInstance, val repeats: Boolean)
/** The countdown's event list, loaded when its picker first opens. */
sealed interface CountdownCandidates {
data object Idle : CountdownCandidates
data object Loading : CountdownCandidates
data object Failed : CountdownCandidates
data class Ready(val events: List<CountdownCandidate>) : CountdownCandidates
}
private fun WidgetDraft.loadContext(settings: WidgetSettings) = WidgetLoadContext(settings, content, hiddenCalendars)
sealed interface WidgetConfigUiState { sealed interface WidgetConfigUiState {
data object Loading : WidgetConfigUiState data object Loading : WidgetConfigUiState
@@ -86,19 +113,22 @@ sealed interface WidgetConfigUiState {
data class Ready( data class Ready(
val kind: WidgetKind, val kind: WidgetKind,
val appearance: WidgetAppearance, val draft: WidgetDraft,
/** Null for a widget with no content options. */ /** The calendars the app shows, for the widget's own calendar choice. */
val agenda: AgendaWidgetOptions?, val calendars: List<CalendarSource>,
val appTheme: ThemeMode, val appTheme: ThemeMode,
val weekStart: DayOfWeek, val weekStart: DayOfWeek,
/** The widget's size on the home screen, which the preview is laid out at. */ /** The widget's size on the home screen, which the preview is laid out at. */
val placedSize: DpSize, val placedSize: DpSize,
/** Null until the first preview load lands. */ /** Null until the first preview load lands. */
val preview: WidgetPreviewData?, val preview: WidgetPreviewData?,
val candidates: CountdownCandidates = CountdownCandidates.Idle,
/** The first preview load failed, so there's nothing to show. */ /** The first preview load failed, so there's nothing to show. */
val previewFailed: Boolean = false, val previewFailed: Boolean = false,
val saving: Boolean = false, val saving: Boolean = false,
) : WidgetConfigUiState ) : WidgetConfigUiState {
val appearance: WidgetAppearance get() = draft.appearance
}
} }
/** One-time outcomes of a save, for the screen that's showing when they land. */ /** One-time outcomes of a save, for the screen that's showing when they land. */
@@ -113,6 +143,8 @@ enum class WidgetConfigEvent { SAVED, SAVE_FAILED }
class WidgetConfigViewModel @Inject constructor( class WidgetConfigViewModel @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val prefs: SettingsPrefs, private val prefs: SettingsPrefs,
private val calendarPrefs: CalendarPrefs,
private val repository: CalendarRepository,
savedState: SavedStateHandle, savedState: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
@@ -147,8 +179,13 @@ class WidgetConfigViewModel @Inject constructor(
val settings = prefs.widgetSettings.first() val settings = prefs.widgetSettings.first()
WidgetConfigUiState.Ready( WidgetConfigUiState.Ready(
kind = kind, kind = kind,
appearance = stored.widgetAppearance, draft = WidgetDraft(
agenda = if (kind == WidgetKind.AGENDA) stored.agendaWidgetOptions(settings.agendaDefaults) else null, appearance = stored.widgetAppearance,
content = stored.agendaWidgetOptions(settings.agendaDefaults),
hiddenCalendars = stored.hiddenCalendars,
countdown = stored.countdownOptions,
),
calendars = if (WidgetOption.CALENDARS in kind.options) shownCalendars() else emptyList(),
appTheme = settings.themeMode, appTheme = settings.themeMode,
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()), weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
placedSize = placedSize(kind), placedSize = placedSize(kind),
@@ -171,16 +208,56 @@ class WidgetConfigViewModel @Inject constructor(
val width = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) ?: 0 val width = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) ?: 0
val height = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) ?: 0 val height = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) ?: 0
if (width > 0 && height > 0) return DpSize(width.dp, height.dp) if (width > 0 && height > 0) return DpSize(width.dp, height.dp)
return when (kind) { return kind.defaultSize
WidgetKind.AGENDA -> DpSize(260.dp, 280.dp) }
WidgetKind.MONTH -> DpSize(320.dp, 340.dp)
/** The calendars the app itself shows; the ones hidden there never reach a widget anyway. */
private suspend fun shownCalendars(): List<CalendarSource> {
val hidden = calendarPrefs.hiddenCalendarIds.first()
return repository.calendars().first().filter { it.isVisibleInSystem && it.id !in hidden }
}
fun setAppearance(appearance: WidgetAppearance) = edit { it.copy(draft = it.draft.copy(appearance = appearance)) }
fun setContent(options: AgendaWidgetOptions) {
edit { it.copy(draft = it.draft.copy(content = options)) }
reloadPreview()
}
fun setCountdown(options: CountdownOptions) {
edit { it.copy(draft = it.draft.copy(countdown = options)) }
reloadPreview()
}
/** Loads the events the countdown can be pinned to, unless they're loaded or loading. */
fun loadCountdownCandidates() {
val ready = _state.value as? WidgetConfigUiState.Ready ?: return
if (ready.candidates is CountdownCandidates.Loading || ready.candidates is CountdownCandidates.Ready) return
edit { it.copy(candidates = CountdownCandidates.Loading) }
viewModelScope.launch {
val candidates = try {
val now = Clock.System.now()
val occurrences = repository.instances(now..now + 365.days).first().filter { it.end > now }
val perEvent = occurrences.groupBy { it.eventId }
CountdownCandidates.Ready(
perEvent.values
.map { CountdownCandidate(it.minBy { o -> o.start }, repeats = it.size > 1) }
.sortedBy { it.event.start },
)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
CountdownCandidates.Failed
}
edit { it.copy(candidates = candidates) }
} }
} }
fun setAppearance(appearance: WidgetAppearance) = edit { it.copy(appearance = appearance) } fun setCalendarShown(id: Long, shown: Boolean) {
edit {
fun setAgendaOptions(options: AgendaWidgetOptions) { val hidden = it.draft.hiddenCalendars
edit { it.copy(agenda = options) } it.copy(draft = it.draft.copy(hiddenCalendars = if (shown) hidden - id else hidden + id))
}
reloadPreview() reloadPreview()
} }
@@ -205,14 +282,28 @@ class WidgetConfigViewModel @Inject constructor(
} }
} }
private suspend fun loadPreview(ready: WidgetConfigUiState.Ready): WidgetPreviewData = when (ready.kind) { private suspend fun loadPreview(ready: WidgetConfigUiState.Ready): WidgetPreviewData {
WidgetKind.AGENDA -> { val draft = ready.draft
val settings = prefs.widgetSettings.first() val settings = prefs.widgetSettings.first()
WidgetPreviewData.Agenda(context.loadAgendaWidgetData(settings, requireNotNull(ready.agenda))) return when (ready.kind) {
} WidgetKind.AGENDA ->
WidgetKind.MONTH -> { WidgetPreviewData.Agenda(context.loadAgendaWidgetData(settings, draft.content, draft.hiddenCalendars))
val index = monthIndexOf(today(systemZone())) WidgetKind.MONTH -> {
WidgetPreviewData.Month(context.loadThemedMonthSource(monthWindowCenter(index)).content, index) val index = monthIndexOf(today(systemZone()))
val source = context.loadThemedMonthSource(monthWindowCenter(index), draft.hiddenCalendars)
WidgetPreviewData.Month(source.content, index)
}
WidgetKind.QUICK_ADD, WidgetKind.DATE -> WidgetPreviewData.Today(today(systemZone()))
WidgetKind.UPCOMING -> WidgetPreviewData.Days(context.loadUpcoming(draft.loadContext(settings)).content)
WidgetKind.TODAY -> WidgetPreviewData.Days(context.loadTodayList(draft.loadContext(settings)).content)
WidgetKind.MONTH_DAY -> {
val index = monthIndexOf(today(systemZone()))
val source = context.loadThemedMonthSource(monthWindowCenter(index), draft.hiddenCalendars)
WidgetPreviewData.MonthDay(context.loadMonthDay(source, draft.content).content, index)
}
WidgetKind.COUNTDOWN ->
WidgetPreviewData.Countdown(context.loadCountdown(draft.loadContext(settings), draft.countdown, null).content)
WidgetKind.WEEK_STRIP -> WidgetPreviewData.Days(context.loadDays(draft.loadContext(settings), ::weekOf).content)
} }
} }
@@ -225,8 +316,10 @@ class WidgetConfigViewModel @Inject constructor(
try { try {
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
ready.kind.widget().updateWithRefresh(context, glanceId) { state -> ready.kind.widget().updateWithRefresh(context, glanceId) { state ->
state.putAppearance(ready.appearance) state.putAppearance(ready.draft.appearance)
ready.agenda?.let { state.putAgendaOptions(it) } if (ready.kind.hasContentOptions) state.putAgendaOptions(ready.draft.content)
if (WidgetOption.CALENDARS in ready.kind.options) state.putHiddenCalendars(ready.draft.hiddenCalendars)
if (ready.kind == WidgetKind.COUNTDOWN) state.putCountdownOptions(ready.draft.countdown)
} }
_events.send(WidgetConfigEvent.SAVED) _events.send(WidgetConfigEvent.SAVED)
} catch (e: CancellationException) { } catch (e: CancellationException) {
@@ -0,0 +1,96 @@
package de.jeanlucmakiola.calendula.widget.countdown
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.isDeclined
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.math.ceil
import kotlin.math.roundToInt
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/** What a countdown widget counts down to (#439). */
enum class CountdownMode { NEXT, PINNED }
data class CountdownOptions(
val mode: CountdownMode = CountdownMode.NEXT,
/** The pinned event or series; a series follows its next occurrence. */
val eventId: Long? = null,
/** The pinned event's title when it was chosen, for the config screen. */
val title: String? = null,
)
enum class CountdownUnit { NOW, MINUTES, HOURS, DAYS }
/** "in 25 min", "ends in 3 h", "in 12 days": [running] counts to the end instead of the start. */
data class CountdownLabel(val running: Boolean, val unit: CountdownUnit, val amount: Int)
private fun target(event: EventInstance, now: Instant): Instant = if (event.start <= now) event.end else event.start
/**
* The label for [event] at [now]. Under a minute reads "now", under an hour
* counts minutes (rounded up), under a day whole hours (rounded), and beyond
* that calendar days in [zone], so "in 1 day" is always tomorrow.
*/
fun countdownLabel(event: EventInstance, now: Instant, zone: TimeZone): CountdownLabel {
val running = event.start <= now
val target = target(event, now)
val delta = target - now
return when {
delta < 1.minutes -> CountdownLabel(running, CountdownUnit.NOW, 0)
delta < 1.hours -> CountdownLabel(running, CountdownUnit.MINUTES, ceil(delta / 1.minutes).toInt())
delta < 24.hours -> CountdownLabel(running, CountdownUnit.HOURS, (delta / 1.hours).roundToInt().coerceAtLeast(1))
else -> {
val days = now.toLocalDateTime(zone).date.daysUntil(target.toLocalDateTime(zone).date)
CountdownLabel(running, CountdownUnit.DAYS, days.coerceAtLeast(1))
}
}
}
/**
* When [countdownLabel] next reads differently, so the widget redraws then
* and not before: every minute in the last hour, at each half hour within the
* day, and at midnight or the 24-hour mark beyond that.
*/
fun nextLabelChange(event: EventInstance, now: Instant, zone: TimeZone): Instant {
val target = target(event, now)
val label = countdownLabel(event, now, zone)
val lowerBound: Duration = when (label.unit) {
CountdownUnit.NOW -> Duration.ZERO
CountdownUnit.MINUTES -> (label.amount - 1).minutes
CountdownUnit.HOURS -> maxOf((label.amount - 0.5).hours, 1.hours)
CountdownUnit.DAYS -> {
val midnight = now.toLocalDateTime(zone).date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
return minOf(midnight, target - 24.hours).coerceAtLeast(now + 1.minutes)
}
}
return (target - lowerBound).coerceAtLeast(now + 1.minutes)
}
/**
* The next-event mode's pick: timed, 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? =
instances
.filter { !it.isAllDay && !it.isDeclined && it.end > now }
.minByOrNull { target(it, now) }
/** A pinned event's occurrence to count to: the running or next one. */
fun pickPinnedOccurrence(occurrences: List<EventInstance>, now: Instant, zone: TimeZone): EventInstance? =
occurrences.filter { it.counted(zone).end > now }.minByOrNull { it.start }
/**
* The span a countdown counts over. An all-day event is stored on UTC
* midnights; it starts and ends at local midnight of those dates instead.
*/
fun EventInstance.counted(zone: TimeZone): EventInstance = if (!isAllDay) this else copy(
start = start.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone),
end = end.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone),
)
@@ -0,0 +1,167 @@
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 de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.widget.Themed
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
import de.jeanlucmakiola.calendula.widget.hasCalendarPermission
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
import de.jeanlucmakiola.calendula.widget.widgetState
import de.jeanlucmakiola.calendula.widget.withoutCalendars
import de.jeanlucmakiola.floret.android.goAsync
import de.jeanlucmakiola.floret.glance.updateWithRefresh
import de.jeanlucmakiola.floret.prefs.toEnum
import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
private val ModeKey = stringPreferencesKey("cfg_countdown_mode")
private val EventKey = longPreferencesKey("cfg_countdown_event")
private val TitleKey = stringPreferencesKey("cfg_countdown_title")
internal val Preferences.countdownOptions: CountdownOptions
get() = CountdownOptions(this[ModeKey].toEnum(CountdownMode.NEXT), this[EventKey], this[TitleKey])
internal fun MutablePreferences.putCountdownOptions(options: CountdownOptions) {
this[ModeKey] = options.mode.name
if (options.eventId != null) this[EventKey] = options.eventId else remove(EventKey)
if (options.title != null) this[TitleKey] = options.title else remove(TitleKey)
}
/** How far ahead the next-event mode looks; past this it shows no event. */
private val NextEventHorizon = 30.days
/** How far ahead a pinned event's next occurrence is looked for. */
private val PinnedHorizon = 730.days
sealed interface CountdownData {
data object NeedsPermission : CountdownData
data class Ready(
val options: CountdownOptions,
/** The occurrence counted to; null when there is none to show. */
val event: EventInstance?,
val now: Instant,
val today: LocalDate,
val is24Hour: Boolean,
val soften: Boolean,
/** The placed widget, for opening its settings; null in a preview. */
val appWidgetId: Int?,
) : CountdownData
}
internal suspend fun Context.loadCountdown(
load: WidgetLoadContext,
options: CountdownOptions,
appWidgetId: Int?,
): Themed<CountdownData> {
val settings = load.settings
if (!hasCalendarPermission()) return Themed(CountdownData.NeedsPermission, settings.themeMode)
val now = Clock.System.now()
val repository = widgetEntryPoint().calendarRepository()
val event = when (options.mode) {
CountdownMode.NEXT ->
pickNextEvent(repository.instances(now..now + NextEventHorizon).first().withoutCalendars(load.hiddenCalendars), now)
CountdownMode.PINNED -> options.eventId?.let { id ->
pickPinnedOccurrence(repository.eventOccurrences(id, now - 1.days..now + PinnedHorizon), now, systemZone())
}
}
return Themed(
CountdownData.Ready(
options = options,
event = event,
now = now,
today = today(systemZone()),
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
soften = settings.soften,
appWidgetId = appWidgetId,
),
settings.themeMode,
)
}
/** [loadCountdown] for placed widget [id], arming its redraw for when the label next changes. */
internal suspend fun Context.loadPlacedCountdown(id: GlanceId): Themed<CountdownData> {
val appWidgetId = GlanceAppWidgetManager(this).getAppWidgetId(id)
val loaded = loadCountdown(widgetLoadContext(id), widgetState(id).countdownOptions, appWidgetId)
val event = (loaded.content as? CountdownData.Ready)?.event
if (event == null) {
CountdownTicks.cancel(this, appWidgetId)
} else {
val zone = systemZone()
CountdownTicks.schedule(this, 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.
*/
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)
}
}
}
}
@@ -0,0 +1,230 @@
package de.jeanlucmakiola.calendula.widget.countdown
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.res.Resources
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.action.Action
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity
import de.jeanlucmakiola.calendula.widget.formatWidgetTime
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetCardColor
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.themedColor
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.minus
import kotlinx.datetime.toLocalDateTime
/**
* Countdown (#439): to the next timed event, or to a chosen event or series
* ("Vacation · in 12 days"). Redraws when its label changes, see
* [nextLabelChange] and [CountdownTicks]. Tapping opens the event.
*/
class CountdownWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override val sizeMode = SizeMode.Single
override val previewSizeMode = previewSizeOf(WidgetKind.COUNTDOWN)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.COUNTDOWN)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val widgetData = WidgetData(context.loadPlacedCountdown(id), refreshStamp(context, id))
provideContent {
val loaded = widgetData.current(currentRefreshStamp()) { context.loadPlacedCountdown(id) }
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
internal fun CountdownWidgetContent(data: CountdownData, appearance: WidgetAppearance, appTheme: ThemeMode) {
CalendulaWidgetRoot(appearance, appTheme) {
val context = LocalContext.current
when (data) {
CountdownData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
is CountdownData.Ready -> {
val event = data.event
when {
event != null -> EventCountdown(event, data)
data.options.mode == CountdownMode.PINNED && data.options.eventId == null ->
Message(context.getString(R.string.widget_countdown_choose), configAction(context, data.appWidgetId))
data.options.mode == CountdownMode.PINNED ->
Message(context.getString(R.string.widget_countdown_over, data.options.title.orEmpty()), configAction(context, data.appWidgetId))
else -> Message(
context.getString(R.string.widget_upcoming_empty),
actionStartActivity(MainActivity.openViewIntent(context, null)),
)
}
}
}
}
}
private fun configAction(context: Context, appWidgetId: Int?): Action? =
appWidgetId?.takeIf { it != AppWidgetManager.INVALID_APPWIDGET_ID }
?.let { actionStartActivity(WidgetConfigActivity.intent(context, it)) }
@Composable
private fun Message(text: String, action: Action?) {
val modifier = if (action != null) GlanceModifier.clickable(action) else GlanceModifier
WidgetMessage(text, modifier = modifier)
}
@Composable
private fun EventCountdown(event: EventInstance, data: CountdownData.Ready) {
val context = LocalContext.current
val label = countdownLabel(event.counted(systemZone()), data.now, systemZone())
val open = actionStartActivity(
MainActivity.openEventIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
source = CalendarView.Agenda,
),
)
// Over a wallpaper the text sits on its own card, as every other widget's rows do.
val card = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
GlanceModifier.cornerRadius(16.dp).background(widgetCardColor())
} else {
GlanceModifier
}
Box(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) {
Row(
modifier = card.fillMaxSize().clickable(open).padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = GlanceModifier
.width(4.dp)
.fillMaxHeight()
.cornerRadius(2.dp)
.background(
themedColor(
day = eventAccent(event.color, dark = false, data.soften),
night = eventAccent(event.color, dark = true, data.soften),
),
),
) {}
Spacer(GlanceModifier.width(10.dp))
Column(modifier = GlanceModifier.defaultWeight(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp, fontWeight = FontWeight.Medium),
)
Text(
text = countdownText(context.resources, label),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 22.sp, fontWeight = FontWeight.Bold),
)
Text(
text = whenText(context, event, data),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
)
}
}
}
}
internal fun countdownText(res: Resources, label: CountdownLabel): String = when (label.unit) {
CountdownUnit.NOW -> res.getString(if (label.running) R.string.widget_countdown_ending_now else R.string.widget_countdown_now)
CountdownUnit.MINUTES -> res.getQuantityString(
if (label.running) R.plurals.widget_countdown_ends_minutes else R.plurals.widget_countdown_in_minutes,
label.amount,
label.amount,
)
CountdownUnit.HOURS -> res.getQuantityString(
if (label.running) R.plurals.widget_countdown_ends_hours else R.plurals.widget_countdown_in_hours,
label.amount,
label.amount,
)
CountdownUnit.DAYS -> when {
label.amount == 1 && label.running -> res.getString(R.string.widget_countdown_ends_tomorrow)
label.amount == 1 -> res.getString(R.string.widget_countdown_tomorrow)
else -> res.getQuantityString(
if (label.running) R.plurals.widget_countdown_ends_days else R.plurals.widget_countdown_in_days,
label.amount,
label.amount,
)
}
}
/** When it starts, or ends once running: "Today · 14:00", "Fri · All day". */
private fun whenText(context: Context, event: EventInstance, data: CountdownData.Ready): String {
val counted = event.counted(systemZone())
val running = counted.start <= data.now
val at = if (running) counted.end else counted.start
val date = at.toLocalDateTime(systemZone()).date
val day = relativeDayLabel(context, date, data.today)
return when {
event.isAllDay && 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)}"
running -> context.getString(R.string.agenda_span_ends, "$day · ${formatWidgetTime(at, data.is24Hour)}")
else -> "$day · ${formatWidgetTime(at, data.is24Hour)}"
}
}
@@ -0,0 +1,130 @@
package de.jeanlucmakiola.calendula.widget.date
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.provideContent
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.semantics.contentDescription
import androidx.glance.semantics.semantics
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.loadToday
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.floret.glance.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
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.wallpaperBacking
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.datetime.LocalDate
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate
import java.time.format.TextStyle as JavaTextStyle
/** 1×1 date (#376): today's weekday above its number on an expressive shape; opens today. */
class DateWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override val sizeMode = SizeMode.Exact
override val previewSizeMode = previewSizeOf(WidgetKind.DATE)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.DATE)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val widgetData = WidgetData(context.loadToday(), refreshStamp(context, id))
provideContent {
val loaded = widgetData.current(currentRefreshStamp()) { context.loadToday() }
DateWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
}
}
}
class DateWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = DateWidget()
}
private val WeekdayLine = 18.dp
@Composable
internal fun DateWidgetContent(today: LocalDate, appearance: WidgetAppearance, appTheme: ThemeMode) {
CalendulaWidgetRoot(appearance, appTheme) {
val context = LocalContext.current
val size = LocalSize.current
val fontScale = context.resources.configuration.fontScale
val side = (minOf(size.width, size.height - WeekdayLine * fontScale) - 8.dp).coerceIn(32.dp, 120.dp)
val label = localizedDateFormatter(widgetLocale(), "EEEEdMMMM").format(today.toJavaLocalDate())
val weekday = TextStyle(
color = GlanceTheme.colors.onSurface,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
)
Column(
modifier = GlanceModifier
.fillMaxSize()
.clickable(actionStartActivity(MainActivity.openDateIntent(context, today, CalendarView.Day)))
.semantics { contentDescription = label },
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
val name = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, widgetLocale())
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
GlanceModifier.wallpaperBacking(radius = 8.dp).padding(horizontal = 6.dp)
} else {
GlanceModifier
}
Text(text = name.uppercase(widgetLocale()), maxLines = 1, style = weekday, modifier = backing)
Spacer(GlanceModifier.height(2.dp))
WidgetShapedBox(shape = WidgetShape.COOKIE, color = GlanceTheme.colors.primary, size = side) {
Text(
text = today.day.toString(),
maxLines = 1,
style = TextStyle(
color = GlanceTheme.colors.onPrimary,
// Sized to the shape, so held against the font scale.
fontSize = (side.value * 0.45f / fontScale).sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
),
)
}
}
}
}
@@ -1,5 +1,8 @@
package de.jeanlucmakiola.calendula.widget.month package de.jeanlucmakiola.calendula.widget.month
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.content.Context import android.content.Context
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
@@ -43,7 +46,6 @@ import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height import androidx.glance.layout.height
import androidx.glance.layout.padding import androidx.glance.layout.padding
import androidx.glance.layout.size import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.state.PreferencesGlanceStateDefinition
import de.jeanlucmakiola.floret.glance.LocalWidgetThemeMode import de.jeanlucmakiola.floret.glance.LocalWidgetThemeMode
import de.jeanlucmakiola.floret.glance.WidgetColorRole import de.jeanlucmakiola.floret.glance.WidgetColorRole
@@ -136,6 +138,11 @@ class MonthWidget : GlanceAppWidget() {
*/ */
override val sizeMode = SizeMode.Exact override val sizeMode = SizeMode.Exact
override val previewSizeMode = previewSizeOf(WidgetKind.MONTH)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.MONTH)
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val shown = getAppWidgetState<Preferences>(context, id)[MONTH_INDEX_KEY] val shown = getAppWidgetState<Preferences>(context, id)[MONTH_INDEX_KEY]
?: currentMonthIndex(systemZone()) ?: currentMonthIndex(systemZone())
@@ -239,8 +246,6 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
is MonthWidgetSource.Ready -> { is MonthWidgetSource.Ready -> {
val zone = systemZone() val zone = systemZone()
val ym = yearMonthOf(index) val ym = yearMonthOf(index)
// Column width from the live widget size, minus our H padding.
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone) val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
val context = LocalContext.current val context = LocalContext.current
val fontScale = context.resources.configuration.fontScale val fontScale = context.resources.configuration.fontScale
@@ -261,7 +266,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
// each carry their own fill, so a transparent widget stays // each carry their own fill, so a transparent widget stays
// readable without one blob over the wallpaper (#292). // readable without one blob over the wallpaper (#292).
Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) { Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
WeekdayHeader(weekStart = source.weekStart, colW = colW) WeekdayHeader(weekStart = source.weekStart)
AndroidRemoteViews( AndroidRemoteViews(
remoteViews = monthGridViews(context, layouts, ym.month, source.today, source.soften, colors, mode), remoteViews = monthGridViews(context, layouts, ym.month, source.today, source.soften, colors, mode),
modifier = GlanceModifier.fillMaxWidth().defaultWeight(), modifier = GlanceModifier.fillMaxWidth().defaultWeight(),
@@ -315,10 +320,10 @@ private fun MonthHeader(label: String, today: LocalDate) {
} }
@Composable @Composable
private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) { private fun WeekdayHeader(weekStart: DayOfWeek) {
Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 1.dp)) { Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 1.dp)) {
weekdayNarrowNames(weekStart).forEach { name -> weekdayNarrowNames(weekStart).forEach { name ->
Box(modifier = GlanceModifier.width(colW).padding(horizontal = 1.dp)) { Box(modifier = GlanceModifier.defaultWeight().padding(horizontal = 1.dp)) {
Text( Text(
text = name, text = name,
style = TextStyle( style = TextStyle(
@@ -1,56 +1,9 @@
package de.jeanlucmakiola.calendula.widget.month package de.jeanlucmakiola.calendula.widget.month
import android.annotation.SuppressLint
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
import de.jeanlucmakiola.floret.glance.restoreState
/** /** Host-facing receiver for the month widget; rendering is [MonthWidget]'s. */
* Host-facing receiver for the month widget. Declared in the manifest with the class MonthWidgetReceiver : CalendulaWidgetReceiver() {
* `appwidget_info_month` provider metadata; delegates rendering to [MonthWidget].
*/
class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = MonthWidget() override val glanceAppWidget: GlanceAppWidget = MonthWidget()
/** First month widget placed — start rolling "today" over at midnight (#228). */
override fun onEnabled(context: Context) {
super.onEnabled(context)
syncWidgetRollover(context)
}
/**
* Last month widget removed. [syncWidgetRollover] cancels only if
* no agenda widget is left either.
*/
override fun onDisabled(context: Context) {
super.onDisabled(context)
syncWidgetRollover(context)
}
/** A backup restore or device transfer renumbered the widgets: keep each one's settings (#292). */
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
restoreState(context, oldWidgetIds, newWidgetIds)
}
/**
* The `updatePeriodMillis` backstop is the one wake-up the *system* still
* owns, so it doubles as the alarm's self-heal: anything that drops a
* pending alarm without a broadcast (force-stop, battery restriction, an OEM
* freeze) is repaired here rather than on the next app open. Re-arming
* closer to midnight also narrows the inexact delivery window.
*/
@SuppressLint("MissingSuperCall")
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
// In place of super, which only recomposes and would take the goAsync handle.
reloadOnUpdate(context, appWidgetIds)
syncWidgetRollover(context)
}
} }
@@ -0,0 +1,92 @@
package de.jeanlucmakiola.calendula.widget.quickadd
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.datastore.preferences.core.Preferences
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.provideContent
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.fillMaxSize
import androidx.glance.state.PreferencesGlanceStateDefinition
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.loadToday
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.WidgetShape
import de.jeanlucmakiola.floret.glance.WidgetShapedIconButton
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.datetime.LocalDate
/**
* 1×1 quick add (#435): one shaped "+" that opens the new-event editor on
* today, with the same defaults as the app's own new-event button.
*/
class QuickAddWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
// Exact: the shape fills whatever square the launcher gives it.
override val sizeMode = SizeMode.Exact
override val previewSizeMode = previewSizeOf(WidgetKind.QUICK_ADD)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.QUICK_ADD)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val widgetData = WidgetData(context.loadToday(), refreshStamp(context, id))
provideContent {
// The rollover moves the stamp, so the editor opens on the new day.
val loaded = widgetData.current(currentRefreshStamp()) { context.loadToday() }
QuickAddWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
}
}
}
class QuickAddWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = QuickAddWidget()
}
@Composable
internal fun QuickAddWidgetContent(today: LocalDate, appearance: WidgetAppearance, appTheme: ThemeMode) {
CalendulaWidgetRoot(appearance, appTheme) {
val context = LocalContext.current
val size = LocalSize.current
val action = actionStartActivity(MainActivity.openCreateIntent(context, today))
Box(
modifier = GlanceModifier.fillMaxSize().clickable(action),
contentAlignment = Alignment.Center,
) {
WidgetShapedIconButton(
shape = WidgetShape.SUNNY,
icon = ImageProvider(R.drawable.ic_widget_add),
contentDescription = context.getString(R.string.widget_new_event),
action = action,
color = GlanceTheme.colors.primary,
iconTint = GlanceTheme.colors.onPrimary,
size = (minOf(size.width, size.height) - 12.dp).coerceIn(36.dp, 120.dp),
)
}
}
}
@@ -0,0 +1,451 @@
package de.jeanlucmakiola.calendula.widget.split
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
import de.jeanlucmakiola.calendula.widget.Themed
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.WidgetEventRow
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
import de.jeanlucmakiola.calendula.widget.compactRowMetrics
import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource
import de.jeanlucmakiola.calendula.widget.monthIndexOf
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
import de.jeanlucmakiola.calendula.widget.weekOf
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.calendula.widget.widgetCardColor
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import de.jeanlucmakiola.calendula.widget.widgetSettings
import de.jeanlucmakiola.calendula.widget.widgetState
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.toJavaLocalDate
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.glance.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
import de.jeanlucmakiola.floret.glance.WidgetHeaderActionGap
import de.jeanlucmakiola.floret.glance.WidgetHeaderTextAction
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.wallpaperBacking
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.coroutines.flow.first
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
import kotlin.time.Clock
import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle
/** Per-widget state: the shown month (`year * 12 + monthOrdinal`) and the selected day; both follow today when absent. */
private val MONTH_INDEX_KEY = intPreferencesKey("split_month_index")
private val SELECTED_KEY = stringPreferencesKey("split_selected")
/** What the month + day widget draws: the month widget's window, plus what its list needs. */
sealed interface MonthDayData {
data object NeedsPermission : MonthDayData
data class Ready(
val source: MonthWidgetSource.Ready,
val options: AgendaWidgetOptions,
val is24Hour: Boolean,
val now: Instant,
) : MonthDayData
}
/** [loaded]'s month window with what the day list draws by. */
internal suspend fun Context.loadMonthDay(loaded: Themed<MonthWidgetSource>, options: AgendaWidgetOptions): Themed<MonthDayData> {
val ready = loaded.content as? MonthWidgetSource.Ready ?: return Themed(MonthDayData.NeedsPermission, loaded.appTheme)
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
return Themed(
MonthDayData.Ready(
source = ready,
options = options,
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
now = Clock.System.now(),
),
loaded.appTheme,
)
}
/**
* Month + day (#441): a compact month grid beside the selected day's events,
* like Samsung Calendar's. A day in the grid selects it, starting on today;
* the list and its events open the app. The plain month widget keeps
* tap-opens-the-day (#386).
*/
class MonthDayWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override val sizeMode = SizeMode.Exact
override val previewSizeMode = previewSizeOf(WidgetKind.MONTH_DAY)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.MONTH_DAY)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val stamp = refreshStamp(context, id)
val shown = context.widgetState(id)[MONTH_INDEX_KEY] ?: monthIndexOf(today(systemZone()))
val center = monthWindowCenter(shown)
val load: suspend (Int, Long) -> Themed<MonthDayData> = { c, s ->
context.loadMonthDay(context.loadCachedMonthSource(id, c, s), context.widgetLoadContext(id).options)
}
val widgetData = WidgetData(load(center, stamp), center, stamp)
provideContent {
val index = currentState(MONTH_INDEX_KEY) ?: monthIndexOf(today(systemZone()))
val currentCenter = monthWindowCenter(index)
val currentStamp = currentRefreshStamp()
val loaded = widgetData.current(currentCenter, currentStamp) { load(currentCenter, currentStamp) }
val selected = currentState(SELECTED_KEY)?.let(LocalDate::parse)
MonthDayWidgetContent(loaded.content, index, selected, currentState<Preferences>().widgetAppearance, loaded.appTheme)
}
}
}
class MonthDayWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = MonthDayWidget()
}
/** Selects the tapped day; the list redraws from what's loaded. */
class SelectDayAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val date = parameters[dateKey] ?: return
updateAppWidgetState(context, glanceId) { prefs ->
if (date == today(systemZone()).toString()) prefs.remove(SELECTED_KEY) else prefs[SELECTED_KEY] = date
}
MonthDayWidget().update(context.applicationContext, glanceId)
}
companion object {
val dateKey = ActionParameters.Key<String>("date")
}
}
/** Pages the grid by the `delta` parameter; landing on the current month follows the date again. */
class ShiftMonthDayAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val delta = parameters[deltaKey] ?: 0
val current = monthIndexOf(today(systemZone()))
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
}
MonthDayWidget().update(context.applicationContext, glanceId)
}
companion object {
val deltaKey = ActionParameters.Key<Int>("delta")
}
}
/** Back to this month with today selected. */
class ResetMonthDayAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
updateAppWidgetState(context, glanceId) { prefs ->
prefs.remove(MONTH_INDEX_KEY)
prefs.remove(SELECTED_KEY)
}
MonthDayWidget().update(context.applicationContext, glanceId)
}
}
/** The grid's days: whole weeks from [weekStart] covering [index]'s month. */
internal fun monthGridDays(index: Int, weekStart: DayOfWeek): List<LocalDate> {
val first = LocalDate(index / 12, index % 12 + 1, 1)
val start = weekOf(first, weekStart).start
val last = first.plus(1, DateTimeUnit.MONTH).plus(-1, DateTimeUnit.DAY)
val weeks = (start.daysUntilInclusive(last) + 6) / 7
return List(weeks * 7) { start.plus(it, DateTimeUnit.DAY) }
}
private fun LocalDate.daysUntilInclusive(other: LocalDate): Int =
(other.toEpochDays() - toEpochDays()).toInt() + 1
private val GridGap = 8.dp
@Composable
internal fun MonthDayWidgetContent(
data: MonthDayData,
index: Int,
selected: LocalDate?,
appearance: WidgetAppearance,
appTheme: ThemeMode,
preview: Boolean = false,
) {
CalendulaWidgetRoot(appearance, appTheme) {
val context = LocalContext.current
when (data) {
MonthDayData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
is MonthDayData.Ready -> {
val source = data.source
val day = selected ?: source.today
val zone = systemZone()
val days = monthGridDays(index, source.weekStart)
val busy = groupAgendaDays(days.first(), days.last(), source.instances, zone).map { it.date }.toSet()
Column(modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 6.dp)) {
Header(index, source.today)
Spacer(GlanceModifier.height(4.dp))
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
val gridWidth = (LocalSize.current.width - 16.dp - GridGap) * 0.52f
Box(modifier = GlanceModifier.width(gridWidth).fillMaxHeight()) {
Grid(days, index, source, day, busy, gridWidth)
}
Spacer(GlanceModifier.width(GridGap))
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) {
DayList(day, data, preview)
}
}
}
}
}
}
}
@Composable
private fun Header(index: Int, today: LocalDate) {
val context = LocalContext.current
WidgetHeader(
title = formatCalendarTitle(
date = java.time.LocalDate.of(index / 12, index % 12 + 1, 1),
locale = widgetLocale(),
currentYear = today.year,
skeleton = "LLLL",
),
titleAction = actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Month)),
modifier = GlanceModifier.padding(bottom = 2.dp),
titleInset = 4.dp,
) {
WidgetHeaderAction(
icon = ImageProvider(R.drawable.ic_widget_chevron_left),
contentDescription = context.getString(R.string.widget_prev_month),
action = actionRunCallback<ShiftMonthDayAction>(actionParametersOf(ShiftMonthDayAction.deltaKey to -1)),
style = WidgetActionStyle.TONAL,
)
WidgetHeaderActionGap()
WidgetHeaderTextAction(
text = today.day.toString(),
contentDescription = context.getString(R.string.widget_today),
action = actionRunCallback<ResetMonthDayAction>(),
style = WidgetActionStyle.TONAL,
)
WidgetHeaderActionGap()
WidgetHeaderAction(
icon = ImageProvider(R.drawable.ic_widget_chevron_right),
contentDescription = context.getString(R.string.widget_next_month),
action = actionRunCallback<ShiftMonthDayAction>(actionParametersOf(ShiftMonthDayAction.deltaKey to 1)),
style = WidgetActionStyle.TONAL,
)
WidgetHeaderActionGap()
WidgetHeaderAction(
icon = ImageProvider(R.drawable.ic_widget_add),
contentDescription = context.getString(R.string.widget_new_event),
action = actionStartActivity(MainActivity.openCreateIntent(context, today)),
)
}
}
/**
* The month as numbers: selected day filled, today in bold primary, days
* with events dotted, days around the month faded. Three views a day keeps
* a six-week month well inside Glance's view ids (#372).
*/
@Composable
private fun Grid(
days: List<LocalDate>,
index: Int,
source: MonthWidgetSource.Ready,
selected: LocalDate,
busy: Set<LocalDate>,
width: Dp,
) {
val locale = widgetLocale()
val month = index % 12 + 1
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
GlanceModifier.cornerRadius(14.dp).background(widgetCardColor())
} else {
GlanceModifier
}
Column(modifier = backing.fillMaxSize().padding(2.dp)) {
Row(modifier = GlanceModifier.fillMaxWidth()) {
for (i in 0 until 7) {
Text(
text = java.time.DayOfWeek.of((source.weekStart.ordinal + i) % 7 + 1).getDisplayName(JavaTextStyle.NARROW, locale),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp, textAlign = TextAlign.Center),
modifier = GlanceModifier.width((width - 4.dp) / 7),
)
}
}
days.chunked(7).forEach { week ->
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight(), verticalAlignment = Alignment.CenterVertically) {
week.forEach { date ->
DayCell(date, date.month.ordinal + 1 == month, date == source.today, date == selected, date in busy, (width - 4.dp) / 7)
}
}
}
}
}
@Composable
private fun DayCell(date: LocalDate, inMonth: Boolean, isToday: Boolean, isSelected: Boolean, hasEvents: Boolean, width: Dp) {
val ink = when {
isSelected -> GlanceTheme.colors.onPrimary
isToday -> GlanceTheme.colors.primary
inMonth -> GlanceTheme.colors.onSurface
else -> GlanceTheme.colors.outline
}
// Always set: the host reapplies onto reused views, so a dropped background would linger.
val fill = if (isSelected) GlanceTheme.colors.primary else ColorProvider(Color.Transparent)
Column(
modifier = GlanceModifier.cornerRadius(10.dp).background(fill)
.width(width)
.fillMaxHeight()
.clickable(actionRunCallback<SelectDayAction>(actionParametersOf(SelectDayAction.dateKey to date.toString()))),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = date.day.toString(),
maxLines = 1,
style = TextStyle(
color = ink,
fontSize = 11.sp,
fontWeight = if (isToday || isSelected) FontWeight.Bold else FontWeight.Normal,
textAlign = TextAlign.Center,
),
)
Text(
text = if (hasEvents) "•" else " ",
maxLines = 1,
style = TextStyle(color = if (isSelected) ink else GlanceTheme.colors.primary, fontSize = 7.sp, textAlign = TextAlign.Center),
)
}
}
@Composable
private fun DayList(day: LocalDate, data: MonthDayData.Ready, preview: Boolean) {
val context = LocalContext.current
val source = data.source
val zone = systemZone()
val events = groupAgendaDays(day, day, source.instances, zone).firstOrNull()?.events.orEmpty()
.let { all -> if (data.options.pastDisplay == PastEventDisplay.HIDE) all.filterNot { it.hasEnded(data.now) } else all }
val metrics = compactRowMetrics(data.options.textSize, context.resources.configuration.fontScale)
val openDay = actionStartActivity(MainActivity.openDateIntent(context, day, CalendarView.Month))
Column(modifier = GlanceModifier.fillMaxSize()) {
val date = localizedDateFormatter(widgetLocale(), "EEEdMMM").format(day.toJavaLocalDate())
val label = when (day) {
source.today, source.today.plus(1, DateTimeUnit.DAY) -> "${relativeDayLabel(context, day, source.today)} · $date"
else -> date
}
Text(
text = label,
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = metrics.time, fontWeight = FontWeight.Medium),
modifier = GlanceModifier
.padding(bottom = 4.dp)
.wallpaperBacking(radius = 8.dp)
.padding(horizontal = 6.dp, vertical = 2.dp)
.clickable(openDay),
)
if (events.isEmpty()) {
Box(
modifier = GlanceModifier.fillMaxSize().cornerRadius(14.dp).background(widgetCardColor()).clickable(openDay),
contentAlignment = Alignment.Center,
) {
Text(
text = context.getString(R.string.widget_day_empty),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.time, textAlign = TextAlign.Center),
modifier = GlanceModifier.padding(8.dp),
)
}
} else {
val row: @Composable (Int) -> Unit = { i ->
val event = events[i]
WidgetEventRow(
event = event,
secondary = widgetTimeLabel(context, event, day, data.is24Hour),
soften = source.soften,
metrics = metrics,
position = positionOf(i, events.size),
source = CalendarView.Month,
dimmed = data.options.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now),
)
}
if (preview) {
Column(modifier = GlanceModifier.fillMaxSize()) { repeat(minOf(events.size, 8)) { row(it) } }
} else {
LazyColumn(modifier = GlanceModifier.fillMaxSize()) { items(events.size) { row(it) } }
}
}
}
}
@@ -0,0 +1,252 @@
package de.jeanlucmakiola.calendula.widget.today
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.semantics.contentDescription
import androidx.glance.semantics.semantics
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
import de.jeanlucmakiola.calendula.widget.EventRowMetrics
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.WidgetEventRow
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
import de.jeanlucmakiola.calendula.widget.compactRowMetrics
import de.jeanlucmakiola.calendula.widget.loadDays
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.glance.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
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.wallpaperBacking
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate
import java.time.format.TextStyle as JavaTextStyle
/**
* Today list (#438): only today's events beside today's date pill. Once
* they're over, or with none at all, it says so and shows tomorrow's first
* one or two events in a quieter style.
*/
class TodayWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
// Exact: whether one or two of tomorrow's events fit follows the height.
override val sizeMode = SizeMode.Exact
override val previewSizeMode = previewSizeOf(WidgetKind.TODAY)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.TODAY)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val widgetData = WidgetData(context.loadTodayList(context.widgetLoadContext(id)), refreshStamp(context, id))
provideContent {
val loaded = widgetData.current(currentRefreshStamp()) { context.loadTodayList(context.widgetLoadContext(id)) }
TodayWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
}
}
}
class TodayWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = TodayWidget()
}
internal suspend fun Context.loadTodayList(load: WidgetLoadContext) = loadDays(load) { today, _ ->
today..today.plus(1, DateTimeUnit.DAY)
}
private val PillWidth = 44.dp
private val RowGap = 2.dp
/** What the list side shows: today's events, or the look ahead once none are left. */
internal sealed interface TodayRows {
data class Today(val events: List<EventInstance>) : TodayRows
data class NothingLeft(val tomorrow: List<EventInstance>) : TodayRows
}
internal fun todayRows(data: DaysWidgetData.Ready): TodayRows {
val today = data.eventsOn(data.today)
if (today.none { !it.hasEnded(data.now) }) {
return TodayRows.NothingLeft(data.eventsOn(data.today.plus(1, DateTimeUnit.DAY)))
}
val shown = if (data.options.pastDisplay == PastEventDisplay.HIDE) today.filterNot { it.hasEnded(data.now) } else today
return TodayRows.Today(shown)
}
/** The whole widget, shared with its config preview, which draws its list as a plain column. */
@Composable
internal fun TodayWidgetContent(
data: DaysWidgetData,
appearance: WidgetAppearance,
appTheme: ThemeMode,
preview: Boolean = false,
) {
CalendulaWidgetRoot(appearance, appTheme) {
val context = LocalContext.current
when (data) {
DaysWidgetData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
is DaysWidgetData.Ready -> Row(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) {
TodayPill(data.today)
Spacer(GlanceModifier.width(6.dp))
val metrics = compactRowMetrics(data.options.textSize, context.resources.configuration.fontScale)
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) {
when (val rows = todayRows(data)) {
is TodayRows.Today -> TodayList(rows.events, data, metrics, preview)
is TodayRows.NothingLeft -> NothingLeft(rows.tomorrow, data, metrics)
}
}
}
}
}
}
@Composable
private fun TodayList(events: List<EventInstance>, data: DaysWidgetData.Ready, metrics: EventRowMetrics, preview: Boolean) {
val context = LocalContext.current
val row: @Composable (Int) -> Unit = { index ->
val event = events[index]
WidgetEventRow(
event = event,
secondary = widgetTimeLabel(context, event, data.today, data.is24Hour),
soften = data.soften,
metrics = metrics,
position = positionOf(index, events.size),
source = CalendarView.Day,
dimmed = data.options.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now),
)
}
if (preview) {
val fits = ((LocalSize.current.height - 12.dp + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceAtLeast(1)
Column(modifier = GlanceModifier.fillMaxSize()) { repeat(minOf(events.size, fits)) { row(it) } }
} else {
LazyColumn(modifier = GlanceModifier.fillMaxSize()) { items(events.size) { row(it) } }
}
}
/** "Nothing left today", then as many of tomorrow's first two events as the height holds. */
@Composable
private fun NothingLeft(tomorrow: List<EventInstance>, data: DaysWidgetData.Ready, metrics: EventRowMetrics) {
val context = LocalContext.current
val fontScale = context.resources.configuration.fontScale
val lineHeight = 20.dp * fontScale
val room = LocalSize.current.height - 12.dp - lineHeight
val fits = ((room + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceIn(0, 2)
val ahead = tomorrow.take(fits)
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
GlanceModifier.wallpaperBacking(radius = 8.dp).padding(horizontal = 8.dp)
} else {
GlanceModifier.padding(horizontal = 4.dp)
}
Column(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = context.getString(R.string.widget_nothing_left_today),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.title, fontWeight = FontWeight.Medium),
modifier = backing.height(lineHeight),
)
val tomorrowDate = data.today.plus(1, DateTimeUnit.DAY)
val tomorrowLabel = relativeDayLabel(context, tomorrowDate, data.today)
ahead.forEachIndexed { index, event ->
WidgetEventRow(
event = event,
secondary = "$tomorrowLabel · ${widgetTimeLabel(context, event, tomorrowDate, data.is24Hour)}",
soften = data.soften,
metrics = metrics,
position = positionOf(index, ahead.size),
source = CalendarView.Day,
quiet = true,
)
}
}
}
/** Today's weekday and number, filled like the agenda's today pill; opens today. */
@Composable
private fun TodayPill(today: LocalDate) {
val context = LocalContext.current
val locale = widgetLocale()
Column(
modifier = GlanceModifier
.width(PillWidth)
.fillMaxHeight()
.cornerRadius(PillWidth / 2)
.background(GlanceTheme.colors.primary)
.clickable(actionStartActivity(MainActivity.openDateIntent(context, today, CalendarView.Day)))
.semantics { contentDescription = localizedDateFormatter(locale, "EEEEdMMMM").format(today.toJavaLocalDate()) },
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, locale),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onPrimary, fontSize = 11.sp, textAlign = TextAlign.Center),
)
Text(
text = today.day.toString(),
maxLines = 1,
style = TextStyle(
color = GlanceTheme.colors.onPrimary,
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
),
)
}
}
@@ -0,0 +1,188 @@
package de.jeanlucmakiola.calendula.widget.upcoming
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.provideContent
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.WidgetEventRow
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
import de.jeanlucmakiola.calendula.widget.compactRowMetrics
import de.jeanlucmakiola.calendula.widget.loadDays
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.glance.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
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.wallpaperBacking
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
import java.time.format.TextStyle as JavaTextStyle
/**
* 2×2 upcoming (#437): today's date on top, then the next events across the
* widget's range in compact rows, as many as its height holds. The date opens
* the agenda, an event opens itself.
*/
class UpcomingWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
// Exact: how many rows show follows the height.
override val sizeMode = SizeMode.Exact
override val previewSizeMode = previewSizeOf(WidgetKind.UPCOMING)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.UPCOMING)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val widgetData = WidgetData(context.loadUpcoming(context.widgetLoadContext(id)), refreshStamp(context, id))
provideContent {
val loaded = widgetData.current(currentRefreshStamp()) { context.loadUpcoming(context.widgetLoadContext(id)) }
UpcomingWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
}
}
}
class UpcomingWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = UpcomingWidget()
}
internal suspend fun Context.loadUpcoming(load: WidgetLoadContext) = loadDays(load) { today, weekStart ->
today..today.plus(load.options.range.dayCount(today, weekStart) - 1, DateTimeUnit.DAY)
}
/** Most rows a tall widget lists; past this it is an agenda, which is its own widget. */
private const val MAX_ROWS = 8
private val RowGap = 2.dp
/** Every event still to come or running, each once, on the first day it shows. */
internal fun upcomingEvents(data: DaysWidgetData.Ready): List<Pair<LocalDate, EventInstance>> =
data.days
.flatMap { day -> day.events.map { day.date to it } }
.filterNot { (_, event) -> event.hasEnded(data.now) }
.distinctBy { (_, event) -> event.instanceId to event.start }
@Composable
internal fun UpcomingWidgetContent(data: DaysWidgetData, appearance: WidgetAppearance, appTheme: ThemeMode) {
CalendulaWidgetRoot(appearance, appTheme) {
val context = LocalContext.current
when (data) {
DaysWidgetData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
is DaysWidgetData.Ready -> {
val fontScale = context.resources.configuration.fontScale
val metrics = compactRowMetrics(data.options.textSize, fontScale)
val headerHeight = 44.dp * fontScale
val room = LocalSize.current.height - 16.dp - headerHeight
val fits = ((room + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceIn(1, MAX_ROWS)
val events = upcomingEvents(data).take(fits)
Column(modifier = GlanceModifier.fillMaxSize().padding(8.dp)) {
DateHeader(data.today, headerHeight)
if (events.isEmpty()) {
WidgetMessage(context.getString(R.string.widget_upcoming_empty))
} else {
events.forEachIndexed { index, (day, event) ->
val time = widgetTimeLabel(context, event, day, data.is24Hour)
WidgetEventRow(
event = event,
secondary = if (day == data.today) time else "${relativeDayLabel(context, day, data.today)} · $time",
soften = data.soften,
metrics = metrics,
position = positionOf(index, events.size),
source = CalendarView.Agenda,
)
}
}
}
}
}
}
}
/** Today's number beside its weekday and month; opens the agenda. */
@Composable
private fun DateHeader(today: LocalDate, height: Dp) {
val context = LocalContext.current
val locale = widgetLocale()
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
GlanceModifier.wallpaperBacking(radius = 12.dp).padding(horizontal = 8.dp)
} else {
GlanceModifier.padding(horizontal = 4.dp)
}
Box(modifier = GlanceModifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
Row(
modifier = backing.clickable(actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Agenda))),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = today.day.toString(),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 28.sp, fontWeight = FontWeight.Bold),
)
Spacer(GlanceModifier.width(8.dp))
Column {
Text(
text = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.FULL, locale),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 13.sp, fontWeight = FontWeight.Medium),
)
Text(
text = java.time.Month.of(today.month.ordinal + 1).getDisplayName(JavaTextStyle.FULL_STANDALONE, locale),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
)
}
}
}
}
@@ -0,0 +1,196 @@
package de.jeanlucmakiola.calendula.widget.week
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.glance.ColorFilter
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.semantics.contentDescription
import androidx.glance.semantics.semantics
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.loadDays
import de.jeanlucmakiola.calendula.widget.weekOf
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.calendula.widget.widgetCardColor
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.themedColor
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate
import java.time.format.TextStyle as JavaTextStyle
/**
* 4×1 week strip (#436): the current week's seven days from the app's week
* start, today filled, a dot per event colour under each day. A day opens
* its day view.
*/
class WeekStripWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override val sizeMode = SizeMode.Single
override val previewSizeMode = previewSizeOf(WidgetKind.WEEK_STRIP)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.WEEK_STRIP)
override suspend fun provideGlance(context: Context, id: GlanceId) {
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)
}
}
}
class WeekStripWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = WeekStripWidget()
}
private suspend fun Context.loadWeek(id: GlanceId) = loadDays(widgetLoadContext(id), ::weekOf)
/** Most dots a day shows; more colours than this read as noise at this size. */
private const val MAX_DOTS = 3
private val DayGap = 4.dp
private val DotSize = 5.dp
@Composable
internal fun WeekStripWidgetContent(data: DaysWidgetData, appearance: WidgetAppearance, appTheme: ThemeMode) {
CalendulaWidgetRoot(appearance, appTheme) {
when (data) {
DaysWidgetData.NeedsPermission ->
WidgetMessage(LocalContext.current.getString(R.string.widget_needs_permission))
is DaysWidgetData.Ready -> Row(
modifier = GlanceModifier.fillMaxSize().padding(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
for (i in 0 until 7) {
if (i > 0) Spacer(GlanceModifier.width(DayGap))
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) {
DayTile(data.firstDay.plus(i, DateTimeUnit.DAY), data)
}
}
}
}
}
}
@Composable
private fun DayTile(date: LocalDate, data: DaysWidgetData.Ready) {
val context = LocalContext.current
val isToday = date == data.today
val container = if (isToday) GlanceTheme.colors.primary else widgetCardColor()
val ink = when {
isToday -> GlanceTheme.colors.onPrimary
date < data.today -> GlanceTheme.colors.onSurfaceVariant
else -> GlanceTheme.colors.onSurface
}
val locale = widgetLocale()
val colors = data.eventsOn(date).map { it.color }.distinct()
val label = localizedDateFormatter(locale, "EEEEdMMMM").format(date.toJavaLocalDate())
Column(
modifier = GlanceModifier
.fillMaxSize()
.cornerRadius(14.dp)
.background(container)
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Week)))
.semantics {
contentDescription = if (colors.isEmpty()) label else context.resources.getQuantityString(
R.plurals.widget_day_with_events, data.eventsOn(date).size, label, data.eventsOn(date).size,
)
},
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = date.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, locale),
maxLines = 1,
style = TextStyle(color = ink, fontSize = 11.sp, textAlign = TextAlign.Center),
)
Text(
text = date.day.toString(),
maxLines = 1,
style = TextStyle(color = ink, fontSize = 17.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center),
)
Spacer(GlanceModifier.height(3.dp))
// A dot row is always drawn, so the numbers line up across days.
Row(modifier = GlanceModifier.height(DotSize)) {
colors.take(MAX_DOTS).forEachIndexed { index, color ->
if (index > 0) Spacer(GlanceModifier.width(3.dp))
Dot(dotColor(color, isToday, data.soften))
}
}
}
}
/** A mark on the tile: the accent tone on a card, the lighter fill tone on today's primary. */
@Composable
private fun dotColor(color: Int, onPrimary: Boolean, soften: Boolean): ColorProvider = if (onPrimary) {
themedColor(day = eventFill(color, dark = false, soften), night = eventFill(color, dark = true, soften))
} else {
themedColor(day = eventAccent(color, dark = false, soften), night = eventAccent(color, dark = true, soften))
}
@Composable
private fun Dot(color: ColorProvider) {
Image(
provider = ImageProvider(R.drawable.widget_dot),
contentDescription = null,
colorFilter = ColorFilter.tint(color),
modifier = GlanceModifier.size(DotSize),
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" <shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"> android:shape="rectangle">
<solid android:color="#4F7CAC" /> <solid android:color="#FFFFFF" />
<corners android:radius="3dp" /> <corners android:radius="3dp" />
</shape> </shape>
@@ -0,0 +1,6 @@
<?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_cell" />
<corners android:radius="12dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="14dp" />
</shape>
@@ -0,0 +1,6 @@
<?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>
@@ -0,0 +1,6 @@
<?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_primary" />
<corners android:radius="14dp" />
</shape>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FFFFFFFF" />
</shape>
+149 -87
View File
@@ -1,124 +1,186 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker (android:previewLayout). Not the <!-- Static mock shown in the widget picker on Android 14 and below
runtime layout — the live widget is rendered by Glance. Colours are the (android:previewLayout); 15+ shows the generated preview instead. -->
brand light scheme so the picker preview reads on its light sheet. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical"
android:background="@drawable/preview_widget_bg" android:background="@drawable/preview_widget_bg"
android:padding="14dp"> android:orientation="vertical"
android:padding="8dp"
<TextView tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent">
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/widget_agenda_title"
android:textColor="@color/widget_preview_primary"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/agenda_header_today"
android:textColor="@color/widget_preview_primary"
android:textSize="12sp"
android:textStyle="bold" />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="10dp" android:gravity="center_vertical"
android:orientation="horizontal" android:orientation="horizontal">
android:gravity="center_vertical"> <TextView
<ImageView android:layout_width="0dp"
android:layout_width="5dp" android:layout_height="wrap_content"
android:layout_height="34dp" android:layout_weight="1"
android:contentDescription="@null" android:maxLines="1"
android:background="@drawable/preview_stripe" android:paddingHorizontal="4dp"
android:backgroundTint="#4F7CAC" /> android:text="@string/widget_agenda_title"
android:textColor="@color/widget_preview_primary"
android:textSize="15sp"
android:textStyle="bold" />
<TextView style="@style/WidgetPreviewAddButton" android:text="+" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:orientation="horizontal">
<TextView
android:layout_width="40dp"
android:layout_height="match_parent"
android:background="@drawable/preview_widget_today_bg"
android:gravity="center_horizontal"
android:paddingTop="6dp"
android:text="Mon&#10;26"
android:textColor="@color/widget_preview_on_primary"
android:textSize="13sp"
tools:ignore="HardcodedText" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:orientation="vertical">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="10dp" android:layout_marginTop="0dp"
android:orientation="vertical"> android:background="@drawable/preview_widget_event_bg"
android:backgroundTint="@color/widget_preview_event_blue"
android:orientation="vertical"
android:paddingHorizontal="10dp"
android:paddingVertical="3dp">
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Standup" android:ellipsize="end"
android:textColor="@color/widget_preview_on_surface" android:maxLines="1"
android:textSize="14sp" /> android:text="@string/settings_past_events_sample_morning"
android:textColor="@color/widget_preview_on_event_light"
android:textSize="12sp"
android:textStyle="bold" />
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="09:00 – 09:30" android:ellipsize="end"
android:textColor="@color/widget_preview_variant" android:maxLines="1"
android:textSize="12sp" /> android:text="9:30 – 10:00"
android:textColor="@color/widget_preview_on_event_light"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:background="@drawable/preview_widget_event_bg"
android:backgroundTint="@color/widget_preview_event_green"
android:orientation="vertical"
android:paddingHorizontal="10dp"
android:paddingVertical="3dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/settings_past_events_sample_midday"
android:textColor="@color/widget_preview_on_event_light"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="12:30 – 13:30"
android:textColor="@color/widget_preview_on_event_light"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:background="@drawable/preview_widget_event_bg"
android:backgroundTint="@color/widget_preview_event_purple"
android:orientation="vertical"
android:paddingHorizontal="10dp"
android:paddingVertical="3dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/settings_past_events_sample_evening"
android:textColor="@color/widget_preview_on_event_dark"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="19:00 – 21:00"
android:textColor="@color/widget_preview_on_event_dark"
android:textSize="11sp" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="6dp"
android:orientation="horizontal" android:orientation="horizontal">
android:gravity="center_vertical"> <TextView
<ImageView android:layout_width="40dp"
android:layout_width="5dp" android:layout_height="match_parent"
android:layout_height="34dp" android:background="@drawable/preview_widget_today_bg"
android:contentDescription="@null" android:gravity="center_horizontal"
android:background="@drawable/preview_stripe" android:paddingTop="6dp"
android:backgroundTint="#6FA37A" /> android:text="Tue&#10;27"
android:textColor="@color/widget_preview_on_primary"
android:textSize="13sp"
tools:ignore="HardcodedText" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:orientation="vertical">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="10dp" android:layout_marginTop="0dp"
android:orientation="vertical"> android:background="@drawable/preview_widget_event_bg"
android:backgroundTint="@color/widget_preview_event_orange"
android:orientation="vertical"
android:paddingHorizontal="10dp"
android:paddingVertical="3dp">
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Design review" android:ellipsize="end"
android:textColor="@color/widget_preview_on_surface" android:maxLines="1"
android:textSize="14sp" /> android:text="@string/widget_preview_sample_trip"
android:textColor="@color/widget_preview_on_event_light"
android:textSize="12sp"
android:textStyle="bold" />
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="11:30 – 12:30" android:ellipsize="end"
android:textColor="@color/widget_preview_variant" android:maxLines="1"
android:textSize="12sp" /> android:text="@string/event_detail_all_day"
android:textColor="@color/widget_preview_on_event_light"
android:textSize="11sp" />
</LinearLayout> </LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageView
android:layout_width="5dp"
android:layout_height="34dp"
android:contentDescription="@null"
android:background="@drawable/preview_stripe"
android:backgroundTint="#C58A56" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="1:1 with Mara"
android:textColor="@color/widget_preview_on_surface"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="14:00 – 14:30"
android:textColor="@color/widget_preview_variant"
android:textSize="12sp" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker on Android 14 and below
(android:previewLayout); 15+ shows the generated preview instead. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/preview_widget_bg"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="12dp"
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent">
<View
android:layout_width="4dp"
android:layout_height="match_parent"
android:background="@drawable/preview_stripe"
android:backgroundTint="@color/widget_preview_event_blue" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="1"
android:text="@string/settings_past_events_sample_morning"
android:textColor="@color/widget_preview_on_surface"
android:textSize="13sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="1"
android:text="in 2 h"
android:textColor="@color/widget_preview_primary"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="1"
android:text="9:30"
android:textColor="@color/widget_preview_on_surface"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker on Android 14 and below
(android:previewLayout); 15+ shows the generated preview instead. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/preview_widget_bg"
android:gravity="center"
android:orientation="vertical"
android:padding="4dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="MON"
android:textColor="@color/widget_preview_on_surface"
android:textSize="11sp"
android:textStyle="bold"
tools:ignore="HardcodedText" />
<FrameLayout
android:layout_width="48dp"
android:layout_height="48dp">
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:src="@drawable/floret_widget_shape_cookie"
android:tint="@color/widget_preview_primary"
tools:ignore="UseAppTint" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="26"
android:textColor="@color/widget_preview_on_primary"
android:textSize="20sp"
android:textStyle="bold"
tools:ignore="HardcodedText" />
</FrameLayout>
</LinearLayout>
+746 -84
View File
@@ -1,114 +1,776 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker (android:previewLayout). The live <!-- Static mock shown in the widget picker on Android 14 and below
widget is rendered by Glance with real events; this only needs to read as (android:previewLayout); 15+ shows the generated preview instead. -->
"a month grid" in the picker. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical"
android:background="@drawable/preview_widget_bg" android:background="@drawable/preview_widget_bg"
android:padding="12dp"> android:orientation="vertical"
android:padding="8dp"
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent">
<!-- Header -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:gravity="center_vertical"
android:gravity="center_vertical"> android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="‹"
android:textColor="@color/widget_preview_variant"
android:textSize="16sp" />
<TextView <TextView
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:gravity="center" android:maxLines="1"
android:text="June 2026" android:paddingHorizontal="4dp"
android:text="October"
android:textColor="@color/widget_preview_primary" android:textColor="@color/widget_preview_primary"
android:textSize="15sp" android:textSize="15sp"
android:textStyle="bold" /> android:textStyle="bold" />
<TextView <TextView style="@style/WidgetPreviewHeaderButton" android:text="‹" />
android:layout_width="wrap_content" <TextView style="@style/WidgetPreviewHeaderButton" android:layout_width="28dp" android:text="26" android:textStyle="bold" />
android:layout_height="wrap_content" <TextView style="@style/WidgetPreviewHeaderButton" android:text="›" />
android:text="›" <TextView style="@style/WidgetPreviewAddButton" android:text="+" />
android:textColor="@color/widget_preview_variant"
android:textSize="16sp" />
</LinearLayout> </LinearLayout>
<!-- Weekday header -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="6dp" android:layout_marginTop="6dp"
android:layout_marginBottom="1dp"
android:orientation="horizontal"> android:orientation="horizontal">
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="M" android:textColor="@color/widget_preview_variant" android:textSize="11sp" /> <TextView
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="T" android:textColor="@color/widget_preview_variant" android:textSize="11sp" /> android:layout_width="0dp"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="W" android:textColor="@color/widget_preview_variant" android:textSize="11sp" /> android:layout_height="wrap_content"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="T" android:textColor="@color/widget_preview_variant" android:textSize="11sp" /> android:layout_marginHorizontal="1dp"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="F" android:textColor="@color/widget_preview_variant" android:textSize="11sp" /> android:layout_weight="1"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="S" android:textColor="@color/widget_preview_variant" android:textSize="11sp" /> android:background="@drawable/preview_widget_cell_bg"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="S" android:textColor="@color/widget_preview_variant" android:textSize="11sp" /> android:gravity="center"
android:text="M"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center"
android:text="T"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center"
android:text="W"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center"
android:text="T"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center"
android:text="F"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center"
android:text="S"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center"
android:text="S"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout> </LinearLayout>
<!-- Week 1: 1..7 (event bar on the 3rd) --> <LinearLayout
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp"> android:layout_width="match_parent"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="1" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" /> android:layout_height="0dp"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="2" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" /> android:layout_weight="1"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="3" android:textColor="#4F7CAC" android:textStyle="bold" android:textSize="12sp" /> android:orientation="horizontal">
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="4" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" /> <LinearLayout
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="5" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" /> android:layout_width="0dp"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="6" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" /> android:layout_height="match_parent"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="7" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" /> android:layout_margin="1dp"
</LinearLayout> android:layout_weight="1"
android:background="@drawable/preview_widget_outside_bg"
<!-- Week 2: 8..14 (bars on 11,12,13) --> android:gravity="center_horizontal"
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp"> android:orientation="vertical">
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="8" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" /> <TextView
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="9" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" /> android:layout_width="wrap_content"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="10" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" /> android:layout_height="18dp"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="11" android:textColor="#6FA37A" android:textStyle="bold" android:textSize="12sp" /> android:gravity="center"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="12" android:textColor="#6FA37A" android:textStyle="bold" android:textSize="12sp" /> android:text="28"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="13" android:textColor="#6FA37A" android:textStyle="bold" android:textSize="12sp" /> android:textColor="@color/widget_preview_on_surface"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="14" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" /> android:textSize="10sp" />
</LinearLayout> </LinearLayout>
<LinearLayout
<!-- Week 3: 15..21 (17 = today) --> android:layout_width="0dp"
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp"> android:layout_height="match_parent"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="15" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" /> android:layout_margin="1dp"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="16" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" /> android:layout_weight="1"
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:orientation="horizontal"> android:background="@drawable/preview_widget_outside_bg"
<TextView android:layout_width="22dp" android:layout_height="22dp" android:gravity="center" android:gravity="center_horizontal"
android:text="17" android:textColor="@color/widget_preview_on_primary" android:textStyle="bold" android:textSize="12sp" android:orientation="vertical">
android:background="@drawable/preview_today_circle" /> <TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="29"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_outside_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="30"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="1"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="2"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="3"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="4"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout> </LinearLayout>
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="18" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="19" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="20" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="21" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
</LinearLayout> </LinearLayout>
<LinearLayout
<!-- Week 4: 22..28 (bar on 24) --> android:layout_width="match_parent"
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp"> android:layout_height="0dp"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="22" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" /> android:layout_weight="1"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="23" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" /> android:orientation="horizontal">
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="24" android:textColor="#C58A56" android:textStyle="bold" android:textSize="12sp" /> <LinearLayout
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="25" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" /> android:layout_width="0dp"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="26" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" /> android:layout_height="match_parent"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="27" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" /> android:layout_margin="1dp"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="28" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" /> android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="5"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="6"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="7"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="8"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="9"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="10"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="11"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
</LinearLayout> </LinearLayout>
<LinearLayout
<!-- Week 5: 29, 30, then trailing July days greyed --> android:layout_width="match_parent"
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp"> android:layout_height="0dp"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="29" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" /> android:layout_weight="1"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="30" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" /> android:orientation="horizontal">
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="1" android:textColor="@color/widget_preview_variant" android:textSize="12sp" /> <LinearLayout
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="2" android:textColor="@color/widget_preview_variant" android:textSize="12sp" /> android:layout_width="0dp"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="3" android:textColor="@color/widget_preview_variant" android:textSize="12sp" /> android:layout_height="match_parent"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="4" android:textColor="@color/widget_preview_variant" android:textSize="12sp" /> android:layout_margin="1dp"
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="5" android:textColor="@color/widget_preview_variant" android:textSize="12sp" /> android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="12"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="13"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="14"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="15"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="16"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="17"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="18"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="19"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="20"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="2dp"
android:background="@drawable/preview_stripe"
android:backgroundTint="@color/widget_preview_event_blue"
android:ellipsize="end"
android:maxLines="1"
android:paddingHorizontal="2dp"
android:text="@string/settings_past_events_sample_morning"
android:textColor="@color/widget_preview_on_event_light"
android:textSize="8sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="21"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="22"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="23"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="24"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="2dp"
android:background="@drawable/preview_stripe"
android:backgroundTint="@color/widget_preview_event_purple"
android:ellipsize="end"
android:maxLines="1"
android:paddingHorizontal="2dp"
android:text="@string/settings_past_events_sample_evening"
android:textColor="@color/widget_preview_on_event_dark"
android:textSize="8sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="25"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="18dp"
android:layout_height="18dp"
android:background="@drawable/preview_today_circle"
android:gravity="center"
android:text="26"
android:textColor="@color/widget_preview_on_primary"
android:textSize="10sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="2dp"
android:background="@drawable/preview_stripe"
android:backgroundTint="@color/widget_preview_event_blue"
android:ellipsize="end"
android:maxLines="1"
android:paddingHorizontal="2dp"
android:text="@string/settings_past_events_sample_morning"
android:textColor="@color/widget_preview_on_event_light"
android:textSize="8sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="27"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="2dp"
android:background="@drawable/preview_stripe"
android:backgroundTint="@color/widget_preview_event_orange"
android:ellipsize="end"
android:maxLines="1"
android:paddingHorizontal="2dp"
android:text="@string/widget_preview_sample_trip"
android:textColor="@color/widget_preview_on_event_light"
android:textSize="8sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="28"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="2dp"
android:background="@drawable/preview_stripe"
android:backgroundTint="@color/widget_preview_event_red"
android:ellipsize="end"
android:maxLines="1"
android:paddingHorizontal="2dp"
android:text="@string/widget_preview_sample_dentist"
android:textColor="@color/widget_preview_on_event_dark"
android:textSize="8sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="29"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="30"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_cell_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="31"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="2dp"
android:background="@drawable/preview_stripe"
android:backgroundTint="@color/widget_preview_event_purple"
android:ellipsize="end"
android:maxLines="1"
android:paddingHorizontal="2dp"
android:text="@string/settings_past_events_sample_evening"
android:textColor="@color/widget_preview_on_event_dark"
android:textSize="8sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layout_weight="1"
android:background="@drawable/preview_widget_outside_bg"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="18dp"
android:gravity="center"
android:text="1"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
@@ -0,0 +1,518 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker on Android 14 and below
(android:previewLayout); 15+ shows the generated preview instead. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/preview_widget_bg"
android:orientation="vertical"
android:padding="8dp"
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:maxLines="1"
android:paddingHorizontal="4dp"
android:text="October"
android:textColor="@color/widget_preview_primary"
android:textSize="15sp"
android:textStyle="bold" />
<TextView style="@style/WidgetPreviewHeaderButton" android:text="‹" />
<TextView style="@style/WidgetPreviewHeaderButton" android:layout_width="28dp" android:text="26" android:textStyle="bold" />
<TextView style="@style/WidgetPreviewHeaderButton" android:text="›" />
<TextView style="@style/WidgetPreviewAddButton" android:text="+" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="4dp"
android:layout_weight="1"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1.1"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="M"
android:textColor="@color/widget_preview_variant"
android:textSize="9sp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="T"
android:textColor="@color/widget_preview_variant"
android:textSize="9sp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="W"
android:textColor="@color/widget_preview_variant"
android:textSize="9sp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="T"
android:textColor="@color/widget_preview_variant"
android:textSize="9sp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="F"
android:textColor="@color/widget_preview_variant"
android:textSize="9sp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="S"
android:textColor="@color/widget_preview_variant"
android:textSize="9sp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="S"
android:textColor="@color/widget_preview_variant"
android:textSize="9sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="28"
android:textColor="@color/widget_preview_variant"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="29"
android:textColor="@color/widget_preview_variant"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="30"
android:textColor="@color/widget_preview_variant"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="1"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="2"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="3"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="4"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="5"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="6"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="7"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="8"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="9"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="10"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="11"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="12"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="13"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="14"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="15"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="16"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="17"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="18"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="19"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="20"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="21"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="22"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="23"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="24"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="25"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/preview_widget_today_bg"
android:gravity="center"
android:text="26"
android:textColor="@color/widget_preview_on_primary"
android:textSize="10sp"
android:textStyle="bold" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="27"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="28"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="29"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="30"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="31"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="1"
android:textColor="@color/widget_preview_variant"
android:textSize="10sp" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="4dp"
android:text="@string/widget_today"
android:textColor="@color/widget_preview_on_surface"
android:textSize="11sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:background="@drawable/preview_widget_event_bg"
android:backgroundTint="@color/widget_preview_event_blue"
android:orientation="vertical"
android:paddingHorizontal="10dp"
android:paddingVertical="3dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/settings_past_events_sample_morning"
android:textColor="@color/widget_preview_on_event_light"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="9:30 – 10:00"
android:textColor="@color/widget_preview_on_event_light"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:background="@drawable/preview_widget_event_bg"
android:backgroundTint="@color/widget_preview_event_green"
android:orientation="vertical"
android:paddingHorizontal="10dp"
android:paddingVertical="3dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/settings_past_events_sample_midday"
android:textColor="@color/widget_preview_on_event_light"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="12:30 – 13:30"
android:textColor="@color/widget_preview_on_event_light"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:background="@drawable/preview_widget_event_bg"
android:backgroundTint="@color/widget_preview_event_purple"
android:orientation="vertical"
android:paddingHorizontal="10dp"
android:paddingVertical="3dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/settings_past_events_sample_evening"
android:textColor="@color/widget_preview_on_event_dark"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="19:00 – 21:00"
android:textColor="@color/widget_preview_on_event_dark"
android:textSize="11sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker on Android 14 and below
(android:previewLayout); 15+ shows the generated preview instead. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/preview_widget_bg"
android:padding="6dp">
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:src="@drawable/floret_widget_shape_sunny"
android:tint="@color/widget_preview_primary"
tools:ignore="UseAppTint" />
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/ic_widget_add"
android:tint="@color/widget_preview_on_primary"
tools:ignore="UseAppTint" />
</FrameLayout>
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker on Android 14 and below
(android:previewLayout); 15+ shows the generated preview instead. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/preview_widget_bg"
android:orientation="horizontal"
android:padding="6dp"
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent">
<TextView
android:layout_width="44dp"
android:layout_height="match_parent"
android:background="@drawable/preview_widget_today_bg"
android:gravity="center"
android:text="Mon&#10;26"
android:textColor="@color/widget_preview_on_primary"
android:textSize="13sp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="0dp"
android:background="@drawable/preview_widget_event_bg"
android:backgroundTint="@color/widget_preview_event_blue"
android:orientation="vertical"
android:paddingHorizontal="10dp"
android:paddingVertical="3dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/settings_past_events_sample_morning"
android:textColor="@color/widget_preview_on_event_light"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="9:30 – 10:00"
android:textColor="@color/widget_preview_on_event_light"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:background="@drawable/preview_widget_event_bg"
android:backgroundTint="@color/widget_preview_event_green"
android:orientation="vertical"
android:paddingHorizontal="10dp"
android:paddingVertical="3dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/settings_past_events_sample_midday"
android:textColor="@color/widget_preview_on_event_light"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="12:30 – 13:30"
android:textColor="@color/widget_preview_on_event_light"
android:textSize="11sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker on Android 14 and below
(android:previewLayout); 15+ shows the generated preview instead. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/preview_widget_bg"
android:orientation="vertical"
android:padding="8dp"
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="4dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="26"
android:textColor="@color/widget_preview_primary"
android:textSize="28sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="Monday&#10;October"
android:textColor="@color/widget_preview_on_surface"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:background="@drawable/preview_widget_event_bg"
android:backgroundTint="@color/widget_preview_event_blue"
android:orientation="vertical"
android:paddingHorizontal="10dp"
android:paddingVertical="3dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/settings_past_events_sample_morning"
android:textColor="@color/widget_preview_on_event_light"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="9:30 – 10:00"
android:textColor="@color/widget_preview_on_event_light"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:background="@drawable/preview_widget_event_bg"
android:backgroundTint="@color/widget_preview_event_green"
android:orientation="vertical"
android:paddingHorizontal="10dp"
android:paddingVertical="3dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/settings_past_events_sample_midday"
android:textColor="@color/widget_preview_on_event_light"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="12:30 – 13:30"
android:textColor="@color/widget_preview_on_event_light"
android:textSize="11sp" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,202 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker on Android 14 and below
(android:previewLayout); 15+ shows the generated preview instead. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/preview_widget_bg"
android:orientation="horizontal"
android:padding="6dp"
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent">
<LinearLayout
style="@style/WidgetPreviewWeekDay"
android:background="@drawable/preview_widget_today_bg"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Mon"
android:textColor="@color/widget_preview_on_primary"
android:textSize="11sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="26"
android:textColor="@color/widget_preview_on_primary"
android:textSize="16sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="6dp"
android:gravity="center"
android:orientation="horizontal">
<View
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
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
android:layout_width="4dp"
android:layout_height="4dp"
android:layout_marginHorizontal="1dp"
android:background="@drawable/preview_today_circle"
android:backgroundTint="@color/widget_preview_event_purple" />
</LinearLayout>
</LinearLayout>
<LinearLayout
style="@style/WidgetPreviewWeekDay"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Tue"
android:textColor="@color/widget_preview_on_surface"
android:textSize="11sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="27"
android:textColor="@color/widget_preview_on_surface"
android:textSize="16sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="6dp"
android:gravity="center"
android:orientation="horizontal">
<View
android:layout_width="4dp"
android:layout_height="4dp"
android:layout_marginHorizontal="1dp"
android:background="@drawable/preview_today_circle"
android:backgroundTint="@color/widget_preview_event_orange" />
</LinearLayout>
</LinearLayout>
<LinearLayout
style="@style/WidgetPreviewWeekDay"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Wed"
android:textColor="@color/widget_preview_on_surface"
android:textSize="11sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="28"
android:textColor="@color/widget_preview_on_surface"
android:textSize="16sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="6dp"
android:gravity="center"
android:orientation="horizontal">
<View
android:layout_width="4dp"
android:layout_height="4dp"
android:layout_marginHorizontal="1dp"
android:background="@drawable/preview_today_circle"
android:backgroundTint="@color/widget_preview_event_red" />
</LinearLayout>
</LinearLayout>
<LinearLayout
style="@style/WidgetPreviewWeekDay"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Thu"
android:textColor="@color/widget_preview_on_surface"
android:textSize="11sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="29"
android:textColor="@color/widget_preview_on_surface"
android:textSize="16sp"
android:textStyle="bold" />
<Space
android:layout_width="wrap_content"
android:layout_height="6dp" />
</LinearLayout>
<LinearLayout
style="@style/WidgetPreviewWeekDay"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Fri"
android:textColor="@color/widget_preview_on_surface"
android:textSize="11sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="30"
android:textColor="@color/widget_preview_on_surface"
android:textSize="16sp"
android:textStyle="bold" />
<Space
android:layout_width="wrap_content"
android:layout_height="6dp" />
</LinearLayout>
<LinearLayout
style="@style/WidgetPreviewWeekDay"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Sat"
android:textColor="@color/widget_preview_on_surface"
android:textSize="11sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="31"
android:textColor="@color/widget_preview_on_surface"
android:textSize="16sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="6dp"
android:gravity="center"
android:orientation="horizontal">
<View
android:layout_width="4dp"
android:layout_height="4dp"
android:layout_marginHorizontal="1dp"
android:background="@drawable/preview_today_circle"
android:backgroundTint="@color/widget_preview_event_purple" />
</LinearLayout>
</LinearLayout>
<LinearLayout
style="@style/WidgetPreviewWeekDay"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Sun"
android:textColor="@color/widget_preview_on_surface"
android:textSize="11sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="1"
android:textColor="@color/widget_preview_on_surface"
android:textSize="16sp"
android:textStyle="bold" />
<Space
android:layout_width="wrap_content"
android:layout_height="6dp" />
</LinearLayout>
</LinearLayout>
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="widget_preview_surface">@android:color/system_neutral1_900</color> <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_on_surface">@android:color/system_neutral1_50</color>
<color name="widget_preview_variant">@android:color/system_neutral2_200</color> <color name="widget_preview_variant">@android:color/system_neutral2_200</color>
<color name="widget_preview_primary">@android:color/system_accent1_200</color> <color name="widget_preview_primary">@android:color/system_accent1_200</color>
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="widget_preview_surface">#101316</color> <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_on_surface">#E1E3E6</color> <color name="widget_preview_on_surface">#E1E3E6</color>
<color name="widget_preview_variant">#A8ADB2</color> <color name="widget_preview_variant">#A8ADB2</color>
<color name="widget_preview_primary">#A3CBE2</color> <color name="widget_preview_primary">#A3CBE2</color>
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="widget_preview_surface">@android:color/system_neutral1_50</color> <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_on_surface">@android:color/system_neutral1_900</color>
<color name="widget_preview_variant">@android:color/system_neutral2_700</color> <color name="widget_preview_variant">@android:color/system_neutral2_700</color>
<color name="widget_preview_primary">@android:color/system_accent1_600</color> <color name="widget_preview_primary">@android:color/system_accent1_600</color>
+70
View File
@@ -435,6 +435,65 @@
<string name="widget_agenda_title">Upcoming</string> <string name="widget_agenda_title">Upcoming</string>
<string name="widget_agenda_label">Calendula agenda</string> <string name="widget_agenda_label">Calendula agenda</string>
<string name="widget_month_label">Calendula month</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>
<!-- 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_empty">No upcoming events</string>
<string name="widget_today_label">Calendula today</string>
<string name="widget_nothing_left_today">Nothing left today</string>
<string name="widget_countdown_label">Calendula countdown</string>
<string name="widget_countdown_now">Now</string>
<string name="widget_countdown_ending_now">Ending now</string>
<string name="widget_countdown_tomorrow">Tomorrow</string>
<string name="widget_countdown_ends_tomorrow">Ends tomorrow</string>
<!-- A running all-day event's last day: "Until Fri" -->
<string name="widget_countdown_until">Until %1$s</string>
<plurals name="widget_countdown_in_minutes">
<item quantity="one">in %d min</item>
<item quantity="other">in %d min</item>
</plurals>
<plurals name="widget_countdown_in_hours">
<item quantity="one">in %d h</item>
<item quantity="other">in %d h</item>
</plurals>
<plurals name="widget_countdown_in_days">
<item quantity="one">in %d day</item>
<item quantity="other">in %d days</item>
</plurals>
<plurals name="widget_countdown_ends_minutes">
<item quantity="one">ends in %d min</item>
<item quantity="other">ends in %d min</item>
</plurals>
<plurals name="widget_countdown_ends_hours">
<item quantity="one">ends in %d h</item>
<item quantity="other">ends in %d h</item>
</plurals>
<plurals name="widget_countdown_ends_days">
<item quantity="one">ends in %d day</item>
<item quantity="other">ends in %d days</item>
</plurals>
<string name="widget_countdown_choose">Tap to choose an event to count down to</string>
<!-- A pinned countdown whose event has no occurrence left: "Vacation is over" -->
<string name="widget_countdown_over">%1$s is over</string>
<string name="widget_config_countdown">Count down to</string>
<string name="widget_config_countdown_next">Next event</string>
<string name="widget_config_countdown_pinned">Chosen event</string>
<string name="widget_config_countdown_next_hint">The next timed event in this widget\'s calendars, or the one running now. All-day and declined events are skipped.</string>
<string name="widget_config_countdown_event">Event</string>
<string name="widget_config_countdown_event_none">None chosen yet</string>
<string name="widget_config_countdown_search">Search events</string>
<string name="widget_config_countdown_empty">No events in the coming year match.</string>
<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_day_empty">No events</string>
<string name="widget_refresh">Refresh</string> <string name="widget_refresh">Refresh</string>
<string name="widget_new_event">New event</string> <string name="widget_new_event">New event</string>
<string name="widget_needs_permission">Open Calendula to grant calendar access</string> <string name="widget_needs_permission">Open Calendula to grant calendar access</string>
@@ -456,6 +515,14 @@
<string name="widget_numbered">%1$s %2$d</string> <string name="widget_numbered">%1$s %2$d</string>
<!-- A placed widget's background and theme: "Tinted · Follow app" --> <!-- A placed widget's background and theme: "Tinted · Follow app" -->
<string name="widget_appearance_summary">%1$s · %2$s</string> <string name="widget_appearance_summary">%1$s · %2$s</string>
<string name="widget_config_calendars">Calendars</string>
<string name="widget_config_calendars_hint">The calendars this widget shows. Calendars you hid in the app stay hidden here too.</string>
<string name="widget_config_calendars_all">All calendars</string>
<!-- How many calendars a widget shows: "2 of 5 calendars" -->
<plurals name="widget_config_calendars_some">
<item quantity="one">%1$d of %2$d calendar</item>
<item quantity="other">%1$d of %2$d calendars</item>
</plurals>
<!-- Calendar filter (M3) --> <!-- Calendar filter (M3) -->
<string name="filter_title">Calendars</string> <string name="filter_title">Calendars</string>
@@ -554,6 +621,9 @@
<string name="settings_past_events_sample_morning">Team review</string> <string name="settings_past_events_sample_morning">Team review</string>
<string name="settings_past_events_sample_midday">Lunch with Robin</string> <string name="settings_past_events_sample_midday">Lunch with Robin</string>
<string name="settings_past_events_sample_evening">Choir practice</string> <string name="settings_past_events_sample_evening">Choir practice</string>
<!-- Stand-in event titles in the widget picker previews. Short, everyday entries. -->
<string name="widget_preview_sample_trip">Weekend trip</string>
<string name="widget_preview_sample_dentist">Dentist</string>
<string name="settings_agenda_header">Agenda</string> <string name="settings_agenda_header">Agenda</string>
<string name="settings_agenda_range">Agenda range</string> <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_range_hint">How far ahead the Agenda screen lists events.</string>
@@ -1,11 +1,21 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Colours for the static widget-picker previews. Base = brand light fallback <!-- Colours for the static widget-picker previews (Android 14 and below; 15+
(API < 31). values-v31 maps them to the Material You dynamic palette so the shows the generated preview). Base = brand light fallback (API < 31).
preview matches the live Glance widget; -night holds the dark variants. --> values-v31 maps them to the Material You dynamic palette so the preview
matches the live Glance widget; -night holds the dark variants. -->
<resources> <resources>
<color name="widget_preview_surface">#FBFCFE</color> <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_on_surface">#191C1F</color> <color name="widget_preview_on_surface">#191C1F</color>
<color name="widget_preview_variant">#6E7479</color> <color name="widget_preview_variant">#6E7479</color>
<color name="widget_preview_primary">#3B5364</color> <color name="widget_preview_primary">#3B5364</color>
<color name="widget_preview_on_primary">#FFFFFF</color> <color name="widget_preview_on_primary">#FFFFFF</color>
<color name="widget_preview_event_blue">#039BE5</color>
<color name="widget_preview_event_green">#33B679</color>
<color name="widget_preview_event_purple">#8E24AA</color>
<color name="widget_preview_event_orange">#F4511E</color>
<color name="widget_preview_event_red">#D50000</color>
<color name="widget_preview_on_event_light">#000000</color>
<color name="widget_preview_on_event_dark">#FFFFFF</color>
</resources> </resources>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Shared looks for the static widget-picker previews. -->
<resources>
<style name="WidgetPreviewHeaderButton">
<item name="android:layout_width">24dp</item>
<item name="android:layout_height">22dp</item>
<item name="android:layout_marginStart">4dp</item>
<item name="android:background">@drawable/preview_widget_cell_bg</item>
<item name="android:gravity">center</item>
<item name="android:textColor">@color/widget_preview_on_surface</item>
<item name="android:textSize">12sp</item>
</style>
<style name="WidgetPreviewAddButton" parent="WidgetPreviewHeaderButton">
<item name="android:layout_width">30dp</item>
<item name="android:background">@drawable/preview_widget_today_bg</item>
<item name="android:textColor">@color/widget_preview_on_primary</item>
<item name="android:textSize">15sp</item>
</style>
<style name="WidgetPreviewWeekDay">
<item name="android:layout_width">0dp</item>
<item name="android:layout_height">match_parent</item>
<item name="android:layout_weight">1</item>
<item name="android:layout_marginHorizontal">2dp</item>
<item name="android:background">@drawable/preview_widget_cell_bg</item>
<item name="android:gravity">center</item>
<item name="android:textColor">@color/widget_preview_on_surface</item>
<item name="android:textSize">12sp</item>
</style>
</resources>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="40dp"
android:targetCellWidth="2"
android:targetCellHeight="1"
android:minResizeWidth="110dp"
android:minResizeHeight="40dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
android:widgetFeatures="reconfigurable"
android:description="@string/widget_countdown_label"
android:updatePeriodMillis="1800000"
android:previewLayout="@layout/widget_preview_countdown"
android:initialLayout="@layout/glance_default_loading_layout" />
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="40dp"
android:minHeight="40dp"
android:targetCellWidth="1"
android:targetCellHeight="1"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
android:widgetFeatures="reconfigurable"
android:description="@string/widget_date_label"
android:updatePeriodMillis="1800000"
android:previewLayout="@layout/widget_preview_date"
android:initialLayout="@layout/glance_default_loading_layout" />
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:minResizeWidth="250dp"
android:minResizeHeight="110dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
android:widgetFeatures="reconfigurable"
android:description="@string/widget_month_day_label"
android:updatePeriodMillis="1800000"
android:previewLayout="@layout/widget_preview_month_day"
android:initialLayout="@layout/glance_default_loading_layout" />
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="40dp"
android:minHeight="40dp"
android:targetCellWidth="1"
android:targetCellHeight="1"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
android:widgetFeatures="reconfigurable"
android:description="@string/widget_quick_add_label"
android:updatePeriodMillis="1800000"
android:previewLayout="@layout/widget_preview_quick_add"
android:initialLayout="@layout/glance_default_loading_layout" />
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="40dp"
android:targetCellWidth="4"
android:targetCellHeight="1"
android:minResizeWidth="180dp"
android:minResizeHeight="40dp"
android:maxResizeWidth="420dp"
android:maxResizeHeight="230dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
android:widgetFeatures="reconfigurable"
android:description="@string/widget_today_label"
android:updatePeriodMillis="1800000"
android:previewLayout="@layout/widget_preview_today"
android:initialLayout="@layout/glance_default_loading_layout" />
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="110dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
android:widgetFeatures="reconfigurable"
android:description="@string/widget_upcoming_label"
android:updatePeriodMillis="1800000"
android:previewLayout="@layout/widget_preview_upcoming"
android:initialLayout="@layout/glance_default_loading_layout" />
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="40dp"
android:targetCellWidth="4"
android:targetCellHeight="1"
android:minResizeWidth="180dp"
android:minResizeHeight="40dp"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
android:widgetFeatures="reconfigurable"
android:description="@string/widget_week_strip_label"
android:updatePeriodMillis="1800000"
android:previewLayout="@layout/widget_preview_week_strip"
android:initialLayout="@layout/glance_default_loading_layout" />
@@ -0,0 +1,18 @@
package de.jeanlucmakiola.calendula.widget
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
class WeekOfTest {
@Test
fun `the week starts on the configured day and holds today`() {
val wednesday = LocalDate(2026, 10, 21)
assertThat(weekOf(wednesday, DayOfWeek.MONDAY)).isEqualTo(LocalDate(2026, 10, 19)..LocalDate(2026, 10, 25))
assertThat(weekOf(wednesday, DayOfWeek.SUNDAY)).isEqualTo(LocalDate(2026, 10, 18)..LocalDate(2026, 10, 24))
assertThat(weekOf(wednesday, DayOfWeek.WEDNESDAY).start).isEqualTo(wednesday)
assertThat(weekOf(wednesday, DayOfWeek.THURSDAY).start).isEqualTo(LocalDate(2026, 10, 15))
}
}
@@ -0,0 +1,89 @@
package de.jeanlucmakiola.calendula.widget.countdown
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventResponse
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
class CountdownTest {
private val zone = TimeZone.UTC
private val now = Instant.parse("2026-10-21T12:00:00Z")
private fun event(
start: Instant,
end: Instant = start + 1.hours,
id: Long = 1,
allDay: Boolean = false,
response: EventResponse = EventResponse.Going,
) = EventInstance(
instanceId = id, eventId = id, calendarId = 1, title = "e$id",
start = start, end = end, isAllDay = allDay, color = 0, location = null, response = response,
)
@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))
assertThat(countdownLabel(event(now + 24.minutes + 10.seconds), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.MINUTES, 25))
assertThat(countdownLabel(event(now + 2.hours + 40.minutes), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.HOURS, 3))
assertThat(countdownLabel(event(now + 1.hours + 10.minutes), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.HOURS, 1))
// Tomorrow 23:00 is 35 h away but one calendar day.
assertThat(countdownLabel(event(now + 35.hours), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 1))
assertThat(countdownLabel(event(now + 12.days), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 12))
}
@Test
fun `a running event counts to its end`() {
val running = event(start = now - 20.minutes, end = now + 10.minutes)
assertThat(countdownLabel(running, now, zone)).isEqualTo(CountdownLabel(true, CountdownUnit.MINUTES, 10))
}
@Test
fun `the next redraw lands where the label changes`() {
// "in 25 min" turns into "in 24 min" a minute later.
assertThat(nextLabelChange(event(now + 25.minutes), now, zone)).isEqualTo(now + 1.minutes)
// "in 3 h" (2 h 40 min) holds until 2 h 30 min are left.
assertThat(nextLabelChange(event(now + 2.hours + 40.minutes), now, zone)).isEqualTo(now + 10.minutes)
// "in 1 h" holds until the last hour, then counts minutes.
assertThat(nextLabelChange(event(now + 1.hours + 10.minutes), now, zone)).isEqualTo(now + 10.minutes)
// Days change at midnight.
assertThat(nextLabelChange(event(now + 5.days), now, zone)).isEqualTo(Instant.parse("2026-10-22T00:00:00Z"))
}
@Test
fun `next mode skips all-day, declined and finished events`() {
val allDay = event(now + 1.hours, id = 1, allDay = true)
val declined = event(now + 1.hours, id = 2, response = EventResponse.Declined)
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)
// A running event ending before the next one starts comes first.
assertThat(pickNextEvent(listOf(later, running), now)?.eventId).isEqualTo(5)
}
@Test
fun `a pinned series follows its next occurrence`() {
val past = event(now - 7.days)
val next = event(now + 7.days)
assertThat(pickPinnedOccurrence(listOf(past, next), now, zone)).isEqualTo(next)
assertThat(pickPinnedOccurrence(listOf(past), now, zone)).isNull()
}
@Test
fun `an all-day event counts in local days west of UTC`() {
val newYork = TimeZone.of("America/New_York")
// 21 Oct, 20:00 in New York; the vacation is all day on the 23rd.
val evening = Instant.parse("2026-10-22T00:00:00Z")
val vacation = event(Instant.parse("2026-10-23T00:00:00Z"), Instant.parse("2026-10-24T00:00:00Z"), allDay = true)
val counted = vacation.counted(newYork)
assertThat(counted.start).isEqualTo(Instant.parse("2026-10-23T04:00:00Z"))
assertThat(countdownLabel(counted, evening, newYork)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 2))
}
}
@@ -0,0 +1,24 @@
package de.jeanlucmakiola.calendula.widget.split
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
class MonthGridDaysTest {
@Test
fun `whole weeks from the week start cover the month and no more`() {
// October 2026 starts on a Thursday and has 31 days.
val october = 2026 * 12 + 9
val monday = monthGridDays(october, DayOfWeek.MONDAY)
assertThat(monday.first()).isEqualTo(LocalDate(2026, 9, 28))
assertThat(monday.last()).isEqualTo(LocalDate(2026, 11, 1))
assertThat(monday).hasSize(35)
// February 2027 starts on a Monday and fills exactly four weeks.
val february = 2027 * 12 + 1
assertThat(monthGridDays(february, DayOfWeek.MONDAY)).hasSize(28)
assertThat(monthGridDays(february, DayOfWeek.SUNDAY)).hasSize(35)
}
}
@@ -0,0 +1,57 @@
package de.jeanlucmakiola.calendula.widget.today
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
import de.jeanlucmakiola.calendula.widget.WidgetSize
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
class TodayRowsTest {
private val now = Instant.parse("2026-10-21T12:00:00Z")
private val today = LocalDate(2026, 10, 21)
private val tomorrow = LocalDate(2026, 10, 22)
private fun event(id: Long, start: Instant, end: Instant) = EventInstance(
instanceId = id, eventId = id, calendarId = 1, title = "e$id",
start = start, end = end, isAllDay = false, color = 0, location = null,
)
private fun data(past: PastEventDisplay, vararg days: AgendaDay) = DaysWidgetData.Ready(
today = today,
firstDay = today,
lastDay = tomorrow,
days = days.toList(),
weekStart = DayOfWeek.MONDAY,
is24Hour = true,
soften = true,
options = AgendaWidgetOptions(AgendaRange.Day, WidgetSize.SMALL, true, past),
now = now,
)
private val ended = event(1, now - 3.hours, now - 1.hours)
private val later = event(2, now + 1.hours, now + 2.hours)
private val next = event(3, now + 22.hours, now + 23.hours)
@Test
fun `an event still to come lists today, finished ones as the past setting says`() {
val days = arrayOf(AgendaDay(today, listOf(ended, later)), AgendaDay(tomorrow, listOf(next)))
assertThat(todayRows(data(PastEventDisplay.DIM, *days))).isEqualTo(TodayRows.Today(listOf(ended, later)))
assertThat(todayRows(data(PastEventDisplay.HIDE, *days))).isEqualTo(TodayRows.Today(listOf(later)))
}
@Test
fun `once today is over it looks ahead to tomorrow`() {
val over = data(PastEventDisplay.SHOW, AgendaDay(today, listOf(ended)), AgendaDay(tomorrow, listOf(next)))
assertThat(todayRows(over)).isEqualTo(TodayRows.NothingLeft(listOf(next)))
assertThat(todayRows(data(PastEventDisplay.SHOW))).isEqualTo(TodayRows.NothingLeft(emptyList()))
}
}
@@ -0,0 +1,51 @@
package de.jeanlucmakiola.calendula.widget.upcoming
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
import de.jeanlucmakiola.calendula.widget.WidgetSize
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
class UpcomingEventsTest {
private val now = Instant.parse("2026-10-21T12:00:00Z")
private fun event(id: Long, start: Instant, end: Instant) = EventInstance(
instanceId = id, eventId = id, calendarId = 1, title = "e$id",
start = start, end = end, isAllDay = false, color = 0, location = null,
)
private fun data(vararg days: AgendaDay) = DaysWidgetData.Ready(
today = LocalDate(2026, 10, 21),
firstDay = LocalDate(2026, 10, 21),
lastDay = LocalDate(2026, 10, 27),
days = days.toList(),
weekStart = DayOfWeek.MONDAY,
is24Hour = true,
soften = true,
options = AgendaWidgetOptions(AgendaRange.Week, WidgetSize.SMALL, true, PastEventDisplay.SHOW),
now = now,
)
@Test
fun `ended events drop out and a multi-day one lists once, on its first day`() {
val ended = event(1, now - 3.hours, now - 1.hours)
val running = event(2, now - 1.hours, now + 30.hours)
val later = event(3, now + 2.hours, now + 3.hours)
val today = AgendaDay(LocalDate(2026, 10, 21), listOf(ended, running, later))
val tomorrow = AgendaDay(LocalDate(2026, 10, 22), listOf(running))
val upcoming = upcomingEvents(data(today, tomorrow))
assertThat(upcoming.map { it.second.instanceId }).containsExactly(2L, 3L).inOrder()
assertThat(upcoming.first().first).isEqualTo(LocalDate(2026, 10, 21))
}
}