From 6da0648b777db293cc1ac6e6b98815a099ada23b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 14:51:29 +0200 Subject: [PATCH 1/6] Widget redesign: agenda date pills and tinted day cards, month labels, pill headers, true-size config preview (#71, #90) Drops the agenda refresh button (redraws are automatic since #420). Caps the config preview at Glance's 10-row Column limit, which made it fail with busy agendas (#292). --- .../calendula/widget/agenda/AgendaScale.kt | 89 +++-- .../calendula/widget/agenda/AgendaWidget.kt | 328 +++++++++--------- .../calendula/widget/month/MonthGrid.kt | 11 +- .../calendula/widget/month/MonthWidget.kt | 90 ++--- .../main/res/drawable/ic_widget_refresh.xml | 6 - app/src/main/res/drawable/ic_widget_today.xml | 6 - .../widget/agenda/AgendaScaleTest.kt | 51 ++- floret-kit | 2 +- 8 files changed, 270 insertions(+), 313 deletions(-) delete mode 100644 app/src/main/res/drawable/ic_widget_refresh.xml delete mode 100644 app/src/main/res/drawable/ic_widget_today.xml diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt index 3414741..8b07067 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt @@ -6,17 +6,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import de.jeanlucmakiola.calendula.widget.WidgetSize -/** - * Horizontal layout constants for an agenda event row. These don't scale with the - * size step, since a wider stripe or gap would eat title width, but [TEXT_INDENT] - * is derived from them so non-event text can't drift out of alignment. - */ -internal val ROW_H_PAD = 4.dp -internal val STRIPE_WIDTH = 5.dp -internal val STRIPE_GAP = 10.dp +/** Horizontal inset of a row's text inside its card; doesn't scale, so it never eats title width. */ +internal val ROW_H_PAD = 12.dp -/** Left indent that lines non-event text up with the event title column. */ -internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP +/** Space between the date-pill gutter and a day's card. */ +internal val GUTTER_GAP = 6.dp /** * Every size the agenda widget varies by [WidgetSize]. Values that genuinely @@ -24,90 +18,91 @@ internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP * constants rather than routing through here. */ internal data class AgendaMetrics( - val title: TextUnit, // header "Upcoming" - val dayHeader: TextUnit, // day label ("Today · …") + val pillWeekday: TextUnit, // date pill weekday ("Thu") + val pillDay: TextUnit, // date pill day number val eventTitle: TextUnit, // event title line val eventTime: TextUnit, // time/location line val placeholder: TextUnit, // "no more events today" (#35) val message: TextUnit, // empty/permission centred message - val stripeH: Dp, // coloured event stripe height - val iconImage: Dp, // header action icon glyph - val iconBox: Dp, // header action touch target + val contentH: Dp, // an event row's two text lines + val pillWidth: Dp, // date pill / gutter width val rowVPad: Dp, // event row vertical padding - val dayHeaderTopPad: Dp, // space above a day header + val dayGap: Dp, // space between two days' cards + val monthLabel: TextUnit, // month name above a new month's first day ) { + /** One event row's height, shared by the date pill beside it and the placeholder row. */ + val rowHeight: Dp get() = contentH + rowVPad * 2 + /** - * Resolves the stripe height against the user's system font scale: the stripe - * is a [Dp] while the lines beside it are `sp`, so without this the text - * outgrows it at large font settings. + * Resolves the row content height and pill width against the user's system font + * scale: both are [Dp] while the text in and beside them is `sp`, so without + * this the text outgrows them at large font settings. */ fun scaledForFont(fontScale: Float): AgendaMetrics = - if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale) + if (fontScale == 1f) this else copy(contentH = contentH * fontScale, pillWidth = pillWidth * fontScale) } /* - * Type sizes are anchored to the Material 3 type scale, with two deviations: + * Type sizes are anchored to the Material 3 type scale, with one deviation: * - * ‡ SMALL's 13sp day header is off-scale, held there so SMALL reproduces the - * widget's original constants verbatim (#51). * † Above Title Medium the M3 scale jumps 16 → 22 → 24, too coarse for four * steps; interpolated values are used where a role would force a ≥1.4x jump. */ private val SMALL_METRICS = AgendaMetrics( - title = 16.sp, // M3 Title Medium - dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline + pillWeekday = 11.sp, + pillDay = 16.sp, eventTitle = 14.sp, // M3 Body Medium eventTime = 12.sp, // M3 Body Small placeholder = 14.sp, // M3 Body Medium message = 14.sp, // M3 Body Medium - stripeH = 36.dp, - iconImage = 22.dp, - iconBox = 40.dp, + contentH = 36.dp, + pillWidth = 40.dp, rowVPad = 4.dp, - dayHeaderTopPad = 10.dp, + dayGap = 8.dp, + monthLabel = 12.sp, ) private val MEDIUM_METRICS = AgendaMetrics( - title = 18.sp, // † - dayHeader = 14.sp, // M3 Title Small + pillWeekday = 12.sp, + pillDay = 18.sp, eventTitle = 16.sp, // M3 Body Large eventTime = 14.sp, // M3 Body Medium placeholder = 16.sp, // M3 Body Large message = 16.sp, // M3 Body Large - stripeH = 40.dp, - iconImage = 24.dp, - iconBox = 44.dp, + contentH = 40.dp, + pillWidth = 44.dp, rowVPad = 5.dp, - dayHeaderTopPad = 11.dp, + dayGap = 9.dp, + monthLabel = 14.sp, ) private val LARGE_METRICS = AgendaMetrics( - title = 20.sp, // † - dayHeader = 16.sp, // M3 Title Medium + pillWeekday = 12.sp, + pillDay = 20.sp, eventTitle = 18.sp, // † eventTime = 14.sp, // M3 Body Medium — the secondary line steps more slowly placeholder = 18.sp, // † on purpose, so the title keeps its message = 18.sp, // † lead and the hierarchy survives. - stripeH = 46.dp, - iconImage = 26.dp, - iconBox = 48.dp, + contentH = 46.dp, + pillWidth = 48.dp, rowVPad = 6.dp, - dayHeaderTopPad = 12.dp, + dayGap = 10.dp, + monthLabel = 14.sp, ) private val EXTRA_LARGE_METRICS = AgendaMetrics( - title = 22.sp, // M3 Title Large - dayHeader = 18.sp, // † + pillWeekday = 14.sp, + pillDay = 22.sp, eventTitle = 20.sp, // † eventTime = 16.sp, // M3 Body Large placeholder = 20.sp, // † message = 20.sp, // † - stripeH = 52.dp, - iconImage = 28.dp, - iconBox = 52.dp, + contentH = 52.dp, + pillWidth = 52.dp, rowVPad = 8.dp, - dayHeaderTopPad = 14.dp, + dayGap = 12.dp, + monthLabel = 16.sp, ) /** Indexed by [WidgetSize.ordinal] so lookup allocates nothing per recomposition. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index 1695baa..3a86aea 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -6,18 +6,13 @@ import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -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.action.ActionParameters 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 @@ -36,6 +31,12 @@ 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.text.TextAlign +import androidx.glance.unit.ColorProvider +import de.jeanlucmakiola.floret.glance.WidgetHeader +import de.jeanlucmakiola.floret.glance.WidgetHeaderAction import androidx.glance.text.FontWeight import androidx.glance.text.Text import androidx.glance.text.TextStyle @@ -49,10 +50,12 @@ import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha +import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.floret.locale.localizedDateFormatter -import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventFill +import de.jeanlucmakiola.calendula.ui.common.eventInk +import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA import de.jeanlucmakiola.calendula.widget.AgendaWidgetData import de.jeanlucmakiola.calendula.widget.WidgetSize import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot @@ -72,20 +75,20 @@ import androidx.glance.currentState import de.jeanlucmakiola.floret.glance.WidgetData import de.jeanlucmakiola.floret.glance.currentRefreshStamp import de.jeanlucmakiola.floret.glance.refreshStamp -import de.jeanlucmakiola.floret.glance.updateWithRefresh import de.jeanlucmakiola.calendula.widget.systemZone import de.jeanlucmakiola.calendula.widget.today import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.plus +import kotlinx.datetime.toJavaDayOfWeek import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant import java.util.Locale /** * "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days - * of events grouped under day headers (the Google "Schedule" widget model). + * of events, each day one card of grouped rows with its date pill beside it. * Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda. */ class AgendaWidget : GlanceAppWidget() { @@ -131,15 +134,8 @@ internal fun AgendaWidgetContent( } } -/** Rows a preview draws: more than any widget shows at once. */ -private const val PREVIEW_ROWS = 16 - -/** Reloads and redraws the tapped widget (header refresh button). */ -class RefreshAgendaAction : ActionCallback { - override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { - AgendaWidget().updateWithRefresh(context.applicationContext, glanceId) - } -} +/** Rows a preview draws: Glance's cap on a Column's children, still more than a placed widget shows. */ +private const val PREVIEW_ROWS = 10 /** * Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews @@ -150,19 +146,18 @@ class RefreshAgendaAction : ActionCallback { */ private const val MAX_AGENDA_ROWS = 100 -/** Flat row model so the [LazyColumn] can mix day headers and events. */ +/** Flat row model: one [LazyColumn] item per event or placeholder, a day's first one carrying its date pill. */ private sealed interface AgendaRow { val date: LocalDate - data class Header(override val date: LocalDate, val today: LocalDate) : AgendaRow data class Event(override val date: LocalDate, val event: EventInstance) : AgendaRow - /** "Nothing left today" line under an anchored, event-less today (#35). */ + /** "Nothing left today" line for an anchored, event-less today (#35). */ data class Placeholder(override val date: LocalDate) : AgendaRow } @Composable private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) { - // The stripe is re-resolved against the system font scale so it tracks the + // The row height is re-resolved against the system font scale so it tracks the // sp-sized text beside it instead of drifting at large accessibility settings. val size = (data as? AgendaWidgetData.Ready)?.textSize ?: WidgetSize.SMALL val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale @@ -172,8 +167,8 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) { .fillMaxSize() .padding(horizontal = 8.dp, vertical = 6.dp), ) { - AgendaHeader(metrics) - Spacer(GlanceModifier.height(4.dp)) + AgendaHeader() + Spacer(GlanceModifier.height(6.dp)) when (data) { AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics) is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics, preview = preview) @@ -212,33 +207,47 @@ private fun AgendaRows( } else { val rows = buildList { visibleDays.forEach { day -> - add(AgendaRow.Header(day.date, data.today)) if (day.events.isEmpty()) { add(AgendaRow.Placeholder(day.date)) } else { day.events.forEach { add(AgendaRow.Event(day.date, it)) } } } - } - // Bound the payload, then drop a day header the cut left - // stranded with nothing under it. - .take(MAX_AGENDA_ROWS) - .dropLastWhile { it is AgendaRow.Header } - // On a transparent widget each day becomes one card of grouped rows, - // so it stays readable over the wallpaper (#292). - val positions = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) dayPositions(rows) else null + }.take(MAX_AGENDA_ROWS) + // 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 item: @Composable (Int) -> Unit = { index -> val row = rows[index] - if (positions != null) { - WidgetGroupedRow( - position = positions[index], - modifier = if (row is AgendaRow.Header && index > 0) GlanceModifier.padding(top = 6.dp) else GlanceModifier, - color = GlanceTheme.colors.widgetBackground, - ) { - AgendaRowContent(row, data, metrics) + val position = positions[index] + val first = position == Position.Top || position == Position.Alone + val dayRow: @Composable (GlanceModifier) -> Unit = { modifier -> + Row(modifier = modifier.fillMaxWidth()) { + Box(modifier = GlanceModifier.width(metrics.pillWidth)) { + if (first) DatePill(row.date, data.today, cardColor, metrics) + } + Spacer(GlanceModifier.width(GUTTER_GAP)) + Box(modifier = GlanceModifier.defaultWeight()) { + val rowColor = when (row) { + is AgendaRow.Event -> row.fill(data).let { themedColor(day = it.day, night = it.night) } + is AgendaRow.Placeholder -> cardColor + } + WidgetGroupedRow(position = position, color = rowColor) { + AgendaRowContent(row, data, metrics) + } + } + } + } + val gap = GlanceModifier.padding(top = if (first && index > 0) metrics.dayGap else 0.dp) + // The pills carry no month, so a new month gets its name above its first day. + if (first && index > 0 && row.date.month != rows[index - 1].date.month) { + Column(modifier = gap.fillMaxWidth()) { + MonthLabel(row.date, data.today, metrics) + dayRow(GlanceModifier) } } else { - AgendaRowContent(row, data, metrics) + dayRow(gap) } } if (preview) { @@ -267,156 +276,159 @@ private fun dayPositions(rows: List): List = buildList { @Composable private fun AgendaRowContent(row: AgendaRow, data: AgendaWidgetData.Ready, metrics: AgendaMetrics) { when (row) { - is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics) is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics) is AgendaRow.Event -> EventRow( event = row.event, day = row.date, - soften = data.soften, + fill = row.fill(data), is24Hour = data.is24Hour, - dimmed = data.pastDisplay == PastEventDisplay.DIM && row.event.hasEnded(data.now), metrics = metrics, ) } } @Composable -private fun AgendaHeader(metrics: AgendaMetrics) { +private fun AgendaHeader() { val context = androidx.glance.LocalContext.current - Row( - modifier = GlanceModifier.fillMaxWidth().wallpaperBacking().padding(horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically, + // Tap the "Upcoming" title to open the app on the user's default view + // (issue #20) — a null target resolves to the default home in the host. + WidgetHeader( + title = context.getString(R.string.widget_agenda_title), + titleAction = actionStartActivity(MainActivity.openViewIntent(context, view = null)), + modifier = GlanceModifier.padding(start = 4.dp), ) { - // Tap the "Upcoming" title to open the app on the user's default view - // (issue #20) — a null target resolves to the default home in the host. - Text( - text = context.getString(R.string.widget_agenda_title), - style = TextStyle( - color = GlanceTheme.colors.primary, - fontSize = metrics.title, - fontWeight = FontWeight.Medium, - ), - modifier = GlanceModifier - .defaultWeight() - .clickable( - actionStartActivity(MainActivity.openViewIntent(context, view = null)), - ), - ) - IconButton( - resId = R.drawable.ic_widget_refresh, - contentDescription = context.getString(R.string.widget_refresh), - onClick = GlanceModifier.clickable(actionRunCallback()), - metrics = metrics, - ) - IconButton( - resId = R.drawable.ic_widget_add, + WidgetHeaderAction( + icon = ImageProvider(R.drawable.ic_widget_add), contentDescription = context.getString(R.string.widget_new_event), - onClick = GlanceModifier.clickable( - actionStartActivity( - MainActivity.openCreateIntent(context, today(systemZone())), - ), - ), - metrics = metrics, + action = actionStartActivity(MainActivity.openCreateIntent(context, today(systemZone()))), ) } } +/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */ @Composable -private fun IconButton( - resId: Int, - contentDescription: String, - onClick: GlanceModifier, - metrics: AgendaMetrics, -) { - Box( - modifier = GlanceModifier.size(metrics.iconBox).then(onClick), - contentAlignment = Alignment.Center, - ) { - Image( - provider = ImageProvider(resId), - contentDescription = contentDescription, - colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant), - modifier = GlanceModifier.size(metrics.iconImage), - ) - } +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 DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) { - val context = androidx.glance.LocalContext.current +private fun MonthLabel(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) { Text( - text = agendaDayLabel(context, date, today), + text = formatCalendarTitle( + date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, 1), + locale = Locale.getDefault(), + currentYear = today.year, + skeleton = "LLLL", + ), + maxLines = 1, style = TextStyle( - color = if (date == today) GlanceTheme.colors.primary - else GlanceTheme.colors.onSurfaceVariant, - fontSize = metrics.dayHeader, + color = GlanceTheme.colors.onSurfaceVariant, + fontSize = metrics.monthLabel, fontWeight = FontWeight.Medium, ), modifier = GlanceModifier - .fillMaxWidth() - .padding( - start = 8.dp, - end = 8.dp, - top = metrics.dayHeaderTopPad, - bottom = metrics.rowVPad, - ) - .clickable( - actionStartActivity( - MainActivity.openDateIntent(context, date, CalendarView.Agenda), - ), - ), + .padding(bottom = 4.dp) + .wallpaperBacking(radius = 8.dp) + .padding(horizontal = 6.dp, vertical = 2.dp), ) } -/** - * "No more events today" under an anchored, event-less today (#35). Kept plain — - * a muted line indented to the event titles — since the widget's event rows are - * plain too (a stripe + text, not cards), so a card here would look out of place. - */ +/** A day's weekday and number in a rounded pill, filled for today; opens that day in the app. */ +@Composable +private fun DatePill(date: LocalDate, today: LocalDate, cardColor: ColorProvider, metrics: AgendaMetrics) { + val context = androidx.glance.LocalContext.current + val isToday = date == today + val container = if (isToday) GlanceTheme.colors.primary else cardColor + val content = if (isToday) GlanceTheme.colors.onPrimary else GlanceTheme.colors.onSurface + val label = agendaDayLabel(context, date, today) + Column( + modifier = GlanceModifier + .width(metrics.pillWidth) + .height(metrics.rowHeight) + .cornerRadius(metrics.pillWidth / 2) + .background(container) + .clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda))) + .semantics { contentDescription = label }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = weekdayShort(date), + maxLines = 1, + style = TextStyle(color = content, fontSize = metrics.pillWeekday, textAlign = TextAlign.Center), + ) + Text( + text = date.day.toString(), + maxLines = 1, + style = TextStyle( + color = content, + fontSize = metrics.pillDay, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + ), + ) + } +} + +/** "No more events today" in an anchored, event-less today's card (#35), as tall as the pill beside it. */ @Composable private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) { val context = androidx.glance.LocalContext.current - Text( - text = context.getString(R.string.agenda_no_more_today), - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder), + Box( modifier = GlanceModifier .fillMaxWidth() - .padding( - start = TEXT_INDENT, - end = 8.dp, - top = 2.dp, - bottom = metrics.rowVPad + 2.dp, - ) + .height(metrics.rowHeight) + .padding(horizontal = ROW_H_PAD) .clickable( actionStartActivity( MainActivity.openDateIntent(context, date, CalendarView.Agenda), ), ), - ) + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = context.getString(R.string.agenda_no_more_today), + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder), + ) + } +} + +/** An event row's light and dark fill: its calendar's container tone, faded when the row is dimmed. */ +private data class EventFill(val day: Color, val night: Color) + +private fun AgendaRow.Event.fill(data: AgendaWidgetData.Ready): EventFill { + val dimmed = data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now) + fun tone(dark: Boolean) = eventFill(event.color, dark, data.soften).let { + if (dimmed) it.copy(alpha = EventDimAlpha) else it + } + return EventFill(day = tone(dark = false), night = tone(dark = true)) } @Composable private fun EventRow( event: EventInstance, day: LocalDate, - soften: Boolean, + fill: EventFill, is24Hour: Boolean, - dimmed: Boolean, metrics: AgendaMetrics, ) { val context = androidx.glance.LocalContext.current val title = event.title.ifBlank { context.getString(R.string.event_untitled) } - // Glance has no generic alpha modifier, so dim by fading the colour stripe and - // dropping both text lines to the lower-emphasis on-surface-variant tone. - fun stripe(dark: Boolean) = eventAccent(event.color, dark, soften).let { - if (dimmed) it.copy(alpha = EventDimAlpha) else it - } - val stripeColor = themedColor(day = stripe(dark = false), night = stripe(dark = true)) - val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface - Row( + // Ink is picked against the opaque fill, then carries the dim along with it. + fun ink(fill: Color, alpha: Float) = eventInk(fill.copy(alpha = 1f), alpha * fill.alpha) + val titleInk = themedColor(day = ink(fill.day, 1f), night = ink(fill.night, 1f)) + val timeInk = themedColor( + day = ink(fill.day, SECONDARY_INK_ALPHA), + night = ink(fill.night, SECONDARY_INK_ALPHA), + ) + Column( modifier = GlanceModifier .fillMaxWidth() - .padding(horizontal = ROW_H_PAD, vertical = metrics.rowVPad) + .height(metrics.rowHeight) + .padding(horizontal = ROW_H_PAD) .clickable( actionStartActivity( MainActivity.openEventIntent( @@ -430,30 +442,21 @@ private fun EventRow( ), verticalAlignment = Alignment.CenterVertically, ) { - Box( - modifier = GlanceModifier - .width(STRIPE_WIDTH) - .height(metrics.stripeH) - .cornerRadius(3.dp) - .background(stripeColor), - ) {} - Spacer(GlanceModifier.width(STRIPE_GAP)) - Column(modifier = GlanceModifier.defaultWeight()) { - Text( - text = title, - maxLines = 1, - style = TextStyle( - color = titleColor, - fontSize = metrics.eventTitle, - textDecoration = glanceDeclinedDecoration(event.isDeclined), - ), - ) - Text( - text = eventTimeSummary(context, event, day, is24Hour), - maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime), - ) - } + Text( + text = title, + maxLines = 1, + style = TextStyle( + color = titleInk, + fontSize = metrics.eventTitle, + fontWeight = FontWeight.Medium, + textDecoration = glanceDeclinedDecoration(event.isDeclined), + ), + ) + Text( + text = eventTimeSummary(context, event, day, is24Hour), + maxLines = 1, + style = TextStyle(color = timeInk, fontSize = metrics.eventTime), + ) } } @@ -489,6 +492,9 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): return if (relative != null) "$relative · $formatted" else formatted } +private fun weekdayShort(date: LocalDate): String = + date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, Locale.getDefault()) + private fun eventTimeSummary( context: Context, event: EventInstance, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt index 01d70b3..c4d7828 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt @@ -32,11 +32,14 @@ internal class ViewIds(sdkInt: Int = Build.VERSION.SDK_INT) { /** * `MonthWidgetBody`'s views outside the week rows: the surface Box, its - * Column, the header row with three sized clickable icon boxes, their sized - * icons and the clickable title, the spacer, the grid's Column and the - * weekday row of sized labels. + * Column, the header row with its clickable title Box, title Text and (on a + * transparent widget) the spacer after the title pill, two + * sized clickable icon pills with their sized icons, the sized clickable + * today pill with its Text and the two sized action gaps, the spacer, the + * grid's Column and the weekday row of sized labels. */ - val fixed = plain + plain + plain + 3 * (clickableSized + sized) + clickablePlain + sized + plain + plain + 7 * sized + val fixed = plain + plain + plain + clickablePlain + plain + plain + 2 * (clickableSized + sized) + + (clickableSized + plain) + 2 * sized + sized + plain + plain + 7 * sized } /** A cell [cols] columns wide holding [item], or a merged gap where [item] is null. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index c0443ad..b89270e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -13,11 +13,9 @@ 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.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.LocalSize @@ -48,6 +46,11 @@ import androidx.glance.layout.padding import androidx.glance.layout.size import androidx.glance.layout.width import androidx.glance.state.PreferencesGlanceStateDefinition +import de.jeanlucmakiola.floret.glance.WidgetActionStyle +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 androidx.glance.text.FontWeight import androidx.glance.text.Text import androidx.glance.text.TextAlign @@ -229,7 +232,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { ) { when (source) { MonthWidgetSource.NeedsPermission -> { - MonthHeader(label = "Calendula") + MonthHeader(label = "Calendula", today = today(systemZone())) PermissionMessage() } is MonthWidgetSource.Ready -> { @@ -239,8 +242,8 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7 val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone) - MonthHeader(label = monthLabel(ym, source.today.year)) - Spacer(GlanceModifier.height(2.dp)) + MonthHeader(label = monthLabel(ym, source.today.year), today = source.today) + Spacer(GlanceModifier.height(4.dp)) // One backing for the whole grid on a transparent widget (#292). Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight().wallpaperBacking()) { WeekdayHeader(weekStart = source.weekStart, colW = colW) @@ -261,68 +264,35 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { } @Composable -private fun MonthHeader(label: String) { +private fun MonthHeader(label: String, today: LocalDate) { val context = LocalContext.current - Row( - modifier = GlanceModifier.fillMaxWidth().wallpaperBacking(), - verticalAlignment = Alignment.CenterVertically, + // Tap the month/year title to open the app on the month view (issue #18), + // rooted over the default home so backing out returns there. + WidgetHeader( + title = label, + titleAction = actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Month)), + modifier = GlanceModifier.padding(start = 4.dp, bottom = 2.dp), ) { - HeaderIcon( - resId = R.drawable.ic_widget_chevron_left, + WidgetHeaderAction( + icon = ImageProvider(R.drawable.ic_widget_chevron_left), contentDescription = context.getString(R.string.widget_prev_month), - onClick = GlanceModifier.clickable( - actionRunCallback( - actionParametersOf(ShiftMonthAction.deltaKey to -1), - ), - ), + action = actionRunCallback(actionParametersOf(ShiftMonthAction.deltaKey to -1)), + style = WidgetActionStyle.TONAL, ) - // Tap the month/year title to open the app on the month view (issue #18), - // rooted over the default home so backing out returns there. - Text( - text = label, - style = TextStyle( - color = GlanceTheme.colors.primary, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center, - ), - modifier = GlanceModifier - .defaultWeight() - .clickable( - actionStartActivity( - MainActivity.openViewIntent(context, CalendarView.Month), - ), - ), - ) - // The "today" button snaps the grid back to the current month in place. - HeaderIcon( - resId = R.drawable.ic_widget_today, + WidgetHeaderActionGap() + // The "today" pill carries today's number like the app icon, and snaps + // the grid back to the current month in place. + WidgetHeaderTextAction( + text = today.day.toString(), contentDescription = context.getString(R.string.widget_today), - onClick = GlanceModifier.clickable(actionRunCallback()), + action = actionRunCallback(), ) - HeaderIcon( - resId = R.drawable.ic_widget_chevron_right, + WidgetHeaderActionGap() + WidgetHeaderAction( + icon = ImageProvider(R.drawable.ic_widget_chevron_right), contentDescription = context.getString(R.string.widget_next_month), - onClick = GlanceModifier.clickable( - actionRunCallback( - actionParametersOf(ShiftMonthAction.deltaKey to 1), - ), - ), - ) - } -} - -@Composable -private fun HeaderIcon(resId: Int, contentDescription: String, onClick: GlanceModifier) { - Box( - modifier = GlanceModifier.size(40.dp).then(onClick), - contentAlignment = Alignment.Center, - ) { - Image( - provider = ImageProvider(resId), - contentDescription = contentDescription, - colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant), - modifier = GlanceModifier.size(20.dp), + action = actionRunCallback(actionParametersOf(ShiftMonthAction.deltaKey to 1)), + style = WidgetActionStyle.TONAL, ) } } diff --git a/app/src/main/res/drawable/ic_widget_refresh.xml b/app/src/main/res/drawable/ic_widget_refresh.xml deleted file mode 100644 index ee48912..0000000 --- a/app/src/main/res/drawable/ic_widget_refresh.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_widget_today.xml b/app/src/main/res/drawable/ic_widget_today.xml deleted file mode 100644 index 075ba88..0000000 --- a/app/src/main/res/drawable/ic_widget_today.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt index 80d64f9..15804b8 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt @@ -15,25 +15,12 @@ class AgendaScaleTest { // SMALL is the default, so if this fails an agenda widget whose owner never // touched the size setting no longer looks as it did (#51). val m = metricsFor(WidgetSize.SMALL) - assertThat(m.title).isEqualTo(16.sp) - assertThat(m.dayHeader).isEqualTo(13.sp) assertThat(m.eventTitle).isEqualTo(14.sp) assertThat(m.eventTime).isEqualTo(12.sp) assertThat(m.placeholder).isEqualTo(14.sp) assertThat(m.message).isEqualTo(14.sp) - assertThat(m.stripeH).isEqualTo(36.dp) - assertThat(m.iconImage).isEqualTo(22.dp) - assertThat(m.iconBox).isEqualTo(40.dp) + assertThat(m.contentH).isEqualTo(36.dp) assertThat(m.rowVPad).isEqualTo(4.dp) - assertThat(m.dayHeaderTopPad).isEqualTo(10.dp) - } - - @Test - fun `the derived text indent matches the original hardcoded inset`() { - // TEXT_INDENT replaced a hardcoded 19dp; it must still resolve to 19dp or - // day headers and the placeholder line stop aligning with event titles. - assertThat(TEXT_INDENT).isEqualTo(19.dp) - assertThat(TEXT_INDENT).isEqualTo(ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP) } // --- the ramp ------------------------------------------------------------ @@ -43,17 +30,17 @@ class AgendaScaleTest { val steps = WidgetSize.entries.map(::metricsFor) steps.zipWithNext { small, big -> - assertThat(big.title.value).isAtLeast(small.title.value) - assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value) + assertThat(big.pillWeekday.value).isAtLeast(small.pillWeekday.value) + assertThat(big.pillDay.value).isAtLeast(small.pillDay.value) assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value) assertThat(big.eventTime.value).isAtLeast(small.eventTime.value) assertThat(big.placeholder.value).isAtLeast(small.placeholder.value) assertThat(big.message.value).isAtLeast(small.message.value) - assertThat(big.stripeH.value).isAtLeast(small.stripeH.value) - assertThat(big.iconImage.value).isAtLeast(small.iconImage.value) - assertThat(big.iconBox.value).isAtLeast(small.iconBox.value) + assertThat(big.contentH.value).isAtLeast(small.contentH.value) + assertThat(big.pillWidth.value).isAtLeast(small.pillWidth.value) assertThat(big.rowVPad.value).isAtLeast(small.rowVPad.value) - assertThat(big.dayHeaderTopPad.value).isAtLeast(small.dayHeaderTopPad.value) + assertThat(big.dayGap.value).isAtLeast(small.dayGap.value) + assertThat(big.monthLabel.value).isAtLeast(small.monthLabel.value) } } @@ -71,20 +58,28 @@ class AgendaScaleTest { // Guards against a future edit turning "more readable" into "absurd". val base = metricsFor(WidgetSize.SMALL) val top = metricsFor(WidgetSize.EXTRA_LARGE) - assertThat(top.title.value / base.title.value).isLessThan(1.5f) + assertThat(top.pillDay.value / base.pillDay.value).isLessThan(1.5f) assertThat(top.eventTitle.value / base.eventTitle.value).isLessThan(1.5f) } // --- font scale ---------------------------------------------------------- @Test - fun `the stripe tracks the system font scale`() { - // The stripe is Dp, the text beside it is sp: without this the two diverge - // at large accessibility font settings and the stripe under-runs the row. + fun `the row height tracks the system font scale`() { + // The row height is Dp, the text in it is sp: without this the two diverge + // at large accessibility font settings and the text outgrows the row. val m = metricsFor(WidgetSize.SMALL) - assertThat(m.scaledForFont(1f).stripeH).isEqualTo(36.dp) - assertThat(m.scaledForFont(1.3f).stripeH.value).isWithin(0.01f).of(46.8f) - assertThat(m.scaledForFont(0.85f).stripeH.value).isWithin(0.01f).of(30.6f) + assertThat(m.scaledForFont(1f).contentH).isEqualTo(36.dp) + assertThat(m.scaledForFont(1.3f).contentH.value).isWithin(0.01f).of(46.8f) + assertThat(m.scaledForFont(0.85f).contentH.value).isWithin(0.01f).of(30.6f) + } + + @Test + fun `the date pill widens with the system font scale`() { + // The pill's text is sp; a fixed-width pill clips "Wed" / "23" at large font settings. + val m = metricsFor(WidgetSize.SMALL) + assertThat(m.scaledForFont(2f).pillWidth).isEqualTo(m.pillWidth * 2) + assertThat(m.scaledForFont(2f).rowHeight).isEqualTo(m.contentH * 2 + m.rowVPad * 2) } @Test @@ -99,7 +94,7 @@ class AgendaScaleTest { // double-count it. val m = metricsFor(WidgetSize.MEDIUM) val scaled = m.scaledForFont(1.3f) - assertThat(scaled.title).isEqualTo(m.title) + assertThat(scaled.pillDay).isEqualTo(m.pillDay) assertThat(scaled.eventTitle).isEqualTo(m.eventTitle) assertThat(scaled.rowVPad).isEqualTo(m.rowVPad) } diff --git a/floret-kit b/floret-kit index 6dc7bc5..d6b5547 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 6dc7bc50c5509d50a2e69cf34430640c90dd33bd +Subproject commit d6b55476d1e1d52e9eecffbf9491e2d47ea7e736 From d43a741c9306adc2f03e56caea377c6d81d431fb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 15:16:28 +0200 Subject: [PATCH 2/6] Agenda widget: format the month label outside the composable (#71) --- .../calendula/widget/agenda/AgendaWidget.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index 3a86aea..66bef32 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -316,12 +316,7 @@ private fun agendaCardColor(): ColorProvider = when (LocalWidgetBackground.curre @Composable private fun MonthLabel(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) { Text( - text = formatCalendarTitle( - date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, 1), - locale = Locale.getDefault(), - currentYear = today.year, - skeleton = "LLLL", - ), + text = monthName(date, today), maxLines = 1, style = TextStyle( color = GlanceTheme.colors.onSurfaceVariant, @@ -492,6 +487,13 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): return if (relative != null) "$relative · $formatted" else formatted } +private fun monthName(date: LocalDate, today: LocalDate): String = formatCalendarTitle( + date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, 1), + locale = Locale.getDefault(), + currentYear = today.year, + skeleton = "LLLL", +) + private fun weekdayShort(date: LocalDate): String = date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, Locale.getDefault()) From d1557825c677a5399da2f9f72cc5c18d52e214da Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 15:12:18 +0200 Subject: [PATCH 3/6] Month widget: draw the weeks as plain RemoteViews, lanes from the widget's height (#372) Glance's 499 view ids forced busy months down to one or two lanes. The week rows are now one embedded RemoteViews tree, so every event that fits the height gets its bar and only the rest folds into +N. The on-device test now checks the payload instead of the view count. --- ...udgetTest.kt => MonthWidgetPayloadTest.kt} | 33 ++- .../calendula/widget/month/MonthGrid.kt | 102 ++------ .../calendula/widget/month/MonthGridViews.kt | 177 ++++++++++++++ .../calendula/widget/month/MonthWidget.kt | 213 ++--------------- .../main/res/drawable/widget_month_bar_bg.xml | 5 + .../res/drawable/widget_month_today_bg.xml | 4 + .../main/res/layout/widget_month_bar_1.xml | 26 +++ .../main/res/layout/widget_month_bar_2.xml | 26 +++ .../main/res/layout/widget_month_bar_3.xml | 26 +++ .../main/res/layout/widget_month_bar_4.xml | 26 +++ .../main/res/layout/widget_month_bar_5.xml | 26 +++ .../main/res/layout/widget_month_bar_6.xml | 26 +++ .../main/res/layout/widget_month_bar_7.xml | 26 +++ .../main/res/layout/widget_month_gap_1.xml | 5 + .../main/res/layout/widget_month_gap_2.xml | 5 + .../main/res/layout/widget_month_gap_3.xml | 5 + .../main/res/layout/widget_month_gap_4.xml | 5 + .../main/res/layout/widget_month_gap_5.xml | 5 + .../main/res/layout/widget_month_gap_6.xml | 5 + .../main/res/layout/widget_month_gap_7.xml | 5 + app/src/main/res/layout/widget_month_grid.xml | 6 + app/src/main/res/layout/widget_month_lane.xml | 8 + app/src/main/res/layout/widget_month_more.xml | 11 + app/src/main/res/layout/widget_month_week.xml | 220 ++++++++++++++++++ .../calendula/widget/month/MonthGridTest.kt | 39 ++-- 25 files changed, 724 insertions(+), 311 deletions(-) rename app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/{MonthWidgetViewBudgetTest.kt => MonthWidgetPayloadTest.kt} (82%) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt create mode 100644 app/src/main/res/drawable/widget_month_bar_bg.xml create mode 100644 app/src/main/res/drawable/widget_month_today_bg.xml create mode 100644 app/src/main/res/layout/widget_month_bar_1.xml create mode 100644 app/src/main/res/layout/widget_month_bar_2.xml create mode 100644 app/src/main/res/layout/widget_month_bar_3.xml create mode 100644 app/src/main/res/layout/widget_month_bar_4.xml create mode 100644 app/src/main/res/layout/widget_month_bar_5.xml create mode 100644 app/src/main/res/layout/widget_month_bar_6.xml create mode 100644 app/src/main/res/layout/widget_month_bar_7.xml create mode 100644 app/src/main/res/layout/widget_month_gap_1.xml create mode 100644 app/src/main/res/layout/widget_month_gap_2.xml create mode 100644 app/src/main/res/layout/widget_month_gap_3.xml create mode 100644 app/src/main/res/layout/widget_month_gap_4.xml create mode 100644 app/src/main/res/layout/widget_month_gap_5.xml create mode 100644 app/src/main/res/layout/widget_month_gap_6.xml create mode 100644 app/src/main/res/layout/widget_month_gap_7.xml create mode 100644 app/src/main/res/layout/widget_month_grid.xml create mode 100644 app/src/main/res/layout/widget_month_lane.xml create mode 100644 app/src/main/res/layout/widget_month_more.xml create mode 100644 app/src/main/res/layout/widget_month_week.xml diff --git a/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetViewBudgetTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt similarity index 82% rename from app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetViewBudgetTest.kt rename to app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt index f57f1d2..301155a 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetViewBudgetTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt @@ -4,6 +4,7 @@ import android.Manifest import android.content.ContentUris import android.content.ContentValues import android.net.Uri +import android.os.Parcel import android.provider.CalendarContract import android.util.Log import android.view.View @@ -29,12 +30,12 @@ import java.time.ZoneId /** * Renders the month widget for a packed month through Glance's own translator - * and counts what it inflates (#372): the view-id model in [WeekLayout] has to - * match what Glance really builds, or a busy month fails with "too many views". + * (#372) and checks what one size costs on the wire: the host's binder buffer is + * 1 MB for every widget update at once, and Exact sends one copy per size (#214). */ @OptIn(ExperimentalGlanceApi::class) @RunWith(AndroidJUnit4::class) -class MonthWidgetViewBudgetTest { +class MonthWidgetPayloadTest { @get:Rule val permissionRule: GrantPermissionRule = @@ -95,31 +96,39 @@ class MonthWidgetViewBudgetTest { } @Test - fun aPackedMonthRendersWithinGlancesViewIds() = runBlocking { + fun aPackedMonthRendersWithinItsPayload() = runBlocking { for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp))) { val views = MonthWidget().compose(context, size = size) - var total = 0 + val bytes = Parcel.obtain().let { parcel -> + try { + views.writeToParcel(parcel, 0) + parcel.dataSize() + } finally { + parcel.recycle() + } + } var texts = 0 instrumentation.runOnMainSync { val root = views.apply(context, FrameLayout(context)) fun walk(view: View) { - total += 1 if (view is TextView) texts += 1 if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i)) } walk(root) } - Log.i(TAG, "size=$size views=$total texts=$texts budget=$VIEW_BUDGET") + Log.i(TAG, "size=$size bytes=$bytes texts=$texts") // The grid itself rendered, not Glance's error layout: every day number is there. - assertThat(texts).isAtLeast(35) - assertThat(total).isAtMost(GLANCE_VIEW_IDS) + assertThat(texts).isAtLeast(42) + assertThat(bytes).isAtMost(MAX_BYTES_PER_SIZE) } } private companion object { - const val TAG = "MonthWidgetBudget" - const val ACCOUNT = "calendula-widget-budget-test" + const val TAG = "MonthWidgetPayload" + const val ACCOUNT = "calendula-widget-payload-test" const val EVENTS_PER_DAY = 6 - const val GLANCE_VIEW_IDS = 499 + + /** A quarter of the host's buffer, leaving room for a second size and every other widget. */ + const val MAX_BYTES_PER_SIZE = 256 * 1024 } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt index c4d7828..d567e4f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt @@ -1,46 +1,11 @@ package de.jeanlucmakiola.calendula.widget.month -import android.os.Build +import androidx.compose.ui.unit.Dp import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.month.DaySeating import de.jeanlucmakiola.calendula.ui.month.MonthWeek import de.jeanlucmakiola.calendula.ui.month.seating import de.jeanlucmakiola.calendula.ui.month.spanAt -import kotlinx.datetime.LocalDate - -/** Event rows (lanes) a week draws at most before the rest collapse into "+N". */ -internal const val MAX_LANES = 3 - -/** - * View ids one rendering may spend. Glance has 499 and fails the whole widget - * past them (#372); staying well under also keeps a busy month's payload, sent - * once per host size, clear of the binder limit #214 ran into. - */ -internal const val VIEW_BUDGET = 400 - -/** - * What views cost in Glance 1.2's view ids. A clickable view that isn't a - * Button is wrapped in a Box sized like it, with a ripple Image and the view - * itself filling it. Below Android 12 a view with a fixed width or height is - * inflated into a sizing frame first, which takes a second id. - */ -internal class ViewIds(sdkInt: Int = Build.VERSION.SDK_INT) { - val plain = 1 - val sized = if (sdkInt < Build.VERSION_CODES.S) 2 else 1 - val clickablePlain = plain + 2 - val clickableSized = sized + 2 - - /** - * `MonthWidgetBody`'s views outside the week rows: the surface Box, its - * Column, the header row with its clickable title Box, title Text and (on a - * transparent widget) the spacer after the title pill, two - * sized clickable icon pills with their sized icons, the sized clickable - * today pill with its Text and the two sized action gaps, the spacer, the - * grid's Column and the weekday row of sized labels. - */ - val fixed = plain + plain + plain + clickablePlain + plain + plain + 2 * (clickableSized + sized) + - (clickableSized + plain) + 2 * sized + sized + plain + plain + 7 * sized -} /** A cell [cols] columns wide holding [item], or a merged gap where [item] is null. */ internal data class GridCell(val item: T?, val cols: Int) @@ -71,7 +36,7 @@ internal fun mergeGaps(columns: Int, cellAt: (Int) -> GridCell?): L return cells } -/** One week of the grid seated with [laneCap] lanes, computed once for both drawing and counting. */ +/** One week of the grid seated with [laneCap] lanes. */ internal class WeekLayout(val week: MonthWeek, val laneCap: Int) { private val seating: List = week.days.indices.map { week.seating(it, laneCap) } @@ -92,57 +57,22 @@ internal class WeekLayout(val week: MonthWeek, val laneCap: Int) { if (hidden.none { it > 0 }) emptyList() else mergeGaps(hidden.size) { col -> hidden[col].takeIf { it > 0 }?.let { GridCell(it, 1) } } } - - /** - * The view ids `WeekRow` spends on this week, counted the way it draws it: - * the week Box, the strip Row and its seven sized clickable strips, the - * content Column, the sized day-number row and its sized numbers (today's - * pill adds a sized Box around a plain Text), the sized spacer, each lane's - * Row and sized spacer with sized gaps and bars (a sized Box around a - * filling Box and Text), and the "+N" Row of sized cells. - */ - fun viewIds(today: LocalDate, ids: ViewIds): Int { - val days = week.days.size - val strips = ids.plain + days * ids.clickableSized - val numbers = ids.sized + days * ids.sized + if (today in week.days) ids.sized + ids.plain else 0 - val lanes = lanes.sumOf { cells -> - ids.plain + ids.sized + cells.sumOf { if (it.item == null) ids.sized else ids.sized + 2 * ids.plain } - } - val overflow = if (overflow.isEmpty()) 0 else ids.plain + overflow.size * ids.sized - return ids.plain + strips + ids.plain + numbers + ids.sized + lanes + overflow - } } +/** Event lanes a week row [weekHeight] tall holds under its day number. */ +internal fun lanesFitting(weekHeight: Dp): Int = + ((weekHeight.value - DAY_ROW_DP - LANES_TOP_DP) / LANE_ROW_DP).toInt().coerceAtLeast(0) + /** - * Seats each week so the whole grid fits [budget] view ids (#372). Each round - * folds the lowest lanes of whichever week saves the most ids per lane given - * up, their events joining "+N", until the month fits. + * Seats each week in [lanes] lanes. A week whose events need more gives its + * last lane to the "+N" row instead. */ -internal fun layoutWeeks( - weeks: List, - today: LocalDate, - ids: ViewIds = ViewIds(), - budget: Int = VIEW_BUDGET - ids.fixed, -): List { - val layouts = weeks.map { week -> WeekLayout(week, WeekLayout(week, MAX_LANES).usedLanes) }.toMutableList() - val costs = layouts.map { it.viewIds(today, ids) }.toMutableList() - while (costs.sum() > budget) { - var best: Triple? = null - var bestRate = 0.0 - layouts.forEachIndexed { i, layout -> - for (cap in layout.laneCap - 1 downTo 0) { - val folded = WeekLayout(layout.week, cap) - val cost = folded.viewIds(today, ids) - val rate = (costs[i] - cost).toDouble() / (layout.laneCap - cap) - if (rate > bestRate) { - bestRate = rate - best = Triple(i, folded, cost) - } - } - } - val (i, folded, cost) = best ?: break - layouts[i] = folded - costs[i] = cost - } - return layouts +internal fun fitWeeks(weeks: List, lanes: Int): List = weeks.map { week -> + val needed = WeekLayout(week, UNCAPPED_LANES).usedLanes + WeekLayout(week, if (needed <= lanes) lanes else (lanes - 1).coerceAtLeast(0)) } + +private const val DAY_ROW_DP = 18f +private const val LANES_TOP_DP = 2f +private const val LANE_ROW_DP = 15f +private const val UNCAPPED_LANES = 64 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt new file mode 100644 index 0000000..88db8c4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt @@ -0,0 +1,177 @@ +package de.jeanlucmakiola.calendula.widget.month + +import android.app.PendingIntent +import android.content.Context +import android.content.res.Configuration +import android.graphics.Paint +import android.os.Build +import android.view.View +import android.widget.RemoteViews +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.glance.unit.ColorProvider +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.eventFill +import de.jeanlucmakiola.calendula.ui.common.eventInk +import de.jeanlucmakiola.floret.glance.WidgetThemeMode +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month + +/** A colour for the light and the dark widget theme; equal when the widget pins one. */ +internal data class DayNight(val day: Int, val night: Int) { + fun current(context: Context): Int = + if (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES) night else day +} + +/** The theme colours the plain-RemoteViews grid draws with, resolved out of Glance's providers. */ +internal data class GridColors( + val onSurface: DayNight, + val onSurfaceVariant: DayNight, + val primary: DayNight, + val onPrimary: DayNight, +) + +internal fun ColorProvider.dayNight(context: Context, mode: WidgetThemeMode): DayNight { + fun at(night: Boolean): Int { + val config = Configuration(context.resources.configuration).apply { + uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or + if (night) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO + } + return getColor(context.createConfigurationContext(config)).toArgb() + } + return when (mode) { + WidgetThemeMode.SYSTEM -> DayNight(day = at(false), night = at(true)) + WidgetThemeMode.LIGHT -> at(false).let { DayNight(it, it) } + WidgetThemeMode.DARK -> at(true).let { DayNight(it, it) } + } +} + +private fun pinned(day: Color, night: Color, mode: WidgetThemeMode): DayNight = when (mode) { + WidgetThemeMode.SYSTEM -> DayNight(day.toArgb(), night.toArgb()) + WidgetThemeMode.LIGHT -> DayNight(day.toArgb(), day.toArgb()) + WidgetThemeMode.DARK -> DayNight(night.toArgb(), night.toArgb()) +} + +/** + * The month's week rows as one plain RemoteViews tree, embedded in the Glance + * widget so its views don't draw on Glance's view-id pool (#372): every event + * gets its bar, and only what can't fit the widget's height folds into "+N". + * + * A host may reapply a new rendering onto the views of the last one rather than + * inflate it afresh, so every container is cleared before it's filled and every + * view state is set both ways, never just when it differs from the layout. + */ +internal fun monthGridViews( + context: Context, + layouts: List, + currentMonth: Month, + today: LocalDate, + soften: Boolean, + colors: GridColors, + mode: WidgetThemeMode, +): RemoteViews { + val grid = RemoteViews(context.packageName, R.layout.widget_month_grid) + grid.removeAllViews(R.id.month_grid) + layouts.forEach { grid.addView(R.id.month_grid, weekViews(context, it, currentMonth, today, soften, colors, mode)) } + return grid +} + +private fun weekViews( + context: Context, + layout: WeekLayout, + currentMonth: Month, + today: LocalDate, + soften: Boolean, + colors: GridColors, + mode: WidgetThemeMode, +): RemoteViews { + val week = RemoteViews(context.packageName, R.layout.widget_month_week) + week.removeAllViews(R.id.week_lanes) + layout.week.days.forEachIndexed { col, date -> + week.setOnClickPendingIntent(STRIP_IDS[col], openDayIntent(context, date)) + val dayId = DAY_IDS[col] + week.setTextViewText(dayId, date.day.toString()) + val isToday = date == today + week.setColor(context, dayId, "setTextColor", when { + isToday -> colors.onPrimary + date.month == currentMonth -> colors.onSurface + else -> colors.onSurfaceVariant + }) + week.setViewVisibility(TODAY_IDS[col], if (isToday) View.VISIBLE else View.GONE) + if (isToday) week.setColor(context, TODAY_IDS[col], "setColorFilter", colors.primary) + } + layout.lanes.forEach { cells -> + val lane = laneViews(context) + cells.forEach { cell -> + val event = cell.item + lane.addView(R.id.lane, if (event == null) gap(context, cell.cols) else bar(context, event, cell.cols, soften, mode)) + } + week.addView(R.id.week_lanes, lane) + } + if (layout.overflow.isNotEmpty()) { + val lane = laneViews(context) + layout.overflow.forEach { cell -> + val count = cell.item + if (count == null) { + lane.addView(R.id.lane, gap(context, cell.cols)) + } else { + val more = RemoteViews(context.packageName, R.layout.widget_month_more) + more.setTextViewText(R.id.more, "+$count") + more.setColor(context, R.id.more, "setTextColor", colors.onSurfaceVariant) + lane.addView(R.id.lane, more) + } + } + week.addView(R.id.week_lanes, lane) + } + return week +} + +private fun laneViews(context: Context) = + RemoteViews(context.packageName, R.layout.widget_month_lane).apply { removeAllViews(R.id.lane) } + +private fun gap(context: Context, cols: Int) = RemoteViews(context.packageName, GAP_LAYOUTS[cols - 1]) + +private fun bar(context: Context, event: EventInstance, cols: Int, soften: Boolean, mode: WidgetThemeMode): RemoteViews { + val views = RemoteViews(context.packageName, BAR_LAYOUTS[cols - 1]) + val dayFill = eventFill(event.color, dark = false, soften) + val nightFill = eventFill(event.color, dark = true, soften) + views.setColor(context, R.id.bar_bg, "setColorFilter", pinned(dayFill, nightFill, mode)) + views.setTextViewText(R.id.bar_title, event.title.ifBlank { context.getString(R.string.event_untitled) }) + views.setColor(context, R.id.bar_title, "setTextColor", pinned(eventInk(dayFill), eventInk(nightFill), mode)) + val strike = if (event.isDeclined) Paint.STRIKE_THRU_TEXT_FLAG else 0 + views.setInt(R.id.bar_title, "setPaintFlags", strike or Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) + return views +} + +private fun RemoteViews.setColor(context: Context, id: Int, method: String, color: DayNight) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && color.day != color.night) { + setColorInt(id, method, color.day, color.night) + } else { + setInt(id, method, color.current(context)) + } +} + +private fun openDayIntent(context: Context, date: LocalDate): PendingIntent = PendingIntent.getActivity( + context, + date.toEpochDays().toInt(), + MainActivity.openDateIntent(context, date, CalendarView.Month), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, +) + +private val STRIP_IDS = intArrayOf(R.id.strip_0, R.id.strip_1, R.id.strip_2, R.id.strip_3, R.id.strip_4, R.id.strip_5, R.id.strip_6) +private val DAY_IDS = intArrayOf(R.id.day_0, R.id.day_1, R.id.day_2, R.id.day_3, R.id.day_4, R.id.day_5, R.id.day_6) +private val TODAY_IDS = intArrayOf( + R.id.today_bg_0, R.id.today_bg_1, R.id.today_bg_2, R.id.today_bg_3, R.id.today_bg_4, R.id.today_bg_5, R.id.today_bg_6, +) +private val BAR_LAYOUTS = intArrayOf( + R.layout.widget_month_bar_1, R.layout.widget_month_bar_2, R.layout.widget_month_bar_3, R.layout.widget_month_bar_4, + R.layout.widget_month_bar_5, R.layout.widget_month_bar_6, R.layout.widget_month_bar_7, +) +private val GAP_LAYOUTS = intArrayOf( + R.layout.widget_month_gap_1, R.layout.widget_month_gap_2, R.layout.widget_month_gap_3, R.layout.widget_month_gap_4, + R.layout.widget_month_gap_5, R.layout.widget_month_gap_6, R.layout.widget_month_gap_7, +) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index b89270e..b756d09 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -23,6 +23,7 @@ import androidx.glance.action.ActionParameters import androidx.glance.action.actionParametersOf import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.AndroidRemoteViews import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback @@ -46,6 +47,7 @@ import androidx.glance.layout.padding import androidx.glance.layout.size import androidx.glance.layout.width import androidx.glance.state.PreferencesGlanceStateDefinition +import de.jeanlucmakiola.floret.glance.LocalWidgetThemeMode import de.jeanlucmakiola.floret.glance.WidgetActionStyle import de.jeanlucmakiola.floret.glance.WidgetHeader import de.jeanlucmakiola.floret.glance.WidgetHeaderAction @@ -93,10 +95,11 @@ import java.util.Locale /** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */ private val MONTH_INDEX_KEY = intPreferencesKey("month_index") -private val LANE_HEIGHT = 14.dp -private val DAY_NUMBER_HEIGHT = 18.dp private val GRID_HPADDING = 8.dp +/** Height around the week rows: body padding, header, its spacer and the weekday row. */ +private val GRID_CHROME_HEIGHT = 62.dp + private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone)) private fun yearMonthOf(index: Int): YearMonth = @@ -107,9 +110,9 @@ private fun yearMonthOf(index: Int): YearMonth = * event bars and titled single-day pills (the in-app lane layout via * [layoutMonthWeeks]), and prev/next/today navigation. * - * Columns are sized explicitly from [LocalSize] (hence [SizeMode.Exact]) so a - * multi-day span renders as a single Box spanning its columns — connected, no - * inter-cell seam, with rounded end caps. The displayed month lives in Glance + * The week rows are one plain RemoteViews tree ([monthGridViews]) embedded in + * the Glance widget, so a busy month isn't held to Glance's view-id pool (#372); + * the lanes each week shows come from the widget's height. The displayed month lives in Glance * state and is read reactively in the composition ([currentState]) so the arrows * move it via plain recomposition, not a (here-unreliable) widget session reload. * @@ -120,7 +123,7 @@ private fun yearMonthOf(index: Int): YearMonth = * the launcher rebinds. [SizeMode.Exact] serialises the grid once per size the * host reports, so the ceiling arrives at half the view count you would expect: a * grid that emitted a view per cell blew past it after three taps (#214). Every - * row here emits only what it fills. + * row here emits only what it fills, and lanes stop at what the height shows. */ class MonthWidget : GlanceAppWidget() { @@ -130,8 +133,8 @@ class MonthWidget : GlanceAppWidget() { * Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which * return early from `GlanceAppWidget.resize` — under those the grid is never * recomposed for a new size and a resized widget clips instead of reflowing. - * Exact costs one serialised copy per host size, which is why a rendering is - * held to [VIEW_BUDGET] view ids, folding lanes into "+N" on a busy month. + * Exact costs one serialised copy per host size, so the grid draws only the + * lanes the size can show, folding the rest into "+N". */ override val sizeMode = SizeMode.Exact @@ -241,22 +244,26 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { // 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 weekHeight = (LocalSize.current.height - GRID_CHROME_HEIGHT) / weeks.size + val layouts = fitWeeks(weeks, lanesFitting(weekHeight)) + val context = LocalContext.current + val mode = LocalWidgetThemeMode.current + val colors = GridColors( + onSurface = GlanceTheme.colors.onSurface.dayNight(context, mode), + onSurfaceVariant = GlanceTheme.colors.onSurfaceVariant.dayNight(context, mode), + primary = GlanceTheme.colors.primary.dayNight(context, mode), + onPrimary = GlanceTheme.colors.onPrimary.dayNight(context, mode), + ) MonthHeader(label = monthLabel(ym, source.today.year), today = source.today) Spacer(GlanceModifier.height(4.dp)) // One backing for the whole grid on a transparent widget (#292). Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight().wallpaperBacking()) { WeekdayHeader(weekStart = source.weekStart, colW = colW) - layoutWeeks(weeks, source.today).forEach { layout -> - WeekRow( - layout = layout, - currentMonth = ym.month, - today = source.today, - soften = source.soften, - colW = colW, - modifier = GlanceModifier.defaultWeight(), - ) - } + AndroidRemoteViews( + remoteViews = monthGridViews(context, layouts, ym.month, source.today, source.soften, colors, mode), + modifier = GlanceModifier.fillMaxWidth().defaultWeight(), + ) } } } @@ -324,176 +331,6 @@ private fun weekdayNarrowNames(weekStart: DayOfWeek): List { } } -@Composable -private fun WeekRow( - layout: WeekLayout, - currentMonth: Month, - today: LocalDate, - soften: Boolean, - colW: Dp, - modifier: GlanceModifier, -) { - val context = LocalContext.current - val week = layout.week - Box(modifier = modifier.fillMaxWidth()) { - // Day-open targets, one full-height strip per column, sitting *under* the - // content: a tap anywhere in a day still opens it, as in the app, without - // every cell in the column carrying a click target of its own. The content - // above is not clickable, event bars included, so touches fall through to - // this layer and a day opens wherever it is tapped (#386). - Row(modifier = GlanceModifier.fillMaxSize()) { - week.days.forEach { date -> - Box( - modifier = GlanceModifier - .width(colW) - .fillMaxHeight() - .clickable(openDayAction(context, date)), - ) {} - } - } - Column(modifier = GlanceModifier.fillMaxWidth()) { - // Day numbers. Fixed height so every week's number row lines up whether - // or not it holds today's larger pill. - Row( - modifier = GlanceModifier.fillMaxWidth().height(DAY_NUMBER_HEIGHT), - verticalAlignment = Alignment.CenterVertically, - ) { - week.days.forEach { date -> - DayNumber( - date = date, - isToday = date == today, - inMonth = date.month == currentMonth, - colW = colW, - ) - } - } - Spacer(GlanceModifier.height(2.dp)) - // One lane row per event row. A multi-day span is a single Box spanning - // its columns (colW * n) so it's connected with no seam and rounded ends. - // Lanes past the last one this week fills emit nothing at all. - // The grid's view budget caps the lanes per week (#372); what doesn't - // fit folds into the "+N" row. Counted the same way by WeekLayout.viewIds. - layout.lanes.forEach { cells -> - LaneRow(cells = cells, soften = soften, colW = colW) - Spacer(GlanceModifier.height(1.dp)) - } - if (layout.overflow.isNotEmpty()) OverflowRow(cells = layout.overflow, colW = colW) - } - } -} - -/** - * Open [date]'s day view rooted in the month view (so back returns to the month - * grid) — the same target the in-app month grid uses when a day cell is tapped. - * The only tap target in a day column, so a tap anywhere on a day opens it. - */ -private fun openDayAction(context: Context, date: LocalDate) = - actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)) - -/** - * A day's number. Every day but today is a bare centred [Text] carrying the column - * width itself — a wrapping Box costs three views, and there are 42 of these. - * Today keeps its filled circle, which does need the Box. - */ -@Composable -private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) { - val style = TextStyle( - color = when { - isToday -> GlanceTheme.colors.onPrimary - inMonth -> GlanceTheme.colors.onSurface - else -> GlanceTheme.colors.onSurfaceVariant - }, - fontSize = 11.sp, - fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, - textAlign = TextAlign.Center, - ) - if (!isToday) { - Text(text = date.day.toString(), style = style, modifier = GlanceModifier.width(colW)) - return - } - Box(modifier = GlanceModifier.width(colW), contentAlignment = Alignment.Center) { - Box( - modifier = GlanceModifier - .size(DAY_NUMBER_HEIGHT) - .cornerRadius(DAY_NUMBER_HEIGHT / 2) - .background(GlanceTheme.colors.primary), - contentAlignment = Alignment.Center, - ) { - Text(text = date.day.toString(), style = style) - } - } -} - -@Composable -private fun LaneRow(cells: List>, soften: Boolean, colW: Dp) { - Row(modifier = GlanceModifier.fillMaxWidth()) { - cells.forEach { cell -> - val event = cell.item - if (event == null) { - Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT)) - } else { - SpanBar( - event = event, - soften = soften, - width = colW * cell.cols, - ) - } - } - } -} - -/** A single connected, rounded event bar [width] wide with its clipped title. */ -@Composable -private fun SpanBar(event: EventInstance, soften: Boolean, width: Dp) { - val context = LocalContext.current - val dayFill = eventFill(event.color, dark = false, soften) - val nightFill = eventFill(event.color, dark = true, soften) - Box( - modifier = GlanceModifier - .width(width) - .height(LANE_HEIGHT) - .padding(horizontal = 1.dp), - ) { - Box( - modifier = GlanceModifier - .fillMaxSize() - .cornerRadius(4.dp) - .background(themedColor(day = dayFill, night = nightFill)), - contentAlignment = Alignment.CenterStart, - ) { - Text( - text = event.title.ifBlank { context.getString(R.string.event_untitled) }, - maxLines = 1, - style = TextStyle( - color = themedColor(day = eventInk(dayFill), night = eventInk(nightFill)), - fontSize = 9.sp, - textDecoration = glanceDeclinedDecoration(event.isDeclined), - ), - modifier = GlanceModifier.padding(horizontal = 3.dp), - ) - } - } -} - -/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */ -@Composable -private fun OverflowRow(cells: List>, colW: Dp) { - Row(modifier = GlanceModifier.fillMaxWidth()) { - cells.forEach { cell -> - if (cell.item == null) { - Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT)) - } else { - Text( - text = "+${cell.item}", - maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp), - modifier = GlanceModifier.width(colW).padding(start = 3.dp), - ) - } - } - } -} - @Composable private fun PermissionMessage() { val context = LocalContext.current diff --git a/app/src/main/res/drawable/widget_month_bar_bg.xml b/app/src/main/res/drawable/widget_month_bar_bg.xml new file mode 100644 index 0000000..eb18a82 --- /dev/null +++ b/app/src/main/res/drawable/widget_month_bar_bg.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_month_today_bg.xml b/app/src/main/res/drawable/widget_month_today_bg.xml new file mode 100644 index 0000000..3626003 --- /dev/null +++ b/app/src/main/res/drawable/widget_month_today_bg.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/layout/widget_month_bar_1.xml b/app/src/main/res/layout/widget_month_bar_1.xml new file mode 100644 index 0000000..38b7c5a --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_1.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_2.xml b/app/src/main/res/layout/widget_month_bar_2.xml new file mode 100644 index 0000000..5310c85 --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_2.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_3.xml b/app/src/main/res/layout/widget_month_bar_3.xml new file mode 100644 index 0000000..75eb3c3 --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_3.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_4.xml b/app/src/main/res/layout/widget_month_bar_4.xml new file mode 100644 index 0000000..bb6557e --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_4.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_5.xml b/app/src/main/res/layout/widget_month_bar_5.xml new file mode 100644 index 0000000..6cd75a8 --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_5.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_6.xml b/app/src/main/res/layout/widget_month_bar_6.xml new file mode 100644 index 0000000..e0cf5f2 --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_6.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_7.xml b/app/src/main/res/layout/widget_month_bar_7.xml new file mode 100644 index 0000000..17d7b32 --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_7.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_gap_1.xml b/app/src/main/res/layout/widget_month_gap_1.xml new file mode 100644 index 0000000..6d18a12 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_1.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_2.xml b/app/src/main/res/layout/widget_month_gap_2.xml new file mode 100644 index 0000000..5d6f690 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_2.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_3.xml b/app/src/main/res/layout/widget_month_gap_3.xml new file mode 100644 index 0000000..d73cef0 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_3.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_4.xml b/app/src/main/res/layout/widget_month_gap_4.xml new file mode 100644 index 0000000..b0ddf81 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_4.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_5.xml b/app/src/main/res/layout/widget_month_gap_5.xml new file mode 100644 index 0000000..d36a2aa --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_5.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_6.xml b/app/src/main/res/layout/widget_month_gap_6.xml new file mode 100644 index 0000000..6da1869 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_6.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_7.xml b/app/src/main/res/layout/widget_month_gap_7.xml new file mode 100644 index 0000000..6ba0099 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_7.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_grid.xml b/app/src/main/res/layout/widget_month_grid.xml new file mode 100644 index 0000000..bdf0ebf --- /dev/null +++ b/app/src/main/res/layout/widget_month_grid.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/main/res/layout/widget_month_lane.xml b/app/src/main/res/layout/widget_month_lane.xml new file mode 100644 index 0000000..57a8df1 --- /dev/null +++ b/app/src/main/res/layout/widget_month_lane.xml @@ -0,0 +1,8 @@ + + diff --git a/app/src/main/res/layout/widget_month_more.xml b/app/src/main/res/layout/widget_month_more.xml new file mode 100644 index 0000000..63f9b17 --- /dev/null +++ b/app/src/main/res/layout/widget_month_more.xml @@ -0,0 +1,11 @@ + + diff --git a/app/src/main/res/layout/widget_month_week.xml b/app/src/main/res/layout/widget_month_week.xml new file mode 100644 index 0000000..e44c7d9 --- /dev/null +++ b/app/src/main/res/layout/widget_month_week.xml @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt index 4af9e7a..e16697a 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt @@ -13,21 +13,18 @@ import kotlinx.datetime.Month import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth import kotlinx.datetime.plus +import androidx.compose.ui.unit.dp import org.junit.jupiter.api.Test -/** The month widget's view budget (#372). */ +/** How the month widget seats a week's events into lanes (#372). */ class MonthGridTest { private val zone = TimeZone.UTC private val jul26 = YearMonth(2026, Month.JULY) - private val today = LocalDate(2026, 7, 15) /** Monday Jul 6 to Sunday Jul 12. */ private val monday = LocalDate(2026, 7, 6) - private val android16 = ViewIds(sdkInt = 36) - private val android10 = ViewIds(sdkInt = 29) - private fun weeks(events: List): List = layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone) @@ -43,33 +40,25 @@ class MonthGridTest { ) } - private fun List.cost(ids: ViewIds) = sumOf { it.viewIds(today, ids) } - @Test - fun `a quiet month keeps every lane it uses`() { - val layouts = layoutWeeks(busyWeeks(perDay = 1), today, android16) - assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(1) + fun `a week that fits keeps every lane`() { + val layouts = fitWeeks(busyWeeks(perDay = 2), lanes = 3) + assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(3) assertThat(layouts.all { it.overflow.isEmpty() }).isTrue() } @Test - fun `a full month folds lanes until it fits, on every Android version`() { - for (ids in listOf(android16, android10)) { - val weeks = busyWeeks(perDay = 5) - assertThat(weeks.map { WeekLayout(it, MAX_LANES) }.cost(ids)).isGreaterThan(VIEW_BUDGET - ids.fixed) - - val layouts = layoutWeeks(weeks, today, ids) - assertThat(layouts.cost(ids)).isAtMost(VIEW_BUDGET - ids.fixed) - assertThat(layouts.any { it.laneCap < MAX_LANES }).isTrue() - } + fun `a week that doesn't fit gives its last lane to the overflow`() { + val layouts = fitWeeks(busyWeeks(perDay = 5), lanes = 3) + assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(2) + assertThat(layouts.all { it.overflow.isNotEmpty() }).isTrue() } @Test - fun `older Android folds more, since a sized view costs it two ids`() { - val weeks = busyWeeks(perDay = 3) - val newer = layoutWeeks(weeks, today, android16).sumOf { it.laneCap } - val older = layoutWeeks(weeks, today, android10).sumOf { it.laneCap } - assertThat(older).isLessThan(newer) + fun `a taller week fits more lanes`() { + assertThat(lanesFitting(19.dp)).isEqualTo(0) + assertThat(lanesFitting(50.dp)).isEqualTo(2) + assertThat(lanesFitting(100.dp)).isEqualTo(5) } @Test @@ -83,7 +72,7 @@ class MonthGridTest { @Test fun `a multi-day bar is one cell as wide as its days`() { val bar = allDayEvent(monday.plus(1, DateTimeUnit.DAY), monday.plus(3, DateTimeUnit.DAY), id = 1) - val layout = WeekLayout(weeks(listOf(bar))[1], MAX_LANES) + val layout = WeekLayout(weeks(listOf(bar))[1], laneCap = 3) assertThat(layout.lanes).hasSize(1) assertThat(layout.lanes[0]).containsExactly(GridCell(null, 1), GridCell(bar, 3), GridCell(null, 3)).inOrder() } From 94b04c13652ef5d2347c5d730a6d3c83440c42c4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 15:45:54 +0200 Subject: [PATCH 4/6] Month widget grid: palette-following colours, day ripples, bold today, strike via span, lane cap (#372) --- .../widget/month/MonthWidgetPayloadTest.kt | 18 ++-- .../calendula/widget/month/MonthGrid.kt | 20 +++-- .../calendula/widget/month/MonthGridViews.kt | 83 +++++++------------ .../calendula/widget/month/MonthWidget.kt | 37 ++++----- app/src/main/res/layout/widget_month_week.xml | 21 +++-- .../calendula/widget/month/MonthGridTest.kt | 12 +++ floret-kit | 2 +- 7 files changed, 98 insertions(+), 95 deletions(-) diff --git a/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt index 301155a..5b02619 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt @@ -25,6 +25,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import de.jeanlucmakiola.calendula.R import java.time.LocalDate import java.time.ZoneId @@ -97,7 +98,7 @@ class MonthWidgetPayloadTest { @Test fun aPackedMonthRendersWithinItsPayload() = runBlocking { - for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp))) { + for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp), DpSize(600.dp, 1000.dp))) { val views = MonthWidget().compose(context, size = size) val bytes = Parcel.obtain().let { parcel -> try { @@ -107,18 +108,21 @@ class MonthWidgetPayloadTest { parcel.recycle() } } - var texts = 0 + var dayNumbers = 0 + var bars = 0 instrumentation.runOnMainSync { val root = views.apply(context, FrameLayout(context)) fun walk(view: View) { - if (view is TextView) texts += 1 + if (view.id in DAY_IDS && view is TextView && view.text.isNotEmpty()) dayNumbers += 1 + if (view.id == R.id.bar_title) bars += 1 if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i)) } walk(root) } - Log.i(TAG, "size=$size bytes=$bytes texts=$texts") - // The grid itself rendered, not Glance's error layout: every day number is there. - assertThat(texts).isAtLeast(42) + Log.i(TAG, "size=$size bytes=$bytes days=$dayNumbers bars=$bars") + // The grid itself rendered, not Glance's error layout: a number in every day of every week. + assertThat(dayNumbers % 7).isEqualTo(0) + assertThat(dayNumbers).isAtLeast(28) assertThat(bytes).isAtMost(MAX_BYTES_PER_SIZE) } } @@ -128,6 +132,8 @@ class MonthWidgetPayloadTest { const val ACCOUNT = "calendula-widget-payload-test" const val EVENTS_PER_DAY = 6 + val DAY_IDS = setOf(R.id.day_0, R.id.day_1, R.id.day_2, R.id.day_3, R.id.day_4, R.id.day_5, R.id.day_6) + /** A quarter of the host's buffer, leaving room for a second size and every other widget. */ const val MAX_BYTES_PER_SIZE = 256 * 1024 } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt index d567e4f..e43d95c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt @@ -36,8 +36,11 @@ internal fun mergeGaps(columns: Int, cellAt: (Int) -> GridCell?): L return cells } -/** One week of the grid seated with [laneCap] lanes. */ -internal class WeekLayout(val week: MonthWeek, val laneCap: Int) { +/** + * One week of the grid seated with [laneCap] lanes. [showsOverflow] is off when + * the week has no room for even the "+N" row. + */ +internal class WeekLayout(val week: MonthWeek, val laneCap: Int, val showsOverflow: Boolean = true) { private val seating: List = week.days.indices.map { week.seating(it, laneCap) } /** Lanes filled from the top; empty lanes below are not drawn. */ @@ -59,19 +62,26 @@ internal class WeekLayout(val week: MonthWeek, val laneCap: Int) { } } -/** Event lanes a week row [weekHeight] tall holds under its day number. */ +/** + * Event lanes a week row [weekHeight] tall holds under its day number, held to + * [MAX_LANES] so a tall widget's payload stays bounded (#214). + */ internal fun lanesFitting(weekHeight: Dp): Int = - ((weekHeight.value - DAY_ROW_DP - LANES_TOP_DP) / LANE_ROW_DP).toInt().coerceAtLeast(0) + ((weekHeight.value - DAY_ROW_DP - LANES_TOP_DP) / LANE_ROW_DP).toInt().coerceIn(0, MAX_LANES) /** * Seats each week in [lanes] lanes. A week whose events need more gives its * last lane to the "+N" row instead. */ internal fun fitWeeks(weeks: List, lanes: Int): List = weeks.map { week -> + if (lanes == 0) return@map WeekLayout(week, laneCap = 0, showsOverflow = false) val needed = WeekLayout(week, UNCAPPED_LANES).usedLanes - WeekLayout(week, if (needed <= lanes) lanes else (lanes - 1).coerceAtLeast(0)) + WeekLayout(week, if (needed <= lanes) lanes else lanes - 1) } +/** Lanes a week shows at most, however tall the widget. */ +internal const val MAX_LANES = 8 + private const val DAY_ROW_DP = 18f private const val LANES_TOP_DP = 2f private const val LANE_ROW_DP = 15f diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt index 88db8c4..29215f7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt @@ -2,14 +2,13 @@ package de.jeanlucmakiola.calendula.widget.month import android.app.PendingIntent import android.content.Context -import android.content.res.Configuration -import android.graphics.Paint -import android.os.Build +import android.graphics.Typeface +import android.text.SpannableString +import android.text.Spanned +import android.text.style.StrikethroughSpan +import android.text.style.StyleSpan import android.view.View import android.widget.RemoteViews -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.glance.unit.ColorProvider import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance @@ -17,45 +16,22 @@ import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventInk +import de.jeanlucmakiola.floret.glance.RemoteColor import de.jeanlucmakiola.floret.glance.WidgetThemeMode +import de.jeanlucmakiola.floret.glance.remoteColor +import de.jeanlucmakiola.floret.glance.setImageTint +import de.jeanlucmakiola.floret.glance.setTextColor import kotlinx.datetime.LocalDate import kotlinx.datetime.Month -/** A colour for the light and the dark widget theme; equal when the widget pins one. */ -internal data class DayNight(val day: Int, val night: Int) { - fun current(context: Context): Int = - if (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES) night else day -} - -/** The theme colours the plain-RemoteViews grid draws with, resolved out of Glance's providers. */ +/** The theme colours the plain-RemoteViews grid draws with. */ internal data class GridColors( - val onSurface: DayNight, - val onSurfaceVariant: DayNight, - val primary: DayNight, - val onPrimary: DayNight, + val onSurface: RemoteColor, + val onSurfaceVariant: RemoteColor, + val primary: RemoteColor, + val onPrimary: RemoteColor, ) -internal fun ColorProvider.dayNight(context: Context, mode: WidgetThemeMode): DayNight { - fun at(night: Boolean): Int { - val config = Configuration(context.resources.configuration).apply { - uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or - if (night) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO - } - return getColor(context.createConfigurationContext(config)).toArgb() - } - return when (mode) { - WidgetThemeMode.SYSTEM -> DayNight(day = at(false), night = at(true)) - WidgetThemeMode.LIGHT -> at(false).let { DayNight(it, it) } - WidgetThemeMode.DARK -> at(true).let { DayNight(it, it) } - } -} - -private fun pinned(day: Color, night: Color, mode: WidgetThemeMode): DayNight = when (mode) { - WidgetThemeMode.SYSTEM -> DayNight(day.toArgb(), night.toArgb()) - WidgetThemeMode.LIGHT -> DayNight(day.toArgb(), day.toArgb()) - WidgetThemeMode.DARK -> DayNight(night.toArgb(), night.toArgb()) -} - /** * The month's week rows as one plain RemoteViews tree, embedded in the Glance * widget so its views don't draw on Glance's view-id pool (#372): every event @@ -94,15 +70,15 @@ private fun weekViews( layout.week.days.forEachIndexed { col, date -> week.setOnClickPendingIntent(STRIP_IDS[col], openDayIntent(context, date)) val dayId = DAY_IDS[col] - week.setTextViewText(dayId, date.day.toString()) val isToday = date == today - week.setColor(context, dayId, "setTextColor", when { + week.setTextViewText(dayId, if (isToday) bold(date.day.toString()) else date.day.toString()) + week.setTextColor(context, dayId, when { isToday -> colors.onPrimary date.month == currentMonth -> colors.onSurface else -> colors.onSurfaceVariant }) week.setViewVisibility(TODAY_IDS[col], if (isToday) View.VISIBLE else View.GONE) - if (isToday) week.setColor(context, TODAY_IDS[col], "setColorFilter", colors.primary) + if (isToday) week.setImageTint(context, TODAY_IDS[col], colors.primary) } layout.lanes.forEach { cells -> val lane = laneViews(context) @@ -112,7 +88,7 @@ private fun weekViews( } week.addView(R.id.week_lanes, lane) } - if (layout.overflow.isNotEmpty()) { + if (layout.showsOverflow && layout.overflow.isNotEmpty()) { val lane = laneViews(context) layout.overflow.forEach { cell -> val count = cell.item @@ -121,7 +97,7 @@ private fun weekViews( } else { val more = RemoteViews(context.packageName, R.layout.widget_month_more) more.setTextViewText(R.id.more, "+$count") - more.setColor(context, R.id.more, "setTextColor", colors.onSurfaceVariant) + more.setTextColor(context, R.id.more, colors.onSurfaceVariant) lane.addView(R.id.lane, more) } } @@ -139,21 +115,18 @@ private fun bar(context: Context, event: EventInstance, cols: Int, soften: Boole val views = RemoteViews(context.packageName, BAR_LAYOUTS[cols - 1]) val dayFill = eventFill(event.color, dark = false, soften) val nightFill = eventFill(event.color, dark = true, soften) - views.setColor(context, R.id.bar_bg, "setColorFilter", pinned(dayFill, nightFill, mode)) - views.setTextViewText(R.id.bar_title, event.title.ifBlank { context.getString(R.string.event_untitled) }) - views.setColor(context, R.id.bar_title, "setTextColor", pinned(eventInk(dayFill), eventInk(nightFill), mode)) - val strike = if (event.isDeclined) Paint.STRIKE_THRU_TEXT_FLAG else 0 - views.setInt(R.id.bar_title, "setPaintFlags", strike or Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) + views.setImageTint(context, R.id.bar_bg, mode.remoteColor(dayFill, nightFill)) + val title = event.title.ifBlank { context.getString(R.string.event_untitled) } + // A span, like Glance's own decoration: paint flags would drop the text's defaults. + views.setTextViewText(R.id.bar_title, if (event.isDeclined) span(title, StrikethroughSpan()) else title) + views.setTextColor(context, R.id.bar_title, mode.remoteColor(eventInk(dayFill), eventInk(nightFill))) return views } -private fun RemoteViews.setColor(context: Context, id: Int, method: String, color: DayNight) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && color.day != color.night) { - setColorInt(id, method, color.day, color.night) - } else { - setInt(id, method, color.current(context)) - } -} +private fun bold(text: String): CharSequence = span(text, StyleSpan(Typeface.BOLD)) + +private fun span(text: String, span: Any): CharSequence = + SpannableString(text).apply { setSpan(span, 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } private fun openDayIntent(context: Context, date: LocalDate): PendingIntent = PendingIntent.getActivity( context, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index b756d09..8ce7fe8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -1,7 +1,5 @@ package de.jeanlucmakiola.calendula.widget.month -import de.jeanlucmakiola.calendula.domain.isDeclined -import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect @@ -21,25 +19,21 @@ 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.AndroidRemoteViews 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.provideContent import androidx.glance.appwidget.state.getAppWidgetState 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 @@ -48,29 +42,24 @@ import androidx.glance.layout.size import androidx.glance.layout.width import androidx.glance.state.PreferencesGlanceStateDefinition import de.jeanlucmakiola.floret.glance.LocalWidgetThemeMode +import de.jeanlucmakiola.floret.glance.WidgetColorRole +import de.jeanlucmakiola.floret.glance.remoteColor import de.jeanlucmakiola.floret.glance.WidgetActionStyle 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 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.domain.EventInstance import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle -import de.jeanlucmakiola.calendula.ui.month.MonthWeek import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks -import de.jeanlucmakiola.calendula.ui.common.eventFill -import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot import de.jeanlucmakiola.calendula.widget.WidgetAppearance import de.jeanlucmakiola.calendula.widget.widgetAppearance -import de.jeanlucmakiola.floret.glance.themedColor import de.jeanlucmakiola.floret.glance.wallpaperBacking import de.jeanlucmakiola.floret.prefs.ThemeMode import de.jeanlucmakiola.calendula.widget.MonthWidgetSource @@ -97,8 +86,13 @@ private val MONTH_INDEX_KEY = intPreferencesKey("month_index") private val GRID_HPADDING = 8.dp -/** Height around the week rows: body padding, header, its spacer and the weekday row. */ -private val GRID_CHROME_HEIGHT = 62.dp +/** + * Height around the week rows: body padding, the header's bottom padding and + * spacer, then the header (its 28dp pills, or a title line in its transparent + * pill when that's taller) and the weekday row, the text lines at [fontScale]. + */ +private fun gridChromeHeight(fontScale: Float): Dp = + 18.dp + maxOf(28.dp, 19.dp * fontScale + 8.dp) + 15.dp * fontScale private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone)) @@ -244,15 +238,16 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { // 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 weekHeight = (LocalSize.current.height - GRID_CHROME_HEIGHT) / weeks.size - val layouts = fitWeeks(weeks, lanesFitting(weekHeight)) val context = LocalContext.current + val fontScale = context.resources.configuration.fontScale + val weekHeight = (LocalSize.current.height - gridChromeHeight(fontScale)) / weeks.size + val layouts = fitWeeks(weeks, lanesFitting(weekHeight)) val mode = LocalWidgetThemeMode.current val colors = GridColors( - onSurface = GlanceTheme.colors.onSurface.dayNight(context, mode), - onSurfaceVariant = GlanceTheme.colors.onSurfaceVariant.dayNight(context, mode), - primary = GlanceTheme.colors.primary.dayNight(context, mode), - onPrimary = GlanceTheme.colors.onPrimary.dayNight(context, mode), + onSurface = remoteColor(WidgetColorRole.ON_SURFACE), + onSurfaceVariant = remoteColor(WidgetColorRole.ON_SURFACE_VARIANT), + primary = remoteColor(WidgetColorRole.PRIMARY), + onPrimary = remoteColor(WidgetColorRole.ON_PRIMARY), ) MonthHeader(label = monthLabel(ym, source.today.year), today = source.today) diff --git a/app/src/main/res/layout/widget_month_week.xml b/app/src/main/res/layout/widget_month_week.xml index e44c7d9..40bf2ff 100644 --- a/app/src/main/res/layout/widget_month_week.xml +++ b/app/src/main/res/layout/widget_month_week.xml @@ -12,37 +12,44 @@ android:id="@+id/strip_0" android:layout_width="0dp" android:layout_height="match_parent" - android:layout_weight="1" /> + android:layout_weight="1" + android:background="?android:attr/selectableItemBackground" /> + android:layout_weight="1" + android:background="?android:attr/selectableItemBackground" /> + android:layout_weight="1" + android:background="?android:attr/selectableItemBackground" /> + android:layout_weight="1" + android:background="?android:attr/selectableItemBackground" /> + android:layout_weight="1" + android:background="?android:attr/selectableItemBackground" /> + android:layout_weight="1" + android:background="?android:attr/selectableItemBackground" /> + android:layout_weight="1" + android:background="?android:attr/selectableItemBackground" /> Date: Fri, 23 Oct 2026 15:48:44 +0200 Subject: [PATCH 5/6] Pin floret-kit past its lint fix --- floret-kit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/floret-kit b/floret-kit index 513c135..76e07fe 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 513c135ec2f7a41404d387a21a421c81f5e30008 +Subproject commit 76e07fe8cbf59dbee564c2413c969de358fd1b4b From 610409012fdbc1e1670660a6e99aa11bcf69937e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 17:59:34 +0200 Subject: [PATCH 6/6] Month widget: day cells and weekday pills, shaded outside days, new-event button (#71) --- .../calendula/widget/agenda/AgendaWidget.kt | 2 +- .../calendula/widget/month/MonthGridViews.kt | 19 +++ .../calendula/widget/month/MonthWidget.kt | 60 +++++-- .../res/drawable/widget_month_cell_bg.xml | 5 + app/src/main/res/layout/widget_month_week.xml | 147 +++++++++++++++++- floret-kit | 2 +- 6 files changed, 211 insertions(+), 24 deletions(-) create mode 100644 app/src/main/res/drawable/widget_month_cell_bg.xml diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index 66bef32..500d823 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -295,7 +295,7 @@ private fun AgendaHeader() { WidgetHeader( title = context.getString(R.string.widget_agenda_title), titleAction = actionStartActivity(MainActivity.openViewIntent(context, view = null)), - modifier = GlanceModifier.padding(start = 4.dp), + titleInset = 4.dp, ) { WidgetHeaderAction( icon = ImageProvider(R.drawable.ic_widget_add), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt index 29215f7..e8aaa41 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt @@ -9,6 +9,7 @@ import android.text.style.StrikethroughSpan import android.text.style.StyleSpan import android.view.View import android.widget.RemoteViews +import androidx.compose.ui.graphics.Color import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance @@ -30,6 +31,7 @@ internal data class GridColors( val onSurfaceVariant: RemoteColor, val primary: RemoteColor, val onPrimary: RemoteColor, + val cell: RemoteColor, ) /** @@ -69,6 +71,15 @@ private fun weekViews( week.removeAllViews(R.id.week_lanes) layout.week.days.forEachIndexed { col, date -> week.setOnClickPendingIntent(STRIP_IDS[col], openDayIntent(context, date)) + // Each day its own rounded cell, as in the app's month grid. Days around + // the month get a shade over their (opaque) cell, so they recede without + // letting a wallpaper show through. + week.setImageTint(context, CELL_IDS[col], colors.cell) + val outside = date.month != currentMonth + week.setViewVisibility(CELL_DIM_IDS[col], if (outside) View.VISIBLE else View.GONE) + if (outside) { + week.setImageTint(context, CELL_DIM_IDS[col], mode.remoteColor(OUTSIDE_SHADE_DAY, OUTSIDE_SHADE_NIGHT)) + } val dayId = DAY_IDS[col] val isToday = date == today week.setTextViewText(dayId, if (isToday) bold(date.day.toString()) else date.day.toString()) @@ -136,6 +147,14 @@ private fun openDayIntent(context: Context, date: LocalDate): PendingIntent = Pe ) private val STRIP_IDS = intArrayOf(R.id.strip_0, R.id.strip_1, R.id.strip_2, R.id.strip_3, R.id.strip_4, R.id.strip_5, R.id.strip_6) +/** The shade over a day outside the month: a dark cell needs more of it to read as dimmed. */ +private val OUTSIDE_SHADE_DAY = Color.Black.copy(alpha = 0.18f) +private val OUTSIDE_SHADE_NIGHT = Color.Black.copy(alpha = 0.50f) + +private val CELL_DIM_IDS = intArrayOf( + R.id.cell_dim_0, R.id.cell_dim_1, R.id.cell_dim_2, R.id.cell_dim_3, R.id.cell_dim_4, R.id.cell_dim_5, R.id.cell_dim_6, +) +private val CELL_IDS = intArrayOf(R.id.cell_0, R.id.cell_1, R.id.cell_2, R.id.cell_3, R.id.cell_4, R.id.cell_5, R.id.cell_6) private val DAY_IDS = intArrayOf(R.id.day_0, R.id.day_1, R.id.day_2, R.id.day_3, R.id.day_4, R.id.day_5, R.id.day_6) private val TODAY_IDS = intArrayOf( R.id.today_bg_0, R.id.today_bg_1, R.id.today_bg_2, R.id.today_bg_3, R.id.today_bg_4, R.id.today_bg_5, R.id.today_bg_6, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index 8ce7fe8..2b49d70 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -21,6 +21,10 @@ import androidx.glance.action.ActionParameters import androidx.glance.action.actionParametersOf import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.AndroidRemoteViews +import de.jeanlucmakiola.floret.glance.WidgetBackground +import de.jeanlucmakiola.floret.glance.LocalWidgetBackground +import androidx.glance.background +import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback @@ -88,11 +92,11 @@ private val GRID_HPADDING = 8.dp /** * Height around the week rows: body padding, the header's bottom padding and - * spacer, then the header (its 28dp pills, or a title line in its transparent + * spacer and the weekday pills' padding, then the header (its 28dp pills, or a title line in its transparent * pill when that's taller) and the weekday row, the text lines at [fontScale]. */ private fun gridChromeHeight(fontScale: Float): Dp = - 18.dp + maxOf(28.dp, 19.dp * fontScale + 8.dp) + 15.dp * fontScale + 21.dp + maxOf(28.dp, 19.dp * fontScale + 8.dp) + 15.dp * fontScale private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone)) @@ -248,12 +252,15 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { onSurfaceVariant = remoteColor(WidgetColorRole.ON_SURFACE_VARIANT), primary = remoteColor(WidgetColorRole.PRIMARY), onPrimary = remoteColor(WidgetColorRole.ON_PRIMARY), + cell = remoteColor(monthCellRole()), ) MonthHeader(label = monthLabel(ym, source.today.year), today = source.today) Spacer(GlanceModifier.height(4.dp)) - // One backing for the whole grid on a transparent widget (#292). - Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight().wallpaperBacking()) { + // No backing behind the grid: the weekday pills and day cells + // each carry their own fill, so a transparent widget stays + // readable without one blob over the wallpaper (#292). + Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) { WeekdayHeader(weekStart = source.weekStart, colW = colW) AndroidRemoteViews( remoteViews = monthGridViews(context, layouts, ym.month, source.today, source.soften, colors, mode), @@ -273,7 +280,8 @@ private fun MonthHeader(label: String, today: LocalDate) { WidgetHeader( title = label, titleAction = actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Month)), - modifier = GlanceModifier.padding(start = 4.dp, bottom = 2.dp), + modifier = GlanceModifier.padding(bottom = 2.dp), + titleInset = 4.dp, ) { WidgetHeaderAction( icon = ImageProvider(R.drawable.ic_widget_chevron_left), @@ -288,6 +296,7 @@ private fun MonthHeader(label: String, today: LocalDate) { text = today.day.toString(), contentDescription = context.getString(R.string.widget_today), action = actionRunCallback(), + style = WidgetActionStyle.TONAL, ) WidgetHeaderActionGap() WidgetHeaderAction( @@ -296,26 +305,47 @@ private fun MonthHeader(label: String, today: LocalDate) { action = actionRunCallback(actionParametersOf(ShiftMonthAction.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)), + ) } } @Composable private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) { - Row(modifier = GlanceModifier.fillMaxWidth()) { + Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 1.dp)) { weekdayNarrowNames(weekStart).forEach { name -> - Text( - text = name, - style = TextStyle( - color = GlanceTheme.colors.onSurfaceVariant, - fontSize = 11.sp, - textAlign = TextAlign.Center, - ), - modifier = GlanceModifier.width(colW), - ) + Box(modifier = GlanceModifier.width(colW).padding(horizontal = 1.dp)) { + Text( + text = name, + style = TextStyle( + color = GlanceTheme.colors.onPrimaryContainer, + fontSize = 11.sp, + textAlign = TextAlign.Center, + ), + modifier = GlanceModifier + .fillMaxWidth() + .cornerRadius(8.dp) + .background(GlanceTheme.colors.primaryContainer) + .padding(vertical = 1.dp), + ) + } } } } +/** The fill of a day cell: one tone off a filled root, the widget surface over a wallpaper. */ +private fun monthCellRole(background: WidgetBackground): WidgetColorRole = when (background) { + WidgetBackground.SOLID -> WidgetColorRole.SURFACE_VARIANT + WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> WidgetColorRole.WIDGET_BACKGROUND +} + +@Composable +private fun monthCellRole(): WidgetColorRole = monthCellRole(LocalWidgetBackground.current) + /** Narrow weekday initials starting at [weekStart], in the device locale. * Computed outside the composable so the locale read stays observable-safe. */ private fun weekdayNarrowNames(weekStart: DayOfWeek): List { diff --git a/app/src/main/res/drawable/widget_month_cell_bg.xml b/app/src/main/res/drawable/widget_month_cell_bg.xml new file mode 100644 index 0000000..34615d9 --- /dev/null +++ b/app/src/main/res/drawable/widget_month_cell_bg.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/widget_month_week.xml b/app/src/main/res/layout/widget_month_week.xml index 40bf2ff..60942ab 100644 --- a/app/src/main/res/layout/widget_month_week.xml +++ b/app/src/main/res/layout/widget_month_week.xml @@ -13,43 +13,176 @@ android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" - android:background="?android:attr/selectableItemBackground" /> + android:foreground="?android:attr/selectableItemBackground" + android:padding="1dp"> + + + + + + android:foreground="?android:attr/selectableItemBackground" + android:padding="1dp"> + + + + + + android:foreground="?android:attr/selectableItemBackground" + android:padding="1dp"> + + + + + + android:foreground="?android:attr/selectableItemBackground" + android:padding="1dp"> + + + + + + android:foreground="?android:attr/selectableItemBackground" + android:padding="1dp"> + + + + + + android:foreground="?android:attr/selectableItemBackground" + android:padding="1dp"> + + + + + + android:foreground="?android:attr/selectableItemBackground" + android:padding="1dp"> + + + + +