Compare commits

..
Author SHA1 Message Date
makiolaj ee0250faf9 chore: 1.1.0-beta.3
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 11m51s
2026-10-09 12:21:05 +02:00
Jean-Luc Makiolaandmakiolaj aa2aade930 feat: tags (#35) (#46)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Adds tags, stored as the standard `CATEGORIES` property so they stay compatible with Nextcloud Tasks, tasks.org and DAVx⁵.

## What changed

- **Storage and sync:** tags are a new `categories` column (database v2). The migration lifts `CATEGORIES` out of the stored unknown properties for tasks that were synced before, so existing tags show up without a re-download. A line Agendula can't write back exactly as it came (e.g. a duplicated value, or the property split across several lines) stays where it was and still syncs byte for byte.
- **Provider mode (OpenTasks / tasks.org):** tags are read and written as the provider's category properties. Writes are a single batch, so a refused tag can't leave half the tags behind or create a task twice.
- **Imports:** the copy from OpenTasks/tasks.org and the legacy one-shot import both bring tags across.
- **Editor:** a new optional Tags field. Type a tag and press Done or type a comma; tags already in use are suggested while typing, and text left in the field is still saved.
- **Display:** tags show as pills on the detail screen and in each row's info line in task lists.
- **Using tags:** the home screen has a Tags section listing every tag on an open task with its count. Tapping one opens that tag's tasks across all lists, and "New task" there starts with the tag set. Every task list's sort button is now a "Sort and filter" picker, where you can tick one or more tags to narrow the list.

## Also in here

- **Keyboard:** a focused input now scrolls fully above the keyboard, not just its text line. This uses floret-kit's new `revealOnFocus` (jlmakiola/floret-kit#12), and the pointer moves to floret-kit main.
- **Edit screen:** once the title scrolls out of view, it shows in the top bar.
- **main merged in:** the branch brings main's #40 and #44 (floret-kit on its shared main, Gradle 9.8), which `release/v1.1.0` doesn't have yet.

## Where this differs from the issue

- **Filtering by several tags** shows tasks that carry *all* of them, since ticking more tags is meant to narrow the list.
- **Tags are case-sensitive:** "Work" and "work" are two tags, matching how they are stored and synced.
- **The overview** lists tags on open tasks only, the same set the editor suggests.
- **Widgets** can't be set to a tag yet.

Closes #35

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/46
2026-10-09 12:20:21 +02:00
Jean-Luc Makiolaandmakiolaj 89775e44a7 chore: merge main into release/v1.1.0 (#42)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Successful in 26m27s
Brings the exact tag checks from #40 into the release branch. beta.yaml runs from the branch's own files, and the old prefix-matching lookup treated v1.1.0-beta.1 as a shipped v1.1.0, so the 1.1.0-beta.2 run was refused.

The one conflict was in publish_codeberg_release.sh: kept #40's stricter upload handling plus the offline APK assets from #41. scripts/release_gate.sh now reports cut=true for 1.1.0-beta.2.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/42
2026-10-06 19:19:06 +02:00
Jean-Luc Makiola c0eb03e5df Merge pull request 'feat: offline flavor without sync or network access (#39)' (#41) from feat/39-offline-flavor into release/v1.1.0
Beta — Codeberg pre-release / detect (push) Failing after 6s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-06 19:14:57 +02:00
makiolaj 7bfda26de9 chore: 1.1.0-beta.2 2026-10-06 19:11:50 +02:00
makiolaj f65aed4fe2 ci: install JetBrains 21 directly instead of fetching it via foojay 2026-10-06 19:00:48 +02:00
makiolaj 84bb1116ec feat: offline flavor without sync or network access (#39) 2026-10-06 18:34:33 +02:00
makiolaj 7fde7ae290 fix: restore the floret glance dependency lost in the rebase
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 10m34s
2026-10-05 18:58:03 +02:00
makiolaj 5d273e05c7 chore: 1.1.0-beta.1
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Failing after 2m31s
2026-10-05 18:51:20 +02:00
makiolaj 59582b3641 docs(release): keep CHANGELOG entries under Unreleased while betas go out 2026-10-05 18:49:31 +02:00
makiolaj 3540d2fb7c chore: release 1.1.0 2026-10-05 18:49:15 +02:00
makiolaj 3812e950ca fix: remove double inset on the list editor's smart-lists row 2026-10-05 18:49:08 +02:00
makiolaj c6d62c36f4 fix(widget): regenerate picker previews to match the final widget designs 2026-10-05 18:49:08 +02:00
makiolaj a4fbbf43c6 feat: allow editing synced lists, not just device-only ones
The edit pencil was gated to local lists even though rename/delete already
worked end to end for synced collections. Read-only shares keep the icon
but show it disabled, with a hint on tap explaining why.

Refs #29
2026-10-05 18:49:08 +02:00
makiolaj d81e99084e fix: remove double inset on the list editor's Where row
Refs #30
2026-10-05 18:49:08 +02:00
Jean-Luc Makiolaandmakiolaj 9a87837917 feat(widget): widget redesign and new Today/Up next/Lists/Week widgets (#33)
Reworks the home-screen widgets on top of #32.

- Tasks widget: agenda-style rows, a row-colour setting, an "All caught up" empty state
- Drops "At a glance" (it duplicated the Tasks widget) for four new widgets: Today (progress ring + today's tasks), Up next (the nearest due task, optionally from one list), Lists (adaptive shortcut grid), Week (day strip with counts)
- Ticking a task in any widget shows a done frame before the row goes away
- Shared Glance primitives live in floret-kit (`feat/glance-module`): progress ring, tiles, check button, header action pill, expressive shapes, done state

Today and Up next don't offer the transparent background, since their content isn't in containers and is unreadable on the wallpaper. No issue behind this one; it came out of reviewing the widgets on-device.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/33
2026-10-05 18:49:08 +02:00
Jean-Luc Makiolaandmakiolaj 9630bd104d feat: let a widget show tasks from several lists (#32)
Widget config now multi-selects real lists (smart lists stay
single-select, mutually exclusive) and offers an optional "group by
list" sort that heads each list's tasks with its name.

Closes #25

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/32
2026-10-05 18:49:08 +02:00
Jean-Luc Makiolaandmakiolaj 7c3b62eb0d feat: setting to default a new task's start to today (#31)
A new Settings → Task form toggle, off by default, pre-fills a fresh
task's start with the current moment instead of leaving it blank.

Closes #27

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/31
2026-10-05 18:49:08 +02:00
42 changed files with 111 additions and 1349 deletions
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
detect:
# Gitea only; see the same guard in release.yaml.
if: github.repository_owner == 'makiolaj'
runs-on: light
runs-on: docker
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: gradle
runs-on: docker
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
+2 -2
View File
@@ -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: light
runs-on: docker
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: gradle
runs-on: docker
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
-8
View File
@@ -7,8 +7,6 @@ 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
@@ -19,10 +17,6 @@ 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
@@ -41,8 +35,6 @@ 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
-10
View File
@@ -76,16 +76,6 @@ 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 |
+2 -2
View File
@@ -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 = 1010099
versionName = "1.1.0"
versionCode = 1010003
versionName = "1.1.0-beta.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -149,8 +149,6 @@ 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"
@@ -200,12 +198,6 @@ 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) {
@@ -223,8 +215,6 @@ 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) {
@@ -1,83 +0,0 @@
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,7 +10,6 @@ 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
@@ -21,7 +20,6 @@ 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
@@ -261,34 +259,6 @@ 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 }
}
@@ -318,8 +288,6 @@ 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,6 +1,5 @@
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
@@ -14,9 +13,6 @@ 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
@@ -31,10 +27,7 @@ interface TasksRepository {
*/
fun writableTaskLists(): Flow<List<TaskList>> =
taskLists().map { lists -> lists.filter { it.acceptsWrites } }
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>
fun tasks(filter: TaskFilter): Flow<List<Task>>
/**
* The direct children of [parentId], re-emitting live like the other flows.
@@ -1,9 +1,7 @@
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
@@ -19,10 +17,7 @@ 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
@@ -37,26 +32,12 @@ 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() }
@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 tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
SeriesCollapse.visible(dataSource.subtasks(parentId))
@@ -74,7 +55,7 @@ class TasksRepositoryImpl @Inject constructor(
}
}
private fun loadTasks(filter: TaskFilter, saved: SavedFilter? = null): List<Task> {
private fun loadTasks(filter: TaskFilter): 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
@@ -86,7 +67,6 @@ 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))
@@ -98,7 +78,7 @@ class TasksRepositoryImpl @Inject constructor(
(if (t.isCompleted) done + 1 else done) to (total + 1)
}
return all
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd, saved) }
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) }
.map { task ->
progress[task.taskId]?.let { (done, total) ->
task.copy(subtaskDone = done, subtaskTotal = total)
@@ -1,96 +0,0 @@
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,8 +13,6 @@ 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
}
/**
@@ -24,32 +22,26 @@ sealed interface TaskFilter {
*/
object TaskFiltering {
/** [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 =
fun matches(task: Task, filter: TaskFilter, todayStart: Instant, todayEnd: Instant): Boolean =
when (filter) {
is TaskFilter.OfList -> task.listId == filter.listId
is TaskFilter.OfLists -> task.listId in filter.listIds
is TaskFilter.Smart -> matchesSmart(task, filter.list, todayStart, todayEnd)
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 =
when (list) {
private fun matchesSmart(task: Task, list: SmartList, todayStart: Instant, todayEnd: Instant): Boolean {
val due = task.due
return when (list) {
SmartList.COMPLETED -> task.isCompleted
SmartList.ALL -> !task.isClosed
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
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
}
}
}
@@ -29,11 +29,11 @@ object TaskSections {
fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant): TaskSection {
if (task.isCompleted) return TaskSection.COMPLETED
return when (dueWindowOf(task, todayStart, todayEnd)) {
DueWindow.OVERDUE -> TaskSection.OVERDUE
DueWindow.TODAY -> TaskSection.TODAY
DueWindow.UPCOMING -> TaskSection.UPCOMING
DueWindow.NO_DATE -> TaskSection.NO_DATE
val due = task.due ?: return TaskSection.NO_DATE
return when {
due < todayStart -> TaskSection.OVERDUE
due < todayEnd -> TaskSection.TODAY
else -> TaskSection.UPCOMING
}
}
}
@@ -1,51 +0,0 @@
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,12 +14,10 @@ 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
@@ -30,7 +28,6 @@ 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
@@ -130,7 +127,6 @@ class TaskEditViewModel @Inject constructor(
parentId: Long? = null,
initialTitle: String? = null,
presetTag: String? = null,
presetFilterId: Long? = null,
) {
if (bound) return
bound = true
@@ -140,16 +136,12 @@ 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 } && (filterLists.isEmpty() || id in filterLists) }
?: writable.firstOrNull { it.id in filterLists }?.id
?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } }
?: writable.firstOrNull { !it.isLocal }?.id
?: writable.firstOrNull()?.id
_state.value = withFields(
@@ -158,22 +150,12 @@ class TaskEditViewModel @Inject constructor(
isNew = true,
autofocusTitle = settings.autofocusTitle,
listId = defaultList,
// 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,
start = if (settings.defaultStartToday) Clock.System.now() else null,
parentId = parentId,
lists = lists,
parentCandidates = loadParents(defaultList, selfId = null),
title = initialTitle.orEmpty(),
tags = (listOfNotNull(presetTag) + preset?.tags.orEmpty()).distinct(),
tags = listOfNotNull(presetTag),
),
).also { baseline = it.toForm().copy(title = "") }
}
@@ -1,259 +0,0 @@
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
},
)
@@ -1,91 +0,0 @@
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,6 +30,7 @@ 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
@@ -39,22 +40,25 @@ 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
@@ -254,14 +258,27 @@ internal fun ListNameField(
autoFocus: Boolean,
onNameChange: (String) -> Unit,
onImeAction: () -> Unit,
) = NameField(
name = name,
placeholder = stringResource(R.string.list_name_hint),
autoFocus = autoFocus,
onNameChange = onNameChange,
onImeAction = onImeAction,
leading = { ListColorChip(color) },
)
) {
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),
)
}
}
}
/**
* The palette as two rows of round swatches; the chosen one carries a check.
@@ -3,7 +3,6 @@ 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
@@ -32,7 +31,6 @@ 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
@@ -89,7 +87,6 @@ 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
@@ -118,7 +115,6 @@ fun ListsScreen(
onNewTask: () -> Unit,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier,
onNewFilter: () -> Unit = {},
viewModel: ListsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -184,7 +180,6 @@ fun ListsScreen(
onOpenFilter = onOpenFilter,
onOpenTask = onOpenTask,
onNewList = { newList = true },
onNewFilter = onNewFilter,
onReorder = viewModel::reorderLists,
topPadding = 0.dp,
bottomPadding = inner.calculateBottomPadding() + 96.dp,
@@ -258,7 +253,6 @@ 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,
@@ -402,65 +396,6 @@ 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,7 +8,6 @@ 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
@@ -76,7 +75,6 @@ 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
@@ -93,8 +91,6 @@ 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
}
@@ -133,9 +129,8 @@ 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(),
settingsPrefs.savedFilters,
) { lists, openTasks, completedTasks, hidden, filters ->
buildContent(lists, openTasks, completedTasks, hidden, filters) as ListsUiState
) { lists, openTasks, completedTasks, hidden ->
buildContent(lists, openTasks, completedTasks, hidden) as ListsUiState
}.recoveringFromProviderFailure { ListsUiState.Failure }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
@@ -144,7 +139,6 @@ 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
@@ -195,15 +189,8 @@ 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,8 +20,6 @@ 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
@@ -62,9 +60,6 @@ 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()
@@ -90,7 +85,6 @@ 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) },
)
}
@@ -121,7 +115,6 @@ 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) }
@@ -132,15 +125,10 @@ fun AgendulaNavHost(
viewModel = vm,
onOpenTask = onOpenTask,
onNewTask = {
nav.navigate(
Dest.TaskEdit.buildNew(
presetListId = (filter as? TaskFilter.OfList)?.listId,
tag = (filter as? TaskFilter.Tagged)?.tag,
filterId = (filter as? TaskFilter.Saved)?.id,
),
)
val presetListId = (filter as? TaskFilter.OfList)?.listId
val tag = (filter as? TaskFilter.Tagged)?.tag
nav.navigate(Dest.TaskEdit.buildNew(presetListId = presetListId, tag = tag))
},
onEditFilter = { id -> nav.navigate(Dest.FilterEdit.build(id)) },
onBack = { nav.popBackStack() },
)
}
@@ -153,32 +141,6 @@ 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 }
@@ -207,7 +169,6 @@ 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) {
@@ -218,7 +179,6 @@ fun AgendulaNavHost(
parentId = parentId.takeIf { it != Dest.NO_ID },
initialTitle = title,
presetTag = tag,
presetFilterId = filterId,
)
}
}
@@ -54,52 +54,36 @@ object Dest {
/**
* One [TaskFilter]'s tasks. Carries one of `listId` (a real list), `smart`
* (a [SmartList] name), `tag` or `filterId` (a saved filter); whichever is
* set decides the filter.
* (a [SmartList] name) or `tag`; 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 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}"
const val route = "taskList?$ARG_LIST_ID={$ARG_LIST_ID}&$ARG_SMART={$ARG_SMART}&$ARG_TAG={$ARG_TAG}"
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, filterId: Long = NO_ID): TaskFilter = when {
fun filterOf(smart: String?, listId: Long, tag: String? = null): 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"
@@ -118,8 +102,7 @@ object Dest {
/**
* The create / edit form. `taskId` set ([NO_ID] otherwise) ⇒ edit; for a new
* task, `presetListId` / `parentId` optionally seed the form, and `filterId`
* fills it so the task lands in that saved filter.
* task, `presetListId` / `parentId` optionally seed the form.
*/
object TaskEdit {
const val ARG_TASK_ID = "taskId"
@@ -128,15 +111,13 @@ 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_FILTER_ID={$ARG_FILTER_ID}"
"&$ARG_TAG={$ARG_TAG}"
val arguments = listOf(
navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID },
@@ -145,7 +126,6 @@ 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 =
@@ -157,14 +137,12 @@ 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,9 +30,6 @@ 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,7 +140,6 @@ fun TaskListScreen(
onNewTask: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
onEditFilter: (Long) -> Unit = {},
viewModel: TaskListViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -159,9 +158,6 @@ 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)
@@ -190,7 +186,7 @@ fun TaskListScreen(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MediumTopAppBar(
title = { Text(titleFor(filter, listName ?: savedFilter?.name)) },
title = { Text(titleFor(filter, listName)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
@@ -205,11 +201,6 @@ 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 = {
@@ -369,8 +360,7 @@ 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 ||
(filter is TaskFilter.Saved && state.savedFilter?.listIds?.size != 1)
val showListName = filter is TaskFilter.Smart || filter is TaskFilter.Tagged
// 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
@@ -1275,9 +1265,8 @@ private fun ViewOptionsPicker(
}
@Composable
private fun titleFor(filter: TaskFilter, name: String?): String = when (filter) {
is TaskFilter.OfList -> name ?: stringResource(R.string.tasks_title)
is TaskFilter.Saved -> name.orEmpty()
private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) {
is TaskFilter.OfList -> listName ?: stringResource(R.string.tasks_title)
// Several lists at once only exists for the widget, which never routes here.
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
is TaskFilter.Tagged -> filter.tag
@@ -7,7 +7,6 @@ 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
@@ -62,10 +61,6 @@ 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
}
@@ -116,10 +111,6 @@ 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")
}
@@ -135,7 +126,6 @@ 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,11 +170,6 @@ 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)
}
@@ -286,7 +281,6 @@ 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,7 +41,6 @@ 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
@@ -119,7 +118,6 @@ 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) }
@@ -145,7 +143,6 @@ class TaskWidgetConfigActivity : ComponentActivity() {
WidgetFilterPicker(
kind = kind,
lists = lists.filter { it.isVisible },
savedFilters = savedFilters,
dark = dark,
selected = current ?: DEFAULT_WIDGET_FILTER,
groupByList = groupByList,
@@ -196,7 +193,6 @@ class TaskWidgetConfigActivity : ComponentActivity() {
private fun WidgetFilterPicker(
kind: WidgetKind,
lists: List<TaskList>,
savedFilters: List<SavedFilter>,
selected: TaskFilter,
dark: Boolean,
groupByList: Boolean,
@@ -215,25 +211,21 @@ private fun WidgetFilterPicker(
var showBackgroundPicker by remember { mutableStateOf(false) }
var showThemePicker by remember { mutableStateOf(false) }
var showRowColorPicker by remember { mutableStateOf(false) }
// 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.
// Smart lists and real lists are mutually exclusive; only real lists support
// picking more than one. `selectedLists` empty + `selectedSmart` non-null is
// a smart-list pick; the reverse (or both empty, before the stored filter
// loads) is a list pick.
var selectedSmart by remember(selected) { mutableStateOf((selected as? TaskFilter.Smart)?.list) }
var 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, is TaskFilter.Saved -> emptySet()
is TaskFilter.Smart, is TaskFilter.Tagged -> emptySet()
},
)
}
// 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()
val canSave = kind != WidgetKind.TASKS || selectedSmart != null || selectedLists.isNotEmpty()
FullScreenPicker(
title = stringResource(if (kind == WidgetKind.LISTS) R.string.widget_lists_config_title else R.string.widget_config_title),
@@ -247,11 +239,8 @@ 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)
when {
selectedSmart != null -> onSave(TaskFilter.Smart(selectedSmart!!))
liveFilter != null -> onSave(TaskFilter.Saved(liveFilter))
else -> onSave(TaskFilter.OfLists(selectedLists))
}
selectedSmart?.let { onSave(TaskFilter.Smart(it)) }
?: onSave(TaskFilter.OfLists(selectedLists))
},
) { Text(stringResource(android.R.string.ok)) }
},
@@ -265,22 +254,9 @@ private fun WidgetFilterPicker(
position = positionOf(index, WIDGET_SMART_LISTS.size),
selected = isSelected,
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { selectedSmart = smart; selectedFilter = null; selectedLists = emptySet() },
onClick = { selectedSmart = smart; 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 ->
@@ -293,7 +269,6 @@ 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,7 +101,6 @@ 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. */
@@ -113,8 +112,6 @@ 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
@@ -165,14 +162,12 @@ 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, is TaskFilter.Saved -> null
is TaskFilter.Smart, is TaskFilter.Tagged -> null
}
/** [singleListId]'s name. */
@@ -277,11 +272,10 @@ internal fun Context.taskWidgetData(filter: TaskFilter, groupByList: Boolean): F
val ep = widgetEntryPoint()
val repository = ep.tasksRepository()
return combine(
repository.filteredTasks(filter),
repository.tasks(filter),
repository.taskLists(),
ep.settingsPrefs().settings,
) { filtered, lists, settings ->
val tasks = filtered.tasks
) { tasks, lists, settings ->
val listNames = lists.associate { it.id to it.name }
val data: TaskWidgetData = TaskWidgetData.Ready(
filter = filter,
@@ -298,7 +292,6 @@ 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) }
-73
View File
@@ -33,77 +33,4 @@
<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>
+1 -24
View File
@@ -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">Ein kleiner Schubs, wenn es darauf ankommt</string>
<string name="reminder_benefit_delivery_title">Agendula erinnert dich</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,27 +409,4 @@
<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>
-24
View File
@@ -219,30 +219,6 @@
<!-- 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>
@@ -1,92 +0,0 @@
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)
}
}
@@ -1,119 +0,0 @@
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,7 +1,6 @@
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 {
@@ -18,13 +17,4 @@ 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,8 +32,7 @@ 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.Saved(7)
TaskFilter.OfLists(setOf(1L, 2L, 3L))
filters.forEach { assertThat(parseWidgetFilter(it.widgetStorageValue())).isEqualTo(it) }
}
@@ -46,7 +45,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", "filter:", "filter:0", "filter:x", "whatever",
"smart:COMPLETED", "smart:nope", "whatever",
).forEach {
assertThat(parseWidgetFilter(it)).isEqualTo(DEFAULT_WIDGET_FILTER)
}
@@ -1,11 +0,0 @@
### جديد
- الوسوم: أضفها إلى المهام، وتصفحها من الشاشة الرئيسية، وصفِّ أي قائمة بها. تُزامَن كـ CATEGORIES.
- عوامل التصفية: احفظ عرضك الخاص للمهام المفتوحة حسب الاستحقاق والقائمة والوسم والأولوية، وفي الأداة أيضًا.
- أدوات جديدة: Today وUp next وLists وWeek. يمكن للأداة عرض عدة قوائم.
### تغييرات
- صفوف بنمط الأجندة في أداة المهام.
- يمكن الآن إعادة تسمية القوائم المتزامنة وحذفها.
### إصلاحات
- لم تعد مهام اليوم الكامل تنزاح يومًا غرب UTC.
@@ -1,11 +1,12 @@
### Neu
- 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.
- 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).
### Geändert
- Agenda-Zeilen im Aufgaben-Widget.
- Synchronisierte Listen lassen sich umbenennen und löschen.
- 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.
### Behoben
- Ganztägige Aufgaben landen westlich von UTC nicht mehr einen Tag daneben.
- Doppelter Abstand im Listen-Editor.
@@ -1,11 +1,12 @@
### Added
- 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.
- 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.
### Changed
- Agenda-style rows in the Tasks widget.
- Synced lists can be renamed and deleted.
- 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.
### Fixed
- All-day tasks no longer land a day off west of UTC.
- Double padding in the list editor.
@@ -1,11 +1,12 @@
### Added
- 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.
- 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.
### Changed
- Agenda-style rows in the Tasks widget.
- Synced lists can be renamed and deleted.
- 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.
### Fixed
- All-day tasks no longer land a day off west of UTC.
- Double padding in the list editor.
@@ -1,11 +0,0 @@
### 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,11 +1,12 @@
### Novo
- 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.
- 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).
### Alterado
- Linhas em estilo agenda no widget Tasks.
- Listas sincronizadas podem ser renomeadas e excluídas.
- 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.
### Corrigido
- Tarefas de dia inteiro não caem mais um dia errado a oeste do UTC.
- Espaçamento duplo no editor de listas.
+1 -1
View File
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500