Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd08c035df | ||
|
|
b6f0391cb3 | ||
|
|
22f3d8c2c5 | ||
|
|
4c2a6dc87b | ||
|
|
33639c0a7c | ||
|
|
a15e696059 | ||
|
|
b8e797f419 | ||
|
|
72b2932197 | ||
|
|
c4a69d30f7 |
@@ -27,7 +27,7 @@ jobs:
|
||||
detect:
|
||||
# Gitea only; see the same guard in release.yaml.
|
||||
if: github.repository_owner == 'makiolaj'
|
||||
runs-on: docker
|
||||
runs-on: light
|
||||
outputs:
|
||||
is_beta: ${{ steps.v.outputs.is_beta }}
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
beta:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.is_beta == 'true'
|
||||
runs-on: docker
|
||||
runs-on: gradle
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
# secrets. repository_owner differs between the two forges regardless of
|
||||
# URL, proxy or instance rename, so this closes it permanently.
|
||||
if: github.repository_owner == 'makiolaj'
|
||||
runs-on: docker
|
||||
runs-on: light
|
||||
outputs:
|
||||
is_release: ${{ steps.v.outputs.is_release }}
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
release:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: docker
|
||||
runs-on: gradle
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
|
||||
@@ -7,6 +7,8 @@ All notable changes to this project are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.1.0] - 2026-10-10
|
||||
|
||||
### Added
|
||||
- Four new home-screen widgets: Today, Up next, Lists and Week.
|
||||
- A widget can show tasks from several lists at once, optionally grouped by
|
||||
@@ -17,6 +19,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
|
||||
@@ -35,6 +41,8 @@ All notable changes to this project are documented here. The format follows
|
||||
- The list editor's Where and smart-lists rows no longer have double padding.
|
||||
- A field you tap now scrolls fully above the keyboard, and the "add subtask"
|
||||
row in task lists no longer ends up hidden behind it.
|
||||
- All-day tasks west of UTC no longer show up a day early in Overdue, Today
|
||||
and Upcoming.
|
||||
|
||||
## [1.0.0] - 2026-09-21
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -33,8 +33,8 @@ android {
|
||||
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
|
||||
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
|
||||
// See docs/RELEASING.md.
|
||||
versionCode = 1010003
|
||||
versionName = "1.1.0-beta.3"
|
||||
versionCode = 1010099
|
||||
versionName = "1.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -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 = lists.any { !it.startsWith("$mode:") },
|
||||
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,34 @@ 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 -> p[SAVED_FILTERS] to p[STORAGE_MODE] }
|
||||
.distinctUntilChanged()
|
||||
.map { (raw, mode) -> SavedFilterCodec.decode(raw, (storedMode(mode) ?: resolver.autoMode()).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 +318,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,5 +1,6 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
@@ -13,6 +14,9 @@ import kotlin.time.Instant
|
||||
/** Whether Agendula can use the tasks provider right now. Drives onboarding. */
|
||||
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
|
||||
|
||||
/** Tasks for a filter; [savedFilter] is a saved filter's definition, `null` once it's deleted. */
|
||||
data class FilteredTasks(val tasks: List<Task>, val savedFilter: SavedFilter? = null)
|
||||
|
||||
/**
|
||||
* The single entry point the UI layer uses. Flows re-emit automatically when the
|
||||
* provider changes (our writes *and* external sync like DAVx5), via the
|
||||
@@ -27,7 +31,10 @@ interface TasksRepository {
|
||||
*/
|
||||
fun writableTaskLists(): Flow<List<TaskList>> =
|
||||
taskLists().map { lists -> lists.filter { it.acceptsWrites } }
|
||||
fun tasks(filter: TaskFilter): Flow<List<Task>>
|
||||
fun tasks(filter: TaskFilter): Flow<List<Task>> = filteredTasks(filter).map { it.tasks }
|
||||
|
||||
/** [tasks] together with the definition behind a [TaskFilter.Saved]. */
|
||||
fun filteredTasks(filter: TaskFilter): Flow<FilteredTasks>
|
||||
|
||||
/**
|
||||
* The direct children of [parentId], re-emitting live like the other flows.
|
||||
|
||||
@@ -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,26 @@ 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 filteredTasks(filter: TaskFilter): Flow<FilteredTasks> = 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(FilteredTasks(emptyList()))
|
||||
else observing {
|
||||
// A deleted list's id would otherwise leave the filter quietly empty.
|
||||
val current = saved.withListsIn(dataSource.taskLists().mapTo(HashSet()) { it.id })
|
||||
FilteredTasks(loadTasks(filter, current), current)
|
||||
}
|
||||
}
|
||||
else -> observing { FilteredTasks(loadTasks(filter)) }
|
||||
}
|
||||
|
||||
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
|
||||
SeriesCollapse.visible(dataSource.subtasks(parentId))
|
||||
@@ -55,7 +74,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 +86,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 +98,7 @@ class TasksRepositoryImpl @Inject constructor(
|
||||
(if (t.isCompleted) done + 1 else done) to (total + 1)
|
||||
}
|
||||
return all
|
||||
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) }
|
||||
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd, saved) }
|
||||
.map { task ->
|
||||
progress[task.taskId]?.let { (done, total) ->
|
||||
task.copy(subtaskDone = done, subtaskTotal = total)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package de.jeanlucmakiola.agendula.domain
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
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 also has lists in another task store.
|
||||
* With none of its lists here it then matches nothing 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
|
||||
|
||||
/** This filter without the list ids missing from [existing], e.g. lists since deleted. */
|
||||
fun withListsIn(existing: Set<Long>): SavedFilter = copy(listIds = listIds intersect existing)
|
||||
|
||||
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, 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where [task]'s due falls against local today ([todayStart] to [todayEnd], from
|
||||
* [DayWindow] in [zone]). An all-day due is stored as UTC midnight, so it is read
|
||||
* as its calendar date first rather than compared as a raw instant.
|
||||
*/
|
||||
fun dueWindowOf(
|
||||
task: Task,
|
||||
todayStart: Instant,
|
||||
todayEnd: Instant,
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
): DueWindow {
|
||||
val due = task.due?.let { if (task.isAllDay) it.rebasedForAllDay(allDay = false, zone = zone) else it }
|
||||
return when {
|
||||
due == null -> DueWindow.NO_DATE
|
||||
due < todayStart -> DueWindow.OVERDUE
|
||||
due < todayEnd -> DueWindow.TODAY
|
||||
else -> DueWindow.UPCOMING
|
||||
}
|
||||
}
|
||||
|
||||
/** What a task created from a [SavedFilter] starts with, so it lands in that filter. */
|
||||
data class NewTaskPreset(
|
||||
val listIds: Set<Long> = emptySet(),
|
||||
val tags: List<String> = emptyList(),
|
||||
val dueDate: LocalDate? = null,
|
||||
val priority: Priority = Priority.NONE,
|
||||
)
|
||||
|
||||
/**
|
||||
* The preset for a new task in this filter. Due prefers today, then tomorrow;
|
||||
* a filter that admits undated tasks, or only overdue ones, leaves it empty.
|
||||
*/
|
||||
fun SavedFilter.newTaskPreset(today: LocalDate): NewTaskPreset = NewTaskPreset(
|
||||
listIds = listIds,
|
||||
tags = when {
|
||||
tagMatch == TagMatch.ALL -> tags.sorted()
|
||||
else -> listOfNotNull(tags.minOrNull())
|
||||
},
|
||||
dueDate = when {
|
||||
due.isEmpty() || DueWindow.NO_DATE in due -> null
|
||||
DueWindow.TODAY in due -> today
|
||||
DueWindow.UPCOMING in due -> today.plusDays(1)
|
||||
else -> null
|
||||
},
|
||||
priority = minPriority,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,26 +24,32 @@ sealed interface TaskFilter {
|
||||
*/
|
||||
object TaskFiltering {
|
||||
|
||||
fun matches(task: Task, filter: TaskFilter, todayStart: Instant, todayEnd: Instant): Boolean =
|
||||
/** [saved] is the definition behind a [TaskFilter.Saved]; without one it matches nothing. */
|
||||
fun matches(
|
||||
task: Task,
|
||||
filter: TaskFilter,
|
||||
todayStart: Instant,
|
||||
todayEnd: Instant,
|
||||
saved: SavedFilter? = null,
|
||||
): Boolean =
|
||||
when (filter) {
|
||||
is TaskFilter.OfList -> task.listId == filter.listId
|
||||
is TaskFilter.OfLists -> task.listId in filter.listIds
|
||||
is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd)
|
||||
is TaskFilter.Tagged -> filter.tag in task.tags
|
||||
is TaskFilter.Saved -> saved?.takeIf { it.id == filter.id }?.matches(task, todayStart, todayEnd) ?: false
|
||||
}
|
||||
|
||||
/** Whether [task] carries every one of [tags]; an empty set matches everything. */
|
||||
fun hasAllTags(task: Task, tags: Set<String>): Boolean = task.tags.containsAll(tags)
|
||||
|
||||
private fun matchesSmart(task: Task, list: SmartList, todayStart: Instant, todayEnd: Instant): Boolean {
|
||||
val due = task.due
|
||||
return when (list) {
|
||||
private fun matchesSmart(task: Task, list: SmartList, todayStart: Instant, todayEnd: Instant): Boolean =
|
||||
when (list) {
|
||||
SmartList.COMPLETED -> task.isCompleted
|
||||
SmartList.ALL -> !task.isClosed
|
||||
SmartList.NO_DATE -> !task.isClosed && due == null
|
||||
SmartList.OVERDUE -> !task.isClosed && due != null && due < todayStart
|
||||
SmartList.TODAY -> !task.isClosed && due != null && due >= todayStart && due < todayEnd
|
||||
SmartList.UPCOMING -> !task.isClosed && due != null && due >= todayEnd
|
||||
SmartList.NO_DATE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.NO_DATE
|
||||
SmartList.OVERDUE -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.OVERDUE
|
||||
SmartList.TODAY -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.TODAY
|
||||
SmartList.UPCOMING -> !task.isClosed && dueWindowOf(task, todayStart, todayEnd) == DueWindow.UPCOMING
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,11 +29,11 @@ object TaskSections {
|
||||
|
||||
fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant): TaskSection {
|
||||
if (task.isCompleted) return TaskSection.COMPLETED
|
||||
val due = task.due ?: return TaskSection.NO_DATE
|
||||
return when {
|
||||
due < todayStart -> TaskSection.OVERDUE
|
||||
due < todayEnd -> TaskSection.TODAY
|
||||
else -> TaskSection.UPCOMING
|
||||
return when (dueWindowOf(task, todayStart, todayEnd)) {
|
||||
DueWindow.OVERDUE -> TaskSection.OVERDUE
|
||||
DueWindow.TODAY -> TaskSection.TODAY
|
||||
DueWindow.UPCOMING -> TaskSection.UPCOMING
|
||||
DueWindow.NO_DATE -> TaskSection.NO_DATE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 +14,12 @@ 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
|
||||
import de.jeanlucmakiola.agendula.domain.looksLikeHtml
|
||||
import de.jeanlucmakiola.agendula.domain.newTaskPreset
|
||||
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||
import de.jeanlucmakiola.agendula.domain.tagsInUse
|
||||
import de.jeanlucmakiola.agendula.domain.rebasedForAllDay
|
||||
@@ -28,6 +30,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 +130,7 @@ class TaskEditViewModel @Inject constructor(
|
||||
parentId: Long? = null,
|
||||
initialTitle: String? = null,
|
||||
presetTag: String? = null,
|
||||
presetFilterId: Long? = null,
|
||||
) {
|
||||
if (bound) return
|
||||
bound = true
|
||||
@@ -136,12 +140,16 @@ class TaskEditViewModel @Inject constructor(
|
||||
val settings = settingsPrefs.settings.first()
|
||||
defaultFields = settings.defaultEditFields
|
||||
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
||||
val preset = presetFilterId?.let { settingsPrefs.savedFilter(it).first() }
|
||||
?.newTaskPreset(LocalDate.now())
|
||||
// A read-only or vanished default — or a widget's preset list, if it was
|
||||
// since deleted or turned read-only — would give the task a list it
|
||||
// cannot be saved to.
|
||||
val writable = lists.filter { it.acceptsWrites }
|
||||
val filterLists = preset?.listIds.orEmpty()
|
||||
val defaultList = presetListId?.takeIf { id -> writable.any { it.id == id } }
|
||||
?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } }
|
||||
?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } && (filterLists.isEmpty() || id in filterLists) }
|
||||
?: writable.firstOrNull { it.id in filterLists }?.id
|
||||
?: writable.firstOrNull { !it.isLocal }?.id
|
||||
?: writable.firstOrNull()?.id
|
||||
_state.value = withFields(
|
||||
@@ -150,12 +158,22 @@ class TaskEditViewModel @Inject constructor(
|
||||
isNew = true,
|
||||
autofocusTitle = settings.autofocusTitle,
|
||||
listId = defaultList,
|
||||
start = if (settings.defaultStartToday) Clock.System.now() else null,
|
||||
// A preset due is all-day, so a default start has to be too, or
|
||||
// today's start would land after a due of today.
|
||||
start = when {
|
||||
!settings.defaultStartToday -> null
|
||||
preset?.dueDate != null -> allDayInstantOf(LocalDate.now())
|
||||
else -> Clock.System.now()
|
||||
},
|
||||
// The same all-day value picking the date in the date field gives.
|
||||
due = preset?.dueDate?.let(::allDayInstantOf),
|
||||
isAllDay = preset?.dueDate != null,
|
||||
priority = preset?.priority ?: Priority.NONE,
|
||||
parentId = parentId,
|
||||
lists = lists,
|
||||
parentCandidates = loadParents(defaultList, selfId = null),
|
||||
title = initialTitle.orEmpty(),
|
||||
tags = listOfNotNull(presetTag),
|
||||
tags = (listOfNotNull(presetTag) + preset?.tags.orEmpty()).distinct(),
|
||||
),
|
||||
).also { baseline = it.toForm().copy(title = "") }
|
||||
}
|
||||
|
||||
@@ -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,91 @@
|
||||
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,
|
||||
private val 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 {
|
||||
val saved = settingsPrefs.savedFilter(filterId).first()
|
||||
// Show it as it matches: lists deleted since drop out, as they do in the repository.
|
||||
val existing = runCatching { repository.taskLists().first().mapTo(HashSet()) { it.id } }.getOrNull()
|
||||
_draft.value = saved?.let { if (existing == null) it else it.withListsIn(existing) }
|
||||
?: 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@ import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -40,25 +39,22 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
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
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
@@ -258,27 +254,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,
|
||||
|
||||
@@ -20,6 +20,8 @@ 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
|
||||
@@ -60,6 +62,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 +90,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 +121,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 +132,15 @@ 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))
|
||||
nav.navigate(
|
||||
Dest.TaskEdit.buildNew(
|
||||
presetListId = (filter as? TaskFilter.OfList)?.listId,
|
||||
tag = (filter as? TaskFilter.Tagged)?.tag,
|
||||
filterId = (filter as? TaskFilter.Saved)?.id,
|
||||
),
|
||||
)
|
||||
},
|
||||
onEditFilter = { id -> nav.navigate(Dest.FilterEdit.build(id)) },
|
||||
onBack = { nav.popBackStack() },
|
||||
)
|
||||
}
|
||||
@@ -141,6 +153,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 +207,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 filterId = args?.getLong(Dest.TaskEdit.ARG_FILTER_ID)?.takeIf { it != Dest.NO_ID }
|
||||
val vm: TaskEditViewModel = hiltViewModel()
|
||||
LaunchedEffect(taskId, presetListId, parentId) {
|
||||
if (taskId != Dest.NO_ID) {
|
||||
@@ -179,6 +218,7 @@ fun AgendulaNavHost(
|
||||
parentId = parentId.takeIf { it != Dest.NO_ID },
|
||||
initialTitle = title,
|
||||
presetTag = tag,
|
||||
presetFilterId = filterId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -102,7 +118,8 @@ object Dest {
|
||||
|
||||
/**
|
||||
* The create / edit form. `taskId` set ([NO_ID] otherwise) ⇒ edit; for a new
|
||||
* task, `presetListId` / `parentId` optionally seed the form.
|
||||
* task, `presetListId` / `parentId` optionally seed the form, and `filterId`
|
||||
* fills it so the task lands in that saved filter.
|
||||
*/
|
||||
object TaskEdit {
|
||||
const val ARG_TASK_ID = "taskId"
|
||||
@@ -111,13 +128,15 @@ object Dest {
|
||||
const val ARG_OCCURRENCE = "occ"
|
||||
const val ARG_TITLE = "title"
|
||||
const val ARG_TAG = "tag"
|
||||
const val ARG_FILTER_ID = "filterId"
|
||||
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_FILTER_ID={$ARG_FILTER_ID}"
|
||||
|
||||
val arguments = listOf(
|
||||
navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
||||
@@ -126,6 +145,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_FILTER_ID) { type = NavType.LongType; defaultValue = NO_ID },
|
||||
)
|
||||
|
||||
fun buildEdit(taskId: Long, occurrenceStart: Long? = null): String =
|
||||
@@ -137,12 +157,14 @@ object Dest {
|
||||
parentId: Long? = null,
|
||||
title: String? = null,
|
||||
tag: String? = null,
|
||||
filterId: Long? = null,
|
||||
): 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 (filterId != null) add("$ARG_FILTER_ID=$filterId")
|
||||
}
|
||||
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 ->
|
||||
repository.filteredTasks(f).map {
|
||||
TaskListUiState.Content(it.tasks, savedFilter = it.savedFilter)
|
||||
}
|
||||
// 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,25 @@ 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()
|
||||
// A widget whose filter was deleted keeps the dead id until something else is picked.
|
||||
val liveFilter = selectedFilter?.takeIf { id -> savedFilters.any { it.id == id } }
|
||||
val canSave = kind != WidgetKind.TASKS || selectedSmart != null || liveFilter != null ||
|
||||
selectedLists.isNotEmpty()
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(if (kind == WidgetKind.LISTS) R.string.widget_lists_config_title else R.string.widget_config_title),
|
||||
@@ -239,8 +247,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!!))
|
||||
liveFilter != null -> onSave(TaskFilter.Saved(liveFilter))
|
||||
else -> onSave(TaskFilter.OfLists(selectedLists))
|
||||
}
|
||||
},
|
||||
) { Text(stringResource(android.R.string.ok)) }
|
||||
},
|
||||
@@ -254,9 +265,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 +293,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
|
||||
},
|
||||
)
|
||||
|
||||
@@ -101,6 +101,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 +113,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 +165,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. */
|
||||
@@ -272,10 +277,11 @@ internal fun Context.taskWidgetData(filter: TaskFilter, groupByList: Boolean): F
|
||||
val ep = widgetEntryPoint()
|
||||
val repository = ep.tasksRepository()
|
||||
return combine(
|
||||
repository.tasks(filter),
|
||||
repository.filteredTasks(filter),
|
||||
repository.taskLists(),
|
||||
ep.settingsPrefs().settings,
|
||||
) { tasks, lists, settings ->
|
||||
) { filtered, lists, settings ->
|
||||
val tasks = filtered.tasks
|
||||
val listNames = lists.associate { it.id to it.name }
|
||||
val data: TaskWidgetData = TaskWidgetData.Ready(
|
||||
filter = filter,
|
||||
@@ -292,6 +298,7 @@ internal fun Context.taskWidgetData(filter: TaskFilter, groupByList: Boolean): F
|
||||
dynamicColor = settings.dynamicColor,
|
||||
themeMode = settings.themeMode,
|
||||
use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)),
|
||||
filterName = filtered.savedFilter?.name,
|
||||
)
|
||||
data
|
||||
}.catch { emit(TaskWidgetData.Unavailable) }
|
||||
|
||||
@@ -33,4 +33,77 @@
|
||||
<string name="priority_medium">متوسط</string>
|
||||
<string name="priority_high">مرتفع</string>
|
||||
<string name="detail_list">القائمة</string>
|
||||
<string name="add_account_step_of">الخطوة %1$d من %2$d</string>
|
||||
<string name="onboarding_welcome_title">كل شئ كنت تقصد لفعله</string>
|
||||
<string name="onboarding_welcome_body">مكان هادئ لمهامك، على هاتف يظل ملكًا لك. لنقم بإعداده — سيستغرق الأمر دقيقة.</string>
|
||||
<string name="onboarding_welcome_lists_title">القوائم والمهام الفرعية</string>
|
||||
<string name="onboarding_welcome_lists_body">ابقي الفصل بين العمل والمنزل والمشروع، وقسّم المهام الكبيرة.</string>
|
||||
<string name="onboarding_welcome_reminders_title">التذكيرات التي تصل</string>
|
||||
<string name="onboarding_welcome_reminders_body">إشعار عندما يكون شيء ما مستحق، وفقًا لجدولك الزمني.</string>
|
||||
<string name="onboarding_welcome_sync_title">خادمك، أو لا شيء على الإطلاق</string>
|
||||
<string name="add_account_browser_confirm_body">توجد صفحة تسجيل الدخول على خادمك، ليس في Agendula — لذا من القيّم إلقاء نظرة عليها قبل كتابة كلمة مرور حسابك هناك.</string>
|
||||
<string name="onboarding_welcome_button">ابدأ</string>
|
||||
<string name="reminder_onboarding_title">ابقى على اطلاع بمهامك</string>
|
||||
<string name="shortcut_today">اليوم</string>
|
||||
<string name="qs_tile_new_task">مهمة جديدة</string>
|
||||
<string name="lists_reorder">إعادة ترتيب القوائم</string>
|
||||
<string name="lists_reorder_done">انتهت إعادة الترتيب</string>
|
||||
<string name="settings_title">الإعدادات</string>
|
||||
<string name="settings_section_appearance">المظهر</string>
|
||||
<string name="settings_about_source">المصدر</string>
|
||||
<string name="settings_license">الترخيص</string>
|
||||
<string name="settings_theme">السمة</string>
|
||||
<string name="settings_theme_light">فاتح</string>
|
||||
<string name="settings_theme_dark">داكن</string>
|
||||
<string name="settings_section_reminders">التذكيرات</string>
|
||||
<string name="settings_list_notifications">الإشعارات</string>
|
||||
<string name="settings_group_data">البيانات</string>
|
||||
<string name="settings_group_app">التطبيق</string>
|
||||
<string name="settings_group_about">حول</string>
|
||||
<string name="settings_time_format_auto">تلقائي</string>
|
||||
<string name="settings_week_start_auto">تلقائي</string>
|
||||
<string name="settings_section_storage">التخزين</string>
|
||||
<string name="settings_section_accounts">الحسابات</string>
|
||||
<string name="settings_list_reminder_inherits">الافتراضي (%1$s)</string>
|
||||
<string name="settings_section_task_form">نموذج مهمة</string>
|
||||
<string name="settings_about_support">دَعم التطوير</string>
|
||||
<string name="settings_about_version">الإصدار %1$s</string>
|
||||
<string name="settings_language">لغة التطبيق</string>
|
||||
<string name="settings_language_auto">افتراضي النظام</string>
|
||||
<string name="settings_translate">المساعدة في الترجمة</string>
|
||||
<string name="settings_theme_system">يتبع النظام</string>
|
||||
<string name="settings_dynamic_color">اللون الديناميكي</string>
|
||||
<string name="settings_reminders">التذكيرات المستحقة</string>
|
||||
<string name="settings_exact_alarms">التوقيت الدقيق</string>
|
||||
<string name="settings_default_list">القائمة الافتراضية</string>
|
||||
<string name="settings_time_format">تنسيق الوقت</string>
|
||||
<string name="settings_time_format_24h">٢٤-ساعة (١٤:٠٠)</string>
|
||||
<string name="settings_week_start_auto_summary">الحالي %1$s</string>
|
||||
<string name="settings_privacy">سياسة الخصوصية</string>
|
||||
<string name="settings_default_reminder_allday">مهام يوم كامل</string>
|
||||
<string name="settings_reliable_delivery">توصيل موثوق</string>
|
||||
<string name="settings_snooze_duration">مدة التأجيل</string>
|
||||
<string name="settings_task_store">مخزن المهام</string>
|
||||
<string name="settings_store_permission_denied">رُفِض الإذن</string>
|
||||
<string name="settings_copy_running">جارٍ نسخ المهام…</string>
|
||||
<string name="settings_export">تصدير المهام</string>
|
||||
<string name="settings_about_author">بواسطة Jean-Luc Makiola</string>
|
||||
<string name="settings_about_logo_desc">رمز تطبيق Agendula</string>
|
||||
<string name="settings_report_problem">الإبلاغ عن مشكلة</string>
|
||||
<string name="settings_default_reminder">متى لتذكير</string>
|
||||
<string name="settings_group_look">المظهر و السلوك</string>
|
||||
<string name="settings_bottom_add_bar">شريط الإضافة السريع السفلي</string>
|
||||
<string name="settings_time_format_12h">١٢-ساعة (٢:٠٠ م)</string>
|
||||
<string name="settings_week_start">الأسبوع يبدأ في</string>
|
||||
<string name="settings_licences">تراخيص مفتوحة المصدر</string>
|
||||
<string name="settings_notifications_blocked">الإشعارات متوقفة</string>
|
||||
<string name="settings_store_own">على هذا الجهاز</string>
|
||||
<string name="settings_allday_reminder_fires_at">يتم الإشعار في %1$s</string>
|
||||
<string name="settings_store_external">تطبيق مهام آخر</string>
|
||||
<string name="settings_store_switch_title">تبديل مخزن المهام؟</string>
|
||||
<string name="settings_copy_confirm_title">نسخ المهام؟</string>
|
||||
<string name="settings_legacy_import_retrying">جارٍ نقلهم الآن…</string>
|
||||
<string name="settings_report_problem_hint">افتح متعقب المشكلات</string>
|
||||
<string name="settings_copy_from_external">نسخ المهام من %1$s</string>
|
||||
<string name="task_completed">مكتمل</string>
|
||||
</resources>
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
<string name="reminder_onboarding_body">Agendula benachrichtigt dich, wenn etwas fällig wird, also muss nichts in deinem Kopf leben.</string>
|
||||
<string name="reminder_onboarding_enable_button">Erinnerungen aktivieren</string>
|
||||
<string name="reminder_onboarding_skip_button">Nicht jetzt</string>
|
||||
<string name="reminder_benefit_delivery_title">Agendula erinnert dich</string>
|
||||
<string name="reminder_benefit_delivery_title">Ein kleiner Schubs, wenn es darauf ankommt</string>
|
||||
<string name="reminder_benefit_delivery_body">Jede Aufgaben mit Fälligkeitsdatum bekommt seine eigene Benachrichtigung.</string>
|
||||
<string name="reminder_benefit_timing_title">Auf deinem Zeitplan</string>
|
||||
<string name="reminder_benefit_timing_body">Wähle, wie weit im Voraus du erinnert werden möchtest — für alles oder pro Liste.</string>
|
||||
@@ -409,4 +409,27 @@
|
||||
<string name="sync_notice_body">%1$s</string>
|
||||
<string name="sync_notices_title">Änderungen durch letzte Synchronisierung</string>
|
||||
<string name="sync_notices_dismiss">Verstanden</string>
|
||||
<string name="recurrence_title">Wiederholung</string>
|
||||
<plurals name="recurrence_with_count">
|
||||
<item quantity="one">%1$s, %2$d Mal</item>
|
||||
<item quantity="other">%1$s, %2$d Mal</item>
|
||||
</plurals>
|
||||
<string name="onboarding_exact_alarms_title">Genau rechtzeitig</string>
|
||||
<string name="onboarding_exact_alarms_body">Android frägt dich, bevor dich eine App minutengenau benachrichtigen darf. Erlaube es und die Erinnerungen kommen, wenn sie fällig sind — ohne die Erlaubnis, könnten sich die Erinnerungen ein paar Minuten verspäten.</string>
|
||||
<string name="onboarding_exact_alarms_allow">Erlaube genaues Timing</string>
|
||||
<string name="settings_time_format_12h">12-Stunden (2:00 PM)</string>
|
||||
<string name="settings_time_format_24h">24-Stunden (14:00)</string>
|
||||
<string name="settings_time_format_auto_summary">Folgend dem System: %1$s</string>
|
||||
<string name="settings_notifications_blocked_hint">Erinnerungen können dich nicht erreichen. Drücke, um Benachrichtigungen von Agendula zu erlauben.</string>
|
||||
<string name="list_delete_confirm_message_synced">\"%1$s\" und alle Aufgaben davon werden von dem Server und jedem anderen Gerät, das damit verbunden ist, gelöscht. Das kann nicht rückgängig gemacht werden.</string>
|
||||
<string name="onboarding_sync_devices_title">Jedes Gerät auf einmal</string>
|
||||
<string name="onboarding_quick_add_bar">Schnell-Hinzufügen-Leiste</string>
|
||||
<string name="onboarding_quick_add_button_hint">Ein schwebender Button, der das ganze Formular öffnet.</string>
|
||||
<string name="settings_default_reminder_allday">Ganztägige Aufgaben</string>
|
||||
<string name="onboarding_use_own_store">Stattdessen lokalen Speicher dieses Geräts nutzen</string>
|
||||
<string name="settings_section_storage">Speicher</string>
|
||||
<string name="settings_store_own">Auf diesem Gerät</string>
|
||||
<string name="settings_store_own_hint">Agendulas eigener Speicher. Nichts weiteres zu installieren nötig.</string>
|
||||
<string name="settings_store_external">Eine andere Aufgaben-App</string>
|
||||
<string name="settings_store_permission_denied">Erlaubnis verweigert</string>
|
||||
</resources>
|
||||
|
||||
@@ -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,92 @@
|
||||
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 `unticking the last list here keeps the other store's lists`() {
|
||||
var raw = SavedFilterCodec.upsert(null, filter, "OWN")
|
||||
raw = SavedFilterCodec.upsert(raw, filter.copy(listIds = setOf(1L)), "EXTERNAL")
|
||||
val external = SavedFilterCodec.decode(raw, "EXTERNAL").single()
|
||||
assertThat(external.listsElsewhere).isTrue()
|
||||
raw = SavedFilterCodec.upsert(raw, external.copy(listIds = emptySet()), "EXTERNAL")
|
||||
assertThat(SavedFilterCodec.decode(raw, "OWN").single().listIds).containsExactly(4L, 9L)
|
||||
assertThat(SavedFilterCodec.decode(raw, "EXTERNAL").single().anyList).isFalse()
|
||||
}
|
||||
|
||||
@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,119 @@
|
||||
package de.jeanlucmakiola.agendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
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()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day due is read as its date west of utc`() {
|
||||
val zone = ZoneId.of("America/New_York")
|
||||
val date = LocalDate.of(2026, 10, 10)
|
||||
val start = Instant.fromEpochMilliseconds(date.atStartOfDay(zone).toInstant().toEpochMilli())
|
||||
val end = Instant.fromEpochMilliseconds(date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli())
|
||||
fun window(d: LocalDate) = dueWindowOf(testTask(due = allDayInstantOf(d), isAllDay = true), start, end, zone)
|
||||
assertThat(window(date)).isEqualTo(DueWindow.TODAY)
|
||||
assertThat(window(date.plusDays(1))).isEqualTo(DueWindow.UPCOMING)
|
||||
assertThat(window(date.minusDays(1))).isEqualTo(DueWindow.OVERDUE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new task preset lands in its filter`() {
|
||||
val day = LocalDate.of(2026, 10, 10)
|
||||
val filter = empty.copy(
|
||||
due = setOf(DueWindow.UPCOMING),
|
||||
tags = setOf("work", "home"),
|
||||
tagMatch = TagMatch.ALL,
|
||||
minPriority = Priority.HIGH,
|
||||
)
|
||||
val preset = filter.newTaskPreset(day)
|
||||
assertThat(preset.dueDate).isEqualTo(day.plusDays(1))
|
||||
assertThat(preset.tags).containsExactly("home", "work")
|
||||
assertThat(preset.priority).isEqualTo(Priority.HIGH)
|
||||
assertThat(empty.copy(due = setOf(DueWindow.TODAY, DueWindow.UPCOMING)).newTaskPreset(day).dueDate).isEqualTo(day)
|
||||
assertThat(empty.copy(due = setOf(DueWindow.TODAY, DueWindow.NO_DATE)).newTaskPreset(day).dueDate).isNull()
|
||||
assertThat(empty.copy(tags = setOf("b", "a")).newTaskPreset(day).tags).containsExactly("a")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lists since deleted drop out`() {
|
||||
val filter = empty.copy(listIds = setOf(1L, 5L))
|
||||
assertThat(filter.withListsIn(setOf(1L, 2L)).listIds).containsExactly(1L)
|
||||
assertThat(filter.withListsIn(setOf(2L)).anyList).isTrue()
|
||||
assertThat(filter.copy(listsElsewhere = true).withListsIn(setOf(2L)).anyList).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(filterId = 5)).isEqualTo("taskEdit?filterId=5")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
### جديد
|
||||
- الوسوم: أضفها إلى المهام، وتصفحها من الشاشة الرئيسية، وصفِّ أي قائمة بها. تُزامَن كـ CATEGORIES.
|
||||
- عوامل التصفية: احفظ عرضك الخاص للمهام المفتوحة حسب الاستحقاق والقائمة والوسم والأولوية، وفي الأداة أيضًا.
|
||||
- أدوات جديدة: Today وUp next وLists وWeek. يمكن للأداة عرض عدة قوائم.
|
||||
|
||||
### تغييرات
|
||||
- صفوف بنمط الأجندة في أداة المهام.
|
||||
- يمكن الآن إعادة تسمية القوائم المتزامنة وحذفها.
|
||||
|
||||
### إصلاحات
|
||||
- لم تعد مهام اليوم الكامل تنزاح يومًا غرب UTC.
|
||||
@@ -1,12 +1,11 @@
|
||||
### Neu
|
||||
- Neue Widgets: Today, Up next, Lists und Week.
|
||||
- Ein Widget kann mehrere Listen zeigen, auf Wunsch nach Liste gruppiert.
|
||||
- Neue Aufgaben können heute beginnen (Einstellungen → Aufgaben-Formular).
|
||||
- Tags: an Aufgaben vergeben, auf dem Startbildschirm durchsuchen, jede Liste danach filtern.
|
||||
- Filter: eigene Ansicht offener Aufgaben nach Fälligkeit, Liste, Tag und Priorität, auch im Widget.
|
||||
- Neue Widgets: Today, Up next, Lists und Week. Ein Widget kann mehrere Listen zeigen.
|
||||
|
||||
### Geändert
|
||||
- Das Aufgaben-Widget zeigt Zeilen im Agenda-Stil, optional farbig.
|
||||
- „At a glance“ entfällt; die Widgets Aufgaben und Today decken es ab.
|
||||
- Synchronisierte Listen lassen sich jetzt auch umbenennen und löschen.
|
||||
- Agenda-Zeilen im Aufgaben-Widget.
|
||||
- Synchronisierte Listen lassen sich umbenennen und löschen.
|
||||
|
||||
### Behoben
|
||||
- Doppelter Abstand im Listen-Editor.
|
||||
- Ganztägige Aufgaben landen westlich von UTC nicht mehr einen Tag daneben.
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
### Added
|
||||
- New home-screen widgets: Today, Up next, Lists and Week.
|
||||
- A widget can show tasks from several lists, optionally grouped by list.
|
||||
- Settings → Task form can pre-fill a new task's start with today.
|
||||
- Tags: add them to tasks, browse them from the home screen and filter any list by them. They sync as CATEGORIES.
|
||||
- Filters: save your own view of open tasks by due date, list, tag and priority, and show it in a widget.
|
||||
- New widgets: Today, Up next, Lists and Week. A widget can show several lists.
|
||||
|
||||
### Changed
|
||||
- The Tasks widget has agenda-style rows and an optional row colour.
|
||||
- "At a glance" is gone; the Tasks and Today widgets cover it.
|
||||
- Synced lists can now be renamed and deleted too.
|
||||
- Agenda-style rows in the Tasks widget.
|
||||
- Synced lists can be renamed and deleted.
|
||||
|
||||
### Fixed
|
||||
- Double padding in the list editor.
|
||||
- All-day tasks no longer land a day off west of UTC.
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
### Added
|
||||
- New home-screen widgets: Today, Up next, Lists and Week.
|
||||
- A widget can show tasks from several lists, optionally grouped by list.
|
||||
- Settings → Task form can pre-fill a new task's start with today.
|
||||
- Tags: add them to tasks, browse them from the home screen and filter any list by them. They sync as CATEGORIES.
|
||||
- Filters: save your own view of open tasks by due date, list, tag and priority, and show it in a widget.
|
||||
- New widgets: Today, Up next, Lists and Week. A widget can show several lists.
|
||||
|
||||
### Changed
|
||||
- The Tasks widget has agenda-style rows and an optional row colour.
|
||||
- "At a glance" is gone; the Tasks and Today widgets cover it.
|
||||
- Synced lists can now be renamed and deleted too.
|
||||
- Agenda-style rows in the Tasks widget.
|
||||
- Synced lists can be renamed and deleted.
|
||||
|
||||
### Fixed
|
||||
- Double padding in the list editor.
|
||||
- All-day tasks no longer land a day off west of UTC.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
### Nouveautés
|
||||
- Étiquettes : ajoutez-les aux tâches, parcourez-les depuis l'accueil, filtrez toute liste.
|
||||
- Filtres : votre vue des tâches ouvertes par échéance, liste, étiquette et priorité, aussi en widget.
|
||||
- Widgets Today, Up next, Lists et Week. Un widget peut montrer plusieurs listes.
|
||||
|
||||
### Modifié
|
||||
- Lignes façon agenda dans le widget Tâches.
|
||||
- Listes synchronisées : renommer et supprimer.
|
||||
|
||||
### Corrigé
|
||||
- Les tâches sur la journée ne sont plus décalées d'un jour à l'ouest d'UTC.
|
||||
@@ -1,12 +1,11 @@
|
||||
### Novo
|
||||
- Novos widgets: Today, Up next, Lists e Week.
|
||||
- Um widget pode mostrar várias listas, agrupadas por lista se quiser.
|
||||
- Novas tarefas podem começar hoje (Ajustes → Formulário de tarefa).
|
||||
- Tags: adicione às tarefas, navegue pela tela inicial e filtre qualquer lista por elas.
|
||||
- Filtros: salve sua visão das tarefas abertas por data, lista, tag e prioridade, também em widget.
|
||||
- Novos widgets: Today, Up next, Lists e Week. Um widget pode mostrar várias listas.
|
||||
|
||||
### Alterado
|
||||
- O widget Tasks mostra linhas em estilo agenda, com cor opcional.
|
||||
- "At a glance" saiu; os widgets Tasks e Today cobrem o que ele mostrava.
|
||||
- Listas sincronizadas agora também podem ser renomeadas e excluídas.
|
||||
- Linhas em estilo agenda no widget Tasks.
|
||||
- Listas sincronizadas podem ser renomeadas e excluídas.
|
||||
|
||||
### Corrigido
|
||||
- Espaçamento duplo no editor de listas.
|
||||
- Tarefas de dia inteiro não caem mais um dia errado a oeste do UTC.
|
||||
|
||||
Reference in New Issue
Block a user