feat: let a widget show tasks from several lists (#32)

Widget config now multi-selects real lists (smart lists stay
single-select, mutually exclusive) and offers an optional "group by
list" sort that heads each list's tasks with its name.

Closes #25

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/32
This commit is contained in:
Jean-Luc Makiola
2026-10-02 18:18:08 +02:00
co-authored by makiolaj
parent 53c518fbe2
commit 7a82f7c3fa
10 changed files with 220 additions and 34 deletions
@@ -63,6 +63,9 @@ class TasksRepositoryImpl @Inject constructor(
// parent's subtask-progress reflects ALL its children — completed ones // parent's subtask-progress reflects ALL its children — completed ones
// included — even on smart lists that hide completed tasks themselves. // included — even on smart lists that hide completed tasks themselves.
is TaskFilter.Smart -> TaskQuery(includeCompleted = true) is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
// Same over-read as Smart: TaskQuery.listId only narrows to one list, so
// several lists are read in full and membership is filtered in-memory.
is TaskFilter.OfLists -> TaskQuery(includeCompleted = true)
} }
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
val all = SeriesCollapse.visible(dataSource.tasks(query)) val all = SeriesCollapse.visible(dataSource.tasks(query))
@@ -8,6 +8,8 @@ enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED }
/** What a task screen is showing: one real list, or a smart list. */ /** What a task screen is showing: one real list, or a smart list. */
sealed interface TaskFilter { sealed interface TaskFilter {
data class OfList(val listId: Long) : TaskFilter data class OfList(val listId: Long) : TaskFilter
/** Several lists shown together, e.g. one widget covering more than one list. */
data class OfLists(val listIds: Set<Long>) : TaskFilter
data class Smart(val list: SmartList) : TaskFilter data class Smart(val list: SmartList) : TaskFilter
} }
@@ -21,6 +23,7 @@ object TaskFiltering {
fun matches(task: Task, filter: TaskFilter, todayStart: Instant, todayEnd: Instant): Boolean = fun matches(task: Task, filter: TaskFilter, todayStart: Instant, todayEnd: Instant): Boolean =
when (filter) { when (filter) {
is TaskFilter.OfList -> task.listId == filter.listId is TaskFilter.OfList -> task.listId == filter.listId
is TaskFilter.OfLists -> task.listId in filter.listIds
is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd) is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd)
} }
@@ -69,6 +69,8 @@ object Dest {
fun build(filter: TaskFilter): String = when (filter) { fun build(filter: TaskFilter): String = when (filter) {
is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}" is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}"
is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}" is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}"
// Several lists at once only exists for the widget, which never routes here.
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
} }
/** Reconstruct the filter from a destination's arguments. */ /** Reconstruct the filter from a destination's arguments. */
@@ -1148,6 +1148,8 @@ private fun CenteredMessage(text: String, inner: PaddingValues) {
@Composable @Composable
private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) { private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) {
is TaskFilter.OfList -> listName ?: stringResource(R.string.tasks_title) is TaskFilter.OfList -> listName ?: stringResource(R.string.tasks_title)
// Several lists at once only exists for the widget, which never routes here.
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
is TaskFilter.Smart -> stringResource( is TaskFilter.Smart -> stringResource(
when (filter.list) { when (filter.list) {
SmartList.TODAY -> R.string.smart_today SmartList.TODAY -> R.string.smart_today
@@ -103,6 +103,8 @@ class TaskListViewModel @Inject constructor(
} }
is TaskFilter.Smart -> is TaskFilter.Smart ->
tasks.map { TaskListUiState.Content(it) } tasks.map { TaskListUiState.Content(it) }
// Several lists at once only exists for the widget, which never routes here.
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
} }
// Drop rows pending an undoable delete so the row vanishes on swipe // Drop rows pending an undoable delete so the row vanishes on swipe
// while the actual provider delete waits for the snackbar to commit, // while the actual provider delete waits for the snackbar to commit,
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.agendula.widget package de.jeanlucmakiola.agendula.widget
import android.content.Context import android.content.Context
import android.content.Intent
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -71,14 +72,16 @@ class TaskWidget : GlanceAppWidget() {
override val sizeMode = SizeMode.Single override val sizeMode = SizeMode.Single
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val stored = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)[WIDGET_FILTER_KEY] val state = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)
val initialFilter = parseWidgetFilter(stored) val initialFilter = parseWidgetFilter(state[WIDGET_FILTER_KEY])
val initial = runCatching { context.taskWidgetData(initialFilter).first() } val initialGroupByList = state[WIDGET_GROUP_BY_LIST_KEY] ?: false
val initial = runCatching { context.taskWidgetData(initialFilter, initialGroupByList).first() }
.getOrDefault(TaskWidgetData.Unavailable) .getOrDefault(TaskWidgetData.Unavailable)
provideContent { provideContent {
val filter = parseWidgetFilter(currentState(WIDGET_FILTER_KEY)) val filter = parseWidgetFilter(currentState(WIDGET_FILTER_KEY))
val data by remember(filter) { context.taskWidgetData(filter) } val groupByList = currentState(WIDGET_GROUP_BY_LIST_KEY) ?: false
.collectAsState(initial = if (filter == initialFilter) initial else null) 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 val ready = data as? TaskWidgetData.Ready ?: initial as? TaskWidgetData.Ready
AgendulaGlanceTheme( AgendulaGlanceTheme(
dynamicColor = ready?.dynamicColor ?: true, dynamicColor = ready?.dynamicColor ?: true,
@@ -109,12 +112,33 @@ class CompleteTaskAction : ActionCallback {
} }
} }
/** A row in the widget's list: a task, or (grouped widgets) a list-name header. */
private sealed interface WidgetListItem {
data class Header(val listId: Long, val name: String) : WidgetListItem
data class TaskItem(val row: WidgetTaskRow) : WidgetListItem
}
/** Inserts a [WidgetListItem.Header] before each run of same-list rows. */
private fun groupedItems(rows: List<WidgetTaskRow>, listNames: Map<Long, String>): List<WidgetListItem> {
val result = mutableListOf<WidgetListItem>()
var lastListId: Long? = null
for (row in rows) {
if (row.listId != lastListId) {
result += WidgetListItem.Header(row.listId, listNames[row.listId].orEmpty())
lastListId = row.listId
}
result += WidgetListItem.TaskItem(row)
}
return result
}
@Composable @Composable
private fun TaskWidgetBody(filter: TaskFilter, data: TaskWidgetData?) { private fun TaskWidgetBody(filter: TaskFilter, data: TaskWidgetData?) {
val context = LocalContext.current val context = LocalContext.current
val ready = data as? TaskWidgetData.Ready
val title = when (filter) { val title = when (filter) {
is TaskFilter.Smart -> context.getString(smartListLabel(filter.list)) is TaskFilter.Smart -> context.getString(smartListLabel(filter.list))
is TaskFilter.OfList -> (data as? TaskWidgetData.Ready)?.listName is TaskFilter.OfList, is TaskFilter.OfLists -> ready?.singleListName
?: context.getString(R.string.widget_tasks_label) ?: context.getString(R.string.widget_tasks_label)
} }
Column( Column(
@@ -132,20 +156,48 @@ private fun TaskWidgetBody(filter: TaskFilter, data: TaskWidgetData?) {
if (data.rows.isEmpty()) { if (data.rows.isEmpty()) {
Message(context.getString(R.string.widget_empty)) Message(context.getString(R.string.widget_empty))
} else { } else {
val items = if (data.groupByList) {
groupedItems(data.rows, data.listNames)
} else {
data.rows.map { WidgetListItem.TaskItem(it) }
}
LazyColumn(modifier = GlanceModifier.fillMaxSize()) { LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(data.rows.size) { index -> TaskRow(data.rows[index], data.use24Hour) } items(items.size) { index ->
when (val item = items[index]) {
is WidgetListItem.Header -> ListHeader(item.name)
is WidgetListItem.TaskItem -> TaskRow(item.row, data.use24Hour)
}
}
} }
} }
} }
} }
} }
@Composable
private fun ListHeader(name: String) {
Text(
text = name,
maxLines = 1,
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
),
modifier = GlanceModifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
)
}
@Composable @Composable
private fun Header(title: String, filter: TaskFilter) { private fun Header(title: String, filter: TaskFilter) {
val context = LocalContext.current val context = LocalContext.current
val open = when (filter) { val open = when (filter) {
is TaskFilter.Smart -> MainActivity.smartListIntent(context, filter.list) is TaskFilter.Smart -> MainActivity.smartListIntent(context, filter.list)
is TaskFilter.OfList -> MainActivity.listIntent(context, filter.listId) is TaskFilter.OfList -> MainActivity.listIntent(context, filter.listId)
// Several lists have no single list screen to open; a single selection
// still opens straight to it, otherwise this just raises the app.
is TaskFilter.OfLists -> filter.listIds.singleOrNull()?.let { MainActivity.listIntent(context, it) }
?: Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
Row( Row(
modifier = GlanceModifier.fillMaxWidth().padding(start = 8.dp), modifier = GlanceModifier.fillMaxWidth().padding(start = 8.dp),
@@ -7,12 +7,17 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Circle import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -22,6 +27,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState
@@ -40,6 +46,7 @@ import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
@@ -49,8 +56,9 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/** /**
* Picks what one widget instance shows: a smart list or a real list. Runs when * Picks what one widget instance shows: a smart list, or one or more real
* the widget is placed (below API 31) and on "reconfigure" from the launcher. * lists (with an optional "group by list" sort). Runs when the widget is
* placed (below API 31) and on "reconfigure" from the launcher.
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class TaskWidgetConfigActivity : ComponentActivity() { class TaskWidgetConfigActivity : ComponentActivity() {
@@ -77,10 +85,11 @@ class TaskWidgetConfigActivity : ComponentActivity() {
val settings by settingsPrefs.settings.collectAsState(initial = Settings()) val settings by settingsPrefs.settings.collectAsState(initial = Settings())
val lists by remember { listsFlow }.collectAsState(initial = emptyList()) val lists by remember { listsFlow }.collectAsState(initial = emptyList())
var current by remember { mutableStateOf<TaskFilter?>(null) } var current by remember { mutableStateOf<TaskFilter?>(null) }
var groupByList by remember { mutableStateOf(false) }
LaunchedEffect(glanceId) { LaunchedEffect(glanceId) {
current = parseWidgetFilter( val state = getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)
getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)[WIDGET_FILTER_KEY], current = parseWidgetFilter(state[WIDGET_FILTER_KEY])
) groupByList = state[WIDGET_GROUP_BY_LIST_KEY] ?: false
} }
val dark = when (settings.themeMode) { val dark = when (settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme() ThemeMode.SYSTEM -> isSystemInDarkTheme()
@@ -93,10 +102,13 @@ class TaskWidgetConfigActivity : ComponentActivity() {
lists = lists.filter { it.isVisible }, lists = lists.filter { it.isVisible },
dark = dark, dark = dark,
selected = current ?: DEFAULT_WIDGET_FILTER, selected = current ?: DEFAULT_WIDGET_FILTER,
onSelect = { filter -> groupByList = groupByList,
onGroupByListChange = { groupByList = it },
onSave = { filter ->
lifecycleScope.launch { lifecycleScope.launch {
updateAppWidgetState(this@TaskWidgetConfigActivity, glanceId) { updateAppWidgetState(this@TaskWidgetConfigActivity, glanceId) {
it[WIDGET_FILTER_KEY] = filter.widgetStorageValue() it[WIDGET_FILTER_KEY] = filter.widgetStorageValue()
it[WIDGET_GROUP_BY_LIST_KEY] = groupByList
} }
TaskWidget().update(this@TaskWidgetConfigActivity, glanceId) TaskWidget().update(this@TaskWidgetConfigActivity, glanceId)
setResult(RESULT_OK, resultIntent(appWidgetId)) setResult(RESULT_OK, resultIntent(appWidgetId))
@@ -118,34 +130,75 @@ private fun WidgetFilterPicker(
lists: List<TaskList>, lists: List<TaskList>,
selected: TaskFilter, selected: TaskFilter,
dark: Boolean, dark: Boolean,
onSelect: (TaskFilter) -> Unit, groupByList: Boolean,
onGroupByListChange: (Boolean) -> Unit,
onSave: (TaskFilter) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
FullScreenPicker(title = stringResource(R.string.widget_config_title), onDismiss = onDismiss) { // 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
// loads) is a list pick.
var selectedSmart by remember(selected) { mutableStateOf((selected as? TaskFilter.Smart)?.list) }
var selectedLists by remember(selected) {
mutableStateOf(
when (selected) {
is TaskFilter.OfList -> setOf(selected.listId)
is TaskFilter.OfLists -> selected.listIds
is TaskFilter.Smart -> emptySet()
},
)
}
val canSave = selectedSmart != null || selectedLists.isNotEmpty()
FullScreenPicker(
title = stringResource(R.string.widget_config_title),
onDismiss = onDismiss,
actions = {
TextButton(
enabled = canSave,
onClick = {
selectedSmart?.let { onSave(TaskFilter.Smart(it)) }
?: onSave(TaskFilter.OfLists(selectedLists))
},
) { Text(stringResource(android.R.string.ok)) }
},
) {
GroupedSectionHeader(stringResource(R.string.widget_config_smart_lists)) GroupedSectionHeader(stringResource(R.string.widget_config_smart_lists))
WIDGET_SMART_LISTS.forEachIndexed { index, smart -> WIDGET_SMART_LISTS.forEachIndexed { index, smart ->
val filter = TaskFilter.Smart(smart) val isSelected = smart == selectedSmart
val isSelected = filter == selected
GroupedRow( GroupedRow(
title = stringResource(smartListLabel(smart)), title = stringResource(smartListLabel(smart)),
position = positionOf(index, WIDGET_SMART_LISTS.size), position = positionOf(index, WIDGET_SMART_LISTS.size),
selected = isSelected, selected = isSelected,
trailing = if (isSelected) ({ SelectedCheck() }) else null, trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { onSelect(filter) }, onClick = { selectedSmart = smart; selectedLists = emptySet() },
) )
} }
if (lists.isNotEmpty()) { if (lists.isNotEmpty()) {
GroupedSectionHeader(stringResource(R.string.lists_header)) GroupedSectionHeader(stringResource(R.string.lists_header))
lists.forEachIndexed { index, list -> lists.forEachIndexed { index, list ->
val filter = TaskFilter.OfList(list.id) val isSelected = list.id in selectedLists
val isSelected = filter == selected
GroupedRow( GroupedRow(
title = list.name, title = list.name,
position = positionOf(index, lists.size), position = positionOf(index, lists.size),
selected = isSelected, selected = isSelected,
leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) }, leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
trailing = if (isSelected) ({ SelectedCheck() }) else null, trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { onSelect(filter) }, onClick = {
selectedSmart = null
selectedLists = if (isSelected) selectedLists - list.id else selectedLists + list.id
},
)
}
if (selectedLists.size > 1) {
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.widget_config_group_by_list),
summary = stringResource(R.string.widget_config_group_by_list_hint),
position = Position.Alone,
trailing = { Switch(checked = groupByList, onCheckedChange = onGroupByListChange) },
onClick = { onGroupByListChange(!groupByList) },
) )
} }
} }
@@ -4,6 +4,7 @@ import android.content.Context
import android.text.format.DateFormat import android.text.format.DateFormat
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.prefs.is24Hour import de.jeanlucmakiola.agendula.data.prefs.is24Hour
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
@@ -21,6 +22,9 @@ import kotlin.time.Instant
/** Per-instance Glance state key holding the widget's [TaskFilter] ([widgetStorageValue]). */ /** Per-instance Glance state key holding the widget's [TaskFilter] ([widgetStorageValue]). */
internal val WIDGET_FILTER_KEY = stringPreferencesKey("task_filter") internal val WIDGET_FILTER_KEY = stringPreferencesKey("task_filter")
/** Per-instance Glance state key: group a multi-list widget's rows under list headers. */
internal val WIDGET_GROUP_BY_LIST_KEY = booleanPreferencesKey("group_by_list")
internal val DEFAULT_WIDGET_FILTER: TaskFilter = TaskFilter.Smart(SmartList.TODAY) internal val DEFAULT_WIDGET_FILTER: TaskFilter = TaskFilter.Smart(SmartList.TODAY)
/** The smart lists a widget can show, in picker order. */ /** The smart lists a widget can show, in picker order. */
@@ -34,6 +38,7 @@ internal const val MAX_WIDGET_ROWS = 60
internal fun TaskFilter.widgetStorageValue(): String = when (this) { internal fun TaskFilter.widgetStorageValue(): String = when (this) {
is TaskFilter.OfList -> "list:$listId" is TaskFilter.OfList -> "list:$listId"
is TaskFilter.OfLists -> "lists:${listIds.sorted().joinToString(",")}"
is TaskFilter.Smart -> "smart:${list.name}" is TaskFilter.Smart -> "smart:${list.name}"
} }
@@ -43,6 +48,9 @@ internal fun parseWidgetFilter(raw: String?): TaskFilter {
return when { return when {
value.startsWith("list:") -> value.startsWith("list:") ->
value.removePrefix("list:").toLongOrNull()?.takeIf { it > 0 }?.let { TaskFilter.OfList(it) } value.removePrefix("list:").toLongOrNull()?.takeIf { it > 0 }?.let { TaskFilter.OfList(it) }
value.startsWith("lists:") ->
value.removePrefix("lists:").split(",").mapNotNull { it.toLongOrNull()?.takeIf { id -> id > 0 } }
.toSet().takeIf { it.isNotEmpty() }?.let { TaskFilter.OfLists(it) }
value.startsWith("smart:") -> value.startsWith("smart:") ->
WIDGET_SMART_LISTS.firstOrNull { it.name == value.removePrefix("smart:") }?.let { TaskFilter.Smart(it) } WIDGET_SMART_LISTS.firstOrNull { it.name == value.removePrefix("smart:") }?.let { TaskFilter.Smart(it) }
else -> null else -> null
@@ -69,6 +77,7 @@ internal fun isOverdue(due: Instant, allDay: Boolean, now: Instant, today: Local
data class WidgetTaskRow( data class WidgetTaskRow(
val taskId: Long, val taskId: Long,
val listId: Long,
val occurrenceStart: Instant?, val occurrenceStart: Instant?,
val title: String, val title: String,
val due: Instant?, val due: Instant?,
@@ -84,35 +93,50 @@ sealed interface TaskWidgetData {
data class Ready( data class Ready(
val filter: TaskFilter, val filter: TaskFilter,
/** The real list's name; `null` for a smart list or a list that no longer exists. */ /** Names of the real lists [rows] can belong to, by list id. */
val listName: String?, val listNames: Map<Long, String>,
val rows: List<WidgetTaskRow>, val rows: List<WidgetTaskRow>,
/** Group [rows] under a header per list (only meaningful with several lists). */
val groupByList: Boolean,
val dynamicColor: Boolean, val dynamicColor: Boolean,
val themeMode: ThemeMode, val themeMode: ThemeMode,
val use24Hour: Boolean, val use24Hour: Boolean,
) : TaskWidgetData ) : TaskWidgetData {
/** The one list's name, when [filter] names exactly one real list. */
val singleListName: String? = when (filter) {
is TaskFilter.OfList -> listNames[filter.listId]
is TaskFilter.OfLists -> filter.listIds.singleOrNull()?.let(listNames::get)
is TaskFilter.Smart -> null
}
}
} }
/** /**
* Open rows for [tasks], sorted the way the app sorts them. A subtask whose * Open rows for [tasks], sorted the way the app sorts them, then grouped by list
* parent is also shown is left out, as on the list screen. * when [groupByList] is on (stable, so each list's own order survives). A
* subtask whose parent is also shown is left out, as on the list screen.
*/ */
internal fun widgetRows( internal fun widgetRows(
tasks: List<Task>, tasks: List<Task>,
comparator: Comparator<Task>, comparator: Comparator<Task>,
now: Instant, now: Instant,
zone: ZoneId, zone: ZoneId,
groupByList: Boolean,
listNames: Map<Long, String>,
): List<WidgetTaskRow> { ): List<WidgetTaskRow> {
val open = tasks.filterNot { it.isClosed } val open = tasks.filterNot { it.isClosed }
val present = open.mapTo(HashSet()) { it.taskId } val present = open.mapTo(HashSet()) { it.taskId }
val today = java.time.Instant.ofEpochMilli(now.toEpochMilliseconds()).atZone(zone).toLocalDate() val today = java.time.Instant.ofEpochMilli(now.toEpochMilliseconds()).atZone(zone).toLocalDate()
return open val sorted = open
.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present } .filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present }
.sortedWith(comparator) .sortedWith(comparator)
.let { if (groupByList) it.sortedBy { task -> listNames[task.listId].orEmpty() } else it }
return sorted
.take(MAX_WIDGET_ROWS) .take(MAX_WIDGET_ROWS)
.map { task -> .map { task ->
WidgetTaskRow( WidgetTaskRow(
taskId = task.taskId, taskId = task.taskId,
listId = task.listId,
occurrenceStart = task.occurrenceStart, occurrenceStart = task.occurrenceStart,
title = task.title, title = task.title,
due = task.due, due = task.due,
@@ -129,7 +153,7 @@ internal fun widgetRows(
* repository's flows observe the store; a store that can't be read (permission * repository's flows observe the store; a store that can't be read (permission
* revoked, provider uninstalled) reads as [TaskWidgetData.Unavailable]. * revoked, provider uninstalled) reads as [TaskWidgetData.Unavailable].
*/ */
internal fun Context.taskWidgetData(filter: TaskFilter): Flow<TaskWidgetData> { internal fun Context.taskWidgetData(filter: TaskFilter, groupByList: Boolean): Flow<TaskWidgetData> {
val ep = widgetEntryPoint() val ep = widgetEntryPoint()
val repository = ep.tasksRepository() val repository = ep.tasksRepository()
return combine( return combine(
@@ -137,10 +161,19 @@ internal fun Context.taskWidgetData(filter: TaskFilter): Flow<TaskWidgetData> {
repository.taskLists(), repository.taskLists(),
ep.settingsPrefs().settings, ep.settingsPrefs().settings,
) { tasks, lists, settings -> ) { tasks, lists, settings ->
val listNames = lists.associate { it.id to it.name }
val data: TaskWidgetData = TaskWidgetData.Ready( val data: TaskWidgetData = TaskWidgetData.Ready(
filter = filter, filter = filter,
listName = (filter as? TaskFilter.OfList)?.let { f -> lists.firstOrNull { it.id == f.listId }?.name }, listNames = listNames,
rows = widgetRows(settings.visibleIn(filter, tasks), settings.sortOrder.comparator(), Clock.System.now(), ZoneId.systemDefault()), rows = widgetRows(
settings.visibleIn(filter, tasks),
settings.sortOrder.comparator(),
Clock.System.now(),
ZoneId.systemDefault(),
groupByList,
listNames,
),
groupByList = groupByList,
dynamicColor = settings.dynamicColor, dynamicColor = settings.dynamicColor,
themeMode = settings.themeMode, themeMode = settings.themeMode,
use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)), use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)),
+2
View File
@@ -762,4 +762,6 @@
<string name="widget_complete_task">Complete %1$s</string> <string name="widget_complete_task">Complete %1$s</string>
<string name="widget_config_title">Show in widget</string> <string name="widget_config_title">Show in widget</string>
<string name="widget_config_smart_lists">Smart lists</string> <string name="widget_config_smart_lists">Smart lists</string>
<string name="widget_config_group_by_list">Group by list</string>
<string name="widget_config_group_by_list_hint">Show each list\'s tasks under its own heading</string>
</resources> </resources>
@@ -27,7 +27,9 @@ class WidgetDataTest {
@Test @Test
fun `every offered filter round-trips through storage`() { fun `every offered filter round-trips through storage`() {
val filters = WIDGET_SMART_LISTS.map { TaskFilter.Smart(it) } + TaskFilter.OfList(42) val filters = WIDGET_SMART_LISTS.map { TaskFilter.Smart(it) } +
TaskFilter.OfList(42) +
TaskFilter.OfLists(setOf(1L, 2L, 3L))
filters.forEach { assertThat(parseWidgetFilter(it.widgetStorageValue())).isEqualTo(it) } filters.forEach { assertThat(parseWidgetFilter(it.widgetStorageValue())).isEqualTo(it) }
} }
@@ -38,7 +40,10 @@ class WidgetDataTest {
@Test @Test
fun `garbage and smart lists the widget does not offer fall back to Today`() { fun `garbage and smart lists the widget does not offer fall back to Today`() {
listOf("", "list:", "list:-3", "list:abc", "smart:COMPLETED", "smart:nope", "whatever").forEach { listOf(
"", "list:", "list:-3", "list:abc", "lists:", "lists:abc", "lists:-1,-2",
"smart:COMPLETED", "smart:nope", "whatever",
).forEach {
assertThat(parseWidgetFilter(it)).isEqualTo(DEFAULT_WIDGET_FILTER) assertThat(parseWidgetFilter(it)).isEqualTo(DEFAULT_WIDGET_FILTER)
} }
} }
@@ -80,6 +85,8 @@ class WidgetDataTest {
TaskSorting.DEFAULT, TaskSorting.DEFAULT,
now, now,
newYork, newYork,
groupByList = false,
listNames = emptyMap(),
) )
assertThat(rows.map { it.title }).containsExactly("soon", "later").inOrder() assertThat(rows.map { it.title }).containsExactly("soon", "later").inOrder()
} }
@@ -89,13 +96,40 @@ class WidgetDataTest {
val parent = testTask(id = 1, title = "parent") val parent = testTask(id = 1, title = "parent")
val child = testTask(id = 2, title = "child").copy(parentId = 1) val child = testTask(id = 2, title = "child").copy(parentId = 1)
val orphan = testTask(id = 3, title = "orphan").copy(parentId = 99) val orphan = testTask(id = 3, title = "orphan").copy(parentId = 99)
val rows = widgetRows(listOf(parent, child, orphan), TaskSorting.DEFAULT, now, newYork) val rows = widgetRows(
listOf(parent, child, orphan),
TaskSorting.DEFAULT,
now,
newYork,
groupByList = false,
listNames = emptyMap(),
)
assertThat(rows.map { it.title }).containsExactly("parent", "orphan") assertThat(rows.map { it.title }).containsExactly("parent", "orphan")
} }
@Test @Test
fun `rows are capped`() { fun `rows are capped`() {
val many = (1L..(MAX_WIDGET_ROWS + 10L)).map { testTask(id = it, title = "t$it") } val many = (1L..(MAX_WIDGET_ROWS + 10L)).map { testTask(id = it, title = "t$it") }
assertThat(widgetRows(many, TaskSorting.DEFAULT, now, newYork)).hasSize(MAX_WIDGET_ROWS) assertThat(
widgetRows(many, TaskSorting.DEFAULT, now, newYork, groupByList = false, listNames = emptyMap()),
).hasSize(MAX_WIDGET_ROWS)
}
@Test
fun `grouping sorts by list name, keeping each list's own order`() {
val tasks = listOf(
testTask(id = 1, listId = 2, title = "b-later", due = at("2026-07-22T09:00:00")),
testTask(id = 2, listId = 1, title = "a-soon", due = at("2026-07-20T15:00:00")),
testTask(id = 3, listId = 1, title = "a-later", due = at("2026-07-22T09:00:00")),
)
val rows = widgetRows(
tasks,
TaskSorting.DEFAULT,
now,
newYork,
groupByList = true,
listNames = mapOf(1L to "Alpha", 2L to "Beta"),
)
assertThat(rows.map { it.title }).containsExactly("a-soon", "a-later", "b-later").inOrder()
} }
} }