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:
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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
+1
-1
Submodule floret-kit updated: 836d8e6be0...435447eae2.
Reference in new issue
Block a user