Merge #71 widget redesign and #372 month grid into the widget refactor

This commit is contained in:
2026-10-23 17:59:34 +02:00
32 changed files with 1208 additions and 646 deletions
@@ -4,6 +4,7 @@ import android.Manifest
import android.content.ContentUris
import android.content.ContentValues
import android.net.Uri
import android.os.Parcel
import android.provider.CalendarContract
import android.util.Log
import android.view.View
@@ -24,17 +25,18 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import de.jeanlucmakiola.calendula.R
import java.time.LocalDate
import java.time.ZoneId
/**
* Renders the month widget for a packed month through Glance's own translator
* and counts what it inflates (#372): the view-id model in [WeekLayout] has to
* match what Glance really builds, or a busy month fails with "too many views".
* (#372) and checks what one size costs on the wire: the host's binder buffer is
* 1 MB for every widget update at once, and Exact sends one copy per size (#214).
*/
@OptIn(ExperimentalGlanceApi::class)
@RunWith(AndroidJUnit4::class)
class MonthWidgetViewBudgetTest {
class MonthWidgetPayloadTest {
@get:Rule
val permissionRule: GrantPermissionRule =
@@ -95,31 +97,44 @@ class MonthWidgetViewBudgetTest {
}
@Test
fun aPackedMonthRendersWithinGlancesViewIds() = runBlocking {
for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp))) {
fun aPackedMonthRendersWithinItsPayload() = runBlocking {
for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp), DpSize(600.dp, 1000.dp))) {
val views = MonthWidget().compose(context, size = size)
var total = 0
var texts = 0
val bytes = Parcel.obtain().let { parcel ->
try {
views.writeToParcel(parcel, 0)
parcel.dataSize()
} finally {
parcel.recycle()
}
}
var dayNumbers = 0
var bars = 0
instrumentation.runOnMainSync {
val root = views.apply(context, FrameLayout(context))
fun walk(view: View) {
total += 1
if (view is TextView) texts += 1
if (view.id in DAY_IDS && view is TextView && view.text.isNotEmpty()) dayNumbers += 1
if (view.id == R.id.bar_title) bars += 1
if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i))
}
walk(root)
}
Log.i(TAG, "size=$size views=$total texts=$texts budget=$VIEW_BUDGET")
// The grid itself rendered, not Glance's error layout: every day number is there.
assertThat(texts).isAtLeast(35)
assertThat(total).isAtMost(GLANCE_VIEW_IDS)
Log.i(TAG, "size=$size bytes=$bytes days=$dayNumbers bars=$bars")
// The grid itself rendered, not Glance's error layout: a number in every day of every week.
assertThat(dayNumbers % 7).isEqualTo(0)
assertThat(dayNumbers).isAtLeast(28)
assertThat(bytes).isAtMost(MAX_BYTES_PER_SIZE)
}
}
private companion object {
const val TAG = "MonthWidgetBudget"
const val ACCOUNT = "calendula-widget-budget-test"
const val TAG = "MonthWidgetPayload"
const val ACCOUNT = "calendula-widget-payload-test"
const val EVENTS_PER_DAY = 6
const val GLANCE_VIEW_IDS = 499
val DAY_IDS = setOf(R.id.day_0, R.id.day_1, R.id.day_2, R.id.day_3, R.id.day_4, R.id.day_5, R.id.day_6)
/** A quarter of the host's buffer, leaving room for a second size and every other widget. */
const val MAX_BYTES_PER_SIZE = 256 * 1024
}
}
@@ -6,17 +6,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.jeanlucmakiola.calendula.widget.WidgetSize
/**
* Horizontal layout constants for an agenda event row. These don't scale with the
* size step, since a wider stripe or gap would eat title width, but [TEXT_INDENT]
* is derived from them so non-event text can't drift out of alignment.
*/
internal val ROW_H_PAD = 4.dp
internal val STRIPE_WIDTH = 5.dp
internal val STRIPE_GAP = 10.dp
/** Horizontal inset of a row's text inside its card; doesn't scale, so it never eats title width. */
internal val ROW_H_PAD = 12.dp
/** Left indent that lines non-event text up with the event title column. */
internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
/** Space between the date-pill gutter and a day's card. */
internal val GUTTER_GAP = 6.dp
/**
* Every size the agenda widget varies by [WidgetSize]. Values that genuinely
@@ -24,90 +18,91 @@ internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
* constants rather than routing through here.
*/
internal data class AgendaMetrics(
val title: TextUnit, // header "Upcoming"
val dayHeader: TextUnit, // day label ("Today · …")
val pillWeekday: TextUnit, // date pill weekday ("Thu")
val pillDay: TextUnit, // date pill day number
val eventTitle: TextUnit, // event title line
val eventTime: TextUnit, // time/location line
val placeholder: TextUnit, // "no more events today" (#35)
val message: TextUnit, // empty/permission centred message
val stripeH: Dp, // coloured event stripe height
val iconImage: Dp, // header action icon glyph
val iconBox: Dp, // header action touch target
val contentH: Dp, // an event row's two text lines
val pillWidth: Dp, // date pill / gutter width
val rowVPad: Dp, // event row vertical padding
val dayHeaderTopPad: Dp, // space above a day header
val dayGap: Dp, // space between two days' cards
val monthLabel: TextUnit, // month name above a new month's first day
) {
/** One event row's height, shared by the date pill beside it and the placeholder row. */
val rowHeight: Dp get() = contentH + rowVPad * 2
/**
* Resolves the stripe height against the user's system font scale: the stripe
* is a [Dp] while the lines beside it are `sp`, so without this the text
* outgrows it at large font settings.
* Resolves the row content height and pill width against the user's system font
* scale: both are [Dp] while the text in and beside them is `sp`, so without
* this the text outgrows them at large font settings.
*/
fun scaledForFont(fontScale: Float): AgendaMetrics =
if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale)
if (fontScale == 1f) this else copy(contentH = contentH * fontScale, pillWidth = pillWidth * fontScale)
}
/*
* Type sizes are anchored to the Material 3 type scale, with two deviations:
* Type sizes are anchored to the Material 3 type scale, with one deviation:
*
* ‡ SMALL's 13sp day header is off-scale, held there so SMALL reproduces the
* widget's original constants verbatim (#51).
* † Above Title Medium the M3 scale jumps 16 → 22 → 24, too coarse for four
* steps; interpolated values are used where a role would force a ≥1.4x jump.
*/
private val SMALL_METRICS = AgendaMetrics(
title = 16.sp, // M3 Title Medium
dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline
pillWeekday = 11.sp,
pillDay = 16.sp,
eventTitle = 14.sp, // M3 Body Medium
eventTime = 12.sp, // M3 Body Small
placeholder = 14.sp, // M3 Body Medium
message = 14.sp, // M3 Body Medium
stripeH = 36.dp,
iconImage = 22.dp,
iconBox = 40.dp,
contentH = 36.dp,
pillWidth = 40.dp,
rowVPad = 4.dp,
dayHeaderTopPad = 10.dp,
dayGap = 8.dp,
monthLabel = 12.sp,
)
private val MEDIUM_METRICS = AgendaMetrics(
title = 18.sp, // †
dayHeader = 14.sp, // M3 Title Small
pillWeekday = 12.sp,
pillDay = 18.sp,
eventTitle = 16.sp, // M3 Body Large
eventTime = 14.sp, // M3 Body Medium
placeholder = 16.sp, // M3 Body Large
message = 16.sp, // M3 Body Large
stripeH = 40.dp,
iconImage = 24.dp,
iconBox = 44.dp,
contentH = 40.dp,
pillWidth = 44.dp,
rowVPad = 5.dp,
dayHeaderTopPad = 11.dp,
dayGap = 9.dp,
monthLabel = 14.sp,
)
private val LARGE_METRICS = AgendaMetrics(
title = 20.sp, // †
dayHeader = 16.sp, // M3 Title Medium
pillWeekday = 12.sp,
pillDay = 20.sp,
eventTitle = 18.sp, // †
eventTime = 14.sp, // M3 Body Medium — the secondary line steps more slowly
placeholder = 18.sp, // † on purpose, so the title keeps its
message = 18.sp, // † lead and the hierarchy survives.
stripeH = 46.dp,
iconImage = 26.dp,
iconBox = 48.dp,
contentH = 46.dp,
pillWidth = 48.dp,
rowVPad = 6.dp,
dayHeaderTopPad = 12.dp,
dayGap = 10.dp,
monthLabel = 14.sp,
)
private val EXTRA_LARGE_METRICS = AgendaMetrics(
title = 22.sp, // M3 Title Large
dayHeader = 18.sp, // †
pillWeekday = 14.sp,
pillDay = 22.sp,
eventTitle = 20.sp, // †
eventTime = 16.sp, // M3 Body Large
placeholder = 20.sp, // †
message = 20.sp, // †
stripeH = 52.dp,
iconImage = 28.dp,
iconBox = 52.dp,
contentH = 52.dp,
pillWidth = 52.dp,
rowVPad = 8.dp,
dayHeaderTopPad = 14.dp,
dayGap = 12.dp,
monthLabel = 16.sp,
)
/** Indexed by [WidgetSize.ordinal] so lookup allocates nothing per recomposition. */
@@ -6,18 +6,13 @@ import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.glance.ColorFilter
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.action.ActionParameters
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn
@@ -36,6 +31,12 @@ import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.semantics.contentDescription
import androidx.glance.semantics.semantics
import androidx.glance.text.TextAlign
import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.floret.glance.WidgetHeader
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
@@ -49,10 +50,12 @@ import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
@@ -72,20 +75,20 @@ import androidx.glance.currentState
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.updateWithRefresh
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import java.util.Locale
/**
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
* of events grouped under day headers (the Google "Schedule" widget model).
* of events, each day one card of grouped rows with its date pill beside it.
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
*/
class AgendaWidget : GlanceAppWidget() {
@@ -131,15 +134,8 @@ internal fun AgendaWidgetContent(
}
}
/** Rows a preview draws: more than any widget shows at once. */
private const val PREVIEW_ROWS = 16
/** Reloads and redraws the tapped widget (header refresh button). */
class RefreshAgendaAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
AgendaWidget().updateWithRefresh(context.applicationContext, glanceId)
}
}
/** Rows a preview draws: Glance's cap on a Column's children, still more than a placed widget shows. */
private const val PREVIEW_ROWS = 10
/**
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
@@ -150,19 +146,18 @@ class RefreshAgendaAction : ActionCallback {
*/
private const val MAX_AGENDA_ROWS = 100
/** Flat row model so the [LazyColumn] can mix day headers and events. */
/** Flat row model: one [LazyColumn] item per event or placeholder, a day's first one carrying its date pill. */
private sealed interface AgendaRow {
val date: LocalDate
data class Header(override val date: LocalDate, val today: LocalDate) : AgendaRow
data class Event(override val date: LocalDate, val event: EventInstance) : AgendaRow
/** "Nothing left today" line under an anchored, event-less today (#35). */
/** "Nothing left today" line for an anchored, event-less today (#35). */
data class Placeholder(override val date: LocalDate) : AgendaRow
}
@Composable
private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) {
// The stripe is re-resolved against the system font scale so it tracks the
// The row height is re-resolved against the system font scale so it tracks the
// sp-sized text beside it instead of drifting at large accessibility settings.
val size = (data as? AgendaWidgetData.Ready)?.textSize ?: WidgetSize.SMALL
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
@@ -172,8 +167,8 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) {
.fillMaxSize()
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
AgendaHeader(metrics)
Spacer(GlanceModifier.height(4.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)
@@ -212,33 +207,47 @@ private fun AgendaRows(
} else {
val rows = buildList {
visibleDays.forEach { day ->
add(AgendaRow.Header(day.date, data.today))
if (day.events.isEmpty()) {
add(AgendaRow.Placeholder(day.date))
} else {
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
}
}
}
// Bound the payload, then drop a day header the cut left
// stranded with nothing under it.
.take(MAX_AGENDA_ROWS)
.dropLastWhile { it is AgendaRow.Header }
// On a transparent widget each day becomes one card of grouped rows,
// so it stays readable over the wallpaper (#292).
val positions = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) dayPositions(rows) else null
}.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 = agendaCardColor()
val item: @Composable (Int) -> Unit = { index ->
val row = rows[index]
if (positions != null) {
WidgetGroupedRow(
position = positions[index],
modifier = if (row is AgendaRow.Header && index > 0) GlanceModifier.padding(top = 6.dp) else GlanceModifier,
color = GlanceTheme.colors.widgetBackground,
) {
AgendaRowContent(row, data, metrics)
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 {
AgendaRowContent(row, data, metrics)
dayRow(gap)
}
}
if (preview) {
@@ -267,156 +276,154 @@ private fun dayPositions(rows: List<AgendaRow>): List<Position> = buildList {
@Composable
private fun AgendaRowContent(row: AgendaRow, data: AgendaWidgetData.Ready, metrics: AgendaMetrics) {
when (row) {
is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
is AgendaRow.Event -> EventRow(
event = row.event,
day = row.date,
soften = data.soften,
fill = row.fill(data),
is24Hour = data.is24Hour,
dimmed = data.pastDisplay == PastEventDisplay.DIM && row.event.hasEnded(data.now),
metrics = metrics,
)
}
}
@Composable
private fun AgendaHeader(metrics: AgendaMetrics) {
private fun AgendaHeader() {
val context = androidx.glance.LocalContext.current
Row(
modifier = GlanceModifier.fillMaxWidth().wallpaperBacking().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
// Tap the "Upcoming" title to open the app on the user's default view
// (issue #20) — a null target resolves to the default home in the host.
WidgetHeader(
title = context.getString(R.string.widget_agenda_title),
titleAction = actionStartActivity(MainActivity.openViewIntent(context, view = null)),
titleInset = 4.dp,
) {
// Tap the "Upcoming" title to open the app on the user's default view
// (issue #20) — a null target resolves to the default home in the host.
Text(
text = context.getString(R.string.widget_agenda_title),
style = TextStyle(
color = GlanceTheme.colors.primary,
fontSize = metrics.title,
fontWeight = FontWeight.Medium,
),
modifier = GlanceModifier
.defaultWeight()
.clickable(
actionStartActivity(MainActivity.openViewIntent(context, view = null)),
),
)
IconButton(
resId = R.drawable.ic_widget_refresh,
contentDescription = context.getString(R.string.widget_refresh),
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
metrics = metrics,
)
IconButton(
resId = R.drawable.ic_widget_add,
WidgetHeaderAction(
icon = ImageProvider(R.drawable.ic_widget_add),
contentDescription = context.getString(R.string.widget_new_event),
onClick = GlanceModifier.clickable(
actionStartActivity(
MainActivity.openCreateIntent(context, today(systemZone())),
),
),
metrics = metrics,
action = actionStartActivity(MainActivity.openCreateIntent(context, today(systemZone()))),
)
}
}
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
@Composable
private fun IconButton(
resId: Int,
contentDescription: String,
onClick: GlanceModifier,
metrics: AgendaMetrics,
) {
Box(
modifier = GlanceModifier.size(metrics.iconBox).then(onClick),
contentAlignment = Alignment.Center,
) {
Image(
provider = ImageProvider(resId),
contentDescription = contentDescription,
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
modifier = GlanceModifier.size(metrics.iconImage),
)
}
private fun agendaCardColor(): ColorProvider = when (LocalWidgetBackground.current) {
WidgetBackground.SOLID -> GlanceTheme.colors.surfaceVariant
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground
}
/** The month name ("November", with its year outside the current one) above a new month's first day. */
@Composable
private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current
private fun MonthLabel(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
Text(
text = agendaDayLabel(context, date, today),
text = monthName(date, today),
maxLines = 1,
style = TextStyle(
color = if (date == today) GlanceTheme.colors.primary
else GlanceTheme.colors.onSurfaceVariant,
fontSize = metrics.dayHeader,
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = metrics.monthLabel,
fontWeight = FontWeight.Medium,
),
modifier = GlanceModifier
.fillMaxWidth()
.padding(
start = 8.dp,
end = 8.dp,
top = metrics.dayHeaderTopPad,
bottom = metrics.rowVPad,
)
.clickable(
actionStartActivity(
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
),
),
.padding(bottom = 4.dp)
.wallpaperBacking(radius = 8.dp)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
/**
* "No more events today" under an anchored, event-less today (#35). Kept plain —
* a muted line indented to the event titles — since the widget's event rows are
* plain too (a stripe + text, not cards), so a card here would look out of place.
*/
/** A day's weekday and number in a rounded pill, filled for today; opens that day in the app. */
@Composable
private fun DatePill(date: LocalDate, today: LocalDate, cardColor: ColorProvider, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current
val isToday = date == today
val container = if (isToday) GlanceTheme.colors.primary else cardColor
val content = if (isToday) GlanceTheme.colors.onPrimary else GlanceTheme.colors.onSurface
val label = agendaDayLabel(context, date, today)
Column(
modifier = GlanceModifier
.width(metrics.pillWidth)
.height(metrics.rowHeight)
.cornerRadius(metrics.pillWidth / 2)
.background(container)
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda)))
.semantics { contentDescription = label },
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = weekdayShort(date),
maxLines = 1,
style = TextStyle(color = content, fontSize = metrics.pillWeekday, textAlign = TextAlign.Center),
)
Text(
text = date.day.toString(),
maxLines = 1,
style = TextStyle(
color = content,
fontSize = metrics.pillDay,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
),
)
}
}
/** "No more events today" in an anchored, event-less today's card (#35), as tall as the pill beside it. */
@Composable
private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current
Text(
text = context.getString(R.string.agenda_no_more_today),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
Box(
modifier = GlanceModifier
.fillMaxWidth()
.padding(
start = TEXT_INDENT,
end = 8.dp,
top = 2.dp,
bottom = metrics.rowVPad + 2.dp,
)
.height(metrics.rowHeight)
.padding(horizontal = ROW_H_PAD)
.clickable(
actionStartActivity(
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
),
),
)
contentAlignment = Alignment.CenterStart,
) {
Text(
text = context.getString(R.string.agenda_no_more_today),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
)
}
}
/** 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 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 = EventDimAlpha) else it
}
return EventFill(day = tone(dark = false), night = tone(dark = true))
}
@Composable
private fun EventRow(
event: EventInstance,
day: LocalDate,
soften: Boolean,
fill: EventFill,
is24Hour: Boolean,
dimmed: Boolean,
metrics: AgendaMetrics,
) {
val context = androidx.glance.LocalContext.current
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
// dropping both text lines to the lower-emphasis on-surface-variant tone.
fun stripe(dark: Boolean) = eventAccent(event.color, dark, soften).let {
if (dimmed) it.copy(alpha = EventDimAlpha) else it
}
val stripeColor = themedColor(day = stripe(dark = false), night = stripe(dark = true))
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
Row(
// Ink is picked against the opaque fill, then carries the dim along with it.
fun ink(fill: Color, alpha: Float) = eventInk(fill.copy(alpha = 1f), alpha * fill.alpha)
val titleInk = themedColor(day = ink(fill.day, 1f), night = ink(fill.night, 1f))
val timeInk = themedColor(
day = ink(fill.day, SECONDARY_INK_ALPHA),
night = ink(fill.night, SECONDARY_INK_ALPHA),
)
Column(
modifier = GlanceModifier
.fillMaxWidth()
.padding(horizontal = ROW_H_PAD, vertical = metrics.rowVPad)
.height(metrics.rowHeight)
.padding(horizontal = ROW_H_PAD)
.clickable(
actionStartActivity(
MainActivity.openEventIntent(
@@ -430,30 +437,21 @@ private fun EventRow(
),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = GlanceModifier
.width(STRIPE_WIDTH)
.height(metrics.stripeH)
.cornerRadius(3.dp)
.background(stripeColor),
) {}
Spacer(GlanceModifier.width(STRIPE_GAP))
Column(modifier = GlanceModifier.defaultWeight()) {
Text(
text = title,
maxLines = 1,
style = TextStyle(
color = titleColor,
fontSize = metrics.eventTitle,
textDecoration = glanceDeclinedDecoration(event.isDeclined),
),
)
Text(
text = eventTimeSummary(context, event, day, is24Hour),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime),
)
}
Text(
text = title,
maxLines = 1,
style = TextStyle(
color = titleInk,
fontSize = metrics.eventTitle,
fontWeight = FontWeight.Medium,
textDecoration = glanceDeclinedDecoration(event.isDeclined),
),
)
Text(
text = eventTimeSummary(context, event, day, is24Hour),
maxLines = 1,
style = TextStyle(color = timeInk, fontSize = metrics.eventTime),
)
}
}
@@ -489,6 +487,16 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
return if (relative != null) "$relative · $formatted" else formatted
}
private fun monthName(date: LocalDate, today: LocalDate): String = formatCalendarTitle(
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, 1),
locale = Locale.getDefault(),
currentYear = today.year,
skeleton = "LLLL",
)
private fun weekdayShort(date: LocalDate): String =
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, Locale.getDefault())
private fun eventTimeSummary(
context: Context,
event: EventInstance,
@@ -1,43 +1,11 @@
package de.jeanlucmakiola.calendula.widget.month
import android.os.Build
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.month.DaySeating
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
import de.jeanlucmakiola.calendula.ui.month.seating
import de.jeanlucmakiola.calendula.ui.month.spanAt
import kotlinx.datetime.LocalDate
/** Event rows (lanes) a week draws at most before the rest collapse into "+N". */
internal const val MAX_LANES = 3
/**
* View ids one rendering may spend. Glance has 499 and fails the whole widget
* past them (#372); staying well under also keeps a busy month's payload, sent
* once per host size, clear of the binder limit #214 ran into.
*/
internal const val VIEW_BUDGET = 400
/**
* What views cost in Glance 1.2's view ids. A clickable view that isn't a
* Button is wrapped in a Box sized like it, with a ripple Image and the view
* itself filling it. Below Android 12 a view with a fixed width or height is
* inflated into a sizing frame first, which takes a second id.
*/
internal class ViewIds(sdkInt: Int = Build.VERSION.SDK_INT) {
val plain = 1
val sized = if (sdkInt < Build.VERSION_CODES.S) 2 else 1
val clickablePlain = plain + 2
val clickableSized = sized + 2
/**
* `MonthWidgetBody`'s views outside the week rows: the surface Box, its
* Column, the header row with three sized clickable icon boxes, their sized
* icons and the clickable title, the spacer, the grid's Column and the
* weekday row of sized labels.
*/
val fixed = plain + plain + plain + 3 * (clickableSized + sized) + clickablePlain + sized + plain + plain + 7 * sized
}
/** A cell [cols] columns wide holding [item], or a merged gap where [item] is null. */
internal data class GridCell<T : Any>(val item: T?, val cols: Int)
@@ -68,8 +36,11 @@ internal fun <T : Any> mergeGaps(columns: Int, cellAt: (Int) -> GridCell<T>?): L
return cells
}
/** One week of the grid seated with [laneCap] lanes, computed once for both drawing and counting. */
internal class WeekLayout(val week: MonthWeek, val laneCap: Int) {
/**
* One week of the grid seated with [laneCap] lanes. [showsOverflow] is off when
* the week has no room for even the "+N" row.
*/
internal class WeekLayout(val week: MonthWeek, val laneCap: Int, val showsOverflow: Boolean = true) {
private val seating: List<DaySeating> = week.days.indices.map { week.seating(it, laneCap) }
/** Lanes filled from the top; empty lanes below are not drawn. */
@@ -89,57 +60,29 @@ internal class WeekLayout(val week: MonthWeek, val laneCap: Int) {
if (hidden.none { it > 0 }) emptyList()
else mergeGaps(hidden.size) { col -> hidden[col].takeIf { it > 0 }?.let { GridCell(it, 1) } }
}
/**
* The view ids `WeekRow` spends on this week, counted the way it draws it:
* the week Box, the strip Row and its seven sized clickable strips, the
* content Column, the sized day-number row and its sized numbers (today's
* pill adds a sized Box around a plain Text), the sized spacer, each lane's
* Row and sized spacer with sized gaps and bars (a sized Box around a
* filling Box and Text), and the "+N" Row of sized cells.
*/
fun viewIds(today: LocalDate, ids: ViewIds): Int {
val days = week.days.size
val strips = ids.plain + days * ids.clickableSized
val numbers = ids.sized + days * ids.sized + if (today in week.days) ids.sized + ids.plain else 0
val lanes = lanes.sumOf { cells ->
ids.plain + ids.sized + cells.sumOf { if (it.item == null) ids.sized else ids.sized + 2 * ids.plain }
}
val overflow = if (overflow.isEmpty()) 0 else ids.plain + overflow.size * ids.sized
return ids.plain + strips + ids.plain + numbers + ids.sized + lanes + overflow
}
}
/**
* Seats each week so the whole grid fits [budget] view ids (#372). Each round
* folds the lowest lanes of whichever week saves the most ids per lane given
* up, their events joining "+N", until the month fits.
* 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 layoutWeeks(
weeks: List<MonthWeek>,
today: LocalDate,
ids: ViewIds = ViewIds(),
budget: Int = VIEW_BUDGET - ids.fixed,
): List<WeekLayout> {
val layouts = weeks.map { week -> WeekLayout(week, WeekLayout(week, MAX_LANES).usedLanes) }.toMutableList()
val costs = layouts.map { it.viewIds(today, ids) }.toMutableList()
while (costs.sum() > budget) {
var best: Triple<Int, WeekLayout, Int>? = null
var bestRate = 0.0
layouts.forEachIndexed { i, layout ->
for (cap in layout.laneCap - 1 downTo 0) {
val folded = WeekLayout(layout.week, cap)
val cost = folded.viewIds(today, ids)
val rate = (costs[i] - cost).toDouble() / (layout.laneCap - cap)
if (rate > bestRate) {
bestRate = rate
best = Triple(i, folded, cost)
}
}
}
val (i, folded, cost) = best ?: break
layouts[i] = folded
costs[i] = cost
}
return layouts
internal fun lanesFitting(weekHeight: Dp): Int =
((weekHeight.value - DAY_ROW_DP - LANES_TOP_DP) / LANE_ROW_DP).toInt().coerceIn(0, MAX_LANES)
/**
* Seats each week in [lanes] lanes. A week whose events need more gives its
* last lane to the "+N" row instead.
*/
internal fun fitWeeks(weeks: List<MonthWeek>, lanes: Int): List<WeekLayout> = weeks.map { week ->
if (lanes == 0) return@map WeekLayout(week, laneCap = 0, showsOverflow = false)
val needed = WeekLayout(week, UNCAPPED_LANES).usedLanes
WeekLayout(week, if (needed <= lanes) lanes else lanes - 1)
}
/** Lanes a week shows at most, however tall the widget. */
internal const val MAX_LANES = 8
private const val DAY_ROW_DP = 18f
private const val LANES_TOP_DP = 2f
private const val LANE_ROW_DP = 15f
private const val UNCAPPED_LANES = 64
@@ -0,0 +1,169 @@
package de.jeanlucmakiola.calendula.widget.month
import android.app.PendingIntent
import android.content.Context
import android.graphics.Typeface
import android.text.SpannableString
import android.text.Spanned
import android.text.style.StrikethroughSpan
import android.text.style.StyleSpan
import android.view.View
import android.widget.RemoteViews
import androidx.compose.ui.graphics.Color
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.floret.glance.RemoteColor
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import de.jeanlucmakiola.floret.glance.remoteColor
import de.jeanlucmakiola.floret.glance.setImageTint
import de.jeanlucmakiola.floret.glance.setTextColor
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
/** The theme colours the plain-RemoteViews grid draws with. */
internal data class GridColors(
val onSurface: RemoteColor,
val onSurfaceVariant: RemoteColor,
val primary: RemoteColor,
val onPrimary: RemoteColor,
val cell: RemoteColor,
)
/**
* The month's week rows as one plain RemoteViews tree, embedded in the Glance
* widget so its views don't draw on Glance's view-id pool (#372): every event
* gets its bar, and only what can't fit the widget's height folds into "+N".
*
* A host may reapply a new rendering onto the views of the last one rather than
* inflate it afresh, so every container is cleared before it's filled and every
* view state is set both ways, never just when it differs from the layout.
*/
internal fun monthGridViews(
context: Context,
layouts: List<WeekLayout>,
currentMonth: Month,
today: LocalDate,
soften: Boolean,
colors: GridColors,
mode: WidgetThemeMode,
): RemoteViews {
val grid = RemoteViews(context.packageName, R.layout.widget_month_grid)
grid.removeAllViews(R.id.month_grid)
layouts.forEach { grid.addView(R.id.month_grid, weekViews(context, it, currentMonth, today, soften, colors, mode)) }
return grid
}
private fun weekViews(
context: Context,
layout: WeekLayout,
currentMonth: Month,
today: LocalDate,
soften: Boolean,
colors: GridColors,
mode: WidgetThemeMode,
): RemoteViews {
val week = RemoteViews(context.packageName, R.layout.widget_month_week)
week.removeAllViews(R.id.week_lanes)
layout.week.days.forEachIndexed { col, date ->
week.setOnClickPendingIntent(STRIP_IDS[col], openDayIntent(context, date))
// Each day its own rounded cell, as in the app's month grid. Days around
// the month get a shade over their (opaque) cell, so they recede without
// letting a wallpaper show through.
week.setImageTint(context, CELL_IDS[col], colors.cell)
val outside = date.month != currentMonth
week.setViewVisibility(CELL_DIM_IDS[col], if (outside) View.VISIBLE else View.GONE)
if (outside) {
week.setImageTint(context, CELL_DIM_IDS[col], mode.remoteColor(OUTSIDE_SHADE_DAY, OUTSIDE_SHADE_NIGHT))
}
val dayId = DAY_IDS[col]
val isToday = date == today
week.setTextViewText(dayId, if (isToday) bold(date.day.toString()) else date.day.toString())
week.setTextColor(context, dayId, when {
isToday -> colors.onPrimary
date.month == currentMonth -> colors.onSurface
else -> colors.onSurfaceVariant
})
week.setViewVisibility(TODAY_IDS[col], if (isToday) View.VISIBLE else View.GONE)
if (isToday) week.setImageTint(context, TODAY_IDS[col], colors.primary)
}
layout.lanes.forEach { cells ->
val lane = laneViews(context)
cells.forEach { cell ->
val event = cell.item
lane.addView(R.id.lane, if (event == null) gap(context, cell.cols) else bar(context, event, cell.cols, soften, mode))
}
week.addView(R.id.week_lanes, lane)
}
if (layout.showsOverflow && layout.overflow.isNotEmpty()) {
val lane = laneViews(context)
layout.overflow.forEach { cell ->
val count = cell.item
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)
}
}
week.addView(R.id.week_lanes, lane)
}
return week
}
private fun laneViews(context: Context) =
RemoteViews(context.packageName, R.layout.widget_month_lane).apply { removeAllViews(R.id.lane) }
private fun gap(context: Context, cols: Int) = RemoteViews(context.packageName, GAP_LAYOUTS[cols - 1])
private fun bar(context: Context, event: EventInstance, cols: Int, soften: Boolean, mode: WidgetThemeMode): RemoteViews {
val views = RemoteViews(context.packageName, BAR_LAYOUTS[cols - 1])
val dayFill = eventFill(event.color, dark = false, soften)
val nightFill = eventFill(event.color, dark = true, soften)
views.setImageTint(context, R.id.bar_bg, mode.remoteColor(dayFill, nightFill))
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
// A span, like Glance's own decoration: paint flags would drop the text's defaults.
views.setTextViewText(R.id.bar_title, if (event.isDeclined) span(title, StrikethroughSpan()) else title)
views.setTextColor(context, R.id.bar_title, mode.remoteColor(eventInk(dayFill), eventInk(nightFill)))
return views
}
private fun bold(text: String): CharSequence = span(text, StyleSpan(Typeface.BOLD))
private fun span(text: String, span: Any): CharSequence =
SpannableString(text).apply { setSpan(span, 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) }
private fun openDayIntent(context: Context, date: LocalDate): PendingIntent = PendingIntent.getActivity(
context,
date.toEpochDays().toInt(),
MainActivity.openDateIntent(context, date, CalendarView.Month),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
private val STRIP_IDS = intArrayOf(R.id.strip_0, R.id.strip_1, R.id.strip_2, R.id.strip_3, R.id.strip_4, R.id.strip_5, R.id.strip_6)
/** The shade over a day outside the month: a dark cell needs more of it to read as dimmed. */
private val OUTSIDE_SHADE_DAY = Color.Black.copy(alpha = 0.18f)
private val OUTSIDE_SHADE_NIGHT = Color.Black.copy(alpha = 0.50f)
private val CELL_DIM_IDS = intArrayOf(
R.id.cell_dim_0, R.id.cell_dim_1, R.id.cell_dim_2, R.id.cell_dim_3, R.id.cell_dim_4, R.id.cell_dim_5, R.id.cell_dim_6,
)
private val CELL_IDS = intArrayOf(R.id.cell_0, R.id.cell_1, R.id.cell_2, R.id.cell_3, R.id.cell_4, R.id.cell_5, R.id.cell_6)
private val DAY_IDS = intArrayOf(R.id.day_0, R.id.day_1, R.id.day_2, R.id.day_3, R.id.day_4, R.id.day_5, R.id.day_6)
private val TODAY_IDS = intArrayOf(
R.id.today_bg_0, R.id.today_bg_1, R.id.today_bg_2, R.id.today_bg_3, R.id.today_bg_4, R.id.today_bg_5, R.id.today_bg_6,
)
private val BAR_LAYOUTS = intArrayOf(
R.layout.widget_month_bar_1, R.layout.widget_month_bar_2, R.layout.widget_month_bar_3, R.layout.widget_month_bar_4,
R.layout.widget_month_bar_5, R.layout.widget_month_bar_6, R.layout.widget_month_bar_7,
)
private val GAP_LAYOUTS = intArrayOf(
R.layout.widget_month_gap_1, R.layout.widget_month_gap_2, R.layout.widget_month_gap_3, R.layout.widget_month_gap_4,
R.layout.widget_month_gap_5, R.layout.widget_month_gap_6, R.layout.widget_month_gap_7,
)
@@ -1,7 +1,5 @@
package de.jeanlucmakiola.calendula.widget.month
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
@@ -13,34 +11,33 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.glance.ColorFilter
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.AndroidRemoteViews
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
import androidx.glance.background
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
@@ -48,24 +45,25 @@ import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import de.jeanlucmakiola.floret.glance.LocalWidgetThemeMode
import de.jeanlucmakiola.floret.glance.WidgetColorRole
import de.jeanlucmakiola.floret.glance.remoteColor
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
import de.jeanlucmakiola.floret.glance.WidgetHeader
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
import de.jeanlucmakiola.floret.glance.WidgetHeaderActionGap
import de.jeanlucmakiola.floret.glance.WidgetHeaderTextAction
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.floret.glance.themedColor
import de.jeanlucmakiola.floret.glance.wallpaperBacking
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
@@ -90,10 +88,16 @@ import java.util.Locale
/** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */
private val MONTH_INDEX_KEY = intPreferencesKey("month_index")
private val LANE_HEIGHT = 14.dp
private val DAY_NUMBER_HEIGHT = 18.dp
private val GRID_HPADDING = 8.dp
/**
* Height around the week rows: body padding, the header's bottom padding and
* spacer and the weekday pills' padding, then the header (its 28dp pills, or a title line in its transparent
* pill when that's taller) and the weekday row, the text lines at [fontScale].
*/
private fun gridChromeHeight(fontScale: Float): Dp =
21.dp + maxOf(28.dp, 19.dp * fontScale + 8.dp) + 15.dp * fontScale
private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone))
private fun yearMonthOf(index: Int): YearMonth =
@@ -104,9 +108,9 @@ private fun yearMonthOf(index: Int): YearMonth =
* event bars and titled single-day pills (the in-app lane layout via
* [layoutMonthWeeks]), and prev/next/today navigation.
*
* Columns are sized explicitly from [LocalSize] (hence [SizeMode.Exact]) so a
* multi-day span renders as a single Box spanning its columns — connected, no
* inter-cell seam, with rounded end caps. The displayed month lives in Glance
* The week rows are one plain RemoteViews tree ([monthGridViews]) embedded in
* the Glance widget, so a busy month isn't held to Glance's view-id pool (#372);
* the lanes each week shows come from the widget's height. The displayed month lives in Glance
* state and is read reactively in the composition ([currentState]) so the arrows
* move it via plain recomposition, not a (here-unreliable) widget session reload.
*
@@ -117,7 +121,7 @@ private fun yearMonthOf(index: Int): YearMonth =
* the launcher rebinds. [SizeMode.Exact] serialises the grid once per size the
* host reports, so the ceiling arrives at half the view count you would expect: a
* grid that emitted a view per cell blew past it after three taps (#214). Every
* row here emits only what it fills.
* row here emits only what it fills, and lanes stop at what the height shows.
*/
class MonthWidget : GlanceAppWidget() {
@@ -127,8 +131,8 @@ class MonthWidget : GlanceAppWidget() {
* Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which
* return early from `GlanceAppWidget.resize` — under those the grid is never
* recomposed for a new size and a resized widget clips instead of reflowing.
* Exact costs one serialised copy per host size, which is why a rendering is
* held to [VIEW_BUDGET] view ids, folding lanes into "+N" on a busy month.
* Exact costs one serialised copy per host size, so the grid draws only the
* lanes the size can show, folding the rest into "+N".
*/
override val sizeMode = SizeMode.Exact
@@ -229,7 +233,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
) {
when (source) {
MonthWidgetSource.NeedsPermission -> {
MonthHeader(label = "Calendula")
MonthHeader(label = "Calendula", today = today(systemZone()))
PermissionMessage()
}
is MonthWidgetSource.Ready -> {
@@ -238,22 +242,30 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
// Column width from the live widget size, minus our H padding.
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
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 mode = LocalWidgetThemeMode.current
val colors = GridColors(
onSurface = remoteColor(WidgetColorRole.ON_SURFACE),
onSurfaceVariant = remoteColor(WidgetColorRole.ON_SURFACE_VARIANT),
primary = remoteColor(WidgetColorRole.PRIMARY),
onPrimary = remoteColor(WidgetColorRole.ON_PRIMARY),
cell = remoteColor(monthCellRole()),
)
MonthHeader(label = monthLabel(ym, source.today.year))
Spacer(GlanceModifier.height(2.dp))
// One backing for the whole grid on a transparent widget (#292).
Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight().wallpaperBacking()) {
MonthHeader(label = monthLabel(ym, source.today.year), today = source.today)
Spacer(GlanceModifier.height(4.dp))
// No backing behind the grid: the weekday pills and day cells
// each carry their own fill, so a transparent widget stays
// readable without one blob over the wallpaper (#292).
Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
WeekdayHeader(weekStart = source.weekStart, colW = colW)
layoutWeeks(weeks, source.today).forEach { layout ->
WeekRow(
layout = layout,
currentMonth = ym.month,
today = source.today,
soften = source.soften,
colW = colW,
modifier = GlanceModifier.defaultWeight(),
)
}
AndroidRemoteViews(
remoteViews = monthGridViews(context, layouts, ym.month, source.today, source.soften, colors, mode),
modifier = GlanceModifier.fillMaxWidth().defaultWeight(),
)
}
}
}
@@ -261,89 +273,79 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
}
@Composable
private fun MonthHeader(label: String) {
private fun MonthHeader(label: String, today: LocalDate) {
val context = LocalContext.current
Row(
modifier = GlanceModifier.fillMaxWidth().wallpaperBacking(),
verticalAlignment = Alignment.CenterVertically,
// Tap the month/year title to open the app on the month view (issue #18),
// rooted over the default home so backing out returns there.
WidgetHeader(
title = label,
titleAction = actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Month)),
modifier = GlanceModifier.padding(bottom = 2.dp),
titleInset = 4.dp,
) {
HeaderIcon(
resId = R.drawable.ic_widget_chevron_left,
WidgetHeaderAction(
icon = ImageProvider(R.drawable.ic_widget_chevron_left),
contentDescription = context.getString(R.string.widget_prev_month),
onClick = GlanceModifier.clickable(
actionRunCallback<ShiftMonthAction>(
actionParametersOf(ShiftMonthAction.deltaKey to -1),
),
),
action = actionRunCallback<ShiftMonthAction>(actionParametersOf(ShiftMonthAction.deltaKey to -1)),
style = WidgetActionStyle.TONAL,
)
// Tap the month/year title to open the app on the month view (issue #18),
// rooted over the default home so backing out returns there.
Text(
text = label,
style = TextStyle(
color = GlanceTheme.colors.primary,
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
),
modifier = GlanceModifier
.defaultWeight()
.clickable(
actionStartActivity(
MainActivity.openViewIntent(context, CalendarView.Month),
),
),
)
// The "today" button snaps the grid back to the current month in place.
HeaderIcon(
resId = R.drawable.ic_widget_today,
WidgetHeaderActionGap()
// The "today" pill carries today's number like the app icon, and snaps
// the grid back to the current month in place.
WidgetHeaderTextAction(
text = today.day.toString(),
contentDescription = context.getString(R.string.widget_today),
onClick = GlanceModifier.clickable(actionRunCallback<ResetMonthAction>()),
action = actionRunCallback<ResetMonthAction>(),
style = WidgetActionStyle.TONAL,
)
HeaderIcon(
resId = R.drawable.ic_widget_chevron_right,
WidgetHeaderActionGap()
WidgetHeaderAction(
icon = ImageProvider(R.drawable.ic_widget_chevron_right),
contentDescription = context.getString(R.string.widget_next_month),
onClick = GlanceModifier.clickable(
actionRunCallback<ShiftMonthAction>(
actionParametersOf(ShiftMonthAction.deltaKey to 1),
),
),
action = actionRunCallback<ShiftMonthAction>(actionParametersOf(ShiftMonthAction.deltaKey to 1)),
style = WidgetActionStyle.TONAL,
)
}
}
@Composable
private fun HeaderIcon(resId: Int, contentDescription: String, onClick: GlanceModifier) {
Box(
modifier = GlanceModifier.size(40.dp).then(onClick),
contentAlignment = Alignment.Center,
) {
Image(
provider = ImageProvider(resId),
contentDescription = contentDescription,
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
modifier = GlanceModifier.size(20.dp),
WidgetHeaderActionGap()
WidgetHeaderAction(
icon = ImageProvider(R.drawable.ic_widget_add),
contentDescription = context.getString(R.string.widget_new_event),
action = actionStartActivity(MainActivity.openCreateIntent(context, today)),
)
}
}
@Composable
private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) {
Row(modifier = GlanceModifier.fillMaxWidth()) {
Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 1.dp)) {
weekdayNarrowNames(weekStart).forEach { name ->
Text(
text = name,
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 11.sp,
textAlign = TextAlign.Center,
),
modifier = GlanceModifier.width(colW),
)
Box(modifier = GlanceModifier.width(colW).padding(horizontal = 1.dp)) {
Text(
text = name,
style = TextStyle(
color = GlanceTheme.colors.onPrimaryContainer,
fontSize = 11.sp,
textAlign = TextAlign.Center,
),
modifier = GlanceModifier
.fillMaxWidth()
.cornerRadius(8.dp)
.background(GlanceTheme.colors.primaryContainer)
.padding(vertical = 1.dp),
)
}
}
}
}
/** The fill of a day cell: one tone off a filled root, the widget surface over a wallpaper. */
private fun monthCellRole(background: WidgetBackground): WidgetColorRole = when (background) {
WidgetBackground.SOLID -> WidgetColorRole.SURFACE_VARIANT
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> WidgetColorRole.WIDGET_BACKGROUND
}
@Composable
private fun monthCellRole(): WidgetColorRole = monthCellRole(LocalWidgetBackground.current)
/** Narrow weekday initials starting at [weekStart], in the device locale.
* Computed outside the composable so the locale read stays observable-safe. */
private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
@@ -354,176 +356,6 @@ private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
}
}
@Composable
private fun WeekRow(
layout: WeekLayout,
currentMonth: Month,
today: LocalDate,
soften: Boolean,
colW: Dp,
modifier: GlanceModifier,
) {
val context = LocalContext.current
val week = layout.week
Box(modifier = modifier.fillMaxWidth()) {
// Day-open targets, one full-height strip per column, sitting *under* the
// content: a tap anywhere in a day still opens it, as in the app, without
// every cell in the column carrying a click target of its own. The content
// above is not clickable, event bars included, so touches fall through to
// this layer and a day opens wherever it is tapped (#386).
Row(modifier = GlanceModifier.fillMaxSize()) {
week.days.forEach { date ->
Box(
modifier = GlanceModifier
.width(colW)
.fillMaxHeight()
.clickable(openDayAction(context, date)),
) {}
}
}
Column(modifier = GlanceModifier.fillMaxWidth()) {
// Day numbers. Fixed height so every week's number row lines up whether
// or not it holds today's larger pill.
Row(
modifier = GlanceModifier.fillMaxWidth().height(DAY_NUMBER_HEIGHT),
verticalAlignment = Alignment.CenterVertically,
) {
week.days.forEach { date ->
DayNumber(
date = date,
isToday = date == today,
inMonth = date.month == currentMonth,
colW = colW,
)
}
}
Spacer(GlanceModifier.height(2.dp))
// One lane row per event row. A multi-day span is a single Box spanning
// its columns (colW * n) so it's connected with no seam and rounded ends.
// Lanes past the last one this week fills emit nothing at all.
// The grid's view budget caps the lanes per week (#372); what doesn't
// fit folds into the "+N" row. Counted the same way by WeekLayout.viewIds.
layout.lanes.forEach { cells ->
LaneRow(cells = cells, soften = soften, colW = colW)
Spacer(GlanceModifier.height(1.dp))
}
if (layout.overflow.isNotEmpty()) OverflowRow(cells = layout.overflow, colW = colW)
}
}
}
/**
* Open [date]'s day view rooted in the month view (so back returns to the month
* grid) — the same target the in-app month grid uses when a day cell is tapped.
* The only tap target in a day column, so a tap anywhere on a day opens it.
*/
private fun openDayAction(context: Context, date: LocalDate) =
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
/**
* A day's number. Every day but today is a bare centred [Text] carrying the column
* width itself — a wrapping Box costs three views, and there are 42 of these.
* Today keeps its filled circle, which does need the Box.
*/
@Composable
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
val style = TextStyle(
color = when {
isToday -> GlanceTheme.colors.onPrimary
inMonth -> GlanceTheme.colors.onSurface
else -> GlanceTheme.colors.onSurfaceVariant
},
fontSize = 11.sp,
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
textAlign = TextAlign.Center,
)
if (!isToday) {
Text(text = date.day.toString(), style = style, modifier = GlanceModifier.width(colW))
return
}
Box(modifier = GlanceModifier.width(colW), contentAlignment = Alignment.Center) {
Box(
modifier = GlanceModifier
.size(DAY_NUMBER_HEIGHT)
.cornerRadius(DAY_NUMBER_HEIGHT / 2)
.background(GlanceTheme.colors.primary),
contentAlignment = Alignment.Center,
) {
Text(text = date.day.toString(), style = style)
}
}
}
@Composable
private fun LaneRow(cells: List<GridCell<EventInstance>>, soften: Boolean, colW: Dp) {
Row(modifier = GlanceModifier.fillMaxWidth()) {
cells.forEach { cell ->
val event = cell.item
if (event == null) {
Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
} else {
SpanBar(
event = event,
soften = soften,
width = colW * cell.cols,
)
}
}
}
}
/** A single connected, rounded event bar [width] wide with its clipped title. */
@Composable
private fun SpanBar(event: EventInstance, soften: Boolean, width: Dp) {
val context = LocalContext.current
val dayFill = eventFill(event.color, dark = false, soften)
val nightFill = eventFill(event.color, dark = true, soften)
Box(
modifier = GlanceModifier
.width(width)
.height(LANE_HEIGHT)
.padding(horizontal = 1.dp),
) {
Box(
modifier = GlanceModifier
.fillMaxSize()
.cornerRadius(4.dp)
.background(themedColor(day = dayFill, night = nightFill)),
contentAlignment = Alignment.CenterStart,
) {
Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1,
style = TextStyle(
color = themedColor(day = eventInk(dayFill), night = eventInk(nightFill)),
fontSize = 9.sp,
textDecoration = glanceDeclinedDecoration(event.isDeclined),
),
modifier = GlanceModifier.padding(horizontal = 3.dp),
)
}
}
}
/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */
@Composable
private fun OverflowRow(cells: List<GridCell<Int>>, colW: Dp) {
Row(modifier = GlanceModifier.fillMaxWidth()) {
cells.forEach { cell ->
if (cell.item == null) {
Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
} else {
Text(
text = "+${cell.item}",
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
modifier = GlanceModifier.width(colW).padding(start = 3.dp),
)
}
}
}
}
@Composable
private fun PermissionMessage() {
val context = LocalContext.current
@@ -1,6 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/white"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>
@@ -1,6 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/white"
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zM19,19H5V8h14v11zM7,10h5v5H7z"/>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<corners android:radius="4dp" />
<solid android:color="@android:color/white" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<corners android:radius="8dp" />
<solid android:color="@android:color/white" />
</shape>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@android:color/white" />
</shape>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:paddingHorizontal="1dp">
<ImageView
android:id="@+id/bar_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_bar_bg" />
<TextView
android:id="@+id/bar_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingHorizontal="3dp"
android:textSize="9sp" />
</FrameLayout>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="2"
android:paddingHorizontal="1dp">
<ImageView
android:id="@+id/bar_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_bar_bg" />
<TextView
android:id="@+id/bar_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingHorizontal="3dp"
android:textSize="9sp" />
</FrameLayout>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="3"
android:paddingHorizontal="1dp">
<ImageView
android:id="@+id/bar_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_bar_bg" />
<TextView
android:id="@+id/bar_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingHorizontal="3dp"
android:textSize="9sp" />
</FrameLayout>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="4"
android:paddingHorizontal="1dp">
<ImageView
android:id="@+id/bar_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_bar_bg" />
<TextView
android:id="@+id/bar_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingHorizontal="3dp"
android:textSize="9sp" />
</FrameLayout>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="5"
android:paddingHorizontal="1dp">
<ImageView
android:id="@+id/bar_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_bar_bg" />
<TextView
android:id="@+id/bar_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingHorizontal="3dp"
android:textSize="9sp" />
</FrameLayout>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="6"
android:paddingHorizontal="1dp">
<ImageView
android:id="@+id/bar_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_bar_bg" />
<TextView
android:id="@+id/bar_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingHorizontal="3dp"
android:textSize="9sp" />
</FrameLayout>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="7"
android:paddingHorizontal="1dp">
<ImageView
android:id="@+id/bar_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_bar_bg" />
<TextView
android:id="@+id/bar_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingHorizontal="3dp"
android:textSize="9sp" />
</FrameLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="2" />
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="3" />
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="4" />
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="5" />
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="6" />
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="7" />
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/month_grid"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical" />
@@ -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="15dp"
android:orientation="horizontal"
android:paddingBottom="1dp"
android:weightSum="7" />
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/more"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingStart="3dp"
android:textSize="9sp" />
@@ -0,0 +1,360 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<FrameLayout
android:id="@+id/strip_0"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:foreground="?android:attr/selectableItemBackground"
android:padding="1dp">
<ImageView
android:id="@+id/cell_0"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg" />
<ImageView
android:id="@+id/cell_dim_0"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg"
android:visibility="gone" />
</FrameLayout>
<FrameLayout
android:id="@+id/strip_1"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:foreground="?android:attr/selectableItemBackground"
android:padding="1dp">
<ImageView
android:id="@+id/cell_1"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg" />
<ImageView
android:id="@+id/cell_dim_1"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg"
android:visibility="gone" />
</FrameLayout>
<FrameLayout
android:id="@+id/strip_2"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:foreground="?android:attr/selectableItemBackground"
android:padding="1dp">
<ImageView
android:id="@+id/cell_2"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg" />
<ImageView
android:id="@+id/cell_dim_2"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg"
android:visibility="gone" />
</FrameLayout>
<FrameLayout
android:id="@+id/strip_3"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:foreground="?android:attr/selectableItemBackground"
android:padding="1dp">
<ImageView
android:id="@+id/cell_3"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg" />
<ImageView
android:id="@+id/cell_dim_3"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg"
android:visibility="gone" />
</FrameLayout>
<FrameLayout
android:id="@+id/strip_4"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:foreground="?android:attr/selectableItemBackground"
android:padding="1dp">
<ImageView
android:id="@+id/cell_4"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg" />
<ImageView
android:id="@+id/cell_dim_4"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg"
android:visibility="gone" />
</FrameLayout>
<FrameLayout
android:id="@+id/strip_5"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:foreground="?android:attr/selectableItemBackground"
android:padding="1dp">
<ImageView
android:id="@+id/cell_5"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg" />
<ImageView
android:id="@+id/cell_dim_5"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg"
android:visibility="gone" />
</FrameLayout>
<FrameLayout
android:id="@+id/strip_6"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:foreground="?android:attr/selectableItemBackground"
android:padding="1dp">
<ImageView
android:id="@+id/cell_6"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg" />
<ImageView
android:id="@+id/cell_dim_6"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_cell_bg"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="18dp"
android:orientation="horizontal">
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<ImageView
android:id="@+id/today_bg_0"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/widget_month_today_bg"
android:visibility="gone" />
<TextView
android:id="@+id/day_0"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:includeFontPadding="false"
android:textSize="11sp" />
</FrameLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<ImageView
android:id="@+id/today_bg_1"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/widget_month_today_bg"
android:visibility="gone" />
<TextView
android:id="@+id/day_1"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:includeFontPadding="false"
android:textSize="11sp" />
</FrameLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<ImageView
android:id="@+id/today_bg_2"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/widget_month_today_bg"
android:visibility="gone" />
<TextView
android:id="@+id/day_2"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:includeFontPadding="false"
android:textSize="11sp" />
</FrameLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<ImageView
android:id="@+id/today_bg_3"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/widget_month_today_bg"
android:visibility="gone" />
<TextView
android:id="@+id/day_3"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:includeFontPadding="false"
android:textSize="11sp" />
</FrameLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<ImageView
android:id="@+id/today_bg_4"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/widget_month_today_bg"
android:visibility="gone" />
<TextView
android:id="@+id/day_4"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:includeFontPadding="false"
android:textSize="11sp" />
</FrameLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<ImageView
android:id="@+id/today_bg_5"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/widget_month_today_bg"
android:visibility="gone" />
<TextView
android:id="@+id/day_5"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:includeFontPadding="false"
android:textSize="11sp" />
</FrameLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<ImageView
android:id="@+id/today_bg_6"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:src="@drawable/widget_month_today_bg"
android:visibility="gone" />
<TextView
android:id="@+id/day_6"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:includeFontPadding="false"
android:textSize="11sp" />
</FrameLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/week_lanes"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="2dp" />
</LinearLayout>
</FrameLayout>
@@ -15,25 +15,12 @@ class AgendaScaleTest {
// SMALL is the default, so if this fails an agenda widget whose owner never
// touched the size setting no longer looks as it did (#51).
val m = metricsFor(WidgetSize.SMALL)
assertThat(m.title).isEqualTo(16.sp)
assertThat(m.dayHeader).isEqualTo(13.sp)
assertThat(m.eventTitle).isEqualTo(14.sp)
assertThat(m.eventTime).isEqualTo(12.sp)
assertThat(m.placeholder).isEqualTo(14.sp)
assertThat(m.message).isEqualTo(14.sp)
assertThat(m.stripeH).isEqualTo(36.dp)
assertThat(m.iconImage).isEqualTo(22.dp)
assertThat(m.iconBox).isEqualTo(40.dp)
assertThat(m.contentH).isEqualTo(36.dp)
assertThat(m.rowVPad).isEqualTo(4.dp)
assertThat(m.dayHeaderTopPad).isEqualTo(10.dp)
}
@Test
fun `the derived text indent matches the original hardcoded inset`() {
// TEXT_INDENT replaced a hardcoded 19dp; it must still resolve to 19dp or
// day headers and the placeholder line stop aligning with event titles.
assertThat(TEXT_INDENT).isEqualTo(19.dp)
assertThat(TEXT_INDENT).isEqualTo(ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP)
}
// --- the ramp ------------------------------------------------------------
@@ -43,17 +30,17 @@ class AgendaScaleTest {
val steps = WidgetSize.entries.map(::metricsFor)
steps.zipWithNext { small, big ->
assertThat(big.title.value).isAtLeast(small.title.value)
assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value)
assertThat(big.pillWeekday.value).isAtLeast(small.pillWeekday.value)
assertThat(big.pillDay.value).isAtLeast(small.pillDay.value)
assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value)
assertThat(big.eventTime.value).isAtLeast(small.eventTime.value)
assertThat(big.placeholder.value).isAtLeast(small.placeholder.value)
assertThat(big.message.value).isAtLeast(small.message.value)
assertThat(big.stripeH.value).isAtLeast(small.stripeH.value)
assertThat(big.iconImage.value).isAtLeast(small.iconImage.value)
assertThat(big.iconBox.value).isAtLeast(small.iconBox.value)
assertThat(big.contentH.value).isAtLeast(small.contentH.value)
assertThat(big.pillWidth.value).isAtLeast(small.pillWidth.value)
assertThat(big.rowVPad.value).isAtLeast(small.rowVPad.value)
assertThat(big.dayHeaderTopPad.value).isAtLeast(small.dayHeaderTopPad.value)
assertThat(big.dayGap.value).isAtLeast(small.dayGap.value)
assertThat(big.monthLabel.value).isAtLeast(small.monthLabel.value)
}
}
@@ -71,20 +58,28 @@ class AgendaScaleTest {
// Guards against a future edit turning "more readable" into "absurd".
val base = metricsFor(WidgetSize.SMALL)
val top = metricsFor(WidgetSize.EXTRA_LARGE)
assertThat(top.title.value / base.title.value).isLessThan(1.5f)
assertThat(top.pillDay.value / base.pillDay.value).isLessThan(1.5f)
assertThat(top.eventTitle.value / base.eventTitle.value).isLessThan(1.5f)
}
// --- font scale ----------------------------------------------------------
@Test
fun `the stripe tracks the system font scale`() {
// The stripe is Dp, the text beside it is sp: without this the two diverge
// at large accessibility font settings and the stripe under-runs the row.
fun `the row height tracks the system font scale`() {
// The row height is Dp, the text in it is sp: without this the two diverge
// at large accessibility font settings and the text outgrows the row.
val m = metricsFor(WidgetSize.SMALL)
assertThat(m.scaledForFont(1f).stripeH).isEqualTo(36.dp)
assertThat(m.scaledForFont(1.3f).stripeH.value).isWithin(0.01f).of(46.8f)
assertThat(m.scaledForFont(0.85f).stripeH.value).isWithin(0.01f).of(30.6f)
assertThat(m.scaledForFont(1f).contentH).isEqualTo(36.dp)
assertThat(m.scaledForFont(1.3f).contentH.value).isWithin(0.01f).of(46.8f)
assertThat(m.scaledForFont(0.85f).contentH.value).isWithin(0.01f).of(30.6f)
}
@Test
fun `the date pill widens with the system font scale`() {
// The pill's text is sp; a fixed-width pill clips "Wed" / "23" at large font settings.
val m = metricsFor(WidgetSize.SMALL)
assertThat(m.scaledForFont(2f).pillWidth).isEqualTo(m.pillWidth * 2)
assertThat(m.scaledForFont(2f).rowHeight).isEqualTo(m.contentH * 2 + m.rowVPad * 2)
}
@Test
@@ -99,7 +94,7 @@ class AgendaScaleTest {
// double-count it.
val m = metricsFor(WidgetSize.MEDIUM)
val scaled = m.scaledForFont(1.3f)
assertThat(scaled.title).isEqualTo(m.title)
assertThat(scaled.pillDay).isEqualTo(m.pillDay)
assertThat(scaled.eventTitle).isEqualTo(m.eventTitle)
assertThat(scaled.rowVPad).isEqualTo(m.rowVPad)
}
@@ -13,21 +13,18 @@ import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.plus
import androidx.compose.ui.unit.dp
import org.junit.jupiter.api.Test
/** The month widget's view budget (#372). */
/** How the month widget seats a week's events into lanes (#372). */
class MonthGridTest {
private val zone = TimeZone.UTC
private val jul26 = YearMonth(2026, Month.JULY)
private val today = LocalDate(2026, 7, 15)
/** Monday Jul 6 to Sunday Jul 12. */
private val monday = LocalDate(2026, 7, 6)
private val android16 = ViewIds(sdkInt = 36)
private val android10 = ViewIds(sdkInt = 29)
private fun weeks(events: List<EventInstance>): List<MonthWeek> =
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)
@@ -43,33 +40,37 @@ class MonthGridTest {
)
}
private fun List<WeekLayout>.cost(ids: ViewIds) = sumOf { it.viewIds(today, ids) }
@Test
fun `a quiet month keeps every lane it uses`() {
val layouts = layoutWeeks(busyWeeks(perDay = 1), today, android16)
assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(1)
fun `a week that fits keeps every lane`() {
val layouts = fitWeeks(busyWeeks(perDay = 2), lanes = 3)
assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(3)
assertThat(layouts.all { it.overflow.isEmpty() }).isTrue()
}
@Test
fun `a full month folds lanes until it fits, on every Android version`() {
for (ids in listOf(android16, android10)) {
val weeks = busyWeeks(perDay = 5)
assertThat(weeks.map { WeekLayout(it, MAX_LANES) }.cost(ids)).isGreaterThan(VIEW_BUDGET - ids.fixed)
val layouts = layoutWeeks(weeks, today, ids)
assertThat(layouts.cost(ids)).isAtMost(VIEW_BUDGET - ids.fixed)
assertThat(layouts.any { it.laneCap < MAX_LANES }).isTrue()
}
fun `a week that doesn't fit gives its last lane to the overflow`() {
val layouts = fitWeeks(busyWeeks(perDay = 5), lanes = 3)
assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(2)
assertThat(layouts.all { it.overflow.isNotEmpty() }).isTrue()
}
@Test
fun `older Android folds more, since a sized view costs it two ids`() {
val weeks = busyWeeks(perDay = 3)
val newer = layoutWeeks(weeks, today, android16).sumOf { it.laneCap }
val older = layoutWeeks(weeks, today, android10).sumOf { it.laneCap }
assertThat(older).isLessThan(newer)
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)
}
@Test
fun `lanes stop at the cap however tall the week`() {
assertThat(lanesFitting(1000.dp)).isEqualTo(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)
assertThat(layouts.none { it.showsOverflow }).isTrue()
assertThat(layouts.all { it.lanes.isEmpty() }).isTrue()
}
@Test
@@ -83,7 +84,7 @@ class MonthGridTest {
@Test
fun `a multi-day bar is one cell as wide as its days`() {
val bar = allDayEvent(monday.plus(1, DateTimeUnit.DAY), monday.plus(3, DateTimeUnit.DAY), id = 1)
val layout = WeekLayout(weeks(listOf(bar))[1], MAX_LANES)
val layout = WeekLayout(weeks(listOf(bar))[1], laneCap = 3)
assertThat(layout.lanes).hasSize(1)
assertThat(layout.lanes[0]).containsExactly(GridCell(null, 1), GridCell(bar, 3), GridCell(null, 3)).inOrder()
}