From 67e009eebf813f34de94869358ca87f6b8cf22d8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 1 Oct 2026 13:31:31 +0200 Subject: [PATCH] feat: let a widget show tasks from several lists 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. TaskFilter gained OfLists for this; the full-screen task view never routes through it, since widgets have their own screens. Closes #25 --- .../data/tasks/TasksRepositoryImpl.kt | 3 + .../agendula/domain/TaskFilter.kt | 3 + .../agendula/ui/navigation/Destinations.kt | 2 + .../agendula/ui/tasklist/TaskListScreen.kt | 2 + .../agendula/ui/tasklist/TaskListViewModel.kt | 2 + .../agendula/widget/TaskWidget.kt | 66 +++++++++++++-- .../widget/TaskWidgetConfigActivity.kt | 81 +++++++++++++++---- .../agendula/widget/WidgetData.kt | 51 +++++++++--- app/src/main/res/values/strings.xml | 2 + .../agendula/widget/WidgetDataTest.kt | 42 +++++++++- 10 files changed, 220 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt index 584a112..9019e3f 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt @@ -63,6 +63,9 @@ class TasksRepositoryImpl @Inject constructor( // parent's subtask-progress reflects ALL its children — completed ones // included — even on smart lists that hide completed tasks themselves. 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 all = SeriesCollapse.visible(dataSource.tasks(query)) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskFilter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskFilter.kt index e0e8550..077c67d 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskFilter.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskFilter.kt @@ -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. */ sealed interface 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) : 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 = when (filter) { is TaskFilter.OfList -> task.listId == filter.listId + is TaskFilter.OfLists -> task.listId in filter.listIds is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt index a89ca47..87127dd 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt @@ -69,6 +69,8 @@ object Dest { fun build(filter: TaskFilter): String = when (filter) { is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}" 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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt index 118c10e..1bf373c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt @@ -1148,6 +1148,8 @@ private fun CenteredMessage(text: String, inner: PaddingValues) { @Composable private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) { 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( when (filter.list) { SmartList.TODAY -> R.string.smart_today diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt index 71f991c..954efbd 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt @@ -103,6 +103,8 @@ class TaskListViewModel @Inject constructor( } is TaskFilter.Smart -> 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 // while the actual provider delete waits for the snackbar to commit, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt index 4da5e2a..59df7b0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.agendula.widget import android.content.Context +import android.content.Intent import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -71,14 +72,16 @@ class TaskWidget : GlanceAppWidget() { override val sizeMode = SizeMode.Single override suspend fun provideGlance(context: Context, id: GlanceId) { - val stored = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)[WIDGET_FILTER_KEY] - val initialFilter = parseWidgetFilter(stored) - val initial = runCatching { context.taskWidgetData(initialFilter).first() } + val state = getAppWidgetState(context, PreferencesGlanceStateDefinition, id) + val initialFilter = parseWidgetFilter(state[WIDGET_FILTER_KEY]) + val initialGroupByList = state[WIDGET_GROUP_BY_LIST_KEY] ?: false + val initial = runCatching { context.taskWidgetData(initialFilter, initialGroupByList).first() } .getOrDefault(TaskWidgetData.Unavailable) provideContent { val filter = parseWidgetFilter(currentState(WIDGET_FILTER_KEY)) - val data by remember(filter) { context.taskWidgetData(filter) } - .collectAsState(initial = if (filter == initialFilter) initial else null) + val groupByList = currentState(WIDGET_GROUP_BY_LIST_KEY) ?: false + 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 AgendulaGlanceTheme( 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, listNames: Map): List { + val result = mutableListOf() + 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 private fun TaskWidgetBody(filter: TaskFilter, data: TaskWidgetData?) { val context = LocalContext.current + val ready = data as? TaskWidgetData.Ready val title = when (filter) { 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) } Column( @@ -132,20 +156,48 @@ private fun TaskWidgetBody(filter: TaskFilter, data: TaskWidgetData?) { if (data.rows.isEmpty()) { Message(context.getString(R.string.widget_empty)) } else { + val items = if (data.groupByList) { + groupedItems(data.rows, data.listNames) + } else { + data.rows.map { WidgetListItem.TaskItem(it) } + } 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 private fun Header(title: String, filter: TaskFilter) { val context = LocalContext.current val open = when (filter) { is TaskFilter.Smart -> MainActivity.smartListIntent(context, filter.list) 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( modifier = GlanceModifier.fillMaxWidth().padding(start = 8.dp), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt index f248321..64fc4cb 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt @@ -7,12 +7,17 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Circle import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme 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.collectAsState import androidx.compose.runtime.LaunchedEffect @@ -22,6 +27,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.state.getAppWidgetState 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.GroupedRow import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.components.positionOf @@ -49,8 +56,9 @@ import kotlinx.coroutines.launch import javax.inject.Inject /** - * Picks what one widget instance shows: a smart list or a real list. Runs when - * the widget is placed (below API 31) and on "reconfigure" from the launcher. + * Picks what one widget instance shows: a smart list, or one or more real + * lists (with an optional "group by list" sort). Runs when the widget is + * placed (below API 31) and on "reconfigure" from the launcher. */ @AndroidEntryPoint class TaskWidgetConfigActivity : ComponentActivity() { @@ -77,10 +85,11 @@ class TaskWidgetConfigActivity : ComponentActivity() { val settings by settingsPrefs.settings.collectAsState(initial = Settings()) val lists by remember { listsFlow }.collectAsState(initial = emptyList()) var current by remember { mutableStateOf(null) } + var groupByList by remember { mutableStateOf(false) } LaunchedEffect(glanceId) { - current = parseWidgetFilter( - getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)[WIDGET_FILTER_KEY], - ) + val state = getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId) + current = parseWidgetFilter(state[WIDGET_FILTER_KEY]) + groupByList = state[WIDGET_GROUP_BY_LIST_KEY] ?: false } val dark = when (settings.themeMode) { ThemeMode.SYSTEM -> isSystemInDarkTheme() @@ -93,10 +102,13 @@ class TaskWidgetConfigActivity : ComponentActivity() { lists = lists.filter { it.isVisible }, dark = dark, selected = current ?: DEFAULT_WIDGET_FILTER, - onSelect = { filter -> + groupByList = groupByList, + onGroupByListChange = { groupByList = it }, + onSave = { filter -> lifecycleScope.launch { updateAppWidgetState(this@TaskWidgetConfigActivity, glanceId) { it[WIDGET_FILTER_KEY] = filter.widgetStorageValue() + it[WIDGET_GROUP_BY_LIST_KEY] = groupByList } TaskWidget().update(this@TaskWidgetConfigActivity, glanceId) setResult(RESULT_OK, resultIntent(appWidgetId)) @@ -118,34 +130,75 @@ private fun WidgetFilterPicker( lists: List, selected: TaskFilter, dark: Boolean, - onSelect: (TaskFilter) -> Unit, + groupByList: Boolean, + onGroupByListChange: (Boolean) -> Unit, + onSave: (TaskFilter) -> 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)) WIDGET_SMART_LISTS.forEachIndexed { index, smart -> - val filter = TaskFilter.Smart(smart) - val isSelected = filter == selected + val isSelected = smart == selectedSmart GroupedRow( title = stringResource(smartListLabel(smart)), position = positionOf(index, WIDGET_SMART_LISTS.size), selected = isSelected, trailing = if (isSelected) ({ SelectedCheck() }) else null, - onClick = { onSelect(filter) }, + onClick = { selectedSmart = smart; selectedLists = emptySet() }, ) } if (lists.isNotEmpty()) { GroupedSectionHeader(stringResource(R.string.lists_header)) lists.forEachIndexed { index, list -> - val filter = TaskFilter.OfList(list.id) - val isSelected = filter == selected + val isSelected = list.id in selectedLists GroupedRow( title = list.name, position = positionOf(index, lists.size), selected = isSelected, leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) }, 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) }, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt index 5a36905..becd60b 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt @@ -4,6 +4,7 @@ import android.content.Context import android.text.format.DateFormat import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.data.prefs.is24Hour +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import de.jeanlucmakiola.agendula.domain.SmartList 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]). */ 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) /** 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) { is TaskFilter.OfList -> "list:$listId" + is TaskFilter.OfLists -> "lists:${listIds.sorted().joinToString(",")}" is TaskFilter.Smart -> "smart:${list.name}" } @@ -43,6 +48,9 @@ internal fun parseWidgetFilter(raw: String?): TaskFilter { return when { value.startsWith("list:") -> 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:") -> WIDGET_SMART_LISTS.firstOrNull { it.name == value.removePrefix("smart:") }?.let { TaskFilter.Smart(it) } else -> null @@ -69,6 +77,7 @@ internal fun isOverdue(due: Instant, allDay: Boolean, now: Instant, today: Local data class WidgetTaskRow( val taskId: Long, + val listId: Long, val occurrenceStart: Instant?, val title: String, val due: Instant?, @@ -84,35 +93,50 @@ sealed interface TaskWidgetData { data class Ready( val filter: TaskFilter, - /** The real list's name; `null` for a smart list or a list that no longer exists. */ - val listName: String?, + /** Names of the real lists [rows] can belong to, by list id. */ + val listNames: Map, val rows: List, + /** Group [rows] under a header per list (only meaningful with several lists). */ + val groupByList: Boolean, val dynamicColor: Boolean, val themeMode: ThemeMode, 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 - * parent is also shown is left out, as on the list screen. + * Open rows for [tasks], sorted the way the app sorts them, then grouped by list + * 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( tasks: List, comparator: Comparator, now: Instant, zone: ZoneId, + groupByList: Boolean, + listNames: Map, ): List { val open = tasks.filterNot { it.isClosed } val present = open.mapTo(HashSet()) { it.taskId } 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 } .sortedWith(comparator) + .let { if (groupByList) it.sortedBy { task -> listNames[task.listId].orEmpty() } else it } + return sorted .take(MAX_WIDGET_ROWS) .map { task -> WidgetTaskRow( taskId = task.taskId, + listId = task.listId, occurrenceStart = task.occurrenceStart, title = task.title, due = task.due, @@ -129,7 +153,7 @@ internal fun widgetRows( * repository's flows observe the store; a store that can't be read (permission * revoked, provider uninstalled) reads as [TaskWidgetData.Unavailable]. */ -internal fun Context.taskWidgetData(filter: TaskFilter): Flow { +internal fun Context.taskWidgetData(filter: TaskFilter, groupByList: Boolean): Flow { val ep = widgetEntryPoint() val repository = ep.tasksRepository() return combine( @@ -137,10 +161,19 @@ internal fun Context.taskWidgetData(filter: TaskFilter): Flow { repository.taskLists(), ep.settingsPrefs().settings, ) { tasks, lists, settings -> + val listNames = lists.associate { it.id to it.name } val data: TaskWidgetData = TaskWidgetData.Ready( filter = filter, - listName = (filter as? TaskFilter.OfList)?.let { f -> lists.firstOrNull { it.id == f.listId }?.name }, - rows = widgetRows(settings.visibleIn(filter, tasks), settings.sortOrder.comparator(), Clock.System.now(), ZoneId.systemDefault()), + listNames = listNames, + rows = widgetRows( + settings.visibleIn(filter, tasks), + settings.sortOrder.comparator(), + Clock.System.now(), + ZoneId.systemDefault(), + groupByList, + listNames, + ), + groupByList = groupByList, dynamicColor = settings.dynamicColor, themeMode = settings.themeMode, use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f89e585..87effbe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -760,4 +760,6 @@ Complete %1$s Show in widget Smart lists + Group by list + Show each list\'s tasks under its own heading diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt index cbc4a77..61dc4c7 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt @@ -27,7 +27,9 @@ class WidgetDataTest { @Test 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) } } @@ -38,7 +40,10 @@ class WidgetDataTest { @Test 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) } } @@ -80,6 +85,8 @@ class WidgetDataTest { TaskSorting.DEFAULT, now, newYork, + groupByList = false, + listNames = emptyMap(), ) assertThat(rows.map { it.title }).containsExactly("soon", "later").inOrder() } @@ -89,13 +96,40 @@ class WidgetDataTest { val parent = testTask(id = 1, title = "parent") val child = testTask(id = 2, title = "child").copy(parentId = 1) 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") } @Test fun `rows are capped`() { 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() } }