Week strip widget: this week's days with event-colour dots, today filled (#436)

This commit is contained in:
2026-10-23 18:51:49 +02:00
parent cfcba283f1
commit a8da8bc7a3
15 changed files with 390 additions and 8 deletions
+12
View File
@@ -376,6 +376,18 @@
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>
<!-- Keeps both widgets fresh: the calendar provider broadcasts
PROVIDER_CHANGED on any data change (external sync; the app's own
writes redraw directly). The day boundary arrives as the app's own
@@ -260,3 +260,67 @@ private suspend fun Context.queryMonthSource(
/** 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)
}
@@ -14,6 +14,8 @@ 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
/** A content option a widget's config screen offers, besides background and theme. */
enum class WidgetOption { RANGE, TEXT_SIZE, PAST_DISPLAY, SHOW_TODAY, CALENDARS }
@@ -53,6 +55,12 @@ enum class WidgetKind(
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),
),
;
fun widget(): GlanceAppWidget = when (this) {
@@ -60,6 +68,7 @@ enum class WidgetKind(
MONTH -> MonthWidget()
QUICK_ADD -> QuickAddWidget()
DATE -> DateWidget()
WEEK_STRIP -> WeekStripWidget()
}
/** Whether this kind stores [AgendaWidgetOptions] of its own. */
@@ -0,0 +1,14 @@
package de.jeanlucmakiola.calendula.widget
import androidx.compose.runtime.Composable
import androidx.glance.GlanceTheme
import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetBackground
/** 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
}
@@ -60,6 +60,7 @@ import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetCardColor
import de.jeanlucmakiola.calendula.widget.loadAgendaWidget
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.floret.components.Position
@@ -217,7 +218,7 @@ private fun AgendaRows(
// Each day is one card of grouped rows (#71), with its date pill
// beside the first row.
val positions = dayPositions(rows)
val cardColor = agendaCardColor()
val cardColor = widgetCardColor()
val item: @Composable (Int) -> Unit = { index ->
val row = rows[index]
val position = positions[index]
@@ -305,13 +306,6 @@ private fun AgendaHeader() {
}
}
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
@Composable
private fun agendaCardColor(): ColorProvider = when (LocalWidgetBackground.current) {
WidgetBackground.SOLID -> GlanceTheme.colors.surfaceVariant
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground
}
/** The month name ("November", with its year outside the current one) above a new month's first day. */
@Composable
private fun MonthLabel(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
@@ -50,6 +50,7 @@ import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetContent
import de.jeanlucmakiola.calendula.widget.date.DateWidgetContent
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetContent
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.floret.components.GroupedChoiceRow
import de.jeanlucmakiola.floret.components.GroupedHint
@@ -149,6 +150,9 @@ internal fun WidgetConfigScreen(
AgendaWidgetContent(preview.data, state.appearance, state.appTheme, preview = true)
is WidgetPreviewData.Month ->
MonthWidgetContent(preview.source, preview.index, state.appearance, state.appTheme)
is WidgetPreviewData.Days -> when (state.kind) {
else -> WeekStripWidgetContent(preview.data, state.appearance, state.appTheme)
}
is WidgetPreviewData.Today -> if (state.kind == WidgetKind.DATE) {
DateWidgetContent(preview.today, state.appearance, state.appTheme)
} else {
@@ -15,6 +15,11 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.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.WidgetOption
import de.jeanlucmakiola.calendula.widget.hiddenCalendars
import de.jeanlucmakiola.calendula.widget.putHiddenCalendars
@@ -58,6 +63,7 @@ sealed interface WidgetPreviewData {
data class Agenda(val data: AgendaWidgetData) : WidgetPreviewData
data class Month(val source: MonthWidgetSource, val index: Int) : WidgetPreviewData
data class Today(val today: LocalDate) : WidgetPreviewData
data class Days(val data: DaysWidgetData) : WidgetPreviewData
}
/** The options being edited, applied to the preview as they change and written on save. */
@@ -68,6 +74,8 @@ data class WidgetDraft(
val hiddenCalendars: Set<Long>,
)
private fun WidgetDraft.loadContext(settings: WidgetSettings) = WidgetLoadContext(settings, content, hiddenCalendars)
sealed interface WidgetConfigUiState {
data object Loading : WidgetConfigUiState
@@ -229,6 +237,7 @@ class WidgetConfigViewModel @Inject constructor(
WidgetPreviewData.Month(source.content, index)
}
WidgetKind.QUICK_ADD, WidgetKind.DATE -> WidgetPreviewData.Today(today(systemZone()))
WidgetKind.WEEK_STRIP -> WidgetPreviewData.Days(context.loadDays(draft.loadContext(settings), ::weekOf).content)
}
}
@@ -0,0 +1,188 @@
package de.jeanlucmakiola.calendula.widget.week
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 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),
)
}
@@ -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>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static mock shown in the widget picker (android:previewLayout). -->
<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="horizontal"
android:padding="6dp"
tools:ignore="HardcodedText">
<TextView style="@style/WidgetPreviewWeekDay" android:text="M\n5" />
<TextView style="@style/WidgetPreviewWeekDay" android:text="T\n6" />
<TextView
style="@style/WidgetPreviewWeekDay"
android:background="@drawable/preview_widget_today_bg"
android:text="W\n7"
android:textColor="@color/widget_preview_on_primary" />
<TextView style="@style/WidgetPreviewWeekDay" android:text="T\n8" />
<TextView style="@style/WidgetPreviewWeekDay" android:text="F\n9" />
<TextView style="@style/WidgetPreviewWeekDay" android:text="S\n10" />
<TextView style="@style/WidgetPreviewWeekDay" android:text="S\n11" />
</LinearLayout>
+6
View File
@@ -437,6 +437,12 @@
<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_refresh">Refresh</string>
<string name="widget_new_event">New event</string>
<string name="widget_needs_permission">Open Calendula to grant calendar access</string>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Shared looks for the static widget-picker previews. -->
<resources>
<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: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="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))
}
}