Author SHA1 Message Date
makiolaj 86f5c0c5c7 feat: custom task filters (#47)
Saved filters by due window, lists, tags (any/all) and minimum priority,
shown on the home screen and selectable as a Tasks widget source.
2026-10-09 19:06:45 +02:00
makiolaj aca6438a31 DOCS: Add a section for which CalDAV servers have been tested
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-09 18:24:35 +02:00
28 changed files with 1044 additions and 50 deletions
+4
View File
@@ -17,6 +17,10 @@ All notable changes to this project are documented here. The format follows
- Tags. Add them while editing a task, see them on the task and in lists,
browse them from the home screen and filter any list by them. They are saved
as `CATEGORIES`, so they sync with Nextcloud Tasks, tasks.org and DAVx⁵ (#35).
- Filters. Save your own view of the open tasks by due date (overdue, today,
upcoming, no date, in any combination), lists, tags and minimum priority.
They live on the home screen and can be shown in the Tasks widget, so today
and overdue tasks can now share one widget (#47).
### Changed
- The Tasks widget has agenda-style rows, an optional row colour and an "All
+10
View File
@@ -76,6 +76,16 @@ back to a full comparison where it does not. Everything the store does not model
is kept verbatim and sent back unchanged, so passing your tasks through Agendula
does not quietly lose fields another client wrote.
### Tested With
The following CalDAV Services have been Tested
| Service | Version | Date | Status | User |
|---|---|---|---|---|
| **Nextcloud** | 25.0.0 | Oct 9th 2026 | ✅ | [@jlmakiola](https://codeberg.org/jlmakiola) [@Schanbro](https://codeberg.org/Schanbro)
## Where your tasks live
| | Where | Sync | Needs |
@@ -149,6 +149,8 @@ class MainActivity : ComponentActivity() {
const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY"
private const val ACTION_OPEN_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART"
private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST"
private const val ACTION_OPEN_FILTER = "de.jeanlucmakiola.agendula.action.OPEN_FILTER"
private const val EXTRA_FILTER_ID = "de.jeanlucmakiola.agendula.extra.FILTER_ID"
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
@@ -198,6 +200,12 @@ class MainActivity : ComponentActivity() {
.putExtra(EXTRA_LIST_ID, listId)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
/** Opens one saved filter. */
fun filterIntent(context: Context, filterId: Long): Intent =
Intent(ACTION_OPEN_FILTER, "agendula://filter/$filterId".toUri(), context, MainActivity::class.java)
.putExtra(EXTRA_FILTER_ID, filterId)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
internal fun navRequestOf(intent: Intent?): NavRequest? {
if (intent == null) return null
if (BuildConfig.SYNC_ENABLED) {
@@ -215,6 +223,8 @@ class MainActivity : ComponentActivity() {
?.let { return NavRequest.OpenSmart(it) }
ACTION_OPEN_LIST -> intent.getLongExtra(EXTRA_LIST_ID, -1L).takeIf { it > 0L }
?.let { return NavRequest.OpenList(it) }
ACTION_OPEN_FILTER -> intent.getLongExtra(EXTRA_FILTER_ID, -1L).takeIf { it > 0L }
?.let { return NavRequest.OpenFilter(it) }
}
importIntentUri(intent)?.let { return NavRequest.Import(it) }
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
@@ -0,0 +1,83 @@
package de.jeanlucmakiola.agendula.data.prefs
import de.jeanlucmakiola.agendula.domain.DueWindow
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.TagMatch
import java.net.URLDecoder
import java.net.URLEncoder
/**
* Stored form of the saved filters: one line per filter,
* `id|name|due|lists|tags|match|priority`, with name and tags URL-encoded and
* list ids qualified by the store they belong to (`OWN:3`), since each store
* numbers its lists on its own. Fixed once released — don't change without a
* data migration.
*/
internal object SavedFilterCodec {
/** The filters in [raw], with only the list ids of [mode]'s store. */
fun decode(raw: String?, mode: String): List<SavedFilter> =
lines(raw).mapNotNull { runCatching { decodeLine(it, mode) }.getOrNull() }
/** [raw] with [filter] replacing the line of the same id, or appended. */
fun upsert(raw: String?, filter: SavedFilter, mode: String): String {
val existing = lines(raw)
val index = existing.indexOfFirst { idOf(it) == filter.id }
// Keep the lists another store contributed, so switching stores and back
// doesn't widen a filter that was edited in between, unless "any list"
// was chosen here, which drops them on purpose.
val foreign = if (filter.anyList) {
emptyList()
} else {
existing.getOrNull(index)?.let(::fields)?.getOrNull(3)
?.let(::splitList)?.filterNot { it.startsWith("$mode:") }.orEmpty()
}
val line = encodeLine(filter, mode, foreign)
val updated = if (index >= 0) existing.toMutableList().also { it[index] = line } else existing + line
return updated.joinToString("\n")
}
fun remove(raw: String?, id: Long): String = lines(raw).filterNot { idOf(it) == id }.joinToString("\n")
private fun lines(raw: String?): List<String> = raw.orEmpty().split('\n').filter { it.isNotBlank() }
private fun fields(line: String): List<String> = line.split('|')
private fun idOf(line: String): Long? = fields(line).firstOrNull()?.toLongOrNull()
private fun splitList(value: String): List<String> = value.split(',').filter { it.isNotEmpty() }
private fun encodeLine(filter: SavedFilter, mode: String, foreignLists: List<String>): String =
listOf(
filter.id.toString(),
encode(filter.name),
filter.due.sorted().joinToString(",") { it.name },
(filter.listIds.sorted().map { "$mode:$it" } + foreignLists).joinToString(","),
filter.tags.sorted().joinToString(",") { encode(it) },
filter.tagMatch.name,
filter.minPriority.name,
).joinToString("|")
private fun decodeLine(line: String, mode: String): SavedFilter? {
val f = fields(line)
if (f.size < 7) return null
val id = f[0].toLongOrNull() ?: return null
val lists = splitList(f[3])
val here = lists.filter { it.startsWith("$mode:") }.mapNotNull { it.removePrefix("$mode:").toLongOrNull() }.toSet()
return SavedFilter(
id = id,
name = decode(f[1]),
due = splitList(f[2]).mapNotNull { runCatching { DueWindow.valueOf(it) }.getOrNull() }.toSet(),
listIds = here,
listsElsewhere = here.isEmpty() && lists.isNotEmpty(),
tags = splitList(f[4]).map(::decode).toSet(),
tagMatch = runCatching { TagMatch.valueOf(f[5]) }.getOrDefault(TagMatch.ANY),
minPriority = runCatching { Priority.valueOf(f[6]) }.getOrDefault(Priority.NONE),
)
}
private fun encode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name())
private fun decode(value: String): String = URLDecoder.decode(value, Charsets.UTF_8.name())
}
@@ -10,6 +10,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFormField
@@ -20,6 +21,7 @@ import de.jeanlucmakiola.floret.reminders.applyReminderOverride
import de.jeanlucmakiola.floret.reminders.normalizeReminders
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import java.time.DayOfWeek
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@@ -259,6 +261,31 @@ class SettingsPrefs @Inject constructor(
p[SMART_LIST_HIDDEN] = if (hidden) current + entry else current - entry
}
/** The saved filters, oldest first, with the list ids of the current store. */
val savedFilters: Flow<List<SavedFilter>> =
dataStore.data.map { p -> SavedFilterCodec.decode(p[SAVED_FILTERS], modeOf(p).name) }.distinctUntilChanged()
/** One saved filter; `null` while it doesn't exist. */
fun savedFilter(id: Long): Flow<SavedFilter?> =
savedFilters.map { all -> all.firstOrNull { it.id == id } }.distinctUntilChanged()
/** Store [filter]; an id of 0 makes a new one. Returns the filter's id. */
suspend fun saveFilter(filter: SavedFilter): Long {
var id = filter.id
dataStore.edit { p ->
if (id <= 0) {
id = p[SAVED_FILTER_NEXT_ID] ?: 1L
p[SAVED_FILTER_NEXT_ID] = id + 1
}
p[SAVED_FILTERS] = SavedFilterCodec.upsert(p[SAVED_FILTERS], filter.copy(id = id), modeOf(p).name)
}
return id
}
suspend fun deleteFilter(id: Long) = dataStore.edit { p ->
p[SAVED_FILTERS] = SavedFilterCodec.remove(p[SAVED_FILTERS], id)
}
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
}
@@ -288,6 +315,8 @@ class SettingsPrefs @Inject constructor(
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
val SAVED_FILTERS = stringPreferencesKey("saved_filters")
val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id")
}
}
@@ -1,7 +1,9 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.SeriesCollapse
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail
@@ -17,7 +19,10 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -32,12 +37,21 @@ class TasksRepositoryImpl @Inject constructor(
private val dataSource: TasksDataSource,
private val providerResolver: ProviderResolver,
private val startupGate: StartupGate,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : TasksRepository {
override fun taskLists(): Flow<List<TaskList>> = observing { dataSource.taskLists() }
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
@OptIn(ExperimentalCoroutinesApi::class)
override fun tasks(filter: TaskFilter): Flow<List<Task>> = when (filter) {
// A deleted filter shows nothing rather than failing the screen or widget on it.
is TaskFilter.Saved -> settingsPrefs.savedFilter(filter.id)
.flatMapLatest { saved ->
if (saved == null) flowOf(emptyList()) else observing { loadTasks(filter, saved) }
}
else -> observing { loadTasks(filter) }
}
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
SeriesCollapse.visible(dataSource.subtasks(parentId))
@@ -55,7 +69,7 @@ class TasksRepositoryImpl @Inject constructor(
}
}
private fun loadTasks(filter: TaskFilter): List<Task> {
private fun loadTasks(filter: TaskFilter, saved: SavedFilter? = null): List<Task> {
val query = when (filter) {
is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true)
// Read completed tasks too, then let [TaskFiltering.matches] enforce each
@@ -67,6 +81,7 @@ class TasksRepositoryImpl @Inject constructor(
// several lists are read in full and membership is filtered in-memory.
is TaskFilter.OfLists -> TaskQuery(includeCompleted = true)
is TaskFilter.Tagged -> TaskQuery(includeCompleted = true)
is TaskFilter.Saved -> TaskQuery(includeCompleted = true)
}
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
val all = SeriesCollapse.visible(dataSource.tasks(query))
@@ -78,7 +93,9 @@ class TasksRepositoryImpl @Inject constructor(
(if (t.isCompleted) done + 1 else done) to (total + 1)
}
return all
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) }
.filter {
saved?.matches(it, todayStart, todayEnd) ?: TaskFiltering.matches(it, filter, todayStart, todayEnd)
}
.map { task ->
progress[task.taskId]?.let { (done, total) ->
task.copy(subtaskDone = done, subtaskTotal = total)
@@ -0,0 +1,51 @@
package de.jeanlucmakiola.agendula.domain
import kotlin.time.Instant
/** Where an open task's due date falls, relative to today. */
enum class DueWindow { OVERDUE, TODAY, UPCOMING, NO_DATE }
/** How a [SavedFilter] with several tags reads them. */
enum class TagMatch { ANY, ALL }
/**
* A user-made view over the open tasks. An empty criterion doesn't narrow:
* no [due] windows means any due date, no [listIds] any list, no [tags] any tag.
* [minPriority] [Priority.NONE] lets every priority through.
*
* [listsElsewhere] is set when the filter's lists all belong to another task
* store: it then matches nothing here rather than every list.
*/
data class SavedFilter(
val id: Long,
val name: String,
val due: Set<DueWindow> = emptySet(),
val listIds: Set<Long> = emptySet(),
val tags: Set<String> = emptySet(),
val tagMatch: TagMatch = TagMatch.ANY,
val minPriority: Priority = Priority.NONE,
val listsElsewhere: Boolean = false,
) {
/** Whether the filter narrows by list at all. */
val anyList: Boolean get() = listIds.isEmpty() && !listsElsewhere
fun matches(task: Task, todayStart: Instant, todayEnd: Instant): Boolean {
if (task.isClosed) return false
if (listsElsewhere && listIds.isEmpty()) return false
if (listIds.isNotEmpty() && task.listId !in listIds) return false
if (task.priority < minPriority) return false
if (due.isNotEmpty() && dueWindowOf(task.due, todayStart, todayEnd) !in due) return false
return when {
tags.isEmpty() -> true
tagMatch == TagMatch.ALL -> task.tags.containsAll(tags)
else -> task.tags.any { it in tags }
}
}
}
fun dueWindowOf(due: Instant?, todayStart: Instant, todayEnd: Instant): DueWindow = when {
due == null -> DueWindow.NO_DATE
due < todayStart -> DueWindow.OVERDUE
due < todayEnd -> DueWindow.TODAY
else -> DueWindow.UPCOMING
}
@@ -13,6 +13,8 @@ sealed interface TaskFilter {
data class Smart(val list: SmartList) : TaskFilter
/** Every task carrying [tag], open and completed, across all lists. */
data class Tagged(val tag: String) : TaskFilter
/** A [SavedFilter] by id; the repository resolves it to its definition. */
data class Saved(val id: Long) : TaskFilter
}
/**
@@ -28,6 +30,7 @@ object TaskFiltering {
is TaskFilter.OfLists -> task.listId in filter.listIds
is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd)
is TaskFilter.Tagged -> filter.tag in task.tags
is TaskFilter.Saved -> error("Match a saved filter through SavedFilter.matches")
}
/** Whether [task] carries every one of [tags]; an empty set matches everything. */
@@ -0,0 +1,51 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
/** A lone grouped card holding a name, for the list and filter editors. */
@Composable
internal fun NameField(
name: String,
placeholder: String,
autoFocus: Boolean,
onNameChange: (String) -> Unit,
onImeAction: () -> Unit,
leading: (@Composable () -> Unit)? = null,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(autoFocus) { if (autoFocus) focusRequester.requestFocus() }
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = GroupedListInset)) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
leading?.invoke()
InlineTextField(
value = name,
onValueChange = onNameChange,
placeholder = placeholder,
imeAction = ImeAction.Done,
onImeAction = onImeAction,
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
)
}
}
}
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskFormError
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.populatedFields
import de.jeanlucmakiola.agendula.domain.htmlToPlainText
@@ -28,6 +29,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.time.LocalDate
import javax.inject.Inject
import kotlin.time.Clock
import kotlin.time.Instant
@@ -127,6 +129,7 @@ class TaskEditViewModel @Inject constructor(
parentId: Long? = null,
initialTitle: String? = null,
presetTag: String? = null,
presetDueToday: Boolean = false,
) {
if (bound) return
bound = true
@@ -151,6 +154,9 @@ class TaskEditViewModel @Inject constructor(
autofocusTitle = settings.autofocusTitle,
listId = defaultList,
start = if (settings.defaultStartToday) Clock.System.now() else null,
// The same all-day value picking today in the date field gives.
due = if (presetDueToday) allDayInstantOf(LocalDate.now()) else null,
isAllDay = presetDueToday,
parentId = parentId,
lists = lists,
parentCandidates = loadParents(defaultList, selfId = null),
@@ -0,0 +1,259 @@
package de.jeanlucmakiola.agendula.ui.filters
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.DueWindow
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.TagMatch
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
import de.jeanlucmakiola.agendula.ui.common.NameField
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
/**
* Creates or edits a [SavedFilter]: a name, then due windows, priority, lists and
* tags. Each multi-choice section leads with an "any" row that is selected while
* nothing in it is, so an untouched section visibly doesn't narrow.
*/
@Composable
fun FilterEditScreen(
viewModel: FilterEditViewModel,
isNew: Boolean,
onSaved: (Long) -> Unit,
onDeleted: () -> Unit,
onBack: () -> Unit,
) {
val draft by viewModel.draft.collectAsStateWithLifecycle()
val lists by viewModel.lists.collectAsStateWithLifecycle()
val tagsInUse by viewModel.tags.collectAsStateWithLifecycle()
val filter = draft
var choosingPriority by rememberSaveable { mutableStateOf(false) }
var confirmDelete by rememberSaveable { mutableStateOf(false) }
val save = { viewModel.save(onSaved) }
CollapsingScaffold(
title = stringResource(if (isNew) R.string.filter_new_title else R.string.filter_edit_title),
onBack = onBack,
largeTopBar = false,
actions = {
Button(
onClick = save,
enabled = filter?.name?.isNotBlank() == true,
modifier = Modifier.padding(end = 12.dp),
) { Text(stringResource(R.string.save)) }
},
) {
if (filter == null) return@CollapsingScaffold
NameField(
name = filter.name,
placeholder = stringResource(R.string.filter_name_hint),
autoFocus = isNew,
onNameChange = { name -> viewModel.update { it.copy(name = name) } },
onImeAction = save,
)
GroupedSectionHeader(stringResource(R.string.filter_section_due))
val windows = DueWindow.entries
ChoiceRow(
title = stringResource(R.string.filter_any_due),
position = positionOf(0, windows.size + 1),
selected = filter.due.isEmpty(),
onClick = { viewModel.update { it.copy(due = emptySet()) } },
)
windows.forEachIndexed { index, window ->
ChoiceRow(
title = dueWindowLabel(window),
position = positionOf(index + 1, windows.size + 1),
selected = window in filter.due,
onClick = { viewModel.update { it.copy(due = it.due.toggle(window)) } },
)
}
GroupedSectionHeader(stringResource(R.string.filter_section_priority))
GroupedRow(
title = minPriorityLabel(filter.minPriority),
position = Position.Alone,
trailing = { Icon(Icons.Rounded.ChevronRight, contentDescription = null) },
onClick = { choosingPriority = true },
)
if (lists.isNotEmpty()) {
GroupedSectionHeader(stringResource(R.string.filter_section_lists))
val showAccount = lists.mapTo(HashSet()) { it.accountName }.size > 1
ChoiceRow(
title = stringResource(R.string.filter_any_list),
position = positionOf(0, lists.size + 1),
selected = filter.anyList,
onClick = { viewModel.update { it.copy(listIds = emptySet(), listsElsewhere = false) } },
)
lists.forEachIndexed { index, list ->
ChoiceRow(
title = list.name,
summary = list.accountName.takeIf { showAccount },
position = positionOf(index + 1, lists.size + 1),
selected = list.id in filter.listIds,
leading = { ListColorChip(list.color) },
onClick = { viewModel.update { it.copy(listIds = it.listIds.toggle(list.id)) } },
)
}
}
// A tag no open task carries any more stays listed while the filter uses
// it, so it can still be unticked.
val tags = remember(tagsInUse, filter.tags) {
(tagsInUse + filter.tags).distinct().sortedWith(String.CASE_INSENSITIVE_ORDER)
}
if (tags.isNotEmpty()) {
GroupedSectionHeader(stringResource(R.string.filter_section_tags))
ChoiceRow(
title = stringResource(R.string.filter_any_tag),
position = positionOf(0, tags.size + 1),
selected = filter.tags.isEmpty(),
onClick = { viewModel.update { it.copy(tags = emptySet()) } },
)
tags.forEachIndexed { index, tag ->
ChoiceRow(
title = tag,
position = positionOf(index + 1, tags.size + 1),
selected = tag in filter.tags,
onClick = { viewModel.update { it.copy(tags = it.tags.toggle(tag)) } },
)
}
if (filter.tags.size > 1) {
Spacer(Modifier.height(8.dp))
GroupedRow(
title = stringResource(R.string.filter_match_all_tags),
summary = stringResource(
if (filter.tagMatch == TagMatch.ALL) R.string.filter_match_all_on else R.string.filter_match_all_off,
),
position = Position.Alone,
trailing = {
Switch(
checked = filter.tagMatch == TagMatch.ALL,
onCheckedChange = { all ->
viewModel.update { it.copy(tagMatch = if (all) TagMatch.ALL else TagMatch.ANY) }
},
)
},
onClick = {
viewModel.update { it.copy(tagMatch = if (it.tagMatch == TagMatch.ALL) TagMatch.ANY else TagMatch.ALL) }
},
)
}
}
if (filter.id > 0) {
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.filter_delete),
position = Position.Alone,
contentColor = MaterialTheme.colorScheme.error,
leading = { Icon(Icons.Rounded.Delete, contentDescription = null) },
onClick = { confirmDelete = true },
)
}
Spacer(Modifier.height(24.dp))
}
if (choosingPriority && filter != null) {
OptionPicker(
title = stringResource(R.string.filter_section_priority),
options = Priority.entries,
selected = filter.minPriority,
label = { minPriorityLabel(it) },
onSelect = { priority -> viewModel.update { it.copy(minPriority = priority) } },
onDismiss = { choosingPriority = false },
)
}
if (confirmDelete && filter != null) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text(stringResource(R.string.filter_delete_title, filter.name)) },
text = { Text(stringResource(R.string.filter_delete_body)) },
confirmButton = {
TextButton(onClick = { confirmDelete = false; viewModel.delete(onDeleted) }) {
Text(stringResource(R.string.filter_delete_confirm))
}
},
dismissButton = {
TextButton(onClick = { confirmDelete = false }) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
}
@Composable
private fun ChoiceRow(
title: String,
position: Position,
selected: Boolean,
onClick: () -> Unit,
summary: String? = null,
leading: @Composable (() -> Unit)? = null,
) {
GroupedRow(
title = title,
summary = summary,
position = position,
selected = selected,
leading = leading,
trailing = if (selected) {
{ SelectedCheck() }
} else {
null
},
onClick = onClick,
)
}
private fun <T> Set<T>.toggle(item: T): Set<T> = if (item in this) this - item else this + item
@Composable
private fun dueWindowLabel(window: DueWindow): String = stringResource(
when (window) {
DueWindow.OVERDUE -> R.string.smart_overdue
DueWindow.TODAY -> R.string.smart_today
DueWindow.UPCOMING -> R.string.smart_upcoming
DueWindow.NO_DATE -> R.string.smart_no_date
},
)
@Composable
internal fun minPriorityLabel(priority: Priority): String = stringResource(
when (priority) {
Priority.NONE -> R.string.filter_priority_any
Priority.LOW -> R.string.filter_priority_low
Priority.MEDIUM -> R.string.filter_priority_medium
Priority.HIGH -> R.string.filter_priority_high
},
)
@@ -0,0 +1,87 @@
package de.jeanlucmakiola.agendula.ui.filters
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.tagsInUse
import de.jeanlucmakiola.agendula.widget.TaskWidgetUpdater
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/** Holds the filter being created or edited until it's saved. Call [bind] first. */
@HiltViewModel
class FilterEditViewModel @Inject constructor(
private val settingsPrefs: SettingsPrefs,
private val widgetUpdater: TaskWidgetUpdater,
repository: TasksRepository,
) : ViewModel() {
private val _draft = MutableStateFlow<SavedFilter?>(null)
/** `null` until [bind] has loaded the filter. An id of 0 is a new one. */
val draft: StateFlow<SavedFilter?> = _draft.asStateFlow()
val lists: StateFlow<List<TaskList>> = repository.taskLists()
.catch { emit(emptyList()) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
/** Every tag on an open task. */
val tags: StateFlow<List<String>> = repository.tasks(TaskFilter.Smart(SmartList.ALL))
.map { tagsInUse(it) }
.catch { emit(emptyList()) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private var bound = false
private var saving = false
fun bind(filterId: Long?) {
if (bound) return
bound = true
if (filterId == null) {
_draft.value = SavedFilter(id = 0, name = "")
return
}
viewModelScope.launch {
_draft.value = settingsPrefs.savedFilter(filterId).first() ?: SavedFilter(id = 0, name = "")
}
}
fun update(transform: (SavedFilter) -> SavedFilter) {
_draft.value = _draft.value?.let(transform)
}
fun save(onSaved: (Long) -> Unit) {
val filter = _draft.value ?: return
if (filter.name.isBlank() || saving) return
saving = true
viewModelScope.launch {
val id = settingsPrefs.saveFilter(filter.copy(name = filter.name.trim()))
// A widget showing this filter doesn't watch the prefs.
widgetUpdater.requestRefresh()
onSaved(id)
}
}
fun delete(onDeleted: () -> Unit) {
val id = _draft.value?.id?.takeIf { it > 0 } ?: return
viewModelScope.launch {
settingsPrefs.deleteFilter(id)
widgetUpdater.requestRefresh()
onDeleted()
}
}
}
@@ -55,6 +55,7 @@ import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.ui.common.DefaultListColor
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
import de.jeanlucmakiola.agendula.ui.common.ListPalette
import de.jeanlucmakiola.agendula.ui.common.NameField
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSurface
@@ -258,27 +259,14 @@ internal fun ListNameField(
autoFocus: Boolean,
onNameChange: (String) -> Unit,
onImeAction: () -> Unit,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(autoFocus) { if (autoFocus) focusRequester.requestFocus() }
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
ListColorChip(color)
InlineTextField(
value = name,
onValueChange = onNameChange,
placeholder = stringResource(R.string.list_name_hint),
imeAction = ImeAction.Done,
onImeAction = onImeAction,
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
)
}
}
}
) = NameField(
name = name,
placeholder = stringResource(R.string.list_name_hint),
autoFocus = autoFocus,
onNameChange = onNameChange,
onImeAction = onImeAction,
leading = { ListColorChip(color) },
)
/**
* The palette as two rows of round swatches; the chosen one carries a check.
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.ui.lists
import de.jeanlucmakiola.floret.components.ReorderableColumn
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.DragHandle
import androidx.compose.material.icons.rounded.FilterList
import androidx.compose.material.icons.rounded.Sell
import androidx.compose.material.icons.rounded.SwapVert
import androidx.activity.compose.BackHandler
@@ -31,6 +32,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
@@ -87,6 +89,7 @@ import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.ui.common.ActionShapes
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SnackChip
import de.jeanlucmakiola.floret.components.SnackChipHeight
import de.jeanlucmakiola.floret.components.SnackChipMargin
@@ -115,6 +118,7 @@ fun ListsScreen(
onNewTask: () -> Unit,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier,
onNewFilter: () -> Unit = {},
viewModel: ListsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -180,6 +184,7 @@ fun ListsScreen(
onOpenFilter = onOpenFilter,
onOpenTask = onOpenTask,
onNewList = { newList = true },
onNewFilter = onNewFilter,
onReorder = viewModel::reorderLists,
topPadding = 0.dp,
bottomPadding = inner.calculateBottomPadding() + 96.dp,
@@ -253,6 +258,7 @@ private fun ListsContent(
onOpenFilter: (TaskFilter) -> Unit,
onOpenTask: (Task) -> Unit,
onNewList: () -> Unit,
onNewFilter: () -> Unit,
onReorder: (List<Long>) -> Unit,
topPadding: androidx.compose.ui.unit.Dp,
bottomPadding: androidx.compose.ui.unit.Dp,
@@ -396,6 +402,65 @@ private fun ListsContent(
)
}
}
if (state.groups.isNotEmpty()) {
filtersSection(state.filters, onOpenFilter, onNewFilter)
}
}
}
/** The saved filters, or a single row offering the first one. */
private fun LazyListScope.filtersSection(
filters: List<FilterOverview>,
onOpenFilter: (TaskFilter) -> Unit,
onNewFilter: () -> Unit,
) {
item(key = "filters-header") {
SectionHeader(
text = stringResource(R.string.filters_header),
action = if (filters.isEmpty()) {
null
} else {
{
IconButton(onClick = onNewFilter) {
Icon(Icons.Rounded.Add, contentDescription = stringResource(R.string.filter_new_title))
}
}
},
)
}
if (filters.isEmpty()) {
item(key = "filters-empty") {
GroupedRow(
title = stringResource(R.string.filter_new_title),
summary = stringResource(R.string.filters_empty_hint),
position = Position.Alone,
leading = { Icon(Icons.Rounded.Add, contentDescription = null) },
onClick = onNewFilter,
)
}
return
}
itemsIndexed(filters, key = { _, f -> "filter-${f.filter.id}" }) { index, overview ->
GroupedRow(
title = overview.filter.name,
position = positionOf(index, filters.size),
leading = {
Icon(
Icons.Rounded.FilterList,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = {
Text(
overview.openCount.toString(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = { onOpenFilter(TaskFilter.Saved(overview.filter.id)) },
)
}
}
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
@@ -75,6 +76,7 @@ data class ListOverview(val list: TaskList, val openCount: Int)
data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
data class SmartCount(val smart: SmartList, val count: Int)
data class TagOverview(val tag: String, val openCount: Int)
data class FilterOverview(val filter: SavedFilter, val openCount: Int)
sealed interface ListsUiState {
data object Loading : ListsUiState
@@ -91,6 +93,8 @@ sealed interface ListsUiState {
val allTasks: List<Task>,
/** Each tag on an open task, alphabetically. */
val tags: List<TagOverview> = emptyList(),
/** The saved filters, oldest first. */
val filters: List<FilterOverview> = emptyList(),
) : ListsUiState
}
@@ -129,8 +133,9 @@ class ListsViewModel @Inject constructor(
// ones already ticked off to show "x of y done", so read them too.
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
settingsPrefs.settings.map { it.hiddenFromSmartLists }.distinctUntilChanged(),
) { lists, openTasks, completedTasks, hidden ->
buildContent(lists, openTasks, completedTasks, hidden) as ListsUiState
settingsPrefs.savedFilters,
) { lists, openTasks, completedTasks, hidden, filters ->
buildContent(lists, openTasks, completedTasks, hidden, filters) as ListsUiState
}.recoveringFromProviderFailure { ListsUiState.Failure }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
@@ -139,6 +144,7 @@ class ListsViewModel @Inject constructor(
openTasks: List<Task>,
completedTasks: List<Task>,
hidden: Set<Long>,
savedFilters: List<SavedFilter>,
): ListsUiState.Content {
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
// Count only top-level tasks: a subtask is represented by its parent (and
@@ -189,8 +195,15 @@ class ListsViewModel @Inject constructor(
val ids = tagged.mapTo(HashSet()) { it.taskId }
TagOverview(tag, tagged.count { !it.isClosed && (!it.isSubtask || it.parentId !in ids) })
}
// Same folding as the tags: a subtask counts only when its parent isn't in the filter too.
val filters = savedFilters.map { filter ->
val matching = openTasks.filter { filter.matches(it, todayStart, todayEnd) }
val ids = matching.mapTo(HashSet()) { it.taskId }
FilterOverview(filter, matching.count { !it.isSubtask || it.parentId !in ids })
}
return ListsUiState.Content(
smartCounts = smartCounts,
filters = filters,
groups = groups,
todayDone = completedDueToday,
todayTotal = todayTotal,
@@ -15,15 +15,19 @@ import kotlin.time.Instant
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import de.jeanlucmakiola.agendula.domain.DueWindow
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.ui.detail.TaskDetailScreen
import de.jeanlucmakiola.agendula.ui.detail.TaskDetailViewModel
import de.jeanlucmakiola.agendula.ui.edit.TaskEditScreen
import de.jeanlucmakiola.agendula.ui.edit.TaskEditViewModel
import de.jeanlucmakiola.agendula.ui.filters.FilterEditScreen
import de.jeanlucmakiola.agendula.ui.filters.FilterEditViewModel
import de.jeanlucmakiola.agendula.ui.imports.ImportScreen
import de.jeanlucmakiola.agendula.ui.lists.ListsScreen
import de.jeanlucmakiola.agendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.agendula.ui.tasklist.TaskListScreen
import de.jeanlucmakiola.agendula.ui.tasklist.TaskListUiState
import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel
/**
@@ -60,6 +64,9 @@ fun AgendulaNavHost(
is NavRequest.OpenList -> nav.navigate(Dest.TaskList.build(TaskFilter.OfList(navRequest.listId))) {
launchSingleTop = true
}
is NavRequest.OpenFilter -> nav.navigate(Dest.TaskList.build(TaskFilter.Saved(navRequest.filterId))) {
launchSingleTop = true
}
is NavRequest.Import -> nav.navigate(Dest.Import.build(navRequest.uri))
}
onNavRequestConsumed()
@@ -85,6 +92,7 @@ fun AgendulaNavHost(
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
onOpenTask = { task -> nav.navigate(task.detailRoute()) },
onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) },
onNewFilter = { nav.navigate(Dest.FilterEdit.build()) },
onOpenSettings = { nav.navigate(Dest.SETTINGS) },
)
}
@@ -115,6 +123,7 @@ fun AgendulaNavHost(
smart = args?.getString(Dest.TaskList.ARG_SMART),
listId = args?.getLong(Dest.TaskList.ARG_LIST_ID) ?: Dest.NO_ID,
tag = args?.getString(Dest.TaskList.ARG_TAG),
filterId = args?.getLong(Dest.TaskList.ARG_FILTER_ID) ?: Dest.NO_ID,
)
val vm: TaskListViewModel = hiltViewModel()
LaunchedEffect(filter) { vm.bind(filter) }
@@ -125,10 +134,14 @@ fun AgendulaNavHost(
viewModel = vm,
onOpenTask = onOpenTask,
onNewTask = {
val presetListId = (filter as? TaskFilter.OfList)?.listId
val tag = (filter as? TaskFilter.Tagged)?.tag
nav.navigate(Dest.TaskEdit.buildNew(presetListId = presetListId, tag = tag))
val saved = (vm.state.value as? TaskListUiState.Content)?.savedFilter
val presetListId = (filter as? TaskFilter.OfList)?.listId ?: saved?.listIds?.singleOrNull()
val tag = (filter as? TaskFilter.Tagged)?.tag ?: saved?.tags?.singleOrNull()
// Due today when the filter shows today, so the new task lands in it.
val dueToday = saved?.due?.contains(DueWindow.TODAY) == true
nav.navigate(Dest.TaskEdit.buildNew(presetListId = presetListId, tag = tag, dueToday = dueToday))
},
onEditFilter = { id -> nav.navigate(Dest.FilterEdit.build(id)) },
onBack = { nav.popBackStack() },
)
}
@@ -141,6 +154,32 @@ fun AgendulaNavHost(
}
}
composable(Dest.FilterEdit.route, arguments = Dest.FilterEdit.arguments) { entry ->
val filterId = entry.arguments?.getLong(Dest.FilterEdit.ARG_FILTER_ID)?.takeIf { it != Dest.NO_ID }
val vm: FilterEditViewModel = hiltViewModel()
LaunchedEffect(filterId) { vm.bind(filterId) }
ReadableWidth {
FilterEditScreen(
viewModel = vm,
isNew = filterId == null,
onSaved = { id ->
// A different id means the edited filter was gone and a new one was made.
if (id != filterId) {
// A new filter opens straight away, with the editor off the stack.
nav.navigate(Dest.TaskList.build(TaskFilter.Saved(id))) {
popUpTo(Dest.FilterEdit.route) { inclusive = true }
}
} else {
nav.popBackStack()
}
},
onDeleted = { nav.popBackStack(Dest.LISTS, inclusive = false) },
onBack = { nav.popBackStack() },
)
}
}
composable(Dest.TaskDetail.route, arguments = Dest.TaskDetail.arguments) { entry ->
val taskId = entry.arguments?.getLong(Dest.TaskDetail.ARG_TASK_ID) ?: Dest.NO_ID
val occurrence = entry.arguments?.getLong(Dest.TaskDetail.ARG_OCC)?.takeIf { it != Dest.NO_ID }
@@ -169,6 +208,7 @@ fun AgendulaNavHost(
val occurrence = args?.getLong(Dest.TaskEdit.ARG_OCCURRENCE)?.takeIf { it != Dest.NO_ID }
val title = args?.getString(Dest.TaskEdit.ARG_TITLE)
val tag = args?.getString(Dest.TaskEdit.ARG_TAG)
val dueToday = args?.getBoolean(Dest.TaskEdit.ARG_DUE_TODAY) ?: false
val vm: TaskEditViewModel = hiltViewModel()
LaunchedEffect(taskId, presetListId, parentId) {
if (taskId != Dest.NO_ID) {
@@ -179,6 +219,7 @@ fun AgendulaNavHost(
parentId = parentId.takeIf { it != Dest.NO_ID },
initialTitle = title,
presetTag = tag,
presetDueToday = dueToday,
)
}
}
@@ -54,36 +54,52 @@ object Dest {
/**
* One [TaskFilter]'s tasks. Carries one of `listId` (a real list), `smart`
* (a [SmartList] name) or `tag`; whichever is set decides the filter.
* (a [SmartList] name), `tag` or `filterId` (a saved filter); whichever is
* set decides the filter.
*/
object TaskList {
const val ARG_LIST_ID = "listId"
const val ARG_SMART = "smart"
const val ARG_TAG = "tag"
const val route = "taskList?$ARG_LIST_ID={$ARG_LIST_ID}&$ARG_SMART={$ARG_SMART}&$ARG_TAG={$ARG_TAG}"
const val ARG_FILTER_ID = "filterId"
const val route = "taskList?$ARG_LIST_ID={$ARG_LIST_ID}&$ARG_SMART={$ARG_SMART}&$ARG_TAG={$ARG_TAG}" +
"&$ARG_FILTER_ID={$ARG_FILTER_ID}"
val arguments = listOf(
navArgument(ARG_LIST_ID) { type = NavType.LongType; defaultValue = NO_ID },
navArgument(ARG_SMART) { type = NavType.StringType; nullable = true; defaultValue = null },
navArgument(ARG_TAG) { type = NavType.StringType; nullable = true; defaultValue = null },
navArgument(ARG_FILTER_ID) { type = NavType.LongType; defaultValue = NO_ID },
)
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}"
is TaskFilter.Tagged -> "taskList?$ARG_TAG=${android.net.Uri.encode(filter.tag)}"
is TaskFilter.Saved -> "taskList?$ARG_FILTER_ID=${filter.id}"
// 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. */
fun filterOf(smart: String?, listId: Long, tag: String? = null): TaskFilter = when {
fun filterOf(smart: String?, listId: Long, tag: String? = null, filterId: Long = NO_ID): TaskFilter = when {
smart != null -> TaskFilter.Smart(SmartList.valueOf(smart))
tag != null -> TaskFilter.Tagged(tag)
filterId != NO_ID -> TaskFilter.Saved(filterId)
else -> TaskFilter.OfList(listId)
}
}
/** Creates a saved filter, or edits the one `filterId` names. */
object FilterEdit {
const val ARG_FILTER_ID = "filterId"
const val route = "filterEdit?$ARG_FILTER_ID={$ARG_FILTER_ID}"
val arguments = listOf(navArgument(ARG_FILTER_ID) { type = NavType.LongType; defaultValue = NO_ID })
fun build(filterId: Long? = null): String =
if (filterId == null) "filterEdit" else "filterEdit?$ARG_FILTER_ID=$filterId"
}
/** A single task's detail; `occ` (epoch millis) picks one occurrence of a series. */
object TaskDetail {
const val ARG_TASK_ID = "taskId"
@@ -111,13 +127,15 @@ object Dest {
const val ARG_OCCURRENCE = "occ"
const val ARG_TITLE = "title"
const val ARG_TAG = "tag"
const val ARG_DUE_TODAY = "dueToday"
const val route =
"taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" +
"&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" +
"&$ARG_PARENT_ID={$ARG_PARENT_ID}" +
"&$ARG_OCCURRENCE={$ARG_OCCURRENCE}" +
"&$ARG_TITLE={$ARG_TITLE}" +
"&$ARG_TAG={$ARG_TAG}"
"&$ARG_TAG={$ARG_TAG}" +
"&$ARG_DUE_TODAY={$ARG_DUE_TODAY}"
val arguments = listOf(
navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID },
@@ -126,6 +144,7 @@ object Dest {
navArgument(ARG_OCCURRENCE) { type = NavType.LongType; defaultValue = NO_ID },
navArgument(ARG_TITLE) { type = NavType.StringType; nullable = true; defaultValue = null },
navArgument(ARG_TAG) { type = NavType.StringType; nullable = true; defaultValue = null },
navArgument(ARG_DUE_TODAY) { type = NavType.BoolType; defaultValue = false },
)
fun buildEdit(taskId: Long, occurrenceStart: Long? = null): String =
@@ -137,12 +156,14 @@ object Dest {
parentId: Long? = null,
title: String? = null,
tag: String? = null,
dueToday: Boolean = false,
): String {
val params = buildList {
if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId")
if (parentId != null) add("$ARG_PARENT_ID=$parentId")
if (!title.isNullOrBlank()) add("$ARG_TITLE=${android.net.Uri.encode(title)}")
if (!tag.isNullOrBlank()) add("$ARG_TAG=${android.net.Uri.encode(tag)}")
if (dueToday) add("$ARG_DUE_TODAY=true")
}
return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}"
}
@@ -30,6 +30,9 @@ sealed interface NavRequest {
/** One real list — a widget header. */
data class OpenList(val listId: Long) : NavRequest
/** One saved filter — a widget header. */
data class OpenFilter(val filterId: Long) : NavRequest
/** An `.ics` another app handed over. */
data class Import(val uri: Uri) : NavRequest
}
@@ -140,6 +140,7 @@ fun TaskListScreen(
onNewTask: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
onEditFilter: (Long) -> Unit = {},
viewModel: TaskListViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -158,6 +159,9 @@ fun TaskListScreen(
val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle()
// The list this screen is about is gone; there is nothing left to show.
LaunchedEffect(listDeleted) { if (listDeleted) onBack() }
val savedFilter = content?.savedFilter
val filterGone = filter is TaskFilter.Saved && content?.filter == filter && savedFilter == null
LaunchedEffect(filterGone) { if (filterGone) onBack() }
LaunchedEffect(listWriteFailure) {
if (listWriteFailure != null) {
delay(4_000)
@@ -186,7 +190,7 @@ fun TaskListScreen(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MediumTopAppBar(
title = { Text(titleFor(filter, listName)) },
title = { Text(titleFor(filter, listName ?: savedFilter?.name)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
@@ -201,6 +205,11 @@ fun TaskListScreen(
Icon(Icons.Rounded.Tune, contentDescription = stringResource(R.string.view_options_title))
}
}
if (savedFilter != null) {
IconButton(onClick = { onEditFilter(savedFilter.id) }) {
Icon(Icons.Rounded.Edit, contentDescription = stringResource(R.string.filter_edit_title))
}
}
if (list != null) {
IconButton(
onClick = {
@@ -360,7 +369,8 @@ private fun TaskListBody(
val listId = (filter as? TaskFilter.OfList)?.listId
// In a mixed view (a smart list) the list colour bar + name disambiguate
// rows; inside a single real list both are redundant.
val showListName = filter is TaskFilter.Smart || filter is TaskFilter.Tagged
val showListName = filter is TaskFilter.Smart || filter is TaskFilter.Tagged ||
(filter is TaskFilter.Saved && state.savedFilter?.listIds?.size != 1)
// Completed tasks collapse away by default to keep the active list in focus.
var completedExpanded by rememberSaveable { mutableStateOf(false) }
// Parents the user expanded to reveal their subtasks inline. Children are
@@ -1265,8 +1275,9 @@ private fun ViewOptionsPicker(
}
@Composable
private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) {
is TaskFilter.OfList -> listName ?: stringResource(R.string.tasks_title)
private fun titleFor(filter: TaskFilter, name: String?): String = when (filter) {
is TaskFilter.OfList -> name ?: stringResource(R.string.tasks_title)
is TaskFilter.Saved -> name.orEmpty()
// 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.Tagged -> filter.tag
@@ -7,6 +7,7 @@ import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFiltering
@@ -61,6 +62,10 @@ sealed interface TaskListUiState {
val availableTags: List<String> = emptyList(),
/** The tags [tasks] is narrowed to; a task must carry all of them. */
val selectedTags: Set<String> = emptySet(),
/** The definition behind a [TaskFilter.Saved]; `null` once it's deleted. */
val savedFilter: SavedFilter? = null,
/** The filter this content was built for; a reused screen briefly holds the previous one's. */
val filter: TaskFilter? = null,
) : TaskListUiState
}
@@ -111,6 +116,10 @@ class TaskListViewModel @Inject constructor(
}
is TaskFilter.Smart, is TaskFilter.Tagged ->
tasks.map { TaskListUiState.Content(it) }
is TaskFilter.Saved ->
combine(tasks, settingsPrefs.savedFilter(f.id)) { rows, saved ->
TaskListUiState.Content(rows, savedFilter = saved)
}
// Several lists at once only exists for the widget, which never routes here.
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
}
@@ -126,6 +135,7 @@ class TaskListViewModel @Inject constructor(
val selected = tags.filterTo(LinkedHashSet()) { it in available }
if (selected.size != tags.size) tagFilter.value = selected
st.copy(
filter = f,
tasks = visible
.filter { TaskFiltering.hasAllTags(it, selected) }
.sortedWith(settings.sortOrder.comparator()),
@@ -170,6 +170,11 @@ private fun TaskWidgetBody(
val title = when (filter) {
is TaskFilter.Smart -> context.getString(smartListLabel(filter.list))
is TaskFilter.Tagged -> filter.tag
is TaskFilter.Saved -> if (ready == null) {
context.getString(R.string.widget_tasks_label)
} else {
ready.filterName ?: context.getString(R.string.filter_missing)
}
is TaskFilter.OfList, is TaskFilter.OfLists -> ready?.singleListName
?: context.getString(R.string.widget_tasks_label)
}
@@ -281,6 +286,7 @@ private fun TaskWidgetHeader(title: String, filter: TaskFilter, singleListId: Lo
is TaskFilter.OfLists -> filter.listIds.singleOrNull()?.let { MainActivity.listIntent(context, it) }
?: Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
is TaskFilter.Tagged -> Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
is TaskFilter.Saved -> MainActivity.filterIntent(context, filter.id)
}
WidgetHeader(
title = title,
@@ -41,6 +41,7 @@ import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskList
@@ -118,6 +119,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
setContent {
val settings by settingsPrefs.settings.collectAsState(initial = Settings())
val lists by remember { listsFlow }.collectAsState(initial = emptyList())
val savedFilters by settingsPrefs.savedFilters.collectAsState(initial = emptyList())
var current by remember { mutableStateOf<TaskFilter?>(null) }
var groupByList by remember { mutableStateOf(false) }
var background by remember { mutableStateOf(WidgetBackground.SOLID) }
@@ -143,6 +145,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
WidgetFilterPicker(
kind = kind,
lists = lists.filter { it.isVisible },
savedFilters = savedFilters,
dark = dark,
selected = current ?: DEFAULT_WIDGET_FILTER,
groupByList = groupByList,
@@ -193,6 +196,7 @@ class TaskWidgetConfigActivity : ComponentActivity() {
private fun WidgetFilterPicker(
kind: WidgetKind,
lists: List<TaskList>,
savedFilters: List<SavedFilter>,
selected: TaskFilter,
dark: Boolean,
groupByList: Boolean,
@@ -211,21 +215,23 @@ private fun WidgetFilterPicker(
var showBackgroundPicker by remember { mutableStateOf(false) }
var showThemePicker by remember { mutableStateOf(false) }
var showRowColorPicker by remember { mutableStateOf(false) }
// Smart lists and real lists are mutually exclusive; only real lists support
// picking more than one. `selectedLists` empty + `selectedSmart` non-null is
// a smart-list pick; the reverse (or both empty, before the stored filter
// loads) is a list pick.
// Smart lists, saved filters and real lists are mutually exclusive; only real
// lists support picking more than one. `selectedSmart` or `selectedFilter`
// non-null is that pick; otherwise (or before the stored filter loads) it's
// a list pick.
var selectedSmart by remember(selected) { mutableStateOf((selected as? TaskFilter.Smart)?.list) }
var selectedFilter by remember(selected) { mutableStateOf((selected as? TaskFilter.Saved)?.id) }
var selectedLists by remember(selected) {
mutableStateOf(
when (selected) {
is TaskFilter.OfList -> setOf(selected.listId)
is TaskFilter.OfLists -> selected.listIds
is TaskFilter.Smart, is TaskFilter.Tagged -> emptySet()
is TaskFilter.Smart, is TaskFilter.Tagged, is TaskFilter.Saved -> emptySet()
},
)
}
val canSave = kind != WidgetKind.TASKS || selectedSmart != null || selectedLists.isNotEmpty()
val canSave = kind != WidgetKind.TASKS || selectedSmart != null || selectedFilter != null ||
selectedLists.isNotEmpty()
FullScreenPicker(
title = stringResource(if (kind == WidgetKind.LISTS) R.string.widget_lists_config_title else R.string.widget_config_title),
@@ -239,8 +245,11 @@ private fun WidgetFilterPicker(
// a smart list, has to clear it here instead; otherwise it's
// stuck on with no way left to see or undo it.
if (selectedLists.size <= 1 && groupByList) onGroupByListChange(false)
selectedSmart?.let { onSave(TaskFilter.Smart(it)) }
?: onSave(TaskFilter.OfLists(selectedLists))
when {
selectedSmart != null -> onSave(TaskFilter.Smart(selectedSmart!!))
selectedFilter != null -> onSave(TaskFilter.Saved(selectedFilter!!))
else -> onSave(TaskFilter.OfLists(selectedLists))
}
},
) { Text(stringResource(android.R.string.ok)) }
},
@@ -254,9 +263,22 @@ private fun WidgetFilterPicker(
position = positionOf(index, WIDGET_SMART_LISTS.size),
selected = isSelected,
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { selectedSmart = smart; selectedLists = emptySet() },
onClick = { selectedSmart = smart; selectedFilter = null; selectedLists = emptySet() },
)
}
if (savedFilters.isNotEmpty()) {
GroupedSectionHeader(stringResource(R.string.filters_header))
savedFilters.forEachIndexed { index, filter ->
val isSelected = filter.id == selectedFilter
GroupedRow(
title = filter.name,
position = positionOf(index, savedFilters.size),
selected = isSelected,
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { selectedFilter = filter.id; selectedSmart = null; selectedLists = emptySet() },
)
}
}
if (lists.isNotEmpty()) {
GroupedSectionHeader(stringResource(R.string.lists_header))
lists.forEachIndexed { index, list ->
@@ -269,6 +291,7 @@ private fun WidgetFilterPicker(
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = {
selectedSmart = null
selectedFilter = null
selectedLists = if (isSelected) selectedLists - list.id else selectedLists + list.id
},
)
@@ -16,6 +16,7 @@ import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import java.time.LocalDate
import java.time.ZoneId
import kotlin.time.Clock
@@ -101,6 +102,7 @@ internal fun TaskFilter.widgetStorageValue(): String = when (this) {
is TaskFilter.OfLists -> "lists:${listIds.sorted().joinToString(",")}"
is TaskFilter.Smart -> "smart:${list.name}"
is TaskFilter.Tagged -> error("A tag has no widget")
is TaskFilter.Saved -> "filter:$id"
}
/** Anything unreadable, or a smart list the widget doesn't offer, falls back to Today. */
@@ -112,6 +114,8 @@ internal fun parseWidgetFilter(raw: String?): TaskFilter {
value.startsWith("lists:") ->
value.removePrefix("lists:").split(",").mapNotNull { it.toLongOrNull()?.takeIf { id -> id > 0 } }
.toSet().takeIf { it.isNotEmpty() }?.let { TaskFilter.OfLists(it) }
value.startsWith("filter:") ->
value.removePrefix("filter:").toLongOrNull()?.takeIf { it > 0 }?.let { TaskFilter.Saved(it) }
value.startsWith("smart:") ->
WIDGET_SMART_LISTS.firstOrNull { it.name == value.removePrefix("smart:") }?.let { TaskFilter.Smart(it) }
else -> null
@@ -162,12 +166,14 @@ sealed interface TaskWidgetData {
val dynamicColor: Boolean,
val themeMode: ThemeMode,
val use24Hour: Boolean,
/** The saved filter's name when [filter] is one; `null` once it's deleted. */
val filterName: String? = null,
) : TaskWidgetData {
/** The one list's id, when [filter] names exactly one real list — seeds a widget's add button. */
val singleListId: Long? = when (filter) {
is TaskFilter.OfList -> filter.listId
is TaskFilter.OfLists -> filter.listIds.singleOrNull()
is TaskFilter.Smart, is TaskFilter.Tagged -> null
is TaskFilter.Smart, is TaskFilter.Tagged, is TaskFilter.Saved -> null
}
/** [singleListId]'s name. */
@@ -275,7 +281,8 @@ internal fun Context.taskWidgetData(filter: TaskFilter, groupByList: Boolean): F
repository.tasks(filter),
repository.taskLists(),
ep.settingsPrefs().settings,
) { tasks, lists, settings ->
(filter as? TaskFilter.Saved)?.let { ep.settingsPrefs().savedFilter(it.id) } ?: flowOf(null),
) { tasks, lists, settings, savedFilter ->
val listNames = lists.associate { it.id to it.name }
val data: TaskWidgetData = TaskWidgetData.Ready(
filter = filter,
@@ -292,6 +299,7 @@ internal fun Context.taskWidgetData(filter: TaskFilter, groupByList: Boolean): F
dynamicColor = settings.dynamicColor,
themeMode = settings.themeMode,
use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)),
filterName = savedFilter?.name,
)
data
}.catch { emit(TaskWidgetData.Unavailable) }
+24
View File
@@ -219,6 +219,30 @@
<!-- Lists overview -->
<string name="lists_header">Lists</string>
<string name="tags_header">Tags</string>
<string name="filters_header">Filters</string>
<string name="filters_empty_hint">Combine due dates, lists, tags and priority into a view of your own</string>
<string name="filter_new_title">New filter</string>
<string name="filter_edit_title">Edit filter</string>
<string name="filter_name_hint">Filter name</string>
<string name="filter_section_due">Due</string>
<string name="filter_section_priority">Priority</string>
<string name="filter_section_lists">Lists</string>
<string name="filter_section_tags">Tags</string>
<string name="filter_any_due">Any due date</string>
<string name="filter_any_list">Any list</string>
<string name="filter_any_tag">Any tag, or none</string>
<string name="filter_priority_any">Any priority</string>
<string name="filter_priority_low">Low and higher</string>
<string name="filter_priority_medium">Medium and higher</string>
<string name="filter_priority_high">High only</string>
<string name="filter_match_all_tags">Match all tags</string>
<string name="filter_match_all_on">A task needs every tag you picked</string>
<string name="filter_match_all_off">A task needs one of the tags you picked</string>
<string name="filter_delete">Delete filter</string>
<string name="filter_delete_title">Delete “%1$s”?</string>
<string name="filter_delete_body">Only the filter goes. Its tasks stay where they are.</string>
<string name="filter_delete_confirm">Delete</string>
<string name="filter_missing">Filter deleted</string>
<string name="new_task">New task</string>
<string name="lists_failure">Could not read your tasks.</string>
<string name="lists_empty">No task lists yet.</string>
@@ -0,0 +1,81 @@
package de.jeanlucmakiola.agendula.data.prefs
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.DueWindow
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.TagMatch
import org.junit.jupiter.api.Test
class SavedFilterCodecTest {
private val filter = SavedFilter(
id = 3,
name = "Work | today, 100%",
due = setOf(DueWindow.TODAY, DueWindow.OVERDUE),
listIds = setOf(4L, 9L),
tags = setOf("a,b", "größe", "x|y"),
tagMatch = TagMatch.ALL,
minPriority = Priority.MEDIUM,
)
@Test
fun `a filter round-trips, separators and all`() {
val raw = SavedFilterCodec.upsert(null, filter, "OWN")
assertThat(SavedFilterCodec.decode(raw, "OWN")).containsExactly(filter)
}
@Test
fun `upsert replaces by id and keeps the order`() {
val first = filter.copy(id = 1, name = "one")
var raw = SavedFilterCodec.upsert(null, first, "OWN")
raw = SavedFilterCodec.upsert(raw, filter, "OWN")
raw = SavedFilterCodec.upsert(raw, first.copy(name = "renamed"), "OWN")
assertThat(SavedFilterCodec.decode(raw, "OWN").map { it.name }).containsExactly("renamed", filter.name).inOrder()
}
@Test
fun `list ids belong to their store`() {
val raw = SavedFilterCodec.upsert(null, filter, "OWN")
val elsewhere = SavedFilterCodec.decode(raw, "EXTERNAL").single()
assertThat(elsewhere.listIds).isEmpty()
assertThat(elsewhere.listsElsewhere).isTrue()
assertThat(SavedFilterCodec.decode(raw, "OWN").single().listsElsewhere).isFalse()
}
@Test
fun `choosing any list in one store drops the other store's lists`() {
var raw = SavedFilterCodec.upsert(null, filter, "OWN")
raw = SavedFilterCodec.upsert(raw, filter.copy(listIds = emptySet(), listsElsewhere = false), "EXTERNAL")
assertThat(SavedFilterCodec.decode(raw, "OWN").single().anyList).isTrue()
}
@Test
fun `saving untouched in the other store keeps the lists`() {
var raw = SavedFilterCodec.upsert(null, filter, "OWN")
val elsewhere = SavedFilterCodec.decode(raw, "EXTERNAL").single()
raw = SavedFilterCodec.upsert(raw, elsewhere.copy(name = "renamed"), "EXTERNAL")
assertThat(SavedFilterCodec.decode(raw, "OWN").single().listIds).containsExactly(4L, 9L)
}
@Test
fun `editing in one store keeps the other store's lists`() {
var raw = SavedFilterCodec.upsert(null, filter, "OWN")
raw = SavedFilterCodec.upsert(raw, filter.copy(listIds = setOf(1L)), "EXTERNAL")
assertThat(SavedFilterCodec.decode(raw, "OWN").single().listIds).containsExactly(4L, 9L)
assertThat(SavedFilterCodec.decode(raw, "EXTERNAL").single().listIds).containsExactly(1L)
}
@Test
fun `remove drops only that filter`() {
var raw = SavedFilterCodec.upsert(null, filter.copy(id = 1), "OWN")
raw = SavedFilterCodec.upsert(raw, filter, "OWN")
assertThat(SavedFilterCodec.decode(SavedFilterCodec.remove(raw, 1), "OWN").map { it.id }).containsExactly(3L)
}
@Test
fun `broken lines are skipped`() {
val raw = "nonsense\n" + SavedFilterCodec.upsert(null, filter, "OWN") + "\n7|short\n8|bad%zz||||ANY|NONE"
assertThat(SavedFilterCodec.decode(raw, "OWN")).containsExactly(filter)
}
}
@@ -0,0 +1,79 @@
package de.jeanlucmakiola.agendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class SavedFilterTest {
private val dayMs = 86_400_000L
private val todayStart = Instant.fromEpochMilliseconds(1000 * dayMs)
private val todayEnd = Instant.fromEpochMilliseconds(1001 * dayMs)
private val overdue = Instant.fromEpochMilliseconds(999 * dayMs)
private val today = Instant.fromEpochMilliseconds(1000 * dayMs + 1)
private val upcoming = Instant.fromEpochMilliseconds(1002 * dayMs)
private fun SavedFilter.matches(task: Task) = matches(task, todayStart, todayEnd)
private val empty = SavedFilter(id = 1, name = "f")
@Test
fun `an empty filter is every open task`() {
assertThat(empty.matches(testTask(due = null))).isTrue()
assertThat(empty.matches(testTask(due = overdue))).isTrue()
assertThat(empty.matches(testTask(status = TaskStatus.COMPLETED))).isFalse()
assertThat(empty.matches(testTask(status = TaskStatus.CANCELLED))).isFalse()
}
@Test
fun `today and overdue together`() {
val filter = empty.copy(due = setOf(DueWindow.TODAY, DueWindow.OVERDUE))
assertThat(filter.matches(testTask(due = overdue))).isTrue()
assertThat(filter.matches(testTask(due = today))).isTrue()
assertThat(filter.matches(testTask(due = upcoming))).isFalse()
assertThat(filter.matches(testTask(due = null))).isFalse()
}
@Test
fun `lists narrow by membership`() {
val filter = empty.copy(listIds = setOf(2L, 3L))
assertThat(filter.matches(testTask(listId = 2))).isTrue()
assertThat(filter.matches(testTask(listId = 1))).isFalse()
}
@Test
fun `lists from another store match nothing here`() {
val filter = empty.copy(listsElsewhere = true)
assertThat(filter.matches(testTask(listId = 1))).isFalse()
assertThat(filter.copy(listIds = setOf(1L)).matches(testTask(listId = 1))).isTrue()
}
@Test
fun `priority is a floor`() {
val filter = empty.copy(minPriority = Priority.MEDIUM)
assertThat(filter.matches(testTask(priority = Priority.HIGH))).isTrue()
assertThat(filter.matches(testTask(priority = Priority.MEDIUM))).isTrue()
assertThat(filter.matches(testTask(priority = Priority.LOW))).isFalse()
assertThat(filter.matches(testTask(priority = Priority.NONE))).isFalse()
}
@Test
fun `tags match any or all`() {
val any = empty.copy(tags = setOf("work", "home"))
val all = any.copy(tagMatch = TagMatch.ALL)
val one = testTask(tags = listOf("work"))
val both = testTask(tags = listOf("home", "work", "x"))
assertThat(any.matches(one)).isTrue()
assertThat(any.matches(testTask(tags = listOf("x")))).isFalse()
assertThat(all.matches(one)).isFalse()
assertThat(all.matches(both)).isTrue()
}
@Test
fun `criteria combine with and`() {
val filter = empty.copy(due = setOf(DueWindow.TODAY), listIds = setOf(1L), tags = setOf("work"))
assertThat(filter.matches(testTask(listId = 1, due = today, tags = listOf("work")))).isTrue()
assertThat(filter.matches(testTask(listId = 2, due = today, tags = listOf("work")))).isFalse()
assertThat(filter.matches(testTask(listId = 1, due = today))).isFalse()
}
}
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.agendula.ui.navigation
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.TaskFilter
import org.junit.jupiter.api.Test
class DestinationsTest {
@@ -17,4 +18,13 @@ class DestinationsTest {
assertThat(Dest.Settings.build()).isEqualTo(Dest.SETTINGS)
assertThat(Dest.Settings.build(Dest.Settings.SECTION_ACCOUNTS)).isEqualTo("settings?section=Accounts")
}
@Test
fun `a saved filter routes by id and comes back from its arguments`() {
assertThat(Dest.TaskList.build(TaskFilter.Saved(5))).isEqualTo("taskList?filterId=5")
assertThat(Dest.TaskList.filterOf(smart = null, listId = Dest.NO_ID, filterId = 5)).isEqualTo(TaskFilter.Saved(5))
assertThat(Dest.FilterEdit.build()).isEqualTo("filterEdit")
assertThat(Dest.FilterEdit.build(5)).isEqualTo("filterEdit?filterId=5")
assertThat(Dest.TaskEdit.buildNew(presetListId = 2, dueToday = true)).isEqualTo("taskEdit?presetListId=2&dueToday=true")
}
}
@@ -32,7 +32,8 @@ class WidgetDataTest {
fun `every offered filter round-trips through storage`() {
val filters = WIDGET_SMART_LISTS.map { TaskFilter.Smart(it) } +
TaskFilter.OfList(42) +
TaskFilter.OfLists(setOf(1L, 2L, 3L))
TaskFilter.OfLists(setOf(1L, 2L, 3L)) +
TaskFilter.Saved(7)
filters.forEach { assertThat(parseWidgetFilter(it.widgetStorageValue())).isEqualTo(it) }
}
@@ -45,7 +46,7 @@ class WidgetDataTest {
fun `garbage and smart lists the widget does not offer fall back to Today`() {
listOf(
"", "list:", "list:-3", "list:abc", "lists:", "lists:abc", "lists:-1,-2",
"smart:COMPLETED", "smart:nope", "whatever",
"smart:COMPLETED", "smart:nope", "filter:", "filter:0", "filter:x", "whatever",
).forEach {
assertThat(parseWidgetFilter(it)).isEqualTo(DEFAULT_WIDGET_FILTER)
}