From 06f658917f66d06afd62e125d06b51131bd678eb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 20:07:18 +0100 Subject: [PATCH] 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