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:
@@ -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)),
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user