Compare commits

..
Author SHA1 Message Date
makiolaj 6d6baf3f35 Merge remote-tracking branch 'origin/release/2.24.1' into fix/475-widget-feedback
# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt
#	floret-kit
2026-11-09 21:05:08 +01:00
Jean-Luc Makiola 72f2b33754 Merge pull request 'Classic agenda widget style and widget opacity (#489)' (#499) from feat/489-classic-agenda into release/2.24.1
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-09 21:03:28 +01:00
makiolaj 89a456f88c Merge remote-tracking branch 'origin/release/2.24.1' into feat/489-classic-agenda
# Conflicts:
#	floret-kit
2026-11-09 21:00:56 +01:00
Jean-Luc Makiola d98d995326 Merge pull request 'Month widget fits events into short weeks (#491)' (#498) from fix/491-month-widget-lanes into release/2.24.1
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-09 20:59:40 +01:00
makiolaj 20a76f1921 Merge remote-tracking branch 'origin/release/2.24.1' into fix/491-month-widget-lanes 2026-11-09 20:57:47 +01:00
Jean-Luc Makiola 0c4a7992a7 Merge pull request 'Week numbers follow the locale and the row's first day (#493)' (#497) from fix/493-week-number into release/2.24.1
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-09 20:57:05 +01:00
makiolaj 5e6b88fa31 Bump floret-kit to main (#493) 2026-11-09 20:55:04 +01:00
makiolaj d8735cc738 Merge remote-tracking branch 'origin/release/2.24.1' into fix/493-week-number 2026-11-09 20:54:51 +01:00
Jean-Luc Makiola 185671adb5 Merge pull request 'Drop unused WorkManager permissions (#448)' (#496) from fix/448-library-permissions into release/2.24.1
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-09 20:54:14 +01:00
makiolaj ee268444b7 Widgets: readable dimmed events, pinned theme matches system (#475) 2026-11-09 20:41:20 +01:00
makiolaj 9387bdc1ee Drop unused WorkManager permissions (#448) 2026-11-09 20:32:00 +01:00
makiolaj 7cfdb43f37 Widget config: readability info as a popover beside Opacity (#489) 2026-11-09 20:18:14 +01:00
makiolaj 06f658917f Classic agenda: invert text colour instead of backings at low opacity (#489) 2026-11-09 20:07:18 +01:00
makiolaj 88d5614158 Bump floret-kit: inset opacity slider row (#489) 2026-11-09 19:58:15 +01:00
makiolaj f896153fb7 Classic agenda: readable dimming on any fill, year on far dates (#489) 2026-11-09 19:27:27 +01:00
makiolaj 1004356e75 Month widget: day totals from countByDay, test split bars (#491) 2026-11-09 19:24:57 +01:00
makiolaj 77b0c6db09 Week numbers follow the device region, shared helper (#493) 2026-11-09 19:22:55 +01:00
makiolaj dec2d1cb05 Month widget: one lane shows bars and per-day counts (#491) 2026-11-09 19:17:57 +01:00
makiolaj 76314aebbd Number weeks by the locale's rules (#493) 2026-11-09 19:15:00 +01:00
makiolaj 7d149d2ab1 Classic agenda: back rows when the fill is see-through (#489) 2026-11-09 19:11:54 +01:00
makiolaj ff4082d5df Classic agenda: line day labels up with the card rows (#489) 2026-11-09 19:02:48 +01:00
makiolaj 51f58e76b2 Classic agenda: seamless day cards, aligned placeholder, shared day action (#489) 2026-11-09 18:59:02 +01:00
makiolaj 347ce74808 Classic agenda: single cap, card inset, shared dim rule (#489) 2026-11-09 18:52:00 +01:00
makiolaj 726dbbbaf3 Month widget: require font scale for lane fitting (#491) 2026-11-09 18:51:00 +01:00
makiolaj fb434f3f15 Month widget: compact-only bar fallback, no compact lane above default font scale (#491) 2026-11-09 18:48:14 +01:00
makiolaj aacd86cebe Classic agenda: cap with labels, visible dimming, solid day cards (#489) 2026-11-09 18:43:56 +01:00
makiolaj bd3b4605cb Fix week number gutter KDoc (#493) 2026-11-09 18:41:40 +01:00
makiolaj f267744c1c Month widget: keep the single lane's bars, compact lane only at normal font scale (#491) 2026-11-09 18:40:30 +01:00
makiolaj fffb033e39 Agenda widget: classic style and background opacity (#489) 2026-11-09 18:31:12 +01:00
makiolaj f574f039f5 Month widget: keep a lane in short week rows (#491) 2026-11-09 18:24:44 +01:00
makiolaj 506ebdb092 Number week rows by their Thursday (#493) 2026-11-09 18:21:56 +01:00
23 changed files with 698 additions and 153 deletions

No files matched your search

+5
View File
@@ -41,6 +41,11 @@
-->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- WorkManager declares these for network constraints and foreground workers, neither of which the app uses (#448). -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
<uses-permission android:name="android.permission.WAKE_LOCK" tools:node="remove" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" tools:node="remove" />
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
returns null and the calendar manager's per-account "manage" button can't
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
@@ -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) }
}
@@ -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.isoWeekNumber
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
@@ -205,6 +204,7 @@ import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
import kotlinx.datetime.toJavaMonth
import kotlinx.datetime.yearMonth
import de.jeanlucmakiola.calendula.ui.common.rememberRowWeekNumber
private val LocalTodayPulse = staticCompositionLocalOf { 0 }
@@ -2701,13 +2701,13 @@ 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.
* Week view's badge), with the week number centred like a day number. It is
* 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 weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
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
@@ -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.isoWeekNumber
import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
@@ -221,6 +220,7 @@ import kotlinx.datetime.toLocalDateTime
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
import kotlinx.datetime.yearMonth
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
@@ -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 = rememberRowWeekNumber(weekStart)
Column(
modifier = modifier
.fillMaxWidth()
@@ -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
}
/**
@@ -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(),
)
}
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.widget
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
@@ -18,7 +19,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
@@ -26,20 +26,33 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
import de.jeanlucmakiola.floret.glance.themedColor
/** A past event's fill when dimmed: fainter than the app's whole-row dim, so its text can stay readable (#90). */
internal const val WidgetDimFillAlpha = 0.3f
/** How much of a past event's tone stays in its dimmed fill (#90). */
private const val WidgetDimFillAlpha = 0.3f
/** A dimmed event's ink, held above its fill. */
private const val WidgetDimInkAlpha = 0.65f
/** Ink for an event on [fill], picked against the opaque tone and faded when the fill is dimmed. */
internal fun widgetEventInk(fill: Color, alpha: Float): Color =
eventInk(fill.copy(alpha = 1f), if (fill.alpha < 1f) alpha * WidgetDimInkAlpha else alpha)
/**
* An event's fill in the light or [dark] theme. A dimmed one is its tone faded
* into that theme's surface, kept opaque: a translucent tint lands on the row
* shape's white, not on the widget, and its ink would be picked for the wrong tone (#475).
*/
internal fun widgetEventFill(rawArgb: Int, dark: Boolean, soften: Boolean, dimmed: Boolean): Color {
val tone = eventFill(rawArgb, dark, soften)
if (!dimmed) return tone
val surface = if (dark) CalendulaDarkFallback.surface else Color.White
return tone.copy(alpha = WidgetDimFillAlpha).compositeOver(surface)
}
/** Ink for an event on [fill], faded when the row is [dimmed]. */
internal fun widgetEventInk(fill: Color, alpha: Float, dimmed: Boolean): Color =
eventInk(fill, if (dimmed) alpha * WidgetDimInkAlpha else alpha)
/** Text and row sizes of a compact event list, from a widget's [WidgetSize]. */
internal data class EventRowMetrics(val title: TextUnit, val time: TextUnit, val rowHeight: Dp, val message: TextUnit)
@@ -72,9 +85,8 @@ internal fun WidgetEventRow(
quiet: Boolean = false,
) {
val context = LocalContext.current
fun tone(dark: Boolean): Color = eventFill(event.color, dark, soften).let { if (dimmed) it.copy(alpha = WidgetDimFillAlpha) else it }
val day = tone(dark = false)
val night = tone(dark = true)
val day = widgetEventFill(event.color, dark = false, soften, dimmed)
val night = widgetEventFill(event.color, dark = true, soften, dimmed)
val card = widgetCardColor()
WidgetGroupedRow(position = position, color = if (quiet) card else themedColor(day = day, night = night)) {
Column(
@@ -84,13 +96,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,
@@ -98,12 +104,12 @@ internal fun WidgetEventRow(
val titleInk = if (quiet) {
GlanceTheme.colors.onSurface
} else {
themedColor(day = widgetEventInk(day, 1f), night = widgetEventInk(night, 1f))
themedColor(day = widgetEventInk(day, 1f, dimmed), night = widgetEventInk(night, 1f, dimmed))
}
val timeInk = if (quiet) {
GlanceTheme.colors.onSurfaceVariant
} else {
themedColor(day = widgetEventInk(day, SECONDARY_INK_ALPHA), night = widgetEventInk(night, SECONDARY_INK_ALPHA))
themedColor(day = widgetEventInk(day, SECONDARY_INK_ALPHA, dimmed), night = widgetEventInk(night, SECONDARY_INK_ALPHA, dimmed))
}
Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
@@ -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(
@@ -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())) {
@@ -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)
}
}
@@ -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. */
@@ -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
@@ -55,8 +56,15 @@ import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
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.widget.WidgetDimFillAlpha
import de.jeanlucmakiola.calendula.widget.widgetEventFill
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.widgetEventInk
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
@@ -65,6 +73,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 +97,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 +161,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 +179,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 +235,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<AgendaRow>): List<Position> = buildList {
/** Each day is one card of grouped rows (#71), with its date pill beside the first row. */
@Composable
private fun CardRows(allRows: List<AgendaRow>, 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<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
* 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<AgendaRow>, 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<LocalDate>): List<Position> = 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 +547,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 +579,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(
@@ -391,14 +591,19 @@ 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)
private data class EventFill(val day: Color, val night: Color, val dimmed: Boolean)
/** 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)
fun tone(dark: Boolean) = eventFill(event.color, dark, data.soften).let {
if (dimmed) it.copy(alpha = WidgetDimFillAlpha) else it
}
return EventFill(day = tone(dark = false), night = tone(dark = true))
val dimmed = dimmed(data)
return EventFill(
day = widgetEventFill(event.color, dark = false, data.soften, dimmed),
night = widgetEventFill(event.color, dark = true, data.soften, dimmed),
dimmed = dimmed,
)
}
@Composable
@@ -411,27 +616,17 @@ private fun EventRow(
) {
val context = androidx.glance.LocalContext.current
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
val titleInk = themedColor(day = widgetEventInk(fill.day, 1f), night = widgetEventInk(fill.night, 1f))
val titleInk = themedColor(day = widgetEventInk(fill.day, 1f, fill.dimmed), night = widgetEventInk(fill.night, 1f, fill.dimmed))
val timeInk = themedColor(
day = widgetEventInk(fill.day, SECONDARY_INK_ALPHA),
night = widgetEventInk(fill.night, SECONDARY_INK_ALPHA),
day = widgetEventInk(fill.day, SECONDARY_INK_ALPHA, fill.dimmed),
night = widgetEventInk(fill.night, SECONDARY_INK_ALPHA, fill.dimmed),
)
Column(
modifier = GlanceModifier
.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
}
@@ -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) {
@@ -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)
@@ -36,11 +36,25 @@ internal fun <T : Any> mergeGaps(columns: Int, cellAt: (Int) -> GridCell<T>?): 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. [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; 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) {
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<DaySeating> = week.days.indices.map { week.seating(it, laneCap) }
/** Lanes filled from the top; empty lanes below are not drawn. */
@@ -55,6 +69,16 @@ internal class WeekLayout(val week: MonthWeek, val laneCap: Int, val showsOverfl
}
}
/** The single lane of a [mixed] week, bars cut short where a day shows its count instead; null otherwise. */
val mixedLane: List<GridCell<LaneItem>>? = if (!mixed) null else mergeGaps(week.days.size) { col ->
val day = seating[col]
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
GridCell(LaneItem.Bar(seat.event), end - col + 1)
}
/** The "+N" row as counts and merged gaps; empty when everything fits. */
val overflow: List<GridCell<Int>> = seating.map { it.hidden.size }.let { hidden ->
if (hidden.none { it > 0 }) emptyList()
@@ -62,19 +86,31 @@ internal class WeekLayout(val week: MonthWeek, val laneCap: Int, val showsOverfl
}
}
/**
* 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).
*/
internal fun lanesFitting(weekHeight: Dp): Int =
((weekHeight.value - DAY_ROW_DP - LANES_TOP_DP) / LANE_ROW_DP).toInt().coerceIn(0, MAX_LANES)
/** 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)
/**
* Seats each week in [lanes] lanes. A week whose events need more gives its
* last lane to the "+N" row instead.
* 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). A row too short
* for one full lane still gets a compact one when its text fits at [fontScale] (#491).
*/
internal fun fitWeeks(weeks: List<MonthWeek>, lanes: Int): List<WeekLayout> = weeks.map { week ->
if (lanes == 0) return@map WeekLayout(week, laneCap = 0, showsOverflow = false)
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
return if (full == 0 && compactFits) LaneFit(1, compact = true) else LaneFit(full)
}
/**
* Seats each week in [fit]'s lanes. A week whose events need more gives its
* 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<MonthWeek>, fit: LaneFit): List<WeekLayout> = weeks.map { week ->
val lanes = fit.lanes
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)
}
@@ -85,4 +121,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 COMPACT_MAX_FONT_SCALE = 1f
private const val UNCAPPED_LANES = 64
@@ -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)
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,8 +129,15 @@ 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 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) }
private fun gap(context: Context, cols: Int) = RemoteViews(context.packageName, GAP_LAYOUTS[cols - 1])
@@ -254,7 +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 layouts = fitWeeks(weeks, lanesFitting(weekHeight))
val layouts = fitWeeks(weeks, lanesFitting(weekHeight, fontScale))
val mode = LocalWidgetThemeMode.current
val colors = GridColors(
onSurface = remoteColor(WidgetColorRole.ON_SURFACE),
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/lane"
android:layout_width="match_parent"
android:layout_height="12dp"
android:orientation="horizontal"
android:paddingBottom="1dp"
android:weightSum="7" />
+9
View File
@@ -517,6 +517,15 @@
<string name="widget_config_background_tinted">Tinted</string>
<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_invert_text">Invert text colour</string>
<string name="widget_config_invert_text_summary">Light text on a dark theme turns dark, and the other way round</string>
<string name="widget_config_readability_info">About readability</string>
<string name="widget_config_readability_hint">At low opacity the wallpaper shows through, so some text may be harder to read. Raise the opacity if it is.</string>
<string name="widget_config_readability_hint_classic">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.</string>
<string name="widget_config_style">Style</string>
<string name="widget_config_style_cards">Cards</string>
<string name="widget_config_style_classic">Classic</string>
<string name="widget_config_content">Content</string>
<string name="widget_config_range">Time range</string>
<string name="widget_config_text_size">Text size</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()
}
}
@@ -42,33 +42,80 @@ 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()
}
@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, 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, 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
fun `no compact lane at a large font scale`() {
assertThat(lanesFitting(34.dp, fontScale = 1.1f)).isEqualTo(LaneFit(0))
}
@Test
fun `a single lane shows counts on busy days`() {
val layouts = fitWeeks(busyWeeks(perDay = 5), LaneFit(1))
assertThat(layouts.all { it.mixedLane == List(7) { GridCell<LaneItem>(LaneItem.Count(5), 1) } }).isTrue()
}
@Test
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<LaneItem>(null, 1),
GridCell<LaneItem>(LaneItem.Bar(lone), 1),
GridCell<LaneItem>(null, 1),
GridCell<LaneItem>(LaneItem.Count(3), 1),
GridCell<LaneItem>(null, 3),
).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>(LaneItem.Bar(trip), 1),
GridCell<LaneItem>(LaneItem.Count(2), 1),
GridCell<LaneItem>(LaneItem.Bar(trip), 2),
GridCell<LaneItem>(null, 3),
).inOrder()
}
@Test
fun `lanes stop at the cap however tall the week`() {
assertThat(lanesFitting(1000.dp)).isEqualTo(MAX_LANES)
assertThat(lanesFitting(1000.dp, fontScale = 1f)).isEqualTo(LaneFit(MAX_LANES))
}
@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()
}