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..4340e42 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,22 @@ 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, + /** 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) { @@ -48,6 +57,9 @@ 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 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") @@ -58,6 +70,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 +83,8 @@ 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), + invertText = this[InvertTextKey] ?: defaults.invertText, ) /** Whether this widget has agenda options of its own, rather than following the app's. */ @@ -78,6 +93,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 +101,8 @@ 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 + 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 86be0e9..2d91c32 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,8 @@ 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, + val invertText: Boolean = false, /** Snapshot instant the data was read at, for "has this event ended?" tests. */ val now: Instant, ) : AgendaWidgetData @@ -184,6 +186,8 @@ internal suspend fun Context.loadAgendaWidgetData( pastDisplay = options.pastDisplay, 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/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/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/WidgetParts.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetParts.kt index 9022f75..a894c46 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,9 @@ 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 +import androidx.glance.appwidget.action.actionStartActivity /** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */ @Composable @@ -27,6 +30,19 @@ 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, +) + +/** 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/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..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 @@ -12,6 +12,15 @@ 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 + +/** 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 * shouldn't grow (the horizontal row constants above, corner radii) stay @@ -29,6 +38,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 +72,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 +88,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 +104,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 +120,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..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 @@ -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,14 @@ 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.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 import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA @@ -65,6 +74,8 @@ 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.openDayAction import de.jeanlucmakiola.calendula.widget.loadAgendaWidget import de.jeanlucmakiola.calendula.widget.widgetAppearance import de.jeanlucmakiola.floret.components.Position @@ -87,7 +98,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() { @@ -150,10 +162,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 @@ -168,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) + } } } } @@ -219,60 +236,248 @@ private fun AgendaRows( day.events.forEach { add(AgendaRow.Event(day.date, it)) } } } - }.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(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 -> + 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. */ +internal 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 + } +} + +/** + * [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 + * 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 = 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 -> + val entry = items[index] + val content: @Composable () -> Unit = { + when (entry) { + 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 + else -> metrics.dayHeaderTopPad + }, + ink = ink, + metrics = metrics, + ) + is ClassicItem.Entry -> when (val row = entry.row) { + is AgendaRow.Placeholder -> ClassicPlaceholderRow(row.date, backed, ink, metrics) + is AgendaRow.Event -> ClassicEventRow(row, data, backed, ink, 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 = cardColor, + gapBelow = false, + content = content, + ) + } else { + content() + } + } + 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, ink: ClassicInk, metrics: AgendaMetrics) { + val context = androidx.glance.LocalContext.current + Text( + text = agendaDayLabel(context, date, today), + maxLines = 1, + style = TextStyle( + color = if (date == today) ink.today else ink.secondary, + fontSize = metrics.dayHeader, + fontWeight = FontWeight.Medium, + ), + modifier = GlanceModifier + .fillMaxWidth() + .padding(start = startPad, end = 8.dp, top = topPad, bottom = metrics.rowVPad) + .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, 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 = 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) + .clickable(openDayAction(context, date)), + ) +} + +/** 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, ink: ClassicInk, metrics: AgendaMetrics) { + val context = androidx.glance.LocalContext.current + val event = row.event + 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 = if (backed) CLASSIC_CARD_H_PAD else CLASSIC_ROW_H_PAD, vertical = metrics.rowVPad) + .clickable(actionStartActivity(event.openIntent(context, CalendarView.Agenda))), + 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) ink.title.withAlpha(CLASSIC_DIM_INK) else ink.title, + fontSize = metrics.eventTitle, + textDecoration = glanceDeclinedDecoration(event.isCrossedOut), + ), + ) + Text( + text = eventTimeSummary(context, event, row.date, data.is24Hour), + maxLines = 1, + style = TextStyle( + color = if (dimmed) ink.secondary.withAlpha(CLASSIC_DIM_INK) else ink.secondary, + fontSize = metrics.eventTime, + ), + ) + } + } +} + +/** 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 + +/** 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 - 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 } @@ -343,7 +548,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, @@ -375,11 +580,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( @@ -393,8 +594,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 } @@ -421,17 +626,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, CalendarView.Agenda))), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -474,7 +669,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 f410e6e..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 @@ -69,6 +69,16 @@ 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.floret.components.InfoPopover +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 @@ -236,6 +246,42 @@ private fun ConfigOptions( onSelect = { onAppearance(state.appearance.copy(background = it)) }, position = Position.Top, ) + 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), + value = opacity, + onValueChange = { onAppearance(state.appearance.copy(opacity = it)) }, + position = Position.Middle, + valueLabel = currentLocale().let { locale -> + remember(opacity, locale) { NumberFormat.getPercentInstance(locale).format(opacity) } + }, + valueRange = MIN_OPACITY..1f, + steps = OPACITY_STEPS, + 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) { + GroupedRow( + title = stringResource(R.string.widget_config_invert_text), + summary = stringResource(R.string.widget_config_invert_text_summary), + position = Position.Middle, + trailing = { + Switch(checked = content.invertText, onCheckedChange = { onContent(content.copy(invertText = it)) }) + }, + onClick = { onContent(content.copy(invertText = !content.invertText)) }, + ) + } GroupedChoiceRow( title = stringResource(R.string.settings_theme), options = WidgetTheme.entries, @@ -265,6 +311,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 +324,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 +372,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 +420,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/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 c5b8f79..ed0a443 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -517,6 +517,15 @@ Tinted 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 Content Time range Text size 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() + } +}