From fffb033e39372cc4259f8115c81cb0a390c0f1db Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 18:31:12 +0100 Subject: [PATCH 01/10] Agenda widget: classic style and background opacity (#489) --- .../calendula/widget/WidgetConfig.kt | 13 + .../calendula/widget/WidgetData.kt | 2 + .../calendula/widget/WidgetKind.kt | 11 +- .../calendula/widget/WidgetTheme.kt | 2 +- .../calendula/widget/agenda/AgendaScale.kt | 18 ++ .../calendula/widget/agenda/AgendaWidget.kt | 271 ++++++++++++++---- .../widget/config/WidgetConfigScreen.kt | 44 +++ app/src/main/res/values/strings.xml | 5 + floret-kit | 2 +- 9 files changed, 305 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt index 32258b8..d50243b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.glance.GlanceId @@ -26,14 +27,20 @@ enum class WidgetTheme { FOLLOW_APP, LIGHT, DARK } data class WidgetAppearance( val background: WidgetBackground = WidgetBackground.TINTED, val theme: WidgetTheme = WidgetTheme.FOLLOW_APP, + /** How opaque a Solid or Tinted fill is (#489). */ + val opacity: Float = 1f, ) +/** The agenda widget's look: the 2.24 day cards, or v2.23's plain day headers and stripes (#489). */ +enum class AgendaWidgetStyle { CARDS, CLASSIC } + /** What the agenda widget lists, per placed widget. */ data class AgendaWidgetOptions( val range: AgendaRange, val textSize: WidgetSize, val showToday: Boolean, val pastDisplay: PastEventDisplay, + val style: AgendaWidgetStyle = AgendaWidgetStyle.CARDS, ) internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this) { @@ -48,6 +55,8 @@ internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this) private val BackgroundKey = stringPreferencesKey("cfg_background") private val ThemeKey = stringPreferencesKey("cfg_theme") +private val OpacityKey = floatPreferencesKey("cfg_opacity") +private val AgendaStyleKey = stringPreferencesKey("cfg_agenda_style") private val AgendaRangeKey = stringPreferencesKey("cfg_agenda_range") private val TextSizeKey = stringPreferencesKey("cfg_text_size") private val ShowTodayKey = booleanPreferencesKey("cfg_show_today") @@ -58,6 +67,7 @@ internal val Preferences.widgetAppearance: WidgetAppearance get() = WidgetAppearance( background = this[BackgroundKey].toEnum(WidgetBackground.TINTED), theme = this[ThemeKey].toEnum(WidgetTheme.FOLLOW_APP), + opacity = this[OpacityKey] ?: 1f, ) /** @@ -70,6 +80,7 @@ internal fun Preferences.agendaWidgetOptions(defaults: AgendaWidgetOptions) = Ag textSize = this[TextSizeKey].toEnum(defaults.textSize), showToday = this[ShowTodayKey] ?: defaults.showToday, pastDisplay = this[PastDisplayKey].toEnum(defaults.pastDisplay), + style = this[AgendaStyleKey].toEnum(defaults.style), ) /** Whether this widget has agenda options of its own, rather than following the app's. */ @@ -78,6 +89,7 @@ internal val Preferences.hasAgendaOptions: Boolean get() = this[AgendaRangeKey] internal fun MutablePreferences.putAppearance(appearance: WidgetAppearance) { this[BackgroundKey] = appearance.background.name this[ThemeKey] = appearance.theme.name + this[OpacityKey] = appearance.opacity } internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) { @@ -85,6 +97,7 @@ internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) { this[TextSizeKey] = options.textSize.name this[ShowTodayKey] = options.showToday this[PastDisplayKey] = options.pastDisplay.name + this[AgendaStyleKey] = options.style.name } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt index 86be0e9..2052135 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -126,6 +126,7 @@ sealed interface AgendaWidgetData { /** "Always show today" (#35): anchor today at the top even with no events left. */ val showToday: Boolean, val textSize: WidgetSize, + val style: AgendaWidgetStyle = AgendaWidgetStyle.CARDS, /** Snapshot instant the data was read at, for "has this event ended?" tests. */ val now: Instant, ) : AgendaWidgetData @@ -184,6 +185,7 @@ internal suspend fun Context.loadAgendaWidgetData( pastDisplay = options.pastDisplay, showToday = options.showToday, textSize = options.textSize, + style = options.style, now = Clock.System.now(), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt index 1fb5ac2..11af1fd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt @@ -26,7 +26,7 @@ import de.jeanlucmakiola.calendula.widget.split.MonthDayWidget import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetReceiver /** A content option a widget's config screen offers, besides background and theme. */ -enum class WidgetOption { RANGE, TEXT_SIZE, PAST_DISPLAY, SHOW_TODAY, CALENDARS } +enum class WidgetOption { STYLE, RANGE, TEXT_SIZE, PAST_DISPLAY, SHOW_TODAY, CALENDARS } /** * Every widget the app ships, told apart by their receiver: what redraws, @@ -42,7 +42,14 @@ enum class WidgetKind( AGENDA( AgendaWidgetReceiver::class.java, R.string.widget_agenda_label, - setOf(WidgetOption.RANGE, WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.SHOW_TODAY, WidgetOption.CALENDARS), + setOf( + WidgetOption.STYLE, + WidgetOption.RANGE, + WidgetOption.TEXT_SIZE, + WidgetOption.PAST_DISPLAY, + WidgetOption.SHOW_TODAY, + WidgetOption.CALENDARS, + ), DpSize(260.dp, 280.dp), ), MONTH( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt index 6dd319e..88b9302 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt @@ -34,7 +34,7 @@ fun CalendulaGlanceTheme(mode: WidgetThemeMode, content: @Composable () -> Unit) @Composable internal fun CalendulaWidgetRoot(appearance: WidgetAppearance, appTheme: ThemeMode, content: @Composable () -> Unit) { CalendulaGlanceTheme(appearance.theme.resolve(appTheme)) { - WidgetSurface(background = appearance.background, content = content) + WidgetSurface(background = appearance.background, opacity = appearance.opacity, content = content) } } 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 8b07067..049d6b3 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 @@ -12,6 +12,14 @@ internal val ROW_H_PAD = 12.dp /** Space between the date-pill gutter and a day's card. */ internal val GUTTER_GAP = 6.dp +/** The classic style's row inset, colour stripe and the gap after it (#489). */ +internal val CLASSIC_ROW_H_PAD = 4.dp +internal val STRIPE_WIDTH = 5.dp +internal val STRIPE_GAP = 10.dp + +/** Where the classic style's event text starts, so its placeholder lines up with the titles. */ +internal val TEXT_INDENT = CLASSIC_ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP + /** * Every size the agenda widget varies by [WidgetSize]. Values that genuinely * shouldn't grow (the horizontal row constants above, corner radii) stay @@ -29,6 +37,8 @@ internal data class AgendaMetrics( val rowVPad: Dp, // event row vertical padding val dayGap: Dp, // space between two days' cards val monthLabel: TextUnit, // month name above a new month's first day + val dayHeader: TextUnit, // classic style's day label ("Today · …") + val dayHeaderTopPad: Dp, // classic style's space above a day label ) { /** One event row's height, shared by the date pill beside it and the placeholder row. */ val rowHeight: Dp get() = contentH + rowVPad * 2 @@ -61,6 +71,8 @@ private val SMALL_METRICS = AgendaMetrics( rowVPad = 4.dp, dayGap = 8.dp, monthLabel = 12.sp, + dayHeader = 13.sp, + dayHeaderTopPad = 10.dp, ) private val MEDIUM_METRICS = AgendaMetrics( @@ -75,6 +87,8 @@ private val MEDIUM_METRICS = AgendaMetrics( rowVPad = 5.dp, dayGap = 9.dp, monthLabel = 14.sp, + dayHeader = 14.sp, // M3 Title Small + dayHeaderTopPad = 11.dp, ) private val LARGE_METRICS = AgendaMetrics( @@ -89,6 +103,8 @@ private val LARGE_METRICS = AgendaMetrics( rowVPad = 6.dp, dayGap = 10.dp, monthLabel = 14.sp, + dayHeader = 16.sp, // M3 Title Medium + dayHeaderTopPad = 12.dp, ) private val EXTRA_LARGE_METRICS = AgendaMetrics( @@ -103,6 +119,8 @@ private val EXTRA_LARGE_METRICS = AgendaMetrics( rowVPad = 8.dp, dayGap = 12.dp, monthLabel = 16.sp, + dayHeader = 18.sp, // † + dayHeaderTopPad = 14.dp, ) /** 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 03dde16..a3a3169 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 @@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.glance.GlanceId import androidx.glance.GlanceModifier @@ -56,6 +57,11 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.calendula.ui.common.eventFill +import de.jeanlucmakiola.calendula.ui.common.eventAccent +import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha +import de.jeanlucmakiola.calendula.widget.AgendaWidgetStyle +import de.jeanlucmakiola.floret.glance.LocalWidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetBackground import de.jeanlucmakiola.calendula.widget.WidgetDimFillAlpha import de.jeanlucmakiola.calendula.widget.widgetEventInk import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA @@ -220,59 +226,216 @@ private fun AgendaRows( } } }.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 = widgetCardColor() - val item: @Composable (Int) -> Unit = { index -> - val row = rows[index] - 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 { - dayRow(gap) - } - } - if (preview) { - Column(modifier = GlanceModifier.fillMaxSize()) { - repeat(minOf(rows.size, PREVIEW_ROWS)) { item(it) } - } - } else { - LazyColumn(modifier = GlanceModifier.fillMaxSize()) { - items(rows.size) { item(it) } - } + when (data.style) { + AgendaWidgetStyle.CARDS -> CardRows(rows, data, metrics, preview) + AgendaWidgetStyle.CLASSIC -> ClassicRows(rows, data, metrics, preview) } } } -/** Where each row sits among the rows of its day, for the grouped-card shapes. */ -private fun dayPositions(rows: List): List = buildList { +/** Each day is one card of grouped rows (#71), with its date pill beside the first row. */ +@Composable +private fun CardRows(rows: List, data: AgendaWidgetData.Ready, metrics: AgendaMetrics, preview: Boolean) { + val positions = dayPositions(rows.map { it.date }) + val cardColor = widgetCardColor() + val item: @Composable (Int) -> Unit = { index -> + val row = rows[index] + 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 { + dayRow(gap) + } + } + RowList(rows.size, preview, item) +} + +/** [count] rows of [item]: a plain column in a [preview], else a [LazyColumn]. */ +@Composable +private fun RowList(count: Int, preview: Boolean, item: @Composable (Int) -> Unit) { + if (preview) { + Column(modifier = GlanceModifier.fillMaxSize()) { + repeat(minOf(count, PREVIEW_ROWS)) { item(it) } + } + } else { + LazyColumn(modifier = GlanceModifier.fillMaxSize()) { + items(count) { item(it) } + } + } +} + +/** A classic list entry: a day's label line, or one of its rows. */ +private sealed interface ClassicItem { + val date: LocalDate + + data class Header(override val date: LocalDate) : ClassicItem + data class Entry(val row: AgendaRow) : ClassicItem { + override val date: LocalDate get() = row.date + } +} + +/** + * v2.23's look (#489): a label line per day over plain rows with a colour + * stripe. On a transparent root each day sits on its own backing card, so the + * text stays readable over the wallpaper. + */ +@Composable +private fun ClassicRows(rows: List, data: AgendaWidgetData.Ready, metrics: AgendaMetrics, preview: Boolean) { + val items = buildList { + rows.forEachIndexed { index, row -> + if (index == 0 || row.date != rows[index - 1].date) add(ClassicItem.Header(row.date)) + add(ClassicItem.Entry(row)) + } + } + val backed = LocalWidgetBackground.current == WidgetBackground.TRANSPARENT + val positions = dayPositions(items.map { it.date }) + val item: @Composable (Int) -> Unit = { index -> + val entry = items[index] + val content: @Composable () -> Unit = { + when (entry) { + is ClassicItem.Header -> DayHeaderRow( + date = entry.date, + today = data.today, + topPad = if (backed) CLASSIC_CARD_TOP_PAD else metrics.dayHeaderTopPad, + metrics = metrics, + ) + is ClassicItem.Entry -> when (val row = entry.row) { + is AgendaRow.Placeholder -> ClassicPlaceholderRow(row.date, metrics) + is AgendaRow.Event -> ClassicEventRow(row, data, metrics) + } + } + } + if (backed) { + val gap = if (entry is ClassicItem.Header && index > 0) metrics.dayGap else 0.dp + WidgetGroupedRow( + position = positions[index], + modifier = GlanceModifier.padding(top = gap), + color = GlanceTheme.colors.widgetBackground, + content = content, + ) + } else { + content() + } + } + RowList(items.size, preview, item) +} + +/** A classic day label, "Today · Wed, 17 Jun"; opens that day in the app. */ +@Composable +private fun DayHeaderRow(date: LocalDate, today: LocalDate, topPad: Dp, metrics: AgendaMetrics) { + val context = androidx.glance.LocalContext.current + Text( + text = agendaDayLabel(context, date, today), + maxLines = 1, + style = TextStyle( + color = if (date == today) GlanceTheme.colors.primary else GlanceTheme.colors.onSurfaceVariant, + fontSize = metrics.dayHeader, + fontWeight = FontWeight.Medium, + ), + modifier = GlanceModifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp, top = topPad, bottom = metrics.rowVPad) + .clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda))), + ) +} + +/** "No more events today" as a muted line under an anchored, event-less today (#35), indented to the titles. */ +@Composable +private fun ClassicPlaceholderRow(date: LocalDate, metrics: AgendaMetrics) { + val context = androidx.glance.LocalContext.current + Text( + text = context.getString(R.string.agenda_no_more_today), + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder), + modifier = GlanceModifier + .fillMaxWidth() + .padding(start = TEXT_INDENT, end = 8.dp, top = 2.dp, bottom = metrics.rowVPad + 2.dp) + .clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda))), + ) +} + +/** A classic event row: the calendar's colour stripe beside the title and time; a dimmed one fades both. */ +@Composable +private fun ClassicEventRow(row: AgendaRow.Event, data: AgendaWidgetData.Ready, metrics: AgendaMetrics) { + val context = androidx.glance.LocalContext.current + val event = row.event + val dimmed = data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now) + fun accent(dark: Boolean) = eventAccent(event.color, dark, data.soften).let { + if (dimmed) it.copy(alpha = EventDimAlpha) else it + } + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(horizontal = CLASSIC_ROW_H_PAD, vertical = metrics.rowVPad) + .clickable(actionStartActivity(event.openIntent(context))), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = GlanceModifier + .width(STRIPE_WIDTH) + .height(metrics.contentH) + .cornerRadius(3.dp) + .background(themedColor(day = accent(dark = false), night = accent(dark = true))), + ) {} + Spacer(GlanceModifier.width(STRIPE_GAP)) + Column(modifier = GlanceModifier.defaultWeight()) { + Text( + text = event.title.ifBlank { context.getString(R.string.event_untitled) }, + maxLines = 1, + style = TextStyle( + color = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface, + fontSize = metrics.eventTitle, + textDecoration = glanceDeclinedDecoration(event.isCrossedOut), + ), + ) + Text( + text = eventTimeSummary(context, event, row.date, data.is24Hour), + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime), + ) + } + } +} + +/** Space above a day's label on its own backing card. */ +private val CLASSIC_CARD_TOP_PAD = 8.dp + +private fun EventInstance.openIntent(context: Context) = MainActivity.openEventIntent( + context = context, + eventId = eventId, + beginMillis = start.toEpochMilliseconds(), + endMillis = end.toEpochMilliseconds(), + source = CalendarView.Agenda, +) + +/** Where each row sits among the rows of its day, from each row's date, for the grouped-card shapes. */ +private fun dayPositions(dates: List): List = buildList { var start = 0 - for (end in 1..rows.size) { - if (end == rows.size || rows[end].date != rows[start].date) { + for (end in 1..dates.size) { + if (end == dates.size || dates[end] != dates[start]) { for (i in start until end) add(positionOf(i - start, end - start)) start = end } @@ -421,17 +584,7 @@ private fun EventRow( .fillMaxWidth() .height(metrics.rowHeight) .padding(horizontal = ROW_H_PAD) - .clickable( - actionStartActivity( - MainActivity.openEventIntent( - context = context, - eventId = event.eventId, - beginMillis = event.start.toEpochMilliseconds(), - endMillis = event.end.toEpochMilliseconds(), - source = CalendarView.Agenda, - ), - ), - ), + .clickable(actionStartActivity(event.openIntent(context))), verticalAlignment = Alignment.CenterVertically, ) { Text( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt index f410e6e..e16200d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt @@ -69,6 +69,8 @@ import de.jeanlucmakiola.floret.glance.GlanceWidgetPreview import de.jeanlucmakiola.floret.glance.WallpaperWindow import de.jeanlucmakiola.floret.glance.WidgetBackground import kotlin.math.roundToInt +import de.jeanlucmakiola.floret.components.GroupedSliderRow +import de.jeanlucmakiola.calendula.widget.AgendaWidgetStyle private val PreviewMaxHeight = 280.dp @@ -236,6 +238,18 @@ private fun ConfigOptions( onSelect = { onAppearance(state.appearance.copy(background = it)) }, position = Position.Top, ) + if (state.appearance.background != WidgetBackground.TRANSPARENT) { + val opacity = state.appearance.opacity + GroupedSliderRow( + title = stringResource(R.string.widget_config_opacity), + value = opacity, + onValueChange = { onAppearance(state.appearance.copy(opacity = it)) }, + position = Position.Middle, + valueLabel = stringResource(R.string.widget_config_opacity_value, (opacity * 100).roundToInt()), + valueRange = MIN_OPACITY..1f, + steps = OPACITY_STEPS, + ) + } GroupedChoiceRow( title = stringResource(R.string.settings_theme), options = WidgetTheme.entries, @@ -265,6 +279,7 @@ private fun ContentOptions( onCalendarShown: (Long, Boolean) -> Unit, ) { val options = state.draft.content + var showStyle by rememberSaveable { mutableStateOf(false) } var showRange by rememberSaveable { mutableStateOf(false) } var showSize by rememberSaveable { mutableStateOf(false) } var showPast by rememberSaveable { mutableStateOf(false) } @@ -277,6 +292,12 @@ private fun ContentOptions( GroupedSectionHeader(stringResource(R.string.widget_config_content)) rows.forEach { option -> when (option) { + WidgetOption.STYLE -> GroupedRow( + title = stringResource(R.string.widget_config_style), + summary = agendaStyleLabel(options.style), + position = position(option), + onClick = { showStyle = true }, + ) WidgetOption.RANGE -> GroupedRow( title = stringResource(R.string.widget_config_range), summary = agendaRangeLabel(options.range), @@ -319,6 +340,17 @@ private fun ContentOptions( ) } + if (showStyle) { + OptionPicker( + title = stringResource(R.string.widget_config_style), + predictiveBack = true, + options = AgendaWidgetStyle.entries, + selected = options.style, + label = { agendaStyleLabel(it) }, + onSelect = { onChange(options.copy(style = it)) }, + onDismiss = { showStyle = false }, + ) + } if (showRange) { AgendaRangePicker( title = stringResource(R.string.widget_config_range), @@ -356,6 +388,18 @@ private fun ContentOptions( } } +@Composable +private fun agendaStyleLabel(style: AgendaWidgetStyle): String = stringResource( + when (style) { + AgendaWidgetStyle.CARDS -> R.string.widget_config_style_cards + AgendaWidgetStyle.CLASSIC -> R.string.widget_config_style_classic + }, +) + +/** The faintest fill the opacity slider allows, and its 10% steps between that and opaque. */ +private const val MIN_OPACITY = 0.2f +private const val OPACITY_STEPS = 7 + @Composable internal fun backgroundLabel(background: WidgetBackground): String = stringResource( when (background) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c5b8f79..a2503ad 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -517,6 +517,11 @@ Tinted Transparent Follow app + Opacity + %1$d%% + Style + Cards + Classic Content Time range Text size diff --git a/floret-kit b/floret-kit index 1ff6702..e62730d 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 1ff6702fe6ff1ab695157e6559ec3761ba9f01b9 +Subproject commit e62730dd18e0a3e3fd50cd327ad11fc9a80d421f From aacd86cebed22c65a25119973db0ff20929cb3a2 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 18:43:56 +0100 Subject: [PATCH 02/10] Classic agenda: cap with labels, visible dimming, solid day cards (#489) --- .../calendula/widget/WidgetEventRow.kt | 9 +--- .../calendula/widget/WidgetParts.kt | 11 ++++ .../calendula/widget/agenda/AgendaWidget.kt | 50 ++++++++++--------- .../widget/config/WidgetConfigScreen.kt | 4 +- .../widget/countdown/CountdownWidget.kt | 9 +--- app/src/main/res/values/strings.xml | 1 - .../calendula/widget/WidgetConfigTest.kt | 33 ++++++++++++ .../widget/agenda/ClassicItemsTest.kt | 28 +++++++++++ floret-kit | 2 +- 9 files changed, 106 insertions(+), 41 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetConfigTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/ClassicItemsTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEventRow.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEventRow.kt index 311715f..7295857 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEventRow.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEventRow.kt @@ -18,7 +18,6 @@ import androidx.glance.layout.padding import androidx.glance.text.FontWeight import androidx.glance.text.Text import androidx.glance.text.TextStyle -import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.isCrossedOut @@ -84,13 +83,7 @@ internal fun WidgetEventRow( .padding(horizontal = 12.dp) .clickable( actionStartActivity( - MainActivity.openEventIntent( - context = context, - eventId = event.eventId, - beginMillis = event.start.toEpochMilliseconds(), - endMillis = event.end.toEpochMilliseconds(), - source = source, - ), + event.openIntent(context, source), ), ), verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetParts.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetParts.kt index 9022f75..b790edf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetParts.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetParts.kt @@ -19,6 +19,8 @@ import kotlinx.datetime.toJavaDayOfWeek import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant +import de.jeanlucmakiola.calendula.MainActivity +import de.jeanlucmakiola.calendula.ui.common.CalendarView /** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */ @Composable @@ -27,6 +29,15 @@ internal fun widgetCardColor(): ColorProvider = when (LocalWidgetBackground.curr WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground } +/** Opens this occurrence in the app, backing out to [source]. */ +internal fun EventInstance.openIntent(context: Context, source: CalendarView) = MainActivity.openEventIntent( + context = context, + eventId = eventId, + beginMillis = start.toEpochMilliseconds(), + endMillis = end.toEpochMilliseconds(), + source = source, +) + /** An event's time on [day]: "All day", "Starts 14:00", "Ends 9:00" or "14:00 – 15:30". */ internal fun widgetTimeLabel(context: Context, event: EventInstance, day: LocalDate, is24Hour: Boolean): String = when (val label = agendaTimeLabel(event, day, systemZone())) { 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 a3a3169..5b699dd 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 @@ -71,6 +71,7 @@ import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot import de.jeanlucmakiola.calendula.widget.WidgetAppearance import de.jeanlucmakiola.calendula.widget.widgetCardColor import de.jeanlucmakiola.calendula.widget.widgetTimeLabel +import de.jeanlucmakiola.calendula.widget.openIntent import de.jeanlucmakiola.calendula.widget.loadAgendaWidget import de.jeanlucmakiola.calendula.widget.widgetAppearance import de.jeanlucmakiola.floret.components.Position @@ -156,10 +157,10 @@ private const val PREVIEW_ROWS = 10 * user scrolling a home-screen widget past a hundred rows is not a case worth * risking a failed update for. */ -private const val MAX_AGENDA_ROWS = 100 +internal const val MAX_AGENDA_ROWS = 100 /** Flat row model: one [LazyColumn] item per event or placeholder, a day's first one carrying its date pill. */ -private sealed interface AgendaRow { +internal sealed interface AgendaRow { val date: LocalDate data class Event(override val date: LocalDate, val event: EventInstance) : AgendaRow @@ -288,7 +289,7 @@ private fun RowList(count: Int, preview: Boolean, item: @Composable (Int) -> Uni } /** A classic list entry: a day's label line, or one of its rows. */ -private sealed interface ClassicItem { +internal sealed interface ClassicItem { val date: LocalDate data class Header(override val date: LocalDate) : ClassicItem @@ -297,6 +298,17 @@ private sealed interface ClassicItem { } } +/** + * [rows] with a label before each day's first, held to [limit] items in all, + * headers included; a label the cut leaves with nothing under it is dropped. + */ +internal fun classicItems(rows: List, limit: Int = MAX_AGENDA_ROWS): List = buildList { + rows.forEachIndexed { index, row -> + if (index == 0 || row.date != rows[index - 1].date) add(ClassicItem.Header(row.date)) + add(ClassicItem.Entry(row)) + } +}.take(limit).dropLastWhile { it is ClassicItem.Header } + /** * v2.23's look (#489): a label line per day over plain rows with a colour * stripe. On a transparent root each day sits on its own backing card, so the @@ -304,14 +316,10 @@ private sealed interface ClassicItem { */ @Composable private fun ClassicRows(rows: List, data: AgendaWidgetData.Ready, metrics: AgendaMetrics, preview: Boolean) { - val items = buildList { - rows.forEachIndexed { index, row -> - if (index == 0 || row.date != rows[index - 1].date) add(ClassicItem.Header(row.date)) - add(ClassicItem.Entry(row)) - } - } + val items = classicItems(rows) val backed = LocalWidgetBackground.current == WidgetBackground.TRANSPARENT - val positions = dayPositions(items.map { it.date }) + val positions = if (backed) dayPositions(items.map { it.date }) else emptyList() + val cardColor = widgetCardColor() val item: @Composable (Int) -> Unit = { index -> val entry = items[index] val content: @Composable () -> Unit = { @@ -333,7 +341,8 @@ private fun ClassicRows(rows: List, data: AgendaWidgetData.Ready, met WidgetGroupedRow( position = positions[index], modifier = GlanceModifier.padding(top = gap), - color = GlanceTheme.colors.widgetBackground, + color = cardColor, + gapBelow = false, content = content, ) } else { @@ -390,7 +399,7 @@ private fun ClassicEventRow(row: AgendaRow.Event, data: AgendaWidgetData.Ready, modifier = GlanceModifier .fillMaxWidth() .padding(horizontal = CLASSIC_ROW_H_PAD, vertical = metrics.rowVPad) - .clickable(actionStartActivity(event.openIntent(context))), + .clickable(actionStartActivity(event.openIntent(context, CalendarView.Agenda))), verticalAlignment = Alignment.CenterVertically, ) { Box( @@ -406,7 +415,7 @@ private fun ClassicEventRow(row: AgendaRow.Event, data: AgendaWidgetData.Ready, text = event.title.ifBlank { context.getString(R.string.event_untitled) }, maxLines = 1, style = TextStyle( - color = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface, + color = if (dimmed) GlanceTheme.colors.outline else GlanceTheme.colors.onSurface, fontSize = metrics.eventTitle, textDecoration = glanceDeclinedDecoration(event.isCrossedOut), ), @@ -414,7 +423,10 @@ private fun ClassicEventRow(row: AgendaRow.Event, data: AgendaWidgetData.Ready, Text( text = eventTimeSummary(context, event, row.date, data.is24Hour), maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime), + style = TextStyle( + color = if (dimmed) GlanceTheme.colors.outline else GlanceTheme.colors.onSurfaceVariant, + fontSize = metrics.eventTime, + ), ) } } @@ -423,14 +435,6 @@ private fun ClassicEventRow(row: AgendaRow.Event, data: AgendaWidgetData.Ready, /** Space above a day's label on its own backing card. */ private val CLASSIC_CARD_TOP_PAD = 8.dp -private fun EventInstance.openIntent(context: Context) = MainActivity.openEventIntent( - context = context, - eventId = eventId, - beginMillis = start.toEpochMilliseconds(), - endMillis = end.toEpochMilliseconds(), - source = CalendarView.Agenda, -) - /** Where each row sits among the rows of its day, from each row's date, for the grouped-card shapes. */ private fun dayPositions(dates: List): List = buildList { var start = 0 @@ -584,7 +588,7 @@ private fun EventRow( .fillMaxWidth() .height(metrics.rowHeight) .padding(horizontal = ROW_H_PAD) - .clickable(actionStartActivity(event.openIntent(context))), + .clickable(actionStartActivity(event.openIntent(context, CalendarView.Agenda))), verticalAlignment = Alignment.CenterVertically, ) { Text( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt index e16200d..4e93c13 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt @@ -71,6 +71,8 @@ import de.jeanlucmakiola.floret.glance.WidgetBackground import kotlin.math.roundToInt import de.jeanlucmakiola.floret.components.GroupedSliderRow import de.jeanlucmakiola.calendula.widget.AgendaWidgetStyle +import java.text.NumberFormat +import androidx.compose.runtime.remember private val PreviewMaxHeight = 280.dp @@ -245,7 +247,7 @@ private fun ConfigOptions( value = opacity, onValueChange = { onAppearance(state.appearance.copy(opacity = it)) }, position = Position.Middle, - valueLabel = stringResource(R.string.widget_config_opacity_value, (opacity * 100).roundToInt()), + valueLabel = remember(opacity) { NumberFormat.getPercentInstance().format(opacity) }, valueRange = MIN_OPACITY..1f, steps = OPACITY_STEPS, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownWidget.kt index 975155d..df620de 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownWidget.kt @@ -42,6 +42,7 @@ import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.coversWholeDays import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.widget.openIntent import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot @@ -132,13 +133,7 @@ private fun EventCountdown(event: EventInstance, data: CountdownData.Ready) { val context = LocalContext.current val label = countdownLabel(event.counted(systemZone()), data.now, systemZone()) val open = actionStartActivity( - MainActivity.openEventIntent( - context = context, - eventId = event.eventId, - beginMillis = event.start.toEpochMilliseconds(), - endMillis = event.end.toEpochMilliseconds(), - source = CalendarView.Agenda, - ), + event.openIntent(context, CalendarView.Agenda), ) // Over a wallpaper the text sits on its own card, as every other widget's rows do. val card = GlanceModifier.wallpaperBacking(radius = 16.dp) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a2503ad..e4ff9c1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -518,7 +518,6 @@ Transparent Follow app Opacity - %1$d%% Style Cards Classic diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetConfigTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetConfigTest.kt new file mode 100644 index 0000000..a9e81e4 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetConfigTest.kt @@ -0,0 +1,33 @@ +package de.jeanlucmakiola.calendula.widget + +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.mutablePreferencesOf +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange +import de.jeanlucmakiola.floret.glance.WidgetBackground +import org.junit.jupiter.api.Test + +/** Per-widget options survive a save and read back (#489). */ +class WidgetConfigTest { + + private val defaults = AgendaWidgetOptions(AgendaRange.Week, WidgetSize.SMALL, true, PastEventDisplay.SHOW) + + @Test + fun `appearance and agenda style round-trip`() { + val appearance = WidgetAppearance(WidgetBackground.SOLID, WidgetTheme.DARK, opacity = 0.6f) + val options = defaults.copy(style = AgendaWidgetStyle.CLASSIC) + val prefs = mutablePreferencesOf().apply { + putAppearance(appearance) + putAgendaOptions(options) + } + assertThat(prefs.widgetAppearance).isEqualTo(appearance) + assertThat(prefs.agendaWidgetOptions(defaults)).isEqualTo(options) + } + + @Test + fun `a widget saved before 2_24_1 stays opaque cards`() { + assertThat(emptyPreferences().widgetAppearance.opacity).isEqualTo(1f) + assertThat(emptyPreferences().agendaWidgetOptions(defaults).style).isEqualTo(AgendaWidgetStyle.CARDS) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/ClassicItemsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/ClassicItemsTest.kt new file mode 100644 index 0000000..dd525f6 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/ClassicItemsTest.kt @@ -0,0 +1,28 @@ +package de.jeanlucmakiola.calendula.widget.agenda + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test + +/** The classic agenda's day labels and row cap (#489). */ +class ClassicItemsTest { + + private val mon = LocalDate(2026, 11, 9) + private val tue = LocalDate(2026, 11, 10) + + @Test + fun `each day gets one label before its rows`() { + val rows = listOf(AgendaRow.Placeholder(mon), AgendaRow.Placeholder(tue), AgendaRow.Placeholder(tue)) + assertThat(classicItems(rows).map { it::class }).containsExactly( + ClassicItem.Header::class, ClassicItem.Entry::class, + ClassicItem.Header::class, ClassicItem.Entry::class, ClassicItem.Entry::class, + ).inOrder() + } + + @Test + fun `labels count towards the cap and a stranded one is dropped`() { + val rows = listOf(AgendaRow.Placeholder(mon), AgendaRow.Placeholder(tue)) + val items = classicItems(rows, limit = 3) + assertThat(items).containsExactly(ClassicItem.Header(mon), ClassicItem.Entry(rows[0])).inOrder() + } +} diff --git a/floret-kit b/floret-kit index e62730d..b7af14a 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit e62730dd18e0a3e3fd50cd327ad11fc9a80d421f +Subproject commit b7af14a31aa32ae32f3f2be2cb7e56b15ec598bf From 347ce74808e89e9326778e8afb1ca2c9481096fb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 18:52:00 +0100 Subject: [PATCH 03/10] Classic agenda: single cap, card inset, shared dim rule (#489) --- .../calendula/widget/agenda/AgendaWidget.kt | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 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 5b699dd..36c2c6f 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 @@ -94,7 +94,8 @@ import kotlinx.datetime.toJavaDayOfWeek /** * "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days - * of events, each day one card of grouped rows with its date pill beside it. + * of events: each day one card of grouped rows with its date pill beside it, or + * in the classic style a label line over plain colour-stripe rows (#489). * Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda. */ class AgendaWidget : GlanceAppWidget() { @@ -226,9 +227,9 @@ private fun AgendaRows( day.events.forEach { add(AgendaRow.Event(day.date, it)) } } } - }.take(MAX_AGENDA_ROWS) + } when (data.style) { - AgendaWidgetStyle.CARDS -> CardRows(rows, data, metrics, preview) + AgendaWidgetStyle.CARDS -> CardRows(rows.take(MAX_AGENDA_ROWS), data, metrics, preview) AgendaWidgetStyle.CLASSIC -> ClassicRows(rows, data, metrics, preview) } } @@ -316,7 +317,7 @@ internal fun classicItems(rows: List, limit: Int = MAX_AGENDA_ROWS): */ @Composable private fun ClassicRows(rows: List, data: AgendaWidgetData.Ready, metrics: AgendaMetrics, preview: Boolean) { - val items = classicItems(rows) + val items = classicItems(rows, if (preview) PREVIEW_ROWS else MAX_AGENDA_ROWS) val backed = LocalWidgetBackground.current == WidgetBackground.TRANSPARENT val positions = if (backed) dayPositions(items.map { it.date }) else emptyList() val cardColor = widgetCardColor() @@ -332,7 +333,7 @@ private fun ClassicRows(rows: List, data: AgendaWidgetData.Ready, met ) is ClassicItem.Entry -> when (val row = entry.row) { is AgendaRow.Placeholder -> ClassicPlaceholderRow(row.date, metrics) - is AgendaRow.Event -> ClassicEventRow(row, data, metrics) + is AgendaRow.Event -> ClassicEventRow(row, data, backed, metrics) } } } @@ -388,17 +389,17 @@ private fun ClassicPlaceholderRow(date: LocalDate, metrics: AgendaMetrics) { /** A classic event row: the calendar's colour stripe beside the title and time; a dimmed one fades both. */ @Composable -private fun ClassicEventRow(row: AgendaRow.Event, data: AgendaWidgetData.Ready, metrics: AgendaMetrics) { +private fun ClassicEventRow(row: AgendaRow.Event, data: AgendaWidgetData.Ready, backed: Boolean, metrics: AgendaMetrics) { val context = androidx.glance.LocalContext.current val event = row.event - val dimmed = data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now) + val dimmed = row.dimmed(data) fun accent(dark: Boolean) = eventAccent(event.color, dark, data.soften).let { if (dimmed) it.copy(alpha = EventDimAlpha) else it } Row( modifier = GlanceModifier .fillMaxWidth() - .padding(horizontal = CLASSIC_ROW_H_PAD, vertical = metrics.rowVPad) + .padding(horizontal = if (backed) CLASSIC_CARD_H_PAD else CLASSIC_ROW_H_PAD, vertical = metrics.rowVPad) .clickable(actionStartActivity(event.openIntent(context, CalendarView.Agenda))), verticalAlignment = Alignment.CenterVertically, ) { @@ -432,8 +433,9 @@ private fun ClassicEventRow(row: AgendaRow.Event, data: AgendaWidgetData.Ready, } } -/** Space above a day's label on its own backing card. */ +/** Space above a day's label on its own backing card, and its rows' inset, clear of the card's corners. */ private val CLASSIC_CARD_TOP_PAD = 8.dp +private val CLASSIC_CARD_H_PAD = 10.dp /** Where each row sits among the rows of its day, from each row's date, for the grouped-card shapes. */ private fun dayPositions(dates: List): List = buildList { @@ -560,8 +562,12 @@ private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) { /** 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) +/** Whether this row is drawn faded as a finished event. */ +private fun AgendaRow.Event.dimmed(data: AgendaWidgetData.Ready): Boolean = + data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now) + private fun AgendaRow.Event.fill(data: AgendaWidgetData.Ready): EventFill { - val dimmed = data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now) + val dimmed = dimmed(data) fun tone(dark: Boolean) = eventFill(event.color, dark, data.soften).let { if (dimmed) it.copy(alpha = WidgetDimFillAlpha) else it } From 51f58e76b235abaab950d37ddfdeea6f66183e94 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 18:59:02 +0100 Subject: [PATCH 04/10] Classic agenda: seamless day cards, aligned placeholder, shared day action (#489) --- .../calendula/widget/WidgetParts.kt | 5 ++++ .../calendula/widget/agenda/AgendaScale.kt | 5 ++-- .../calendula/widget/agenda/AgendaWidget.kt | 30 ++++++++++--------- floret-kit | 2 +- 4 files changed, 25 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetParts.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetParts.kt index b790edf..a894c46 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetParts.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetParts.kt @@ -21,6 +21,7 @@ import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.ui.common.CalendarView +import androidx.glance.appwidget.action.actionStartActivity /** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */ @Composable @@ -38,6 +39,10 @@ internal fun EventInstance.openIntent(context: Context, source: CalendarView) = source = source, ) +/** Opens [date] in the app's agenda. */ +internal fun openDayAction(context: Context, date: LocalDate) = + actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda)) + /** An event's time on [day]: "All day", "Starts 14:00", "Ends 9:00" or "14:00 – 15:30". */ internal fun widgetTimeLabel(context: Context, event: EventInstance, day: LocalDate, is24Hour: Boolean): String = when (val label = agendaTimeLabel(event, day, systemZone())) { 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 049d6b3..f1e8475 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 @@ -17,8 +17,9 @@ internal val CLASSIC_ROW_H_PAD = 4.dp internal val STRIPE_WIDTH = 5.dp internal val STRIPE_GAP = 10.dp -/** Where the classic style's event text starts, so its placeholder lines up with the titles. */ -internal val TEXT_INDENT = CLASSIC_ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP +/** The stripe and its gap, and where the classic style's event text starts, so its placeholder lines up with the titles. */ +internal val STRIPE_INSET = STRIPE_WIDTH + STRIPE_GAP +internal val TEXT_INDENT = CLASSIC_ROW_H_PAD + STRIPE_INSET /** * Every size the agenda widget varies by [WidgetSize]. Values that genuinely 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 36c2c6f..babc7c6 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 @@ -72,6 +72,7 @@ import de.jeanlucmakiola.calendula.widget.WidgetAppearance import de.jeanlucmakiola.calendula.widget.widgetCardColor import de.jeanlucmakiola.calendula.widget.widgetTimeLabel import de.jeanlucmakiola.calendula.widget.openIntent +import de.jeanlucmakiola.calendula.widget.openDayAction import de.jeanlucmakiola.calendula.widget.loadAgendaWidget import de.jeanlucmakiola.calendula.widget.widgetAppearance import de.jeanlucmakiola.floret.components.Position @@ -229,7 +230,7 @@ private fun AgendaRows( } } when (data.style) { - AgendaWidgetStyle.CARDS -> CardRows(rows.take(MAX_AGENDA_ROWS), data, metrics, preview) + AgendaWidgetStyle.CARDS -> CardRows(rows, data, metrics, preview) AgendaWidgetStyle.CLASSIC -> ClassicRows(rows, data, metrics, preview) } } @@ -237,7 +238,8 @@ private fun AgendaRows( /** Each day is one card of grouped rows (#71), with its date pill beside the first row. */ @Composable -private fun CardRows(rows: List, data: AgendaWidgetData.Ready, metrics: AgendaMetrics, preview: Boolean) { +private fun CardRows(allRows: List, data: AgendaWidgetData.Ready, metrics: AgendaMetrics, preview: Boolean) { + val rows = allRows.take(MAX_AGENDA_ROWS) val positions = dayPositions(rows.map { it.date }) val cardColor = widgetCardColor() val item: @Composable (Int) -> Unit = { index -> @@ -328,11 +330,15 @@ private fun ClassicRows(rows: List, data: AgendaWidgetData.Ready, met is ClassicItem.Header -> DayHeaderRow( date = entry.date, today = data.today, - topPad = if (backed) CLASSIC_CARD_TOP_PAD else metrics.dayHeaderTopPad, + topPad = when { + backed -> CLASSIC_CARD_TOP_PAD + index == 0 -> 0.dp + else -> metrics.dayHeaderTopPad + }, metrics = metrics, ) is ClassicItem.Entry -> when (val row = entry.row) { - is AgendaRow.Placeholder -> ClassicPlaceholderRow(row.date, metrics) + is AgendaRow.Placeholder -> ClassicPlaceholderRow(row.date, backed, metrics) is AgendaRow.Event -> ClassicEventRow(row, data, backed, metrics) } } @@ -368,13 +374,13 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate, topPad: Dp, metrics: modifier = GlanceModifier .fillMaxWidth() .padding(start = 8.dp, end = 8.dp, top = topPad, bottom = metrics.rowVPad) - .clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda))), + .clickable(openDayAction(context, date)), ) } /** "No more events today" as a muted line under an anchored, event-less today (#35), indented to the titles. */ @Composable -private fun ClassicPlaceholderRow(date: LocalDate, metrics: AgendaMetrics) { +private fun ClassicPlaceholderRow(date: LocalDate, backed: Boolean, metrics: AgendaMetrics) { val context = androidx.glance.LocalContext.current Text( text = context.getString(R.string.agenda_no_more_today), @@ -382,8 +388,8 @@ private fun ClassicPlaceholderRow(date: LocalDate, metrics: AgendaMetrics) { style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder), modifier = GlanceModifier .fillMaxWidth() - .padding(start = TEXT_INDENT, end = 8.dp, top = 2.dp, bottom = metrics.rowVPad + 2.dp) - .clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda))), + .padding(start = if (backed) CLASSIC_CARD_H_PAD + STRIPE_INSET else TEXT_INDENT, end = 8.dp, top = 2.dp, bottom = metrics.rowVPad + 2.dp) + .clickable(openDayAction(context, date)), ) } @@ -512,7 +518,7 @@ private fun DatePill(date: LocalDate, today: LocalDate, cardColor: ColorProvider .height(metrics.rowHeight) .cornerRadius(metrics.pillWidth / 2) .background(container) - .clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda))) + .clickable(openDayAction(context, date)) .semantics { contentDescription = label }, horizontalAlignment = Alignment.CenterHorizontally, verticalAlignment = Alignment.CenterVertically, @@ -544,11 +550,7 @@ private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) { .fillMaxWidth() .height(metrics.rowHeight) .padding(horizontal = ROW_H_PAD) - .clickable( - actionStartActivity( - MainActivity.openDateIntent(context, date, CalendarView.Agenda), - ), - ), + .clickable(openDayAction(context, date)), contentAlignment = Alignment.CenterStart, ) { Text( diff --git a/floret-kit b/floret-kit index b7af14a..5e8702b 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit b7af14a31aa32ae32f3f2be2cb7e56b15ec598bf +Subproject commit 5e8702b7ac884a03043520df5045e423fa646a27 From ff4082d5df655f7b2e5edc894a9cce8104e361f2 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 19:02:48 +0100 Subject: [PATCH 05/10] Classic agenda: line day labels up with the card rows (#489) --- .../jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 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 babc7c6..db723c0 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 @@ -330,6 +330,7 @@ private fun ClassicRows(rows: List, data: AgendaWidgetData.Ready, met is ClassicItem.Header -> DayHeaderRow( date = entry.date, today = data.today, + startPad = if (backed) CLASSIC_CARD_H_PAD else 8.dp, topPad = when { backed -> CLASSIC_CARD_TOP_PAD index == 0 -> 0.dp @@ -361,7 +362,7 @@ private fun ClassicRows(rows: List, data: AgendaWidgetData.Ready, met /** A classic day label, "Today · Wed, 17 Jun"; opens that day in the app. */ @Composable -private fun DayHeaderRow(date: LocalDate, today: LocalDate, topPad: Dp, metrics: AgendaMetrics) { +private fun DayHeaderRow(date: LocalDate, today: LocalDate, startPad: Dp, topPad: Dp, metrics: AgendaMetrics) { val context = androidx.glance.LocalContext.current Text( text = agendaDayLabel(context, date, today), @@ -373,7 +374,7 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate, topPad: Dp, metrics: ), modifier = GlanceModifier .fillMaxWidth() - .padding(start = 8.dp, end = 8.dp, top = topPad, bottom = metrics.rowVPad) + .padding(start = startPad, end = 8.dp, top = topPad, bottom = metrics.rowVPad) .clickable(openDayAction(context, date)), ) } From 7d149d2ab13ffa2fc159f8cde0a08acd71dcafdc Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 19:11:54 +0100 Subject: [PATCH 06/10] Classic agenda: back rows when the fill is see-through (#489) --- .../jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt | 7 +++---- floret-kit | 2 +- 2 files changed, 4 insertions(+), 5 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 db723c0..e620b57 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 @@ -60,8 +60,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.widget.AgendaWidgetStyle -import de.jeanlucmakiola.floret.glance.LocalWidgetBackground -import de.jeanlucmakiola.floret.glance.WidgetBackground +import de.jeanlucmakiola.floret.glance.LocalWidgetSeeThrough import de.jeanlucmakiola.calendula.widget.WidgetDimFillAlpha import de.jeanlucmakiola.calendula.widget.widgetEventInk import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA @@ -314,13 +313,13 @@ internal fun classicItems(rows: List, limit: Int = MAX_AGENDA_ROWS): /** * v2.23's look (#489): a label line per day over plain rows with a colour - * stripe. On a transparent root each day sits on its own backing card, so the + * stripe. On a see-through root each day sits on its own backing card, so the * text stays readable over the wallpaper. */ @Composable private fun ClassicRows(rows: List, data: AgendaWidgetData.Ready, metrics: AgendaMetrics, preview: Boolean) { val items = classicItems(rows, if (preview) PREVIEW_ROWS else MAX_AGENDA_ROWS) - val backed = LocalWidgetBackground.current == WidgetBackground.TRANSPARENT + val backed = LocalWidgetSeeThrough.current val positions = if (backed) dayPositions(items.map { it.date }) else emptyList() val cardColor = widgetCardColor() val item: @Composable (Int) -> Unit = { index -> diff --git a/floret-kit b/floret-kit index 5e8702b..8ce070f 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 5e8702b7ac884a03043520df5045e423fa646a27 +Subproject commit 8ce070f4903318f393899b0b3cdc0cfd8ed78b0d From f896153fb7ba29a02c1b2ee1106a300d8d41282b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 19:27:27 +0100 Subject: [PATCH 07/10] Classic agenda: readable dimming on any fill, year on far dates (#489) --- .../calendula/widget/agenda/AgendaWidget.kt | 14 +++++++++++--- .../calendula/widget/config/WidgetConfigScreen.kt | 5 ++++- floret-kit | 2 +- 3 files changed, 16 insertions(+), 5 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 e620b57..9c81d37 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 @@ -61,6 +61,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.widget.AgendaWidgetStyle import de.jeanlucmakiola.floret.glance.LocalWidgetSeeThrough +import de.jeanlucmakiola.floret.glance.withAlpha import de.jeanlucmakiola.calendula.widget.WidgetDimFillAlpha import de.jeanlucmakiola.calendula.widget.widgetEventInk import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA @@ -422,7 +423,7 @@ private fun ClassicEventRow(row: AgendaRow.Event, data: AgendaWidgetData.Ready, text = event.title.ifBlank { context.getString(R.string.event_untitled) }, maxLines = 1, style = TextStyle( - color = if (dimmed) GlanceTheme.colors.outline else GlanceTheme.colors.onSurface, + color = if (dimmed) GlanceTheme.colors.onSurface.withAlpha(CLASSIC_DIM_INK) else GlanceTheme.colors.onSurface, fontSize = metrics.eventTitle, textDecoration = glanceDeclinedDecoration(event.isCrossedOut), ), @@ -431,7 +432,11 @@ private fun ClassicEventRow(row: AgendaRow.Event, data: AgendaWidgetData.Ready, text = eventTimeSummary(context, event, row.date, data.is24Hour), maxLines = 1, style = TextStyle( - color = if (dimmed) GlanceTheme.colors.outline else GlanceTheme.colors.onSurfaceVariant, + color = if (dimmed) { + GlanceTheme.colors.onSurfaceVariant.withAlpha(CLASSIC_DIM_INK) + } else { + GlanceTheme.colors.onSurfaceVariant + }, fontSize = metrics.eventTime, ), ) @@ -443,6 +448,9 @@ private fun ClassicEventRow(row: AgendaRow.Event, data: AgendaWidgetData.Ready, private val CLASSIC_CARD_TOP_PAD = 8.dp private val CLASSIC_CARD_H_PAD = 10.dp +/** How strongly a finished event's text shows in the classic style, on any fill. */ +private const val CLASSIC_DIM_INK = 0.5f + /** Where each row sits among the rows of its day, from each row's date, for the grouped-card shapes. */ private fun dayPositions(dates: List): List = buildList { var start = 0 @@ -639,7 +647,7 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) // Weekday + date in the locale's own field order (compact: no year), rather // than a hardcoded day-month layout. - val formatted = localizedDateFormatter(locale, "EEEdMMM").format(java) + val formatted = localizedDateFormatter(locale, if (date.year == today.year) "EEEdMMM" else "EEEdMMMy").format(java) return if (relative != null) "$relative · $formatted" else formatted } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt index 4e93c13..125fa9b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt @@ -72,6 +72,7 @@ import kotlin.math.roundToInt import de.jeanlucmakiola.floret.components.GroupedSliderRow import de.jeanlucmakiola.calendula.widget.AgendaWidgetStyle import java.text.NumberFormat +import de.jeanlucmakiola.floret.locale.currentLocale import androidx.compose.runtime.remember private val PreviewMaxHeight = 280.dp @@ -247,7 +248,9 @@ private fun ConfigOptions( value = opacity, onValueChange = { onAppearance(state.appearance.copy(opacity = it)) }, position = Position.Middle, - valueLabel = remember(opacity) { NumberFormat.getPercentInstance().format(opacity) }, + valueLabel = currentLocale().let { locale -> + remember(opacity, locale) { NumberFormat.getPercentInstance(locale).format(opacity) } + }, valueRange = MIN_OPACITY..1f, steps = OPACITY_STEPS, ) diff --git a/floret-kit b/floret-kit index 8ce070f..24c9257 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 8ce070f4903318f393899b0b3cdc0cfd8ed78b0d +Subproject commit 24c92571ab8bac74ce8dd27b10876ee0a335848f From 88d561415887f5df427bb2537fca6691640dd291 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 19:58:15 +0100 Subject: [PATCH 08/10] Bump floret-kit: inset opacity slider row (#489) --- floret-kit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/floret-kit b/floret-kit index 24c9257..377fbee 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 24c92571ab8bac74ce8dd27b10876ee0a335848f +Subproject commit 377fbee0655291dc7f4b9cba885bf47cbfd34c06 From 06f658917f66d06afd62e125d06b51131bd678eb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 20:07:18 +0100 Subject: [PATCH 09/10] Classic agenda: invert text colour instead of backings at low opacity (#489) --- .../calendula/widget/WidgetConfig.kt | 5 ++ .../calendula/widget/WidgetData.kt | 2 + .../calendula/widget/agenda/AgendaWidget.kt | 74 ++++++++++++------- .../widget/config/WidgetConfigScreen.kt | 43 ++++++++++- app/src/main/res/values/strings.xml | 5 ++ floret-kit | 2 +- 6 files changed, 103 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt index d50243b..4340e42 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt @@ -41,6 +41,8 @@ data class AgendaWidgetOptions( val showToday: Boolean, val pastDisplay: PastEventDisplay, val style: AgendaWidgetStyle = AgendaWidgetStyle.CARDS, + /** Classic style only: draw the text in the opposite theme's colours, for a faded fill over the wallpaper. */ + val invertText: Boolean = false, ) internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this) { @@ -57,6 +59,7 @@ private val BackgroundKey = stringPreferencesKey("cfg_background") private val ThemeKey = stringPreferencesKey("cfg_theme") private val OpacityKey = floatPreferencesKey("cfg_opacity") private val AgendaStyleKey = stringPreferencesKey("cfg_agenda_style") +private val InvertTextKey = booleanPreferencesKey("cfg_invert_text") private val AgendaRangeKey = stringPreferencesKey("cfg_agenda_range") private val TextSizeKey = stringPreferencesKey("cfg_text_size") private val ShowTodayKey = booleanPreferencesKey("cfg_show_today") @@ -81,6 +84,7 @@ internal fun Preferences.agendaWidgetOptions(defaults: AgendaWidgetOptions) = Ag showToday = this[ShowTodayKey] ?: defaults.showToday, pastDisplay = this[PastDisplayKey].toEnum(defaults.pastDisplay), style = this[AgendaStyleKey].toEnum(defaults.style), + invertText = this[InvertTextKey] ?: defaults.invertText, ) /** Whether this widget has agenda options of its own, rather than following the app's. */ @@ -98,6 +102,7 @@ internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) { this[ShowTodayKey] = options.showToday this[PastDisplayKey] = options.pastDisplay.name this[AgendaStyleKey] = options.style.name + this[InvertTextKey] = options.invertText } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt index 2052135..2d91c32 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -127,6 +127,7 @@ sealed interface AgendaWidgetData { val showToday: Boolean, val textSize: WidgetSize, val style: AgendaWidgetStyle = AgendaWidgetStyle.CARDS, + val invertText: Boolean = false, /** Snapshot instant the data was read at, for "has this event ended?" tests. */ val now: Instant, ) : AgendaWidgetData @@ -186,6 +187,7 @@ internal suspend fun Context.loadAgendaWidgetData( showToday = options.showToday, textSize = options.textSize, style = options.style, + invertText = options.invertText, now = Clock.System.now(), ) } 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 9c81d37..869011f 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 @@ -60,7 +60,10 @@ import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.widget.AgendaWidgetStyle +import de.jeanlucmakiola.floret.glance.LocalWidgetBackground import de.jeanlucmakiola.floret.glance.LocalWidgetSeeThrough +import androidx.compose.runtime.CompositionLocalProvider +import de.jeanlucmakiola.floret.glance.WidgetBackground import de.jeanlucmakiola.floret.glance.withAlpha import de.jeanlucmakiola.calendula.widget.WidgetDimFillAlpha import de.jeanlucmakiola.calendula.widget.widgetEventInk @@ -177,16 +180,21 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) { val size = (data as? AgendaWidgetData.Ready)?.textSize ?: WidgetSize.SMALL val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale val metrics = metricsFor(size).scaledForFont(fontScale) - Column( - modifier = GlanceModifier - .fillMaxSize() - .padding(horizontal = 8.dp, vertical = 6.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) + // The classic style adds no backings for a faded fill, only on a transparent one (#489). + val classic = (data as? AgendaWidgetData.Ready)?.style == AgendaWidgetStyle.CLASSIC + val seeThrough = if (classic) LocalWidgetBackground.current == WidgetBackground.TRANSPARENT else LocalWidgetSeeThrough.current + CompositionLocalProvider(LocalWidgetSeeThrough provides seeThrough) { + Column( + modifier = GlanceModifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 6.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) + } } } } @@ -314,13 +322,15 @@ internal fun classicItems(rows: List, limit: Int = MAX_AGENDA_ROWS): /** * v2.23's look (#489): a label line per day over plain rows with a colour - * stripe. On a see-through root each day sits on its own backing card, so the - * text stays readable over the wallpaper. + * stripe. On a transparent root each day sits on its own backing card, so the + * text stays readable over the wallpaper; a faded fill is left to the opacity + * and "Invert text colour" options. */ @Composable private fun ClassicRows(rows: List, data: AgendaWidgetData.Ready, metrics: AgendaMetrics, preview: Boolean) { val items = classicItems(rows, if (preview) PREVIEW_ROWS else MAX_AGENDA_ROWS) - val backed = LocalWidgetSeeThrough.current + val backed = LocalWidgetBackground.current == WidgetBackground.TRANSPARENT + val ink = classicInk(inverted = data.invertText && !backed) val positions = if (backed) dayPositions(items.map { it.date }) else emptyList() val cardColor = widgetCardColor() val item: @Composable (Int) -> Unit = { index -> @@ -336,11 +346,12 @@ private fun ClassicRows(rows: List, data: AgendaWidgetData.Ready, met index == 0 -> 0.dp else -> metrics.dayHeaderTopPad }, + ink = ink, metrics = metrics, ) is ClassicItem.Entry -> when (val row = entry.row) { - is AgendaRow.Placeholder -> ClassicPlaceholderRow(row.date, backed, metrics) - is AgendaRow.Event -> ClassicEventRow(row, data, backed, metrics) + is AgendaRow.Placeholder -> ClassicPlaceholderRow(row.date, backed, ink, metrics) + is AgendaRow.Event -> ClassicEventRow(row, data, backed, ink, metrics) } } } @@ -360,15 +371,27 @@ private fun ClassicRows(rows: List, data: AgendaWidgetData.Ready, met RowList(items.size, preview, item) } +/** The classic style's text colours: the theme's, or its inverse for the "Invert text colour" option. */ +private data class ClassicInk(val title: ColorProvider, val secondary: ColorProvider, val today: ColorProvider) + +@Composable +private fun classicInk(inverted: Boolean): ClassicInk = with(GlanceTheme.colors) { + if (inverted) { + ClassicInk(inverseOnSurface, inverseOnSurface.withAlpha(INVERTED_SECONDARY_INK), inversePrimary) + } else { + ClassicInk(onSurface, onSurfaceVariant, primary) + } +} + /** A classic day label, "Today · Wed, 17 Jun"; opens that day in the app. */ @Composable -private fun DayHeaderRow(date: LocalDate, today: LocalDate, startPad: Dp, topPad: Dp, metrics: AgendaMetrics) { +private fun DayHeaderRow(date: LocalDate, today: LocalDate, startPad: Dp, topPad: Dp, ink: ClassicInk, metrics: AgendaMetrics) { val context = androidx.glance.LocalContext.current Text( text = agendaDayLabel(context, date, today), maxLines = 1, style = TextStyle( - color = if (date == today) GlanceTheme.colors.primary else GlanceTheme.colors.onSurfaceVariant, + color = if (date == today) ink.today else ink.secondary, fontSize = metrics.dayHeader, fontWeight = FontWeight.Medium, ), @@ -381,12 +404,12 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate, startPad: Dp, topPad /** "No more events today" as a muted line under an anchored, event-less today (#35), indented to the titles. */ @Composable -private fun ClassicPlaceholderRow(date: LocalDate, backed: Boolean, metrics: AgendaMetrics) { +private fun ClassicPlaceholderRow(date: LocalDate, backed: Boolean, ink: ClassicInk, metrics: AgendaMetrics) { val context = androidx.glance.LocalContext.current Text( text = context.getString(R.string.agenda_no_more_today), maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder), + style = TextStyle(color = ink.secondary, fontSize = metrics.placeholder), modifier = GlanceModifier .fillMaxWidth() .padding(start = if (backed) CLASSIC_CARD_H_PAD + STRIPE_INSET else TEXT_INDENT, end = 8.dp, top = 2.dp, bottom = metrics.rowVPad + 2.dp) @@ -396,7 +419,7 @@ private fun ClassicPlaceholderRow(date: LocalDate, backed: Boolean, metrics: Age /** A classic event row: the calendar's colour stripe beside the title and time; a dimmed one fades both. */ @Composable -private fun ClassicEventRow(row: AgendaRow.Event, data: AgendaWidgetData.Ready, backed: Boolean, metrics: AgendaMetrics) { +private fun ClassicEventRow(row: AgendaRow.Event, data: AgendaWidgetData.Ready, backed: Boolean, ink: ClassicInk, metrics: AgendaMetrics) { val context = androidx.glance.LocalContext.current val event = row.event val dimmed = row.dimmed(data) @@ -423,7 +446,7 @@ private fun ClassicEventRow(row: AgendaRow.Event, data: AgendaWidgetData.Ready, text = event.title.ifBlank { context.getString(R.string.event_untitled) }, maxLines = 1, style = TextStyle( - color = if (dimmed) GlanceTheme.colors.onSurface.withAlpha(CLASSIC_DIM_INK) else GlanceTheme.colors.onSurface, + color = if (dimmed) ink.title.withAlpha(CLASSIC_DIM_INK) else ink.title, fontSize = metrics.eventTitle, textDecoration = glanceDeclinedDecoration(event.isCrossedOut), ), @@ -432,11 +455,7 @@ private fun ClassicEventRow(row: AgendaRow.Event, data: AgendaWidgetData.Ready, text = eventTimeSummary(context, event, row.date, data.is24Hour), maxLines = 1, style = TextStyle( - color = if (dimmed) { - GlanceTheme.colors.onSurfaceVariant.withAlpha(CLASSIC_DIM_INK) - } else { - GlanceTheme.colors.onSurfaceVariant - }, + color = if (dimmed) ink.secondary.withAlpha(CLASSIC_DIM_INK) else ink.secondary, fontSize = metrics.eventTime, ), ) @@ -451,6 +470,9 @@ private val CLASSIC_CARD_H_PAD = 10.dp /** How strongly a finished event's text shows in the classic style, on any fill. */ private const val CLASSIC_DIM_INK = 0.5f +/** The inverted secondary text's strength, as `onSurfaceVariant` sits below `onSurface`. */ +private const val INVERTED_SECONDARY_INK = 0.75f + /** Where each row sits among the rows of its day, from each row's date, for the grouped-card shapes. */ private fun dayPositions(dates: List): List = buildList { var start = 0 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt index 125fa9b..3461629 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt @@ -74,6 +74,10 @@ import de.jeanlucmakiola.calendula.widget.AgendaWidgetStyle import java.text.NumberFormat import de.jeanlucmakiola.floret.locale.currentLocale import androidx.compose.runtime.remember +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.Switch +import androidx.compose.foundation.layout.Row private val PreviewMaxHeight = 280.dp @@ -241,7 +245,11 @@ private fun ConfigOptions( onSelect = { onAppearance(state.appearance.copy(background = it)) }, position = Position.Top, ) - if (state.appearance.background != WidgetBackground.TRANSPARENT) { + var showReadability by rememberSaveable { mutableStateOf(false) } + val faded = state.appearance.background != WidgetBackground.TRANSPARENT + val content = state.draft.content + val classic = state.kind == WidgetKind.AGENDA && content.style == AgendaWidgetStyle.CLASSIC + if (faded) { val opacity = state.appearance.opacity GroupedSliderRow( title = stringResource(R.string.widget_config_opacity), @@ -253,6 +261,21 @@ private fun ConfigOptions( }, valueRange = MIN_OPACITY..1f, steps = OPACITY_STEPS, + trailing = { ReadabilityInfoButton { showReadability = !showReadability } }, + ) + } + if (faded && classic) { + GroupedRow( + title = stringResource(R.string.widget_config_invert_text), + summary = stringResource(R.string.widget_config_invert_text_summary), + position = Position.Middle, + trailing = { + Row(verticalAlignment = Alignment.CenterVertically) { + ReadabilityInfoButton { showReadability = !showReadability } + Switch(checked = content.invertText, onCheckedChange = { onContent(content.copy(invertText = it)) }) + } + }, + onClick = { onContent(content.copy(invertText = !content.invertText)) }, ) } GroupedChoiceRow( @@ -263,6 +286,12 @@ private fun ConfigOptions( onSelect = { onAppearance(state.appearance.copy(theme = it)) }, position = Position.Bottom, ) + AnimatedVisibility(visible = faded && showReadability) { + GroupedHint( + stringResource(if (classic) R.string.widget_config_readability_hint_classic else R.string.widget_config_readability_hint), + verticalPadding = 8.dp, + ) + } if (state.kind == WidgetKind.COUNTDOWN) { CountdownOptionsSection( options = state.draft.countdown, @@ -393,6 +422,18 @@ private fun ContentOptions( } } +/** The "i" beside the opacity and text-colour options; toggles the readability hint below them. */ +@Composable +private fun ReadabilityInfoButton(onClick: () -> Unit) { + IconButton(onClick = onClick) { + Icon( + Icons.Outlined.Info, + contentDescription = stringResource(R.string.widget_config_readability_info), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + @Composable private fun agendaStyleLabel(style: AgendaWidgetStyle): String = stringResource( when (style) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e4ff9c1..ed0a443 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -518,6 +518,11 @@ Transparent Follow app Opacity + Invert text colour + Light text on a dark theme turns dark, and the other way round + About readability + At low opacity the wallpaper shows through, so some text may be harder to read. Raise the opacity if it is. + At low opacity the wallpaper shows through, so some text may be hard to read. If it is, raise the opacity or invert the text colour. Style Cards Classic diff --git a/floret-kit b/floret-kit index 377fbee..7105140 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 377fbee0655291dc7f4b9cba885bf47cbfd34c06 +Subproject commit 7105140b8f8b74bed67a02d2e47d8e2453b5d305 From 7cfdb43f37a46c2c5233ceb86008aa370bccadc0 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 20:18:14 +0100 Subject: [PATCH 10/10] Widget config: readability info as a popover beside Opacity (#489) --- .../widget/config/WidgetConfigScreen.kt | 34 ++++++------------- floret-kit | 2 +- 2 files changed, 11 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt index 3461629..77c31a9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt @@ -70,6 +70,7 @@ import de.jeanlucmakiola.floret.glance.WallpaperWindow import de.jeanlucmakiola.floret.glance.WidgetBackground import kotlin.math.roundToInt import de.jeanlucmakiola.floret.components.GroupedSliderRow +import de.jeanlucmakiola.floret.components.InfoPopover import de.jeanlucmakiola.calendula.widget.AgendaWidgetStyle import java.text.NumberFormat import de.jeanlucmakiola.floret.locale.currentLocale @@ -245,7 +246,6 @@ private fun ConfigOptions( onSelect = { onAppearance(state.appearance.copy(background = it)) }, position = Position.Top, ) - var showReadability by rememberSaveable { mutableStateOf(false) } val faded = state.appearance.background != WidgetBackground.TRANSPARENT val content = state.draft.content val classic = state.kind == WidgetKind.AGENDA && content.style == AgendaWidgetStyle.CLASSIC @@ -261,7 +261,14 @@ private fun ConfigOptions( }, valueRange = MIN_OPACITY..1f, steps = OPACITY_STEPS, - trailing = { ReadabilityInfoButton { showReadability = !showReadability } }, + titleAccessory = { + InfoPopover( + text = stringResource( + if (classic) R.string.widget_config_readability_hint_classic else R.string.widget_config_readability_hint, + ), + contentDescription = stringResource(R.string.widget_config_readability_info), + ) + }, ) } if (faded && classic) { @@ -270,10 +277,7 @@ private fun ConfigOptions( summary = stringResource(R.string.widget_config_invert_text_summary), position = Position.Middle, trailing = { - Row(verticalAlignment = Alignment.CenterVertically) { - ReadabilityInfoButton { showReadability = !showReadability } - Switch(checked = content.invertText, onCheckedChange = { onContent(content.copy(invertText = it)) }) - } + Switch(checked = content.invertText, onCheckedChange = { onContent(content.copy(invertText = it)) }) }, onClick = { onContent(content.copy(invertText = !content.invertText)) }, ) @@ -286,12 +290,6 @@ private fun ConfigOptions( onSelect = { onAppearance(state.appearance.copy(theme = it)) }, position = Position.Bottom, ) - AnimatedVisibility(visible = faded && showReadability) { - GroupedHint( - stringResource(if (classic) R.string.widget_config_readability_hint_classic else R.string.widget_config_readability_hint), - verticalPadding = 8.dp, - ) - } if (state.kind == WidgetKind.COUNTDOWN) { CountdownOptionsSection( options = state.draft.countdown, @@ -422,18 +420,6 @@ private fun ContentOptions( } } -/** The "i" beside the opacity and text-colour options; toggles the readability hint below them. */ -@Composable -private fun ReadabilityInfoButton(onClick: () -> Unit) { - IconButton(onClick = onClick) { - Icon( - Icons.Outlined.Info, - contentDescription = stringResource(R.string.widget_config_readability_info), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - @Composable private fun agendaStyleLabel(style: AgendaWidgetStyle): String = stringResource( when (style) { diff --git a/floret-kit b/floret-kit index 7105140..2be1196 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 7105140b8f8b74bed67a02d2e47d8e2453b5d305 +Subproject commit 2be119661324d642244cab5f9f55a59388938695