Compare commits

...

3 Commits

Author SHA1 Message Date
Jean-Luc Makiola
2bf7d00f39 Shrink the month widget's RemoteViews so it stops killing the widget host (#214) (#215)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/215
2026-08-17 20:55:40 +02:00
7431a7390e Keep SizeMode.Exact: Single and Responsive never reflow on resize (#214)
GlanceAppWidget.resize returns early for both, so the grid was never recomposed
for a new size and a resized widget clipped instead of reflowing. Exact costs one
serialised copy per host size, which the view cuts have made affordable again.

Drops the options-derived width that went with Single; LocalSize is exact here.
2026-08-17 19:56:20 +02:00
a0a559648e Shrink the month widget's RemoteViews so it stops killing the widget host (#214)
The grid rendered ~740 views and SizeMode.Exact serialised a full copy per host
size, so one update was a 450-540 KB oneway binder push. Three of those overran
the launcher's 1 MB async buffer, and AppWidgetServiceImpl dropped the whole
host on the TransactionTooLargeException — which is why the arrows went dead
after a couple of taps and only came back when the launcher rebound.

One composition sized from the host's own options, day-open taps moved to a
single strip per column, day numbers as bare text, and no views for lanes or a
"+N" row a week doesn't fill. 742 views -> 192.
2026-08-17 19:30:49 +02:00

View File

@@ -33,6 +33,7 @@ import androidx.glance.layout.Box
import androidx.glance.layout.Column import androidx.glance.layout.Column
import androidx.glance.layout.Row import androidx.glance.layout.Row
import androidx.glance.layout.Spacer import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height import androidx.glance.layout.height
@@ -97,10 +98,27 @@ private fun yearMonthOf(index: Int): YearMonth =
* inter-cell seam, with rounded end caps. The displayed month lives in Glance * inter-cell seam, with rounded end caps. The displayed month lives in Glance
* state and is read reactively in the composition ([currentState]) so the arrows * state and is read reactively in the composition ([currentState]) so the arrows
* move it via plain recomposition, not a (here-unreliable) widget session reload. * move it via plain recomposition, not a (here-unreliable) widget session reload.
*
* Everything here is written to keep the published `RemoteViews` small. A widget
* update reaches the launcher as a *oneway* binder call, and the host's async
* buffer is 1 MB for all of it; overrun throws `TransactionTooLargeException` and
* the system drops the whole host, freezing every widget on the home screen until
* 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.
*/ */
class MonthWidget : GlanceAppWidget() { class MonthWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition override val stateDefinition = PreferencesGlanceStateDefinition
/**
* 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 affordable now that
* a rendering is ~190 views rather than ~740.
*/
override val sizeMode = SizeMode.Exact override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
@@ -296,9 +314,31 @@ private fun WeekRow(
colW: Dp, colW: Dp,
modifier: GlanceModifier, modifier: GlanceModifier,
) { ) {
Column(modifier = modifier.fillMaxWidth()) { val context = LocalContext.current
// Day numbers. val hidden = week.hiddenPerDay()
Row(modifier = GlanceModifier.fillMaxWidth()) { 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, so touches fall through to this layer; only an
// event bar opts out to open its own detail.
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 -> week.days.forEach { date ->
DayNumber( DayNumber(
date = date, date = date,
@@ -311,12 +351,36 @@ private fun WeekRow(
Spacer(GlanceModifier.height(2.dp)) Spacer(GlanceModifier.height(2.dp))
// One lane row per event row. A multi-day span is a single Box spanning // 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. // its columns (colW * n) so it's connected with no seam and rounded ends.
repeat(MAX_LANES) { lane -> // Lanes past the last one this week fills emit nothing at all.
repeat(week.usedLanes()) { lane ->
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW) LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
Spacer(GlanceModifier.height(1.dp)) Spacer(GlanceModifier.height(1.dp))
} }
OverflowRow(week = week, colW = colW) if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, colW = colW)
} }
}
}
/**
* How many lanes this week actually fills, counting from the top. The grid draws
* that many rows instead of a fixed [MAX_LANES]; an empty lane costs three views
* per day and most weeks leave the last two empty.
*/
private fun MonthWeek.usedLanes(): Int {
for (lane in MAX_LANES - 1 downTo 0) {
val filled = spans.any { it.lane == lane } ||
days.indices.any { col -> timedEventAt(this, lane, col, days[col]) != null }
if (filled) return lane + 1
}
return 0
}
/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */
private fun MonthWeek.hiddenPerDay(): List<Int> = days.mapIndexed { col, date ->
val shownSpans = spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
val timedShown = minOf(freeSlots, timedByDay[date].orEmpty().size)
((countByDay[date] ?: 0) - shownSpans - timedShown).coerceAtLeast(0)
} }
/** /**
@@ -328,25 +392,14 @@ private fun WeekRow(
private fun openDayAction(context: Context, date: LocalDate) = private fun openDayAction(context: Context, date: LocalDate) =
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)) 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 @Composable
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) { private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
val context = LocalContext.current val style = TextStyle(
Box(
modifier = GlanceModifier
.width(colW)
.height(DAY_NUMBER_HEIGHT)
.clickable(openDayAction(context, date)),
contentAlignment = Alignment.Center,
) {
Box(
modifier = GlanceModifier
.size(DAY_NUMBER_HEIGHT)
.then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
contentAlignment = Alignment.Center,
) {
Text(
text = date.day.toString(),
style = TextStyle(
color = when { color = when {
isToday -> GlanceTheme.colors.onPrimary isToday -> GlanceTheme.colors.onPrimary
inMonth -> GlanceTheme.colors.onSurface inMonth -> GlanceTheme.colors.onSurface
@@ -354,38 +407,78 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
}, },
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, 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)
} }
} }
} }
/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */
private data class LaneCell(val event: EventInstance?, val cols: Int)
/**
* A lane split into bars and the gaps between them, with consecutive blank columns
* merged into one gap: the week's day-open taps come from the strip underneath, so
* a gap needs no per-column view and a mostly-empty lane collapses to a spacer.
*/
private fun MonthWeek.laneCells(lane: Int): List<LaneCell> {
val cells = mutableListOf<LaneCell>()
var gap = 0
var col = 0
fun closeGap() {
if (gap > 0) cells += LaneCell(null, gap)
gap = 0
}
while (col < 7) {
val span = spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
val timed = if (span == null) timedEventAt(this, lane, col, days[col]) else null
when {
span != null -> {
closeGap()
cells += LaneCell(span.event, span.endCol - col + 1)
col = span.endCol + 1
}
timed != null -> {
closeGap()
cells += LaneCell(timed, 1)
col += 1
}
else -> {
gap += 1
col += 1
}
}
}
closeGap()
return cells
}
@Composable @Composable
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) { private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
val context = LocalContext.current
Row(modifier = GlanceModifier.fillMaxWidth()) { Row(modifier = GlanceModifier.fillMaxWidth()) {
var col = 0 week.laneCells(lane).forEach { cell ->
while (col < 7) { if (cell.event == null) {
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol } Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
if (span != null) {
val cols = span.endCol - col + 1
SpanBar(event = span.event, dark = dark, soften = soften, width = colW * cols)
col = span.endCol + 1
} else { } else {
val timed = timedEventAt(week, lane, col, week.days[col]) SpanBar(
if (timed != null) { event = cell.event,
SpanBar(event = timed, dark = dark, soften = soften, width = colW) dark = dark,
} else { soften = soften,
// Empty lane cell: a tap opens that day, so blank space in a width = colW * cell.cols,
// day column is a day-open target just like the number is. )
Box(
GlanceModifier
.width(colW)
.height(LANE_HEIGHT)
.clickable(openDayAction(context, week.days[col])),
) {}
}
col += 1
} }
} }
} }
@@ -435,34 +528,28 @@ private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width:
} }
} }
/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */
@Composable @Composable
private fun OverflowRow(week: MonthWeek, colW: Dp) { private fun OverflowRow(hidden: List<Int>, colW: Dp) {
val context = LocalContext.current
Row(modifier = GlanceModifier.fillMaxWidth()) { Row(modifier = GlanceModifier.fillMaxWidth()) {
week.days.forEachIndexed { col, date -> var gap = 0
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES } hidden.forEach { count ->
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0) if (count == 0) {
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size) gap += 1
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown return@forEach
// The overflow row is part of the day column too: tapping it (whether }
// it shows "+N" or is blank) opens that day, same as the app. if (gap > 0) {
Box( Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
modifier = GlanceModifier gap = 0
.width(colW) }
.height(LANE_HEIGHT)
.clickable(openDayAction(context, date)),
contentAlignment = Alignment.CenterStart,
) {
if (hidden > 0) {
Text( Text(
text = "+$hidden", text = "+$count",
maxLines = 1, maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp), style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
modifier = GlanceModifier.padding(start = 3.dp), modifier = GlanceModifier.width(colW).padding(start = 3.dp),
) )
} }
} if (gap > 0) Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
}
} }
} }