fix(widget): Glance child cap, tile gaps, backgrounds and done state

- Week shows as many days as fit; Lists and Week tiles keep their gaps
  (Glance caps a Row/Column at 10 children and has no margins)
- Today and Up next drop the transparent background they can't read on
- The Tasks widget's empty state reads as done rather than empty
This commit is contained in:
2026-10-02 18:07:52 +02:00
parent eced226de1
commit f0d41477b2
9 changed files with 58 additions and 29 deletions
@@ -22,9 +22,11 @@ import androidx.glance.color.ColorProvider
import androidx.glance.currentState
import androidx.glance.unit.ColorProvider as GlanceColorProvider
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
@@ -49,7 +51,6 @@ import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import de.jeanlucmakiola.floret.glance.WidgetTile
import de.jeanlucmakiola.floret.glance.R as GlanceR
import kotlinx.coroutines.flow.first
import kotlin.math.max
import kotlin.math.min
/**
@@ -92,7 +93,7 @@ private fun ListsWidgetBody(data: WidgetStatsData?, background: WidgetBackground
val tiles = ready?.snapshot?.lists?.let { lists -> listIds?.let { ids -> lists.filter { it.id in ids } } ?: lists }
val size = LocalSize.current
WidgetSurface(background = background) {
Column(modifier = GlanceModifier.fillMaxSize().padding(Padding)) {
Column(modifier = GlanceModifier.fillMaxSize().padding(Padding - Gap / 2)) {
when {
data == null -> Unit
ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable))
@@ -100,12 +101,15 @@ private fun ListsWidgetBody(data: WidgetStatsData?, background: WidgetBackground
else -> {
val grid = listGrid(tiles.size, size.width - Padding * 2, size.height - Padding * 2)
val rows = tiles.take(grid.columns * grid.rows).chunked(grid.columns)
rows.forEachIndexed { index, rowTiles ->
if (index > 0) Spacer(GlanceModifier.height(Gap))
// Gaps are each tile's padding rather than Spacers, which would
// eat into Glance's 10-children-per-Row/Column cap.
rows.forEach { rowTiles ->
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
rowTiles.forEachIndexed { i, tile ->
if (i > 0) Spacer(GlanceModifier.width(Gap))
ListTileCell(tile, grid, GlanceModifier.defaultWeight())
rowTiles.forEach { tile ->
// Glance has no margin — padding on the tile itself lands inside its fill.
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight().padding(Gap / 2)) {
ListTileCell(tile, grid, GlanceModifier.fillMaxSize())
}
}
}
}
@@ -128,9 +132,9 @@ internal fun listGrid(count: Int, width: Dp, height: Dp): ListGrid {
fun span(total: Dp, cells: Int) = (total - Gap * (cells - 1)) / cells
fun maxRows(columns: Int): Int {
val minHeight = if (span(width, columns) < MinOneLineTileWidth) MinNarrowTileHeight else MinTileHeight
return max(1, ((height + Gap) / (minHeight + Gap)).toInt())
return ((height + Gap) / (minHeight + Gap)).toInt().coerceIn(1, MaxCells)
}
val maxColumns = max(1, ((width + Gap) / (MinTileWidth + Gap)).toInt())
val maxColumns = ((width + Gap) / (MinTileWidth + Gap)).toInt().coerceIn(1, MaxCells)
val best = (1..min(count, maxColumns))
.map { columns -> columns to (count + columns - 1) / columns }
.filter { (columns, rows) -> rows <= maxRows(columns) }
@@ -226,3 +230,6 @@ private val MinNarrowTileHeight = 52.dp
private val MinTwoLineNameHeight = 70.dp
private val MinOneLineTileWidth = 120.dp
private const val TileAspect = 1.5f
/** Glance's per-Row/Column child cap. */
private const val MaxCells = 10
@@ -60,6 +60,7 @@ import de.jeanlucmakiola.floret.glance.WidgetCheckButton
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
import de.jeanlucmakiola.floret.glance.WidgetHeader
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
import de.jeanlucmakiola.floret.glance.WidgetDoneState
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.WidgetSurface
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
@@ -182,7 +183,7 @@ private fun TaskWidgetBody(
TaskWidgetData.Unavailable -> WidgetMessage(context.getString(R.string.widget_unavailable))
is TaskWidgetData.Ready ->
if (data.rows.isEmpty()) {
WidgetMessage(context.getString(R.string.widget_empty))
WidgetDoneState(context.getString(R.string.widget_empty), context.getString(R.string.widget_empty_hint))
} else {
val allSections = widgetSections(
data.rows,
@@ -128,7 +128,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
val state = getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)
current = parseWidgetFilter(state[WIDGET_FILTER_KEY])
groupByList = state[WIDGET_GROUP_BY_LIST_KEY] ?: false
background = parseWidgetBackground(state[WIDGET_BACKGROUND_KEY])
background = parseWidgetBackground(state[WIDGET_BACKGROUND_KEY], kind)
themeOverride = parseWidgetThemeOverride(state[WIDGET_THEME_KEY])
rowColor = parseWidgetRowColor(state[WIDGET_ROW_COLOR_KEY])
selectedListIds = parseWidgetListIds(state[WIDGET_LIST_IDS_KEY])
@@ -359,7 +359,7 @@ private fun WidgetFilterPicker(
if (showBackgroundPicker) {
OptionPicker(
title = stringResource(R.string.widget_config_background),
options = WidgetBackground.entries,
options = kind.backgrounds(),
selected = background,
label = { stringResource(backgroundLabel(it)) },
summary = { stringResource(backgroundHint(it)) },
@@ -68,7 +68,7 @@ class TodayWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val initial = runCatching { context.widgetStats().first() }.getOrDefault(WidgetStatsData.Unavailable)
provideContent {
val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY))
val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY), WidgetKind.TODAY)
val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
val data by remember { context.widgetStats() }.collectAsState(initial = initial)
val ready = data as? WidgetStatsData.Ready ?: initial as? WidgetStatsData.Ready
@@ -83,7 +83,7 @@ class UpNextWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val initial = runCatching { context.widgetStats().first() }.getOrDefault(WidgetStatsData.Unavailable)
provideContent {
val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY))
val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY), WidgetKind.UP_NEXT)
val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
val listId = parseWidgetListIds(currentState(WIDGET_LIST_IDS_KEY))?.singleOrNull()
val data by remember { context.widgetStats() }.collectAsState(initial = initial)
@@ -184,7 +184,7 @@ private fun UpNextTall(rows: List<WidgetTaskRow>, snapshot: WidgetStatsSnapshot,
val accent = if (row.isOverdue) GlanceTheme.colors.error else GlanceTheme.colors.primary
val heroSize = (height.value * 0.15f).coerceIn(26f, 38f)
val checkSize = (height * 0.2f).coerceIn(44.dp, 56.dp)
val laterCount = ((height - MinTallHeight - 40.dp) / LaterRowHeight).toInt().coerceIn(0, rows.size - 1)
val laterCount = ((height - MinTallHeight - 40.dp) / LaterRowHeight).toInt().coerceIn(0, minOf(rows.size - 1, MAX_LATER_ROWS))
Column(modifier = GlanceModifier.fillMaxSize().padding(12.dp)) {
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
ListDot(row.color)
@@ -228,7 +228,7 @@ private fun UpNextTall(rows: List<WidgetTaskRow>, snapshot: WidgetStatsSnapshot,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp, fontWeight = FontWeight.Bold),
)
Spacer(GlanceModifier.height(4.dp))
rows.drop(1).take(laterCount).forEach { later -> LaterRow(later, snapshot.use24Hour) }
Column { rows.drop(1).take(laterCount).forEach { later -> LaterRow(later, snapshot.use24Hour) } }
}
}
}
@@ -404,6 +404,7 @@ private fun daysUntil(context: Context, due: Instant, now: Instant): String {
}
private const val HOURS_PER_DAY = 24L
private const val MAX_LATER_ROWS = 10
private val MinTallHeight = 150.dp
private val MinRelativeInlineWidth = 200.dp
private val LaterRowHeight = 24.dp
@@ -5,7 +5,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.ColorFilter
@@ -15,6 +14,7 @@ import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
@@ -27,6 +27,7 @@ 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.height
import androidx.glance.layout.padding
@@ -63,7 +64,8 @@ class WeekWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override val sizeMode = SizeMode.Responsive(setOf(CompactSize, TallSize))
// Exact, so the day count can follow the real width.
override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
val initial = runCatching { context.widgetStats().first() }.getOrDefault(WidgetStatsData.Unavailable)
@@ -82,11 +84,6 @@ class WeekWidget : GlanceAppWidget() {
}
}
}
private companion object {
val CompactSize = DpSize(250.dp, 56.dp)
val TallSize = DpSize(250.dp, 140.dp)
}
}
@Composable
@@ -98,10 +95,16 @@ private fun WeekWidgetBody(data: WidgetStatsData?, background: WidgetBackground)
data == null -> Unit
ready == null -> WidgetMessage(context.getString(R.string.widget_unavailable), GlanceModifier.padding(8.dp))
else -> {
Row(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) {
ready.snapshot.week.forEachIndexed { index, day ->
if (index > 0) Spacer(GlanceModifier.width(4.dp))
WeekDayCell(day, GlanceModifier.defaultWeight())
// As many days as fit at a comfortable width, up to the week.
// Gaps come from each cell's padding, not Spacers: Glance caps a
// Row at 10 children.
val days = ((LocalSize.current.width - 8.dp) / MinDayWidth).toInt().coerceIn(1, ready.snapshot.week.size)
Row(modifier = GlanceModifier.fillMaxSize().padding(4.dp)) {
ready.snapshot.week.take(days).forEach { day ->
// Glance has no margin — padding on the tile itself lands inside its fill.
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight().padding(horizontal = 2.dp)) {
WeekDayCell(day, GlanceModifier.fillMaxSize())
}
}
}
}
@@ -170,3 +173,5 @@ private fun WeekDayCell(day: WeekDay, modifier: GlanceModifier) {
}
private fun isToday(date: LocalDate): Boolean = date == java.time.LocalDate.now()
private val MinDayWidth = 64.dp
@@ -56,6 +56,20 @@ internal fun parseWidgetRowColor(raw: String?): WidgetRowColor =
internal fun parseWidgetBackground(raw: String?): WidgetBackground =
raw?.let { runCatching { WidgetBackground.valueOf(it) }.getOrNull() } ?: WidgetBackground.SOLID
/**
* [WidgetBackground]s a widget offers. Today and Up next draw their hero (ring
* label, time and title) straight on the surface rather than in containers, so
* on [WidgetBackground.TRANSPARENT] it would sit unreadable on the wallpaper.
*/
internal fun WidgetKind.backgrounds(): List<WidgetBackground> = when (this) {
WidgetKind.TODAY, WidgetKind.UP_NEXT -> WidgetBackground.entries - WidgetBackground.TRANSPARENT
else -> WidgetBackground.entries
}
/** [raw] parsed, falling back to SOLID for a stored value [kind] no longer offers. */
internal fun parseWidgetBackground(raw: String?, kind: WidgetKind): WidgetBackground =
parseWidgetBackground(raw).takeIf { it in kind.backgrounds() } ?: WidgetBackground.SOLID
/** `null` means "follow the app's own theme setting" — resolved by the caller against [TaskWidgetData.Ready.themeMode]. */
internal fun parseWidgetThemeOverride(raw: String?): WidgetThemeMode? =
raw?.let { runCatching { WidgetThemeMode.valueOf(it) }.getOrNull() }
+2 -1
View File
@@ -755,7 +755,8 @@
<!-- Home-screen widget -->
<string name="widget_tasks_label">Tasks</string>
<string name="widget_tasks_description">Today\'s tasks, or any list, with a tap to tick them off</string>
<string name="widget_empty">Nothing to do here</string>
<string name="widget_empty">All caught up</string>
<string name="widget_empty_hint">Nothing left to do, enjoy it</string>
<string name="widget_unavailable">Open Agendula to finish setting up</string>
<string name="widget_complete_task">Complete %1$s</string>
<string name="widget_config_title">Show in widget</string>