Month and day widget: compact month grid beside the selected day's events (#441)

This commit is contained in:
2026-10-23 18:51:49 +02:00
parent 343a8904ea
commit c9cc40ff60
9 changed files with 568 additions and 0 deletions
+12
View File
@@ -429,6 +429,18 @@
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
PROVIDER_CHANGED on any data change (external sync; the app's own
writes redraw directly). The day boundary arrives as the app's own
@@ -22,6 +22,8 @@ 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 }
@@ -85,6 +87,12 @@ enum class WidgetKind(
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) {
@@ -96,6 +104,7 @@ enum class WidgetKind(
UPCOMING -> UpcomingWidget()
TODAY -> TodayWidget()
COUNTDOWN -> CountdownWidget()
MONTH_DAY -> MonthDayWidget()
}
/** Whether this kind stores [AgendaWidgetOptions] of its own. */
@@ -55,6 +55,7 @@ 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.GroupedHint
@@ -156,6 +157,14 @@ 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.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) {
@@ -28,6 +28,8 @@ 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
@@ -75,6 +77,7 @@ sealed interface 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. */
@@ -293,6 +296,11 @@ class WidgetConfigViewModel @Inject constructor(
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)
@@ -0,0 +1,440 @@
package de.jeanlucmakiola.calendula.widget.split
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.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 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 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
}
val fill = if (isSelected) GlanceModifier.cornerRadius(10.dp).background(GlanceTheme.colors.primary) else GlanceModifier
Column(
modifier = 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,48 @@
<?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:orientation="vertical"
android:padding="10dp"
tools:ignore="HardcodedText">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="October"
android:textColor="@color/widget_preview_primary"
android:textSize="15sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="4dp"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:lineSpacingExtra="2dp"
android:text="1 2 3 4 5 6 7\n8 9 10 11 12 13 14\n15 16 17 18 19 20 21\n22 23 24 25 26 27 28"
android:textColor="@color/widget_preview_on_surface"
android:textSize="10sp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView style="@style/WidgetPreviewEventRow" android:layout_marginTop="0dp" android:text="Stand-up\n9:30" />
<TextView style="@style/WidgetPreviewEventRow" android:text="Lunch\n12:30" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
+2
View File
@@ -492,6 +492,8 @@
<string name="widget_config_countdown_load_failed">Couldn\'t load your events.</string>
<!-- A repeating event in the countdown's event list: "Next: Fri, 24 Oct · repeats" -->
<string name="widget_config_countdown_repeats">%1$s · repeats</string>
<string name="widget_month_day_label">Calendula month and day</string>
<string name="widget_day_empty">No events</string>
<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,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,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)
}
}