From 506ebdb0925d9adb5bdcfbfe1aa9b2107aaf391c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 18:21:56 +0100 Subject: [PATCH 01/22] Number week rows by their Thursday (#493) --- .../java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt | 4 ++-- .../java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt | 4 ++-- floret-kit | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 2115f57..b28aab5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -187,7 +187,7 @@ import de.jeanlucmakiola.floret.identity.itemEnter import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.next -import de.jeanlucmakiola.floret.time.isoWeekNumber +import de.jeanlucmakiola.floret.time.rowWeekNumber import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull @@ -2707,7 +2707,7 @@ private fun monthChipDragModifier( */ @Composable private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) { - val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() } + val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().rowWeekNumber() } val label = stringResource(R.string.week_number_label) val openLabel = stringResource(R.string.month_open_week, weekNumber) val onOpenWeek = LocalOpenWeek.current diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 72c0999..6c2b82d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -209,7 +209,7 @@ import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset import de.jeanlucmakiola.calendula.ui.common.hourGridCells import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay -import de.jeanlucmakiola.floret.time.isoWeekNumber +import de.jeanlucmakiola.floret.time.rowWeekNumber import kotlinx.coroutines.launch import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate @@ -1137,7 +1137,7 @@ private fun WeekDayHeader( */ @Composable private fun WeekNumberCell(weekStart: LocalDate, showBadge: Boolean, modifier: Modifier = Modifier) { - val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() } + val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().rowWeekNumber() } Column( modifier = modifier .fillMaxWidth() diff --git a/floret-kit b/floret-kit index 1ff6702..8b850b5 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 1ff6702fe6ff1ab695157e6559ec3761ba9f01b9 +Subproject commit 8b850b5b756689b6c86ad6e77ec7fda2ad0765a2 From f574f039f53377b187bf1ab64bd7833a98c38547 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 18:24:44 +0100 Subject: [PATCH 02/22] Month widget: keep a lane in short week rows (#491) --- .../calendula/widget/month/MonthGrid.kt | 16 ++++++++++++--- .../calendula/widget/month/MonthGridViews.kt | 13 +++++++----- .../calendula/widget/month/MonthWidget.kt | 5 +++-- .../res/layout/widget_month_lane_compact.xml | 8 ++++++++ .../calendula/widget/month/MonthGridTest.kt | 20 +++++++++++++++---- 5 files changed, 48 insertions(+), 14 deletions(-) create mode 100644 app/src/main/res/layout/widget_month_lane_compact.xml diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt index e43d95c..b6b007a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt @@ -62,12 +62,20 @@ internal class WeekLayout(val week: MonthWeek, val laneCap: Int, val showsOverfl } } +/** How many lanes a week row holds, and whether they're the [compact] height. */ +internal data class LaneFit(val lanes: Int, val compact: Boolean = false) + /** * Event lanes a week row [weekHeight] tall holds under its day number, held to - * [MAX_LANES] so a tall widget's payload stays bounded (#214). + * [MAX_LANES] so a tall widget's payload stays bounded (#214). The last lane's + * bottom gap may be cut off, and a row too short for one full lane still gets + * one compact lane when that fits (#491). */ -internal fun lanesFitting(weekHeight: Dp): Int = - ((weekHeight.value - DAY_ROW_DP - LANES_TOP_DP) / LANE_ROW_DP).toInt().coerceIn(0, MAX_LANES) +internal fun lanesFitting(weekHeight: Dp): LaneFit { + val room = weekHeight.value - DAY_ROW_DP - LANES_TOP_DP + LANE_GAP_DP + val full = (room / LANE_ROW_DP).toInt().coerceIn(0, MAX_LANES) + return if (full == 0 && room >= COMPACT_LANE_ROW_DP) LaneFit(1, compact = true) else LaneFit(full) +} /** * Seats each week in [lanes] lanes. A week whose events need more gives its @@ -85,4 +93,6 @@ internal const val MAX_LANES = 8 private const val DAY_ROW_DP = 18f private const val LANES_TOP_DP = 2f private const val LANE_ROW_DP = 15f +private const val COMPACT_LANE_ROW_DP = 12f +private const val LANE_GAP_DP = 1f private const val UNCAPPED_LANES = 64 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt index f5854d2..82566f4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt @@ -46,6 +46,7 @@ internal data class GridColors( internal fun monthGridViews( context: Context, layouts: List, + compact: Boolean, currentMonth: Month, today: LocalDate, soften: Boolean, @@ -54,13 +55,14 @@ internal fun monthGridViews( ): RemoteViews { val grid = RemoteViews(context.packageName, R.layout.widget_month_grid) grid.removeAllViews(R.id.month_grid) - layouts.forEach { grid.addView(R.id.month_grid, weekViews(context, it, currentMonth, today, soften, colors, mode)) } + layouts.forEach { grid.addView(R.id.month_grid, weekViews(context, it, compact, currentMonth, today, soften, colors, mode)) } return grid } private fun weekViews( context: Context, layout: WeekLayout, + compact: Boolean, currentMonth: Month, today: LocalDate, soften: Boolean, @@ -92,7 +94,7 @@ private fun weekViews( if (isToday) week.setImageTint(context, TODAY_IDS[col], colors.primary) } layout.lanes.forEach { cells -> - val lane = laneViews(context) + val lane = laneViews(context, compact) cells.forEach { cell -> val event = cell.item lane.addView(R.id.lane, if (event == null) gap(context, cell.cols) else bar(context, event, cell.cols, soften, mode)) @@ -100,7 +102,7 @@ private fun weekViews( week.addView(R.id.week_lanes, lane) } if (layout.showsOverflow && layout.overflow.isNotEmpty()) { - val lane = laneViews(context) + val lane = laneViews(context, compact) layout.overflow.forEach { cell -> val count = cell.item if (count == null) { @@ -117,8 +119,9 @@ private fun weekViews( return week } -private fun laneViews(context: Context) = - RemoteViews(context.packageName, R.layout.widget_month_lane).apply { removeAllViews(R.id.lane) } +private fun laneViews(context: Context, compact: Boolean) = + RemoteViews(context.packageName, if (compact) R.layout.widget_month_lane_compact else R.layout.widget_month_lane) + .apply { removeAllViews(R.id.lane) } private fun gap(context: Context, cols: Int) = RemoteViews(context.packageName, GAP_LAYOUTS[cols - 1]) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index 227405c..4938852 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -254,7 +254,8 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { val context = LocalContext.current val fontScale = context.resources.configuration.fontScale val weekHeight = (LocalSize.current.height - gridChromeHeight(fontScale)) / weeks.size - val layouts = fitWeeks(weeks, lanesFitting(weekHeight)) + val fit = lanesFitting(weekHeight) + val layouts = fitWeeks(weeks, fit.lanes) val mode = LocalWidgetThemeMode.current val colors = GridColors( onSurface = remoteColor(WidgetColorRole.ON_SURFACE), @@ -272,7 +273,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) { WeekdayHeader(weekStart = source.weekStart) AndroidRemoteViews( - remoteViews = monthGridViews(context, layouts, ym.month, source.today, source.soften, colors, mode), + remoteViews = monthGridViews(context, layouts, fit.compact, ym.month, source.today, source.soften, colors, mode), modifier = GlanceModifier.fillMaxWidth().defaultWeight(), ) } diff --git a/app/src/main/res/layout/widget_month_lane_compact.xml b/app/src/main/res/layout/widget_month_lane_compact.xml new file mode 100644 index 0000000..71010c2 --- /dev/null +++ b/app/src/main/res/layout/widget_month_lane_compact.xml @@ -0,0 +1,8 @@ + + diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt index fa69afc..9223f52 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt @@ -56,14 +56,26 @@ class MonthGridTest { @Test fun `a taller week fits more lanes`() { - assertThat(lanesFitting(19.dp)).isEqualTo(0) - assertThat(lanesFitting(50.dp)).isEqualTo(2) - assertThat(lanesFitting(100.dp)).isEqualTo(5) + assertThat(lanesFitting(19.dp)).isEqualTo(LaneFit(0)) + assertThat(lanesFitting(50.dp)).isEqualTo(LaneFit(2)) + assertThat(lanesFitting(100.dp)).isEqualTo(LaneFit(5)) + } + + @Test + fun `the last lane may lose its bottom gap`() { + assertThat(lanesFitting(34.dp)).isEqualTo(LaneFit(1)) + assertThat(lanesFitting(49.dp)).isEqualTo(LaneFit(2)) + } + + @Test + fun `a row too short for a full lane gets a compact one`() { + assertThat(lanesFitting(31.dp)).isEqualTo(LaneFit(1, compact = true)) + assertThat(lanesFitting(30.dp)).isEqualTo(LaneFit(0)) } @Test fun `lanes stop at the cap however tall the week`() { - assertThat(lanesFitting(1000.dp)).isEqualTo(MAX_LANES) + assertThat(lanesFitting(1000.dp)).isEqualTo(LaneFit(MAX_LANES)) } @Test From fffb033e39372cc4259f8115c81cb0a390c0f1db Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 18:31:12 +0100 Subject: [PATCH 03/22] 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 f267744c1c8891b779efc29043fa7727adcc284a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 18:40:30 +0100 Subject: [PATCH 04/22] Month widget: keep the single lane's bars, compact lane only at normal font scale (#491) --- .../calendula/widget/month/MonthGrid.kt | 37 ++++++++++++------- .../calendula/widget/month/MonthGridViews.kt | 8 ++-- .../calendula/widget/month/MonthWidget.kt | 5 +-- .../calendula/widget/month/MonthGridTest.kt | 26 ++++++++----- 4 files changed, 45 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt index b6b007a..2a60fe1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt @@ -37,10 +37,16 @@ internal fun mergeGaps(columns: Int, cellAt: (Int) -> GridCell?): L } /** - * One week of the grid seated with [laneCap] lanes. [showsOverflow] is off when - * the week has no room for even the "+N" row. + * One week of the grid seated with [laneCap] lanes, drawn [compact] when the + * row is too short for a full one. [showsOverflow] is off when the week has no + * room for the "+N" row. */ -internal class WeekLayout(val week: MonthWeek, val laneCap: Int, val showsOverflow: Boolean = true) { +internal class WeekLayout( + val week: MonthWeek, + val laneCap: Int, + val showsOverflow: Boolean = true, + val compact: Boolean = false, +) { private val seating: List = week.days.indices.map { week.seating(it, laneCap) } /** Lanes filled from the top; empty lanes below are not drawn. */ @@ -67,22 +73,24 @@ internal data class LaneFit(val lanes: Int, val compact: Boolean = false) /** * Event lanes a week row [weekHeight] tall holds under its day number, held to - * [MAX_LANES] so a tall widget's payload stays bounded (#214). The last lane's - * bottom gap may be cut off, and a row too short for one full lane still gets - * one compact lane when that fits (#491). + * [MAX_LANES] so a tall widget's payload stays bounded (#214). A row too short + * for one full lane still gets a compact one when its text fits at [fontScale] (#491). */ -internal fun lanesFitting(weekHeight: Dp): LaneFit { - val room = weekHeight.value - DAY_ROW_DP - LANES_TOP_DP + LANE_GAP_DP +internal fun lanesFitting(weekHeight: Dp, fontScale: Float = 1f): LaneFit { + val room = weekHeight.value - DAY_ROW_DP - LANES_TOP_DP val full = (room / LANE_ROW_DP).toInt().coerceIn(0, MAX_LANES) - return if (full == 0 && room >= COMPACT_LANE_ROW_DP) LaneFit(1, compact = true) else LaneFit(full) + val compactFits = room >= COMPACT_LANE_ROW_DP && fontScale <= COMPACT_MAX_FONT_SCALE + return if (full == 0 && compactFits) LaneFit(1, compact = true) else LaneFit(full) } /** - * Seats each week in [lanes] lanes. A week whose events need more gives its - * last lane to the "+N" row instead. + * Seats each week in [fit]'s lanes. A week whose events need more gives its + * last lane to the "+N" row instead, unless that would leave it no lane at all: + * a single lane keeps its bars and drops the count (#491). */ -internal fun fitWeeks(weeks: List, lanes: Int): List = weeks.map { week -> - if (lanes == 0) return@map WeekLayout(week, laneCap = 0, showsOverflow = false) +internal fun fitWeeks(weeks: List, fit: LaneFit): List = weeks.map { week -> + val lanes = fit.lanes + if (lanes <= 1) return@map WeekLayout(week, laneCap = lanes, showsOverflow = false, compact = fit.compact) val needed = WeekLayout(week, UNCAPPED_LANES).usedLanes WeekLayout(week, if (needed <= lanes) lanes else lanes - 1) } @@ -93,6 +101,7 @@ internal const val MAX_LANES = 8 private const val DAY_ROW_DP = 18f private const val LANES_TOP_DP = 2f private const val LANE_ROW_DP = 15f +/** Matches `widget_month_lane_compact`'s height; its 9sp text clips past [COMPACT_MAX_FONT_SCALE]. */ private const val COMPACT_LANE_ROW_DP = 12f -private const val LANE_GAP_DP = 1f +private const val COMPACT_MAX_FONT_SCALE = 1.1f private const val UNCAPPED_LANES = 64 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt index 82566f4..bbd280b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt @@ -46,7 +46,6 @@ internal data class GridColors( internal fun monthGridViews( context: Context, layouts: List, - compact: Boolean, currentMonth: Month, today: LocalDate, soften: Boolean, @@ -55,14 +54,13 @@ internal fun monthGridViews( ): RemoteViews { val grid = RemoteViews(context.packageName, R.layout.widget_month_grid) grid.removeAllViews(R.id.month_grid) - layouts.forEach { grid.addView(R.id.month_grid, weekViews(context, it, compact, currentMonth, today, soften, colors, mode)) } + layouts.forEach { grid.addView(R.id.month_grid, weekViews(context, it, currentMonth, today, soften, colors, mode)) } return grid } private fun weekViews( context: Context, layout: WeekLayout, - compact: Boolean, currentMonth: Month, today: LocalDate, soften: Boolean, @@ -94,7 +92,7 @@ private fun weekViews( if (isToday) week.setImageTint(context, TODAY_IDS[col], colors.primary) } layout.lanes.forEach { cells -> - val lane = laneViews(context, compact) + val lane = laneViews(context, layout.compact) cells.forEach { cell -> val event = cell.item lane.addView(R.id.lane, if (event == null) gap(context, cell.cols) else bar(context, event, cell.cols, soften, mode)) @@ -102,7 +100,7 @@ private fun weekViews( week.addView(R.id.week_lanes, lane) } if (layout.showsOverflow && layout.overflow.isNotEmpty()) { - val lane = laneViews(context, compact) + val lane = laneViews(context, layout.compact) layout.overflow.forEach { cell -> val count = cell.item if (count == null) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index 4938852..abe674b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -254,8 +254,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { val context = LocalContext.current val fontScale = context.resources.configuration.fontScale val weekHeight = (LocalSize.current.height - gridChromeHeight(fontScale)) / weeks.size - val fit = lanesFitting(weekHeight) - val layouts = fitWeeks(weeks, fit.lanes) + val layouts = fitWeeks(weeks, lanesFitting(weekHeight, fontScale)) val mode = LocalWidgetThemeMode.current val colors = GridColors( onSurface = remoteColor(WidgetColorRole.ON_SURFACE), @@ -273,7 +272,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) { WeekdayHeader(weekStart = source.weekStart) AndroidRemoteViews( - remoteViews = monthGridViews(context, layouts, fit.compact, ym.month, source.today, source.soften, colors, mode), + remoteViews = monthGridViews(context, layouts, ym.month, source.today, source.soften, colors, mode), modifier = GlanceModifier.fillMaxWidth().defaultWeight(), ) } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt index 9223f52..946af7c 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt @@ -42,14 +42,14 @@ class MonthGridTest { @Test fun `a week that fits keeps every lane`() { - val layouts = fitWeeks(busyWeeks(perDay = 2), lanes = 3) + val layouts = fitWeeks(busyWeeks(perDay = 2), LaneFit(3)) assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(3) assertThat(layouts.all { it.overflow.isEmpty() }).isTrue() } @Test fun `a week that doesn't fit gives its last lane to the overflow`() { - val layouts = fitWeeks(busyWeeks(perDay = 5), lanes = 3) + val layouts = fitWeeks(busyWeeks(perDay = 5), LaneFit(3)) assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(2) assertThat(layouts.all { it.overflow.isNotEmpty() }).isTrue() } @@ -62,15 +62,23 @@ class MonthGridTest { } @Test - fun `the last lane may lose its bottom gap`() { - assertThat(lanesFitting(34.dp)).isEqualTo(LaneFit(1)) - assertThat(lanesFitting(49.dp)).isEqualTo(LaneFit(2)) + fun `a row too short for a full lane gets a compact one`() { + assertThat(lanesFitting(34.dp)).isEqualTo(LaneFit(1, compact = true)) + assertThat(lanesFitting(32.dp)).isEqualTo(LaneFit(1, compact = true)) + assertThat(lanesFitting(31.dp)).isEqualTo(LaneFit(0)) + assertThat(lanesFitting(35.dp)).isEqualTo(LaneFit(1)) } @Test - fun `a row too short for a full lane gets a compact one`() { - assertThat(lanesFitting(31.dp)).isEqualTo(LaneFit(1, compact = true)) - assertThat(lanesFitting(30.dp)).isEqualTo(LaneFit(0)) + fun `no compact lane at a large font scale`() { + assertThat(lanesFitting(34.dp, fontScale = 1.3f)).isEqualTo(LaneFit(0)) + } + + @Test + fun `a single lane keeps its bars in a busy week`() { + val layouts = fitWeeks(busyWeeks(perDay = 5), LaneFit(1, compact = true)) + assertThat(layouts.all { it.laneCap == 1 && it.lanes.size == 1 && it.compact }).isTrue() + assertThat(layouts.none { it.showsOverflow }).isTrue() } @Test @@ -80,7 +88,7 @@ class MonthGridTest { @Test fun `a week with no room for a lane draws no overflow row either`() { - val layouts = fitWeeks(busyWeeks(perDay = 5), lanes = 0) + val layouts = fitWeeks(busyWeeks(perDay = 5), LaneFit(0)) assertThat(layouts.none { it.showsOverflow }).isTrue() assertThat(layouts.all { it.lanes.isEmpty() }).isTrue() } From bd3b4605cb7220ff2cbcc87973b2c6b85defe510 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 18:41:40 +0100 Subject: [PATCH 05/22] Fix week number gutter KDoc (#493) --- .../java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt | 4 ++-- floret-kit | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index b28aab5..9eab737 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -2702,8 +2702,8 @@ private fun monthChipDragModifier( * Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the * day cells' geometry, set apart by the secondaryContainer tint (matching the * Week view's badge), with the ISO week number centred like a day number. The - * week is computed on the row's first day — the same basis as the Week view — so - * the two agree. + * number comes from the row's Thursday, as in the Week view, so it is right for + * any first weekday (#493). */ @Composable private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) { diff --git a/floret-kit b/floret-kit index 8b850b5..968d630 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 8b850b5b756689b6c86ad6e77ec7fda2ad0765a2 +Subproject commit 968d63034f0c78450dc04927c47d84aadbb3479e From aacd86cebed22c65a25119973db0ff20929cb3a2 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 18:43:56 +0100 Subject: [PATCH 06/22] 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 fb434f3f152a29507179defe22c49908cc1214be Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 18:48:14 +0100 Subject: [PATCH 07/22] Month widget: compact-only bar fallback, no compact lane above default font scale (#491) --- .../jeanlucmakiola/calendula/widget/month/MonthGrid.kt | 9 +++++---- .../calendula/widget/month/MonthGridViews.kt | 4 ++-- .../calendula/widget/month/MonthGridTest.kt | 10 ++++++++-- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt index 2a60fe1..80acdbe 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt @@ -85,12 +85,13 @@ internal fun lanesFitting(weekHeight: Dp, fontScale: Float = 1f): LaneFit { /** * Seats each week in [fit]'s lanes. A week whose events need more gives its - * last lane to the "+N" row instead, unless that would leave it no lane at all: - * a single lane keeps its bars and drops the count (#491). + * last lane to the "+N" row instead. A compact lane has no room for that row, + * so it keeps its bars (#491). */ internal fun fitWeeks(weeks: List, fit: LaneFit): List = weeks.map { week -> val lanes = fit.lanes - if (lanes <= 1) return@map WeekLayout(week, laneCap = lanes, showsOverflow = false, compact = fit.compact) + if (lanes == 0) return@map WeekLayout(week, laneCap = 0, showsOverflow = false) + if (fit.compact) return@map WeekLayout(week, laneCap = lanes, showsOverflow = false, compact = true) val needed = WeekLayout(week, UNCAPPED_LANES).usedLanes WeekLayout(week, if (needed <= lanes) lanes else lanes - 1) } @@ -103,5 +104,5 @@ private const val LANES_TOP_DP = 2f private const val LANE_ROW_DP = 15f /** Matches `widget_month_lane_compact`'s height; its 9sp text clips past [COMPACT_MAX_FONT_SCALE]. */ private const val COMPACT_LANE_ROW_DP = 12f -private const val COMPACT_MAX_FONT_SCALE = 1.1f +private const val COMPACT_MAX_FONT_SCALE = 1f private const val UNCAPPED_LANES = 64 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt index bbd280b..2ac5145 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt @@ -100,7 +100,7 @@ private fun weekViews( week.addView(R.id.week_lanes, lane) } if (layout.showsOverflow && layout.overflow.isNotEmpty()) { - val lane = laneViews(context, layout.compact) + val lane = laneViews(context) layout.overflow.forEach { cell -> val count = cell.item if (count == null) { @@ -117,7 +117,7 @@ private fun weekViews( return week } -private fun laneViews(context: Context, compact: Boolean) = +private fun laneViews(context: Context, compact: Boolean = false) = RemoteViews(context.packageName, if (compact) R.layout.widget_month_lane_compact else R.layout.widget_month_lane) .apply { removeAllViews(R.id.lane) } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt index 946af7c..d861769 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt @@ -71,11 +71,17 @@ class MonthGridTest { @Test fun `no compact lane at a large font scale`() { - assertThat(lanesFitting(34.dp, fontScale = 1.3f)).isEqualTo(LaneFit(0)) + assertThat(lanesFitting(34.dp, fontScale = 1.1f)).isEqualTo(LaneFit(0)) } @Test - fun `a single lane keeps its bars in a busy week`() { + fun `a single full lane still folds a busy week into the count`() { + val layouts = fitWeeks(busyWeeks(perDay = 5), LaneFit(1)) + assertThat(layouts.all { it.laneCap == 0 && it.showsOverflow && it.overflow.isNotEmpty() }).isTrue() + } + + @Test + fun `a compact lane keeps its bars in a busy week`() { val layouts = fitWeeks(busyWeeks(perDay = 5), LaneFit(1, compact = true)) assertThat(layouts.all { it.laneCap == 1 && it.lanes.size == 1 && it.compact }).isTrue() assertThat(layouts.none { it.showsOverflow }).isTrue() From 726dbbbaf3f5f293d8048b54cb757062fa8866c9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 18:51:00 +0100 Subject: [PATCH 08/22] Month widget: require font scale for lane fitting (#491) --- .../calendula/widget/month/MonthGrid.kt | 5 ++--- .../calendula/widget/month/MonthGridTest.kt | 16 ++++++++-------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt index 80acdbe..23783c1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt @@ -76,7 +76,7 @@ internal data class LaneFit(val lanes: Int, val compact: Boolean = false) * [MAX_LANES] so a tall widget's payload stays bounded (#214). A row too short * for one full lane still gets a compact one when its text fits at [fontScale] (#491). */ -internal fun lanesFitting(weekHeight: Dp, fontScale: Float = 1f): LaneFit { +internal fun lanesFitting(weekHeight: Dp, fontScale: Float): LaneFit { val room = weekHeight.value - DAY_ROW_DP - LANES_TOP_DP val full = (room / LANE_ROW_DP).toInt().coerceIn(0, MAX_LANES) val compactFits = room >= COMPACT_LANE_ROW_DP && fontScale <= COMPACT_MAX_FONT_SCALE @@ -90,8 +90,7 @@ internal fun lanesFitting(weekHeight: Dp, fontScale: Float = 1f): LaneFit { */ internal fun fitWeeks(weeks: List, fit: LaneFit): List = weeks.map { week -> val lanes = fit.lanes - if (lanes == 0) return@map WeekLayout(week, laneCap = 0, showsOverflow = false) - if (fit.compact) return@map WeekLayout(week, laneCap = lanes, showsOverflow = false, compact = true) + if (lanes == 0 || fit.compact) return@map WeekLayout(week, laneCap = lanes, showsOverflow = false, compact = fit.compact) val needed = WeekLayout(week, UNCAPPED_LANES).usedLanes WeekLayout(week, if (needed <= lanes) lanes else lanes - 1) } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt index d861769..c336b50 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt @@ -56,17 +56,17 @@ class MonthGridTest { @Test fun `a taller week fits more lanes`() { - assertThat(lanesFitting(19.dp)).isEqualTo(LaneFit(0)) - assertThat(lanesFitting(50.dp)).isEqualTo(LaneFit(2)) - assertThat(lanesFitting(100.dp)).isEqualTo(LaneFit(5)) + assertThat(lanesFitting(19.dp, fontScale = 1f)).isEqualTo(LaneFit(0)) + assertThat(lanesFitting(50.dp, fontScale = 1f)).isEqualTo(LaneFit(2)) + assertThat(lanesFitting(100.dp, fontScale = 1f)).isEqualTo(LaneFit(5)) } @Test fun `a row too short for a full lane gets a compact one`() { - assertThat(lanesFitting(34.dp)).isEqualTo(LaneFit(1, compact = true)) - assertThat(lanesFitting(32.dp)).isEqualTo(LaneFit(1, compact = true)) - assertThat(lanesFitting(31.dp)).isEqualTo(LaneFit(0)) - assertThat(lanesFitting(35.dp)).isEqualTo(LaneFit(1)) + assertThat(lanesFitting(34.dp, fontScale = 1f)).isEqualTo(LaneFit(1, compact = true)) + assertThat(lanesFitting(32.dp, fontScale = 1f)).isEqualTo(LaneFit(1, compact = true)) + assertThat(lanesFitting(31.dp, fontScale = 1f)).isEqualTo(LaneFit(0)) + assertThat(lanesFitting(35.dp, fontScale = 1f)).isEqualTo(LaneFit(1)) } @Test @@ -89,7 +89,7 @@ class MonthGridTest { @Test fun `lanes stop at the cap however tall the week`() { - assertThat(lanesFitting(1000.dp)).isEqualTo(LaneFit(MAX_LANES)) + assertThat(lanesFitting(1000.dp, fontScale = 1f)).isEqualTo(LaneFit(MAX_LANES)) } @Test From 347ce74808e89e9326778e8afb1ca2c9481096fb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 18:52:00 +0100 Subject: [PATCH 09/22] 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 10/22] 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 11/22] 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 12/22] 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 76314aebbd91393cb75235d5bfaf1dc05774af7e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 19:15:00 +0100 Subject: [PATCH 13/22] Number weeks by the locale's rules (#493) --- .../jeanlucmakiola/calendula/ui/month/MonthScreen.kt | 10 ++++++---- .../de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt | 4 +++- floret-kit | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 9eab737..a4dc918 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -205,6 +205,7 @@ import java.time.format.TextStyle as JavaTextStyle import java.util.Locale import kotlinx.datetime.toJavaMonth import kotlinx.datetime.yearMonth +import java.time.temporal.WeekFields private val LocalTodayPulse = staticCompositionLocalOf { 0 } @@ -2701,13 +2702,14 @@ private fun monthChipDragModifier( /** * Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the * day cells' geometry, set apart by the secondaryContainer tint (matching the - * Week view's badge), with the ISO week number centred like a day number. The - * number comes from the row's Thursday, as in the Week view, so it is right for - * any first weekday (#493). + * Week view's badge), with the week number centred like a day number. It is + * numbered the locale's way from the row's mid-week day, as in the Week view, + * so it is right for any first weekday (#493). */ @Composable private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) { - val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().rowWeekNumber() } + val locale = currentLocale() + val weekNumber = remember(weekStart, locale) { weekStart.toJavaLocalDate().rowWeekNumber(WeekFields.of(locale)) } val label = stringResource(R.string.week_number_label) val openLabel = stringResource(R.string.month_open_week, weekNumber) val onOpenWeek = LocalOpenWeek.current diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 6c2b82d..6a2cc62 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -221,6 +221,7 @@ import kotlinx.datetime.toLocalDateTime import java.time.format.TextStyle as JavaTextStyle import java.util.Locale import kotlinx.datetime.yearMonth +import java.time.temporal.WeekFields /** One lane of the all-day strip, sized to a trimmed bar line (#190). */ private val ALL_DAY_ROW_HEIGHT = 20.dp @@ -1137,7 +1138,8 @@ private fun WeekDayHeader( */ @Composable private fun WeekNumberCell(weekStart: LocalDate, showBadge: Boolean, modifier: Modifier = Modifier) { - val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().rowWeekNumber() } + val locale = currentLocale() + val weekNumber = remember(weekStart, locale) { weekStart.toJavaLocalDate().rowWeekNumber(WeekFields.of(locale)) } Column( modifier = modifier .fillMaxWidth() diff --git a/floret-kit b/floret-kit index 968d630..003eca3 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 968d63034f0c78450dc04927c47d84aadbb3479e +Subproject commit 003eca337a124cf52e236479eafe5cd98465aa4c From dec2d1cb05501e55386c85fcb4afe77f214960f6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 19:17:57 +0100 Subject: [PATCH 14/22] Month widget: one lane shows bars and per-day counts (#491) --- .../calendula/widget/month/MonthGrid.kt | 28 ++++++++++++++++--- .../calendula/widget/month/MonthGridViews.kt | 26 ++++++++++++++--- .../calendula/widget/month/MonthGridTest.kt | 20 +++++++++---- 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt index 23783c1..601f28c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt @@ -36,16 +36,24 @@ internal fun mergeGaps(columns: Int, cellAt: (Int) -> GridCell?): L return cells } +/** What a cell of the single mixed lane shows: an event's bar, or a day's event count. */ +internal sealed interface LaneItem { + data class Bar(val event: EventInstance) : LaneItem + data class Count(val count: Int) : LaneItem +} + /** * One week of the grid seated with [laneCap] lanes, drawn [compact] when the * row is too short for a full one. [showsOverflow] is off when the week has no - * room for the "+N" row. + * room for the "+N" row; a [mixed] single lane then shows each day's count in + * place of its bar where the day has more than fits (#491). */ internal class WeekLayout( val week: MonthWeek, val laneCap: Int, val showsOverflow: Boolean = true, val compact: Boolean = false, + val mixed: Boolean = false, ) { private val seating: List = week.days.indices.map { week.seating(it, laneCap) } @@ -61,6 +69,16 @@ internal class WeekLayout( } } + /** The single lane of a [mixed] week, bars cut short where a day shows its count instead; null otherwise. */ + val mixedLane: List>? = if (!mixed) null else mergeGaps(week.days.size) { col -> + val day = seating[col] + if (day.hidden.isNotEmpty()) return@mergeGaps GridCell(LaneItem.Count(day.seated.size + day.hidden.size), 1) + val seat = day.seated.firstOrNull() ?: return@mergeGaps null + val spanEnd = if (seat.bar) week.spanAt(col, seat.lane)?.endCol ?: col else col + val end = (col + 1..spanEnd).firstOrNull { seating[it].hidden.isNotEmpty() }?.minus(1) ?: spanEnd + GridCell(LaneItem.Bar(seat.event), end - col + 1) + } + /** The "+N" row as counts and merged gaps; empty when everything fits. */ val overflow: List> = seating.map { it.hidden.size }.let { hidden -> if (hidden.none { it > 0 }) emptyList() @@ -85,12 +103,14 @@ internal fun lanesFitting(weekHeight: Dp, fontScale: Float): LaneFit { /** * Seats each week in [fit]'s lanes. A week whose events need more gives its - * last lane to the "+N" row instead. A compact lane has no room for that row, - * so it keeps its bars (#491). + * last lane to the "+N" row instead. A single lane has no room for that row, + * so it mixes bars and per-day counts (#491). */ internal fun fitWeeks(weeks: List, fit: LaneFit): List = weeks.map { week -> val lanes = fit.lanes - if (lanes == 0 || fit.compact) return@map WeekLayout(week, laneCap = lanes, showsOverflow = false, compact = fit.compact) + if (lanes <= 1) { + return@map WeekLayout(week, laneCap = lanes, showsOverflow = false, compact = fit.compact, mixed = lanes == 1) + } val needed = WeekLayout(week, UNCAPPED_LANES).usedLanes WeekLayout(week, if (needed <= lanes) lanes else lanes - 1) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt index 2ac5145..ce0a8d9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt @@ -91,6 +91,21 @@ private fun weekViews( week.setViewVisibility(TODAY_IDS[col], if (isToday) View.VISIBLE else View.GONE) if (isToday) week.setImageTint(context, TODAY_IDS[col], colors.primary) } + layout.mixedLane?.let { cells -> + val lane = laneViews(context, layout.compact) + cells.forEach { cell -> + lane.addView( + R.id.lane, + when (val item = cell.item) { + null -> gap(context, cell.cols) + is LaneItem.Bar -> bar(context, item.event, cell.cols, soften, mode) + is LaneItem.Count -> more(context, item.count, colors) + }, + ) + } + week.addView(R.id.week_lanes, lane) + return week + } layout.lanes.forEach { cells -> val lane = laneViews(context, layout.compact) cells.forEach { cell -> @@ -106,10 +121,7 @@ private fun weekViews( if (count == null) { lane.addView(R.id.lane, gap(context, cell.cols)) } else { - val more = RemoteViews(context.packageName, R.layout.widget_month_more) - more.setTextViewText(R.id.more, "+$count") - more.setTextColor(context, R.id.more, colors.onSurfaceVariant) - lane.addView(R.id.lane, more) + lane.addView(R.id.lane, more(context, count, colors)) } } week.addView(R.id.week_lanes, lane) @@ -117,6 +129,12 @@ private fun weekViews( return week } +private fun more(context: Context, count: Int, colors: GridColors) = + RemoteViews(context.packageName, R.layout.widget_month_more).apply { + setTextViewText(R.id.more, "+$count") + setTextColor(context, R.id.more, colors.onSurfaceVariant) + } + private fun laneViews(context: Context, compact: Boolean = false) = RemoteViews(context.packageName, if (compact) R.layout.widget_month_lane_compact else R.layout.widget_month_lane) .apply { removeAllViews(R.id.lane) } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt index c336b50..b1a647f 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt @@ -75,16 +75,24 @@ class MonthGridTest { } @Test - fun `a single full lane still folds a busy week into the count`() { + fun `a single lane shows counts on busy days`() { val layouts = fitWeeks(busyWeeks(perDay = 5), LaneFit(1)) - assertThat(layouts.all { it.laneCap == 0 && it.showsOverflow && it.overflow.isNotEmpty() }).isTrue() + assertThat(layouts.all { it.mixedLane == List(7) { GridCell(LaneItem.Count(5), 1) } }).isTrue() } @Test - fun `a compact lane keeps its bars in a busy week`() { - val layouts = fitWeeks(busyWeeks(perDay = 5), LaneFit(1, compact = true)) - assertThat(layouts.all { it.laneCap == 1 && it.lanes.size == 1 && it.compact }).isTrue() - assertThat(layouts.none { it.showsOverflow }).isTrue() + fun `a single lane keeps bars on days that fit`() { + val lone = timedEvent(monday.plus(1, DateTimeUnit.DAY), 9, zone = zone, id = 1, title = "Lone") + val busy = (2L..4L).map { timedEvent(monday.plus(3, DateTimeUnit.DAY), it.toInt() + 8, zone = zone, id = it, title = "B$it") } + val week = fitWeeks(weeks(listOf(lone) + busy), LaneFit(1, compact = true))[1] + assertThat(week.compact).isTrue() + assertThat(week.mixedLane).containsExactly( + GridCell(null, 1), + GridCell(LaneItem.Bar(lone), 1), + GridCell(null, 1), + GridCell(LaneItem.Count(3), 1), + GridCell(null, 3), + ).inOrder() } @Test From 77b0c6db09933109740ead9cee5291d833f72221 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 19:22:55 +0100 Subject: [PATCH 15/22] Week numbers follow the device region, shared helper (#493) --- .../calendula/ui/common/WeekNumber.kt | 21 +++++++++++++++++++ .../calendula/ui/month/MonthScreen.kt | 10 ++++----- .../calendula/ui/week/WeekScreen.kt | 6 ++---- 3 files changed, 27 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/WeekNumber.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/WeekNumber.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/WeekNumber.kt new file mode 100644 index 0000000..6b34b62 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/WeekNumber.kt @@ -0,0 +1,21 @@ +package de.jeanlucmakiola.calendula.ui.common + +import android.content.res.Resources +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import de.jeanlucmakiola.floret.time.rowWeekNumber +import kotlinx.datetime.LocalDate +import kotlinx.datetime.toJavaLocalDate +import java.time.temporal.WeekFields + +/** + * The week number of the grid row starting on [rowStart], numbered by the + * device region's rules (#493). The region, not the app language: an English + * UI in the Netherlands still numbers weeks the ISO way. Shared by the Month + * gutter and the Week badge so the two always agree. + */ +@Composable +internal fun rememberRowWeekNumber(rowStart: LocalDate): Int { + val fields = remember { WeekFields.of(Resources.getSystem().configuration.locales[0]) } + return remember(rowStart, fields) { rowStart.toJavaLocalDate().rowWeekNumber(fields) } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index a4dc918..83001df 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -187,7 +187,6 @@ import de.jeanlucmakiola.floret.identity.itemEnter import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.next -import de.jeanlucmakiola.floret.time.rowWeekNumber import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull @@ -205,7 +204,7 @@ import java.time.format.TextStyle as JavaTextStyle import java.util.Locale import kotlinx.datetime.toJavaMonth import kotlinx.datetime.yearMonth -import java.time.temporal.WeekFields +import de.jeanlucmakiola.calendula.ui.common.rememberRowWeekNumber private val LocalTodayPulse = staticCompositionLocalOf { 0 } @@ -2703,13 +2702,12 @@ private fun monthChipDragModifier( * Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the * day cells' geometry, set apart by the secondaryContainer tint (matching the * Week view's badge), with the week number centred like a day number. It is - * numbered the locale's way from the row's mid-week day, as in the Week view, - * so it is right for any first weekday (#493). + * numbered by the device region from the row's mid-week day, as in the Week + * view, so it is right for any first weekday (#493). */ @Composable private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) { - val locale = currentLocale() - val weekNumber = remember(weekStart, locale) { weekStart.toJavaLocalDate().rowWeekNumber(WeekFields.of(locale)) } + val weekNumber = rememberRowWeekNumber(weekStart) val label = stringResource(R.string.week_number_label) val openLabel = stringResource(R.string.month_open_week, weekNumber) val onOpenWeek = LocalOpenWeek.current diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 6a2cc62..f05c0fc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -209,7 +209,6 @@ import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset import de.jeanlucmakiola.calendula.ui.common.hourGridCells import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay -import de.jeanlucmakiola.floret.time.rowWeekNumber import kotlinx.coroutines.launch import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate @@ -221,7 +220,7 @@ import kotlinx.datetime.toLocalDateTime import java.time.format.TextStyle as JavaTextStyle import java.util.Locale import kotlinx.datetime.yearMonth -import java.time.temporal.WeekFields +import de.jeanlucmakiola.calendula.ui.common.rememberRowWeekNumber /** One lane of the all-day strip, sized to a trimmed bar line (#190). */ private val ALL_DAY_ROW_HEIGHT = 20.dp @@ -1138,8 +1137,7 @@ private fun WeekDayHeader( */ @Composable private fun WeekNumberCell(weekStart: LocalDate, showBadge: Boolean, modifier: Modifier = Modifier) { - val locale = currentLocale() - val weekNumber = remember(weekStart, locale) { weekStart.toJavaLocalDate().rowWeekNumber(WeekFields.of(locale)) } + val weekNumber = rememberRowWeekNumber(weekStart) Column( modifier = modifier .fillMaxWidth() From 1004356e75a5527ac647338a20dc3e4584957f10 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 19:24:57 +0100 Subject: [PATCH 16/22] Month widget: day totals from countByDay, test split bars (#491) --- .../calendula/widget/month/MonthGrid.kt | 2 +- .../calendula/widget/month/MonthGridViews.kt | 2 +- .../calendula/widget/month/MonthGridTest.kt | 13 +++++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt index 601f28c..f893a11 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt @@ -72,7 +72,7 @@ internal class WeekLayout( /** The single lane of a [mixed] week, bars cut short where a day shows its count instead; null otherwise. */ val mixedLane: List>? = if (!mixed) null else mergeGaps(week.days.size) { col -> val day = seating[col] - if (day.hidden.isNotEmpty()) return@mergeGaps GridCell(LaneItem.Count(day.seated.size + day.hidden.size), 1) + if (day.hidden.isNotEmpty()) return@mergeGaps GridCell(LaneItem.Count(week.countByDay[week.days[col]] ?: day.hidden.size), 1) val seat = day.seated.firstOrNull() ?: return@mergeGaps null val spanEnd = if (seat.bar) week.spanAt(col, seat.lane)?.endCol ?: col else col val end = (col + 1..spanEnd).firstOrNull { seating[it].hidden.isNotEmpty() }?.minus(1) ?: spanEnd diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt index ce0a8d9..c7df398 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt @@ -107,7 +107,7 @@ private fun weekViews( return week } layout.lanes.forEach { cells -> - val lane = laneViews(context, layout.compact) + val lane = laneViews(context) cells.forEach { cell -> val event = cell.item lane.addView(R.id.lane, if (event == null) gap(context, cell.cols) else bar(context, event, cell.cols, soften, mode)) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt index b1a647f..5bd5058 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt @@ -95,6 +95,19 @@ class MonthGridTest { ).inOrder() } + @Test + fun `a single lane cuts a multi-day bar around a busy day`() { + val trip = allDayEvent(monday, monday.plus(3, DateTimeUnit.DAY), id = 1, title = "Trip") + val busy = timedEvent(monday.plus(1, DateTimeUnit.DAY), 9, zone = zone, id = 2, title = "Busy") + val week = fitWeeks(weeks(listOf(trip, busy)), LaneFit(1))[1] + assertThat(week.mixedLane).containsExactly( + GridCell(LaneItem.Bar(trip), 1), + GridCell(LaneItem.Count(2), 1), + GridCell(LaneItem.Bar(trip), 2), + GridCell(null, 3), + ).inOrder() + } + @Test fun `lanes stop at the cap however tall the week`() { assertThat(lanesFitting(1000.dp, fontScale = 1f)).isEqualTo(LaneFit(MAX_LANES)) From f896153fb7ba29a02c1b2ee1106a300d8d41282b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 19:27:27 +0100 Subject: [PATCH 17/22] 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 18/22] 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 19/22] 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 20/22] 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 From 9387bdc1eeca9831e2d53cc66142a8fb4aa76c56 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 9 Nov 2026 20:32:00 +0100 Subject: [PATCH 21/22] Drop unused WorkManager permissions (#448) --- app/src/main/AndroidManifest.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 24e5c3e..b272d8e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -41,6 +41,11 @@ --> + + + + +