diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e50a31..7373957 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt index 2ecc8a0..1ac4cd6 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt @@ -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) { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SavedFilterCodec.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SavedFilterCodec.kt new file mode 100644 index 0000000..72bad37 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SavedFilterCodec.kt @@ -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 = + 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 = raw.orEmpty().split('\n').filter { it.isNotBlank() } + + private fun fields(line: String): List = line.split('|') + + private fun idOf(line: String): Long? = fields(line).firstOrNull()?.toLongOrNull() + + private fun splitList(value: String): List = value.split(',').filter { it.isNotEmpty() } + + private fun encodeLine(filter: SavedFilter, mode: String, foreignLists: List): 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()) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt index baaaaeb..d93d19d 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt @@ -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> = + 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 = + 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) = 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") } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt index 516ea70..f14f578 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt @@ -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> = observing { dataSource.taskLists() } - override fun tasks(filter: TaskFilter): Flow> = observing { loadTasks(filter) } + @OptIn(ExperimentalCoroutinesApi::class) + override fun tasks(filter: TaskFilter): Flow> = 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> = observing { SeriesCollapse.visible(dataSource.subtasks(parentId)) @@ -55,7 +69,7 @@ class TasksRepositoryImpl @Inject constructor( } } - private fun loadTasks(filter: TaskFilter): List { + private fun loadTasks(filter: TaskFilter, saved: SavedFilter? = null): List { 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) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/SavedFilter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/SavedFilter.kt new file mode 100644 index 0000000..30c738e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/SavedFilter.kt @@ -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 = emptySet(), + val listIds: Set = emptySet(), + val tags: Set = 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskFilter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskFilter.kt index 9ce9d61..99944fd 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskFilter.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskFilter.kt @@ -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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/NameField.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/NameField.kt new file mode 100644 index 0000000..a607a46 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/NameField.kt @@ -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), + ) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt index fd5add5..1e8daa6 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt @@ -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), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/filters/FilterEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/filters/FilterEditScreen.kt new file mode 100644 index 0000000..378398b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/filters/FilterEditScreen.kt @@ -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 Set.toggle(item: T): Set = 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 + }, +) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/filters/FilterEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/filters/FilterEditViewModel.kt new file mode 100644 index 0000000..e8bd244 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/filters/FilterEditViewModel.kt @@ -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(null) + + /** `null` until [bind] has loaded the filter. An id of 0 is a new one. */ + val draft: StateFlow = _draft.asStateFlow() + + val lists: StateFlow> = repository.taskLists() + .catch { emit(emptyList()) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** Every tag on an open task. */ + val tags: StateFlow> = 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() + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt index 9612518..5054741 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt @@ -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. diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt index 56e6968..f64cd57 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt @@ -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) -> 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, + 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)) }, + ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt index 918dac5..90f2c18 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt @@ -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) 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, /** Each tag on an open task, alphabetically. */ val tags: List = emptyList(), + /** The saved filters, oldest first. */ + val filters: List = 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, completedTasks: List, hidden: Set, + savedFilters: List, ): 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, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt index 44b54d9..0c4130d 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt @@ -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, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt index be6ecf9..efa59fc 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt @@ -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("&")}" } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt index 41cb335..3743cb3 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt @@ -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 } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt index 9d2b30c..337cd6f 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt index 477efb5..1c794f8 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt @@ -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 = emptyList(), /** The tags [tasks] is narrowed to; a task must carry all of them. */ val selectedTags: Set = 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()), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt index 8e10742..de873dc 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt index ff21de6..0869bd9 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt @@ -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(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, + savedFilters: List, 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 }, ) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt index 352e5d9..5ad03f0 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt @@ -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) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cca85bc..06da004 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -219,6 +219,30 @@ Lists Tags + Filters + Combine due dates, lists, tags and priority into a view of your own + New filter + Edit filter + Filter name + Due + Priority + Lists + Tags + Any due date + Any list + Any tag, or none + Any priority + Low and higher + Medium and higher + High only + Match all tags + A task needs every tag you picked + A task needs one of the tags you picked + Delete filter + Delete “%1$s”? + Only the filter goes. Its tasks stay where they are. + Delete + Filter deleted New task Could not read your tasks. No task lists yet. diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/prefs/SavedFilterCodecTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/prefs/SavedFilterCodecTest.kt new file mode 100644 index 0000000..a0d9133 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/prefs/SavedFilterCodecTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/SavedFilterTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/SavedFilterTest.kt new file mode 100644 index 0000000..4bcbf32 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/SavedFilterTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/ui/navigation/DestinationsTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/ui/navigation/DestinationsTest.kt index 022915d..f356111 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/ui/navigation/DestinationsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/ui/navigation/DestinationsTest.kt @@ -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") + } } diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt index 137d9db..1951ca0 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt @@ -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) }