feat(widget): agenda-style rows for the task widget, plus a row-colour setting

Replaces the joined tonal cards with standalone Calendar-agenda pills
(leading check, title/due to the right), tuned after on-device review:
a leading checkbox with a bigger, filled add button; squished padding
with real margin between pills; light text on coloured pills for
contrast; a bare time instead of "Today · 14:00" once the due-bucket
header already says it; and a single remaining group drops its now
redundant header.

New per-widget "Row colour" setting (Neutral / By list) tints each
pill with its own list's colour instead of a flat tonal fill.

Bumps the floret-kit submodule for the WidgetGroupedRow gap fix, the
square checkbox glyph, and WidgetActionButton's height/icon-size knobs.
This commit is contained in:
makiolaj committed 2026-10-01 16:31:27 +02:00
1 parent 802c9af87a
commit ebbe0b04e8
6 files changed
+147 -37

No files matched your search

@@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -47,6 +48,7 @@ import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider as GlanceColorProvider
import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.TaskFilter
@@ -57,7 +59,6 @@ import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
import de.jeanlucmakiola.floret.glance.WidgetActionButton
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
import de.jeanlucmakiola.floret.glance.WidgetHeader
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.WidgetSurface
@@ -70,9 +71,10 @@ import kotlin.time.Instant
/**
* "Tasks" home-screen widget: one smart list (Today by default) or one or more
* real lists per instance, with a tonal container per section — grouped by
* list, or by due bucket (Overdue / Today / Tomorrow / Later / No date) — and a
* check to complete each task in place.
* real lists per instance, with a Calendar-agenda look — a plain section label
* per group (by list, or by due bucket: Overdue / Today / Tomorrow / Later /
* No date) above a run of standalone, fully-rounded task pills, each with a
* trailing check to complete it in place.
*
* The data is collected live inside the composition rather than loaded once in
* the `provideGlance` preamble: an `update` on a running session only
@@ -96,6 +98,7 @@ class TaskWidget : GlanceAppWidget() {
val groupByList = currentState(WIDGET_GROUP_BY_LIST_KEY) ?: false
val background = parseWidgetBackground(currentState(WIDGET_BACKGROUND_KEY))
val themeOverride = parseWidgetThemeOverride(currentState(WIDGET_THEME_KEY))
val rowColor = parseWidgetRowColor(currentState(WIDGET_ROW_COLOR_KEY))
val data by remember(filter, groupByList) { context.taskWidgetData(filter, groupByList) }
.collectAsState(initial = if (filter == initialFilter && groupByList == initialGroupByList) initial else null)
val ready = data as? TaskWidgetData.Ready ?: initial as? TaskWidgetData.Ready
@@ -105,7 +108,7 @@ class TaskWidget : GlanceAppWidget() {
dynamicColor = ready?.dynamicColor ?: true,
mode = ready?.effectiveThemeMode(themeOverride) ?: WidgetThemeMode.SYSTEM,
) {
TaskWidgetBody(filter, data, background)
TaskWidgetBody(filter, data, background, rowColor)
}
}
}
@@ -140,6 +143,7 @@ private fun TaskWidgetBody(
filter: TaskFilter,
data: TaskWidgetData?,
background: WidgetBackground,
rowColor: WidgetRowColor,
) {
val context = LocalContext.current
val ready = data as? TaskWidgetData.Ready
@@ -150,10 +154,10 @@ private fun TaskWidgetBody(
}
WidgetSurface(background = background) {
Column(
modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 6.dp),
modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 4.dp),
) {
TaskWidgetHeader(title, filter, ready?.singleListId)
Spacer(GlanceModifier.height(4.dp))
Spacer(GlanceModifier.height(2.dp))
when (data) {
null -> Unit
TaskWidgetData.Unavailable -> WidgetMessage(context.getString(R.string.widget_unavailable))
@@ -161,19 +165,47 @@ private fun TaskWidgetBody(
if (data.rows.isEmpty()) {
WidgetMessage(context.getString(R.string.widget_empty))
} else {
val sections = widgetSections(
val allSections = widgetSections(
data.rows,
data.groupByList,
data.listNames,
) { kind -> context.getString(dueBucketLabel(kind)) }
// A single group repeats what the widget's own title already
// says (e.g. the "Today" smart list showing one "Today"
// bucket) — the label only earns its place once there's more
// than one group to tell apart.
val singleGroup = allSections.count { it is WidgetSection.Header } <= 1
val sections = if (singleGroup) allSections.filterNot { it is WidgetSection.Header } else allSections
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(sections.size) { index ->
when (val section = sections[index]) {
is WidgetSection.Header -> SectionHeader(section.name)
is WidgetSection.TaskItem ->
WidgetGroupedRow(position = section.position) {
TaskRow(section.row, data.use24Hour)
is WidgetSection.TaskItem -> {
val container = if (rowColor == WidgetRowColor.BY_LIST) {
ColorProvider(
day = pastelize(section.row.color, false),
night = pastelize(section.row.color, true),
)
} else {
GlanceTheme.colors.surfaceVariant
}
// A LazyColumn item's host view isn't a linear layout on
// its own, so the row and its gap need an explicit Column
// to stack rather than overlap.
Column {
AgendaTaskRow(
row = section.row,
use24Hour = data.use24Hour,
color = container,
colored = rowColor == WidgetRowColor.BY_LIST,
// The due-bucket header above already names the day
// (Today / Tomorrow); grouped by list, there's no
// such header, so the row needs to say it itself.
suppressDayLabel = !data.groupByList,
)
Spacer(GlanceModifier.height(8.dp))
}
}
}
}
}
@@ -224,32 +256,49 @@ private fun TaskWidgetHeader(title: String, filter: TaskFilter, singleListId: Lo
icon = ImageProvider(GlanceR.drawable.floret_ic_widget_add),
contentDescription = context.getString(R.string.new_task),
action = actionStartActivity(MainActivity.newTaskIntent(context, singleListId)),
style = WidgetActionStyle.TONAL,
style = WidgetActionStyle.FILLED,
size = 36.dp,
height = 28.dp,
)
}
}
/**
* A Calendar-agenda-style task chip: its own fully-rounded pill (list colour
* or a neutral tone, per [WidgetRowColor]), a leading check to complete it,
* title and due to its right. [colored] pills use fixed light text/icon —
* [pastelize][de.jeanlucmakiola.floret.components.pastelize] keeps them
* mid-bright rather than pale, so the theme's own `onSurface` (tuned for the
* neutral tonal fill) reads too dark against them.
*/
@Composable
private fun TaskRow(row: WidgetTaskRow, use24Hour: Boolean) {
private fun AgendaTaskRow(
row: WidgetTaskRow,
use24Hour: Boolean,
color: GlanceColorProvider,
colored: Boolean,
suppressDayLabel: Boolean,
) {
val context = LocalContext.current
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
val open = actionStartActivity(
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()),
)
val onColor = if (colored) GlanceColorProvider(Color.White) else GlanceTheme.colors.onSurface
val onColorVariant = if (colored) GlanceColorProvider(Color.White.copy(alpha = 0.8f)) else GlanceTheme.colors.onSurfaceVariant
val onColorError = if (colored) GlanceColorProvider(Color.White) else GlanceTheme.colors.error
Row(
modifier = GlanceModifier.fillMaxWidth().padding(vertical = 2.dp, horizontal = 4.dp),
modifier = GlanceModifier
.fillMaxWidth()
.cornerRadius(16.dp)
.background(color)
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = GlanceModifier
.width(4.dp)
.height(28.dp)
.cornerRadius(2.dp)
.background(ColorProvider(day = pastelize(row.color, false), night = pastelize(row.color, true))),
) {}
Box(
modifier = GlanceModifier
.size(48.dp)
.size(28.dp)
.cornerRadius(14.dp)
.clickable(
actionRunCallback<CompleteTaskAction>(
actionParametersOf(
@@ -264,26 +313,23 @@ private fun TaskRow(row: WidgetTaskRow, use24Hour: Boolean) {
Image(
provider = ImageProvider(GlanceR.drawable.floret_ic_widget_check),
contentDescription = context.getString(R.string.widget_complete_task, title),
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
modifier = GlanceModifier.size(22.dp),
colorFilter = ColorFilter.tint(onColorVariant),
modifier = GlanceModifier.size(16.dp),
)
}
Column(
modifier = GlanceModifier.defaultWeight().padding(end = 8.dp, top = 4.dp, bottom = 4.dp)
.clickable(open),
) {
Spacer(GlanceModifier.width(8.dp))
Column(modifier = GlanceModifier.defaultWeight().clickable(open)) {
Text(
text = title,
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
style = TextStyle(color = onColor, fontSize = 14.sp, fontWeight = FontWeight.Medium),
)
dueText(context, row, use24Hour)?.let { due ->
dueText(context, row, use24Hour, suppressDayLabel)?.let { due ->
Text(
text = due,
maxLines = 1,
style = TextStyle(
color = if (row.isOverdue) GlanceTheme.colors.error
else GlanceTheme.colors.onSurfaceVariant,
color = if (row.isOverdue) onColorError else onColorVariant,
fontSize = 12.sp,
),
)
@@ -292,13 +338,21 @@ private fun TaskRow(row: WidgetTaskRow, use24Hour: Boolean) {
}
}
/** "Today · 14:00", "Tomorrow", or the compact date; `null` without a due date. */
private fun dueText(context: Context, row: WidgetTaskRow, use24Hour: Boolean): String? {
/**
* The chip's own due line — bare "14:00" when [suppressDayLabel] (the due-bucket
* header above already says "Today"/"Tomorrow", Calendar-agenda style: the day
* is the group, the chip only needs the time), otherwise "Today · 14:00" /
* "Tomorrow", or the compact date for anything else. `null` without a due date,
* or for an all-day task once its day is already said by the header.
*/
private fun dueText(context: Context, row: WidgetTaskRow, use24Hour: Boolean, suppressDayLabel: Boolean): String? {
val due = row.due ?: return null
val relative = when (row.dueKind) {
DueKind.TODAY -> context.getString(R.string.home_due_today)
DueKind.TOMORROW -> context.getString(R.string.home_due_tomorrow)
else -> null
} ?: return due.formatDateTimeCompact(row.isAllDay, use24Hour)
return if (row.isAllDay) relative else "$relative · ${due.formatTime(use24Hour)}"
if (row.isAllDay) return if (suppressDayLabel) null else relative
val time = due.formatTime(use24Hour)
return if (suppressDayLabel) time else "$relative · $time"
}
@@ -91,12 +91,14 @@ class TaskWidgetConfigActivity : ComponentActivity() {
var groupByList by remember { mutableStateOf(false) }
var background by remember { mutableStateOf(WidgetBackground.SOLID) }
var themeOverride by remember { mutableStateOf<WidgetThemeMode?>(null) }
var rowColor by remember { mutableStateOf(WidgetRowColor.NEUTRAL) }
LaunchedEffect(glanceId) {
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])
themeOverride = parseWidgetThemeOverride(state[WIDGET_THEME_KEY])
rowColor = parseWidgetRowColor(state[WIDGET_ROW_COLOR_KEY])
}
val dark = when (settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
@@ -115,6 +117,8 @@ class TaskWidgetConfigActivity : ComponentActivity() {
onBackgroundChange = { background = it },
themeOverride = themeOverride,
onThemeOverrideChange = { themeOverride = it },
rowColor = rowColor,
onRowColorChange = { rowColor = it },
onSave = { filter ->
lifecycleScope.launch {
updateAppWidgetState(this@TaskWidgetConfigActivity, glanceId) {
@@ -123,6 +127,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
it[WIDGET_BACKGROUND_KEY] = background.name
themeOverride?.let { mode -> it[WIDGET_THEME_KEY] = mode.name }
?: it.remove(WIDGET_THEME_KEY)
it[WIDGET_ROW_COLOR_KEY] = rowColor.name
}
TaskWidget().update(this@TaskWidgetConfigActivity, glanceId)
setResult(RESULT_OK, resultIntent(appWidgetId))
@@ -150,11 +155,14 @@ private fun WidgetFilterPicker(
onBackgroundChange: (WidgetBackground) -> Unit,
themeOverride: WidgetThemeMode?,
onThemeOverrideChange: (WidgetThemeMode?) -> Unit,
rowColor: WidgetRowColor,
onRowColorChange: (WidgetRowColor) -> Unit,
onSave: (TaskFilter) -> Unit,
onDismiss: () -> Unit,
) {
var showBackgroundPicker by remember { mutableStateOf(false) }
var showThemePicker by remember { mutableStateOf(false) }
var showRowColorPicker by remember { mutableStateOf(false) }
// Smart lists and real lists are mutually exclusive; only real lists support
// picking more than one. `selectedLists` empty + `selectedSmart` non-null is
// a smart-list pick; the reverse (or both empty, before the stored filter
@@ -232,9 +240,15 @@ private fun WidgetFilterPicker(
GroupedRow(
title = stringResource(R.string.widget_config_theme),
summary = stringResource(themeOverrideLabel(themeOverride)),
position = Position.Bottom,
position = Position.Middle,
onClick = { showThemePicker = true },
)
GroupedRow(
title = stringResource(R.string.widget_config_row_color),
summary = stringResource(rowColorLabel(rowColor)),
position = Position.Bottom,
onClick = { showRowColorPicker = true },
)
}
if (showBackgroundPicker) {
@@ -258,6 +272,17 @@ private fun WidgetFilterPicker(
onDismiss = { showThemePicker = false },
)
}
if (showRowColorPicker) {
OptionPicker(
title = stringResource(R.string.widget_config_row_color),
options = WidgetRowColor.entries,
selected = rowColor,
label = { stringResource(rowColorLabel(it)) },
summary = { stringResource(rowColorHint(it)) },
onSelect = onRowColorChange,
onDismiss = { showRowColorPicker = false },
)
}
}
internal fun smartListLabel(list: SmartList): Int = when (list) {
@@ -285,3 +310,13 @@ private fun themeOverrideLabel(mode: WidgetThemeMode?): Int = when (mode) {
WidgetThemeMode.DARK -> R.string.widget_config_theme_dark
WidgetThemeMode.SYSTEM -> R.string.widget_config_theme_follow_app
}
private fun rowColorLabel(rowColor: WidgetRowColor): Int = when (rowColor) {
WidgetRowColor.NEUTRAL -> R.string.widget_config_row_color_neutral
WidgetRowColor.BY_LIST -> R.string.widget_config_row_color_by_list
}
private fun rowColorHint(rowColor: WidgetRowColor): Int = when (rowColor) {
WidgetRowColor.NEUTRAL -> R.string.widget_config_row_color_neutral_hint
WidgetRowColor.BY_LIST -> R.string.widget_config_row_color_by_list_hint
}
@@ -35,8 +35,17 @@ internal val WIDGET_BACKGROUND_KEY = stringPreferencesKey("widget_background")
/** Per-instance Glance state key holding a [WidgetThemeMode] override; unset = follow the app's own theme setting. */
internal val WIDGET_THEME_KEY = stringPreferencesKey("widget_theme")
/** Per-instance Glance state key holding the widget's [WidgetRowColor] ([name]); unset = [WidgetRowColor.NEUTRAL]. */
internal val WIDGET_ROW_COLOR_KEY = stringPreferencesKey("widget_row_color")
internal val DEFAULT_WIDGET_FILTER: TaskFilter = TaskFilter.Smart(SmartList.TODAY)
/** How a row's tonal container is tinted: the neutral surface role, or the task's own list colour. */
internal enum class WidgetRowColor { NEUTRAL, BY_LIST }
internal fun parseWidgetRowColor(raw: String?): WidgetRowColor =
raw?.let { runCatching { WidgetRowColor.valueOf(it) }.getOrNull() } ?: WidgetRowColor.NEUTRAL
internal fun parseWidgetBackground(raw: String?): WidgetBackground =
raw?.let { runCatching { WidgetBackground.valueOf(it) }.getOrNull() } ?: WidgetBackground.SOLID
+5
View File
@@ -776,6 +776,11 @@
<string name="widget_config_theme_follow_app">Follow app</string>
<string name="widget_config_theme_light">Light</string>
<string name="widget_config_theme_dark">Dark</string>
<string name="widget_config_row_color">Row colour</string>
<string name="widget_config_row_color_neutral">Neutral</string>
<string name="widget_config_row_color_neutral_hint">Every row uses the same tonal fill</string>
<string name="widget_config_row_color_by_list">By list</string>
<string name="widget_config_row_color_by_list_hint">Each row is tinted with its list\'s own colour</string>
<string name="widget_glance_label">At a glance</string>
<string name="widget_glance_description">Your next task, or a quick way to add one</string>
<string name="widget_glance_no_tasks">Nothing due today</string>
@@ -217,6 +217,13 @@ class WidgetDataTest {
assertThat(readyWithTheme(ThemeMode.DARK).effectiveThemeMode(WidgetThemeMode.LIGHT)).isEqualTo(WidgetThemeMode.LIGHT)
}
@Test
fun `an unset row colour is Neutral, and round-trips`() {
assertThat(parseWidgetRowColor(null)).isEqualTo(WidgetRowColor.NEUTRAL)
WidgetRowColor.entries.forEach { assertThat(parseWidgetRowColor(it.name)).isEqualTo(it) }
assertThat(parseWidgetRowColor("garbage")).isEqualTo(WidgetRowColor.NEUTRAL)
}
@Test
fun `overdue count only counts overdue rows`() {
val ready = readyWithTheme(ThemeMode.SYSTEM).copy(