Classic agenda: cap with labels, visible dimming, solid day cards (#489)

This commit is contained in:
makiolaj committed 2026-11-09 18:43:56 +01:00
1 parent fffb033e39
commit aacd86cebe
9 files changed
+106 -41

No files matched your search

@@ -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,
@@ -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())) {
@@ -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<AgendaRow>, limit: Int = MAX_AGENDA_ROWS): List<ClassicItem> = 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<AgendaRow>, 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<AgendaRow>, 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<LocalDate>): List<Position> = 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(
@@ -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,
)
@@ -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)
-1
View File
@@ -518,7 +518,6 @@
<string name="widget_config_background_transparent">Transparent</string>
<string name="widget_config_theme_follow_app">Follow app</string>
<string name="widget_config_opacity">Opacity</string>
<string name="widget_config_opacity_value">%1$d%%</string>
<string name="widget_config_style">Style</string>
<string name="widget_config_style_cards">Cards</string>
<string name="widget_config_style_classic">Classic</string>
@@ -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)
}
}
@@ -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()
}
}