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.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.month
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -19,6 +20,7 @@ import androidx.glance.action.ActionParameters
|
|||||||
import androidx.glance.action.actionParametersOf
|
import androidx.glance.action.actionParametersOf
|
||||||
import androidx.glance.action.clickable
|
import androidx.glance.action.clickable
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.LocalAppWidgetOptions
|
||||||
import androidx.glance.appwidget.SizeMode
|
import androidx.glance.appwidget.SizeMode
|
||||||
import androidx.glance.appwidget.action.ActionCallback
|
import androidx.glance.appwidget.action.ActionCallback
|
||||||
import androidx.glance.appwidget.action.actionRunCallback
|
import androidx.glance.appwidget.action.actionRunCallback
|
||||||
@@ -33,6 +35,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
|
||||||
@@ -92,16 +95,31 @@ private fun yearMonthOf(index: Int): YearMonth =
|
|||||||
* event bars and titled single-day pills (the in-app lane layout via
|
* event bars and titled single-day pills (the in-app lane layout via
|
||||||
* [layoutMonthWeeks]), and prev/next/today navigation.
|
* [layoutMonthWeeks]), and prev/next/today navigation.
|
||||||
*
|
*
|
||||||
* Columns are sized explicitly from [LocalSize] (hence [SizeMode.Exact]) so a
|
* Columns are sized explicitly from [gridWidth] so a multi-day span renders as a
|
||||||
* multi-day span renders as a single Box spanning its columns — connected, no
|
* single Box spanning its columns — connected, no inter-cell seam, with rounded
|
||||||
* inter-cell seam, with rounded end caps. The displayed month lives in Glance
|
* end caps. The displayed month lives in Glance state and is read reactively in
|
||||||
* state and is read reactively in the composition ([currentState]) so the arrows
|
* the composition ([currentState]) so the arrows move it via plain recomposition,
|
||||||
* move it via plain recomposition, not a (here-unreliable) widget session reload.
|
* 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. A 6x7 grid is close enough to that ceiling to have hit it
|
||||||
|
* (#214), so the grid emits no view it does not need.
|
||||||
*/
|
*/
|
||||||
class MonthWidget : GlanceAppWidget() {
|
class MonthWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
override val sizeMode = SizeMode.Exact
|
|
||||||
|
/**
|
||||||
|
* [SizeMode.Exact] would serialise the whole grid once per size the host
|
||||||
|
* reports — two full copies of ~700 views on any launcher that offers a
|
||||||
|
* portrait and a landscape size, which is what pushed the payload over the
|
||||||
|
* binder ceiling (#214). One composition, sized from [gridWidth], which the
|
||||||
|
* host re-runs on resize and rotation via `onAppWidgetOptionsChanged`.
|
||||||
|
*/
|
||||||
|
override val sizeMode = SizeMode.Single
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val source = context.loadMonthWidgetSource()
|
val source = context.loadMonthWidgetSource()
|
||||||
@@ -170,7 +188,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo
|
|||||||
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
|
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
|
||||||
val ym = yearMonthOf(index)
|
val ym = yearMonthOf(index)
|
||||||
// Column width from the live widget size, minus our H padding.
|
// Column width from the live widget size, minus our H padding.
|
||||||
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
val colW = (gridWidth() - GRID_HPADDING * 2) / 7
|
||||||
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
||||||
|
|
||||||
MonthHeader(label = monthLabel(ym, source.today.year))
|
MonthHeader(label = monthLabel(ym, source.today.year))
|
||||||
@@ -192,6 +210,28 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The widget's current width. Under [SizeMode.Single] `LocalSize` is the provider's
|
||||||
|
* declared minimum rather than the placed size, so read the host's own numbers —
|
||||||
|
* min width in portrait, max width in landscape, the same pair Glance itself picks
|
||||||
|
* from — and keep `LocalSize` only as a fallback for a host that reports neither.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun gridWidth(): Dp {
|
||||||
|
val options = LocalAppWidgetOptions.current
|
||||||
|
val landscape = LocalContext.current.resources.configuration.orientation ==
|
||||||
|
Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
val width = options.getInt(
|
||||||
|
if (landscape) {
|
||||||
|
AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH
|
||||||
|
} else {
|
||||||
|
AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
return if (width > 0) width.dp else LocalSize.current.width
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthHeader(label: String) {
|
private fun MonthHeader(label: String) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -296,29 +336,75 @@ 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 ->
|
week.days.forEach { date ->
|
||||||
DayNumber(
|
Box(
|
||||||
date = date,
|
modifier = GlanceModifier
|
||||||
isToday = date == today,
|
.width(colW)
|
||||||
inMonth = date.month == currentMonth,
|
.fillMaxHeight()
|
||||||
colW = colW,
|
.clickable(openDayAction(context, date)),
|
||||||
)
|
) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(GlanceModifier.height(2.dp))
|
Column(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
// One lane row per event row. A multi-day span is a single Box spanning
|
// Day numbers. Fixed height so every week's number row lines up whether
|
||||||
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
// or not it holds today's larger pill.
|
||||||
repeat(MAX_LANES) { lane ->
|
Row(
|
||||||
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
|
modifier = GlanceModifier.fillMaxWidth().height(DAY_NUMBER_HEIGHT),
|
||||||
Spacer(GlanceModifier.height(1.dp))
|
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.
|
||||||
|
repeat(week.usedLanes()) { lane ->
|
||||||
|
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
|
||||||
|
Spacer(GlanceModifier.height(1.dp))
|
||||||
|
}
|
||||||
|
if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, colW = colW)
|
||||||
}
|
}
|
||||||
OverflowRow(week = week, 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)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open [date]'s day view rooted in the month view (so back returns to the month
|
* 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.
|
* grid) — the same target the in-app month grid uses when a day cell is tapped.
|
||||||
@@ -328,64 +414,93 @@ 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(
|
color = when {
|
||||||
modifier = GlanceModifier
|
isToday -> GlanceTheme.colors.onPrimary
|
||||||
.width(colW)
|
inMonth -> GlanceTheme.colors.onSurface
|
||||||
.height(DAY_NUMBER_HEIGHT)
|
else -> GlanceTheme.colors.onSurfaceVariant
|
||||||
.clickable(openDayAction(context, date)),
|
},
|
||||||
contentAlignment = Alignment.Center,
|
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(
|
Box(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.size(DAY_NUMBER_HEIGHT)
|
.size(DAY_NUMBER_HEIGHT)
|
||||||
.then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
|
.cornerRadius(DAY_NUMBER_HEIGHT / 2)
|
||||||
|
.background(GlanceTheme.colors.primary),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(text = date.day.toString(), style = style)
|
||||||
text = date.day.toString(),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 +550,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.
|
|
||||||
Box(
|
|
||||||
modifier = GlanceModifier
|
|
||||||
.width(colW)
|
|
||||||
.height(LANE_HEIGHT)
|
|
||||||
.clickable(openDayAction(context, date)),
|
|
||||||
contentAlignment = Alignment.CenterStart,
|
|
||||||
) {
|
|
||||||
if (hidden > 0) {
|
|
||||||
Text(
|
|
||||||
text = "+$hidden",
|
|
||||||
maxLines = 1,
|
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
|
||||||
modifier = GlanceModifier.padding(start = 3.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (gap > 0) {
|
||||||
|
Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
|
||||||
|
gap = 0
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "+$count",
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
||||||
|
modifier = GlanceModifier.width(colW).padding(start = 3.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
if (gap > 0) Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user