From 6da0648b777db293cc1ac6e6b98815a099ada23b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 23 Oct 2026 14:51:29 +0200 Subject: [PATCH] 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