Author SHA1 Message Date
makiolaj 67e009eebf feat: let a widget show tasks from several lists
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.

TaskFilter gained OfLists for this; the full-screen task view never
routes through it, since widgets have their own screens.

Closes #25
2026-10-01 13:31:31 +02:00
Jean-Luc Makiolaandmakiolaj a3de9f3d93 docs(readme): show the store screenshots (#24)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Adds the five phone screenshots from `fastlane/metadata/android/en-US/images/phoneScreenshots/` to the README header, as a centered row beneath the badges.

The README references the fastlane files directly rather than copies, so the README and the store listings stay in sync. Each image has descriptive alt text.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/24
2026-09-28 14:11:27 +02:00
Jean-Luc Makiola c823243e53 Merge pull request 'docs: sync the F-Droid recipe with the submitted one' (#22) from docs/fdroid-recipe into main
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
2026-09-24 22:35:14 +02:00
makiolaj d5a74213d6 docs: sync the F-Droid recipe with the submitted one
Pinned to the v1.0.0 commit hash as fdroiddata requires; the README now
tracks the submission (fdroiddata!49998) instead of the steps to make it.
2026-09-24 22:32:01 +02:00
13 changed files with 238 additions and 57 deletions
+8
View File
@@ -20,6 +20,14 @@ required.</p>
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
</p>
<p>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.png" alt="Home screen: today's progress, overdue and upcoming tasks, and your lists" width="18%">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.png" alt="Onboarding: sync with your own CalDAV server" width="18%">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.png" alt="Upcoming list with a task expanded into its subtasks" width="18%">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.png" alt="Task detail: a monthly recurring task with a reminder and priority" width="18%">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05.png" alt="Reminder notification with Done and Snooze actions" width="18%">
</p>
</div>
Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
@@ -63,6 +63,9 @@ class TasksRepositoryImpl @Inject constructor(
// parent's subtask-progress reflects ALL its children — completed ones
// included — even on smart lists that hide completed tasks themselves.
is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
// Same over-read as Smart: TaskQuery.listId only narrows to one list, so
// several lists are read in full and membership is filtered in-memory.
is TaskFilter.OfLists -> TaskQuery(includeCompleted = true)
}
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
val all = SeriesCollapse.visible(dataSource.tasks(query))
@@ -8,6 +8,8 @@ enum class SmartList { ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED }
/** What a task screen is showing: one real list, or a smart list. */
sealed interface TaskFilter {
data class OfList(val listId: Long) : TaskFilter
/** Several lists shown together, e.g. one widget covering more than one list. */
data class OfLists(val listIds: Set<Long>) : TaskFilter
data class Smart(val list: SmartList) : TaskFilter
}
@@ -21,6 +23,7 @@ object TaskFiltering {
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)
}
@@ -69,6 +69,8 @@ object Dest {
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}"
// 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. */
@@ -1148,6 +1148,8 @@ private fun CenteredMessage(text: String, inner: PaddingValues) {
@Composable
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.Smart -> stringResource(
when (filter.list) {
SmartList.TODAY -> R.string.smart_today
@@ -103,6 +103,8 @@ class TaskListViewModel @Inject constructor(
}
is TaskFilter.Smart ->
tasks.map { TaskListUiState.Content(it) }
// Several lists at once only exists for the widget, which never routes here.
is TaskFilter.OfLists -> error("OfLists has no task-list screen")
}
// Drop rows pending an undoable delete so the row vanishes on swipe
// while the actual provider delete waits for the snackbar to commit,
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.agendula.widget
import android.content.Context
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -71,14 +72,16 @@ class TaskWidget : GlanceAppWidget() {
override val sizeMode = SizeMode.Single
override suspend fun provideGlance(context: Context, id: GlanceId) {
val stored = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)[WIDGET_FILTER_KEY]
val initialFilter = parseWidgetFilter(stored)
val initial = runCatching { context.taskWidgetData(initialFilter).first() }
val state = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)
val initialFilter = parseWidgetFilter(state[WIDGET_FILTER_KEY])
val initialGroupByList = state[WIDGET_GROUP_BY_LIST_KEY] ?: false
val initial = runCatching { context.taskWidgetData(initialFilter, initialGroupByList).first() }
.getOrDefault(TaskWidgetData.Unavailable)
provideContent {
val filter = parseWidgetFilter(currentState(WIDGET_FILTER_KEY))
val data by remember(filter) { context.taskWidgetData(filter) }
.collectAsState(initial = if (filter == initialFilter) initial else null)
val groupByList = currentState(WIDGET_GROUP_BY_LIST_KEY) ?: false
val data by remember(filter, groupByList) { context.taskWidgetData(filter, groupByList) }
.collectAsState(initial = if (filter == initialFilter && groupByList == initialGroupByList) initial else null)
val ready = data as? TaskWidgetData.Ready ?: initial as? TaskWidgetData.Ready
AgendulaGlanceTheme(
dynamicColor = ready?.dynamicColor ?: true,
@@ -109,12 +112,33 @@ class CompleteTaskAction : ActionCallback {
}
}
/** A row in the widget's list: a task, or (grouped widgets) a list-name header. */
private sealed interface WidgetListItem {
data class Header(val listId: Long, val name: String) : WidgetListItem
data class TaskItem(val row: WidgetTaskRow) : WidgetListItem
}
/** Inserts a [WidgetListItem.Header] before each run of same-list rows. */
private fun groupedItems(rows: List<WidgetTaskRow>, listNames: Map<Long, String>): List<WidgetListItem> {
val result = mutableListOf<WidgetListItem>()
var lastListId: Long? = null
for (row in rows) {
if (row.listId != lastListId) {
result += WidgetListItem.Header(row.listId, listNames[row.listId].orEmpty())
lastListId = row.listId
}
result += WidgetListItem.TaskItem(row)
}
return result
}
@Composable
private fun TaskWidgetBody(filter: TaskFilter, data: TaskWidgetData?) {
val context = LocalContext.current
val ready = data as? TaskWidgetData.Ready
val title = when (filter) {
is TaskFilter.Smart -> context.getString(smartListLabel(filter.list))
is TaskFilter.OfList -> (data as? TaskWidgetData.Ready)?.listName
is TaskFilter.OfList, is TaskFilter.OfLists -> ready?.singleListName
?: context.getString(R.string.widget_tasks_label)
}
Column(
@@ -132,20 +156,48 @@ private fun TaskWidgetBody(filter: TaskFilter, data: TaskWidgetData?) {
if (data.rows.isEmpty()) {
Message(context.getString(R.string.widget_empty))
} else {
val items = if (data.groupByList) {
groupedItems(data.rows, data.listNames)
} else {
data.rows.map { WidgetListItem.TaskItem(it) }
}
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(data.rows.size) { index -> TaskRow(data.rows[index], data.use24Hour) }
items(items.size) { index ->
when (val item = items[index]) {
is WidgetListItem.Header -> ListHeader(item.name)
is WidgetListItem.TaskItem -> TaskRow(item.row, data.use24Hour)
}
}
}
}
}
}
}
@Composable
private fun ListHeader(name: String) {
Text(
text = name,
maxLines = 1,
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
),
modifier = GlanceModifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
)
}
@Composable
private fun Header(title: String, filter: TaskFilter) {
val context = LocalContext.current
val open = when (filter) {
is TaskFilter.Smart -> MainActivity.smartListIntent(context, filter.list)
is TaskFilter.OfList -> MainActivity.listIntent(context, filter.listId)
// Several lists have no single list screen to open; a single selection
// still opens straight to it, otherwise this just raises the app.
is TaskFilter.OfLists -> filter.listIds.singleOrNull()?.let { MainActivity.listIntent(context, it) }
?: Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
Row(
modifier = GlanceModifier.fillMaxWidth().padding(start = 8.dp),
@@ -7,12 +7,17 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
@@ -22,6 +27,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState
@@ -40,6 +46,7 @@ import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf
@@ -49,8 +56,9 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Picks what one widget instance shows: a smart list or a real list. Runs when
* the widget is placed (below API 31) and on "reconfigure" from the launcher.
* Picks what one widget instance shows: a smart list, or one or more real
* lists (with an optional "group by list" sort). Runs when the widget is
* placed (below API 31) and on "reconfigure" from the launcher.
*/
@AndroidEntryPoint
class TaskWidgetConfigActivity : ComponentActivity() {
@@ -77,10 +85,11 @@ class TaskWidgetConfigActivity : ComponentActivity() {
val settings by settingsPrefs.settings.collectAsState(initial = Settings())
val lists by remember { listsFlow }.collectAsState(initial = emptyList())
var current by remember { mutableStateOf<TaskFilter?>(null) }
var groupByList by remember { mutableStateOf(false) }
LaunchedEffect(glanceId) {
current = parseWidgetFilter(
getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)[WIDGET_FILTER_KEY],
)
val state = getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)
current = parseWidgetFilter(state[WIDGET_FILTER_KEY])
groupByList = state[WIDGET_GROUP_BY_LIST_KEY] ?: false
}
val dark = when (settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
@@ -93,10 +102,13 @@ class TaskWidgetConfigActivity : ComponentActivity() {
lists = lists.filter { it.isVisible },
dark = dark,
selected = current ?: DEFAULT_WIDGET_FILTER,
onSelect = { filter ->
groupByList = groupByList,
onGroupByListChange = { groupByList = it },
onSave = { filter ->
lifecycleScope.launch {
updateAppWidgetState(this@TaskWidgetConfigActivity, glanceId) {
it[WIDGET_FILTER_KEY] = filter.widgetStorageValue()
it[WIDGET_GROUP_BY_LIST_KEY] = groupByList
}
TaskWidget().update(this@TaskWidgetConfigActivity, glanceId)
setResult(RESULT_OK, resultIntent(appWidgetId))
@@ -118,34 +130,75 @@ private fun WidgetFilterPicker(
lists: List<TaskList>,
selected: TaskFilter,
dark: Boolean,
onSelect: (TaskFilter) -> Unit,
groupByList: Boolean,
onGroupByListChange: (Boolean) -> Unit,
onSave: (TaskFilter) -> Unit,
onDismiss: () -> Unit,
) {
FullScreenPicker(title = stringResource(R.string.widget_config_title), onDismiss = onDismiss) {
// Smart lists and real lists are mutually exclusive; only real lists support
// picking more than one. `selectedLists` empty + `selectedSmart` non-null is
// a smart-list pick; the reverse (or both empty, before the stored filter
// loads) is a list pick.
var selectedSmart by remember(selected) { mutableStateOf((selected as? TaskFilter.Smart)?.list) }
var selectedLists by remember(selected) {
mutableStateOf(
when (selected) {
is TaskFilter.OfList -> setOf(selected.listId)
is TaskFilter.OfLists -> selected.listIds
is TaskFilter.Smart -> emptySet()
},
)
}
val canSave = selectedSmart != null || selectedLists.isNotEmpty()
FullScreenPicker(
title = stringResource(R.string.widget_config_title),
onDismiss = onDismiss,
actions = {
TextButton(
enabled = canSave,
onClick = {
selectedSmart?.let { onSave(TaskFilter.Smart(it)) }
?: onSave(TaskFilter.OfLists(selectedLists))
},
) { Text(stringResource(android.R.string.ok)) }
},
) {
GroupedSectionHeader(stringResource(R.string.widget_config_smart_lists))
WIDGET_SMART_LISTS.forEachIndexed { index, smart ->
val filter = TaskFilter.Smart(smart)
val isSelected = filter == selected
val isSelected = smart == selectedSmart
GroupedRow(
title = stringResource(smartListLabel(smart)),
position = positionOf(index, WIDGET_SMART_LISTS.size),
selected = isSelected,
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { onSelect(filter) },
onClick = { selectedSmart = smart; selectedLists = emptySet() },
)
}
if (lists.isNotEmpty()) {
GroupedSectionHeader(stringResource(R.string.lists_header))
lists.forEachIndexed { index, list ->
val filter = TaskFilter.OfList(list.id)
val isSelected = filter == selected
val isSelected = list.id in selectedLists
GroupedRow(
title = list.name,
position = positionOf(index, lists.size),
selected = isSelected,
leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) },
trailing = if (isSelected) ({ SelectedCheck() }) else null,
onClick = { onSelect(filter) },
onClick = {
selectedSmart = null
selectedLists = if (isSelected) selectedLists - list.id else selectedLists + list.id
},
)
}
if (selectedLists.size > 1) {
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.widget_config_group_by_list),
summary = stringResource(R.string.widget_config_group_by_list_hint),
position = Position.Alone,
trailing = { Switch(checked = groupByList, onCheckedChange = onGroupByListChange) },
onClick = { onGroupByListChange(!groupByList) },
)
}
}
@@ -4,6 +4,7 @@ import android.content.Context
import android.text.format.DateFormat
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task
@@ -21,6 +22,9 @@ import kotlin.time.Instant
/** Per-instance Glance state key holding the widget's [TaskFilter] ([widgetStorageValue]). */
internal val WIDGET_FILTER_KEY = stringPreferencesKey("task_filter")
/** Per-instance Glance state key: group a multi-list widget's rows under list headers. */
internal val WIDGET_GROUP_BY_LIST_KEY = booleanPreferencesKey("group_by_list")
internal val DEFAULT_WIDGET_FILTER: TaskFilter = TaskFilter.Smart(SmartList.TODAY)
/** The smart lists a widget can show, in picker order. */
@@ -34,6 +38,7 @@ internal const val MAX_WIDGET_ROWS = 60
internal fun TaskFilter.widgetStorageValue(): String = when (this) {
is TaskFilter.OfList -> "list:$listId"
is TaskFilter.OfLists -> "lists:${listIds.sorted().joinToString(",")}"
is TaskFilter.Smart -> "smart:${list.name}"
}
@@ -43,6 +48,9 @@ internal fun parseWidgetFilter(raw: String?): TaskFilter {
return when {
value.startsWith("list:") ->
value.removePrefix("list:").toLongOrNull()?.takeIf { it > 0 }?.let { TaskFilter.OfList(it) }
value.startsWith("lists:") ->
value.removePrefix("lists:").split(",").mapNotNull { it.toLongOrNull()?.takeIf { id -> id > 0 } }
.toSet().takeIf { it.isNotEmpty() }?.let { TaskFilter.OfLists(it) }
value.startsWith("smart:") ->
WIDGET_SMART_LISTS.firstOrNull { it.name == value.removePrefix("smart:") }?.let { TaskFilter.Smart(it) }
else -> null
@@ -69,6 +77,7 @@ internal fun isOverdue(due: Instant, allDay: Boolean, now: Instant, today: Local
data class WidgetTaskRow(
val taskId: Long,
val listId: Long,
val occurrenceStart: Instant?,
val title: String,
val due: Instant?,
@@ -84,35 +93,50 @@ sealed interface TaskWidgetData {
data class Ready(
val filter: TaskFilter,
/** The real list's name; `null` for a smart list or a list that no longer exists. */
val listName: String?,
/** Names of the real lists [rows] can belong to, by list id. */
val listNames: Map<Long, String>,
val rows: List<WidgetTaskRow>,
/** Group [rows] under a header per list (only meaningful with several lists). */
val groupByList: Boolean,
val dynamicColor: Boolean,
val themeMode: ThemeMode,
val use24Hour: Boolean,
) : TaskWidgetData
) : TaskWidgetData {
/** The one list's name, when [filter] names exactly one real list. */
val singleListName: String? = when (filter) {
is TaskFilter.OfList -> listNames[filter.listId]
is TaskFilter.OfLists -> filter.listIds.singleOrNull()?.let(listNames::get)
is TaskFilter.Smart -> null
}
}
}
/**
* Open rows for [tasks], sorted the way the app sorts them. A subtask whose
* parent is also shown is left out, as on the list screen.
* Open rows for [tasks], sorted the way the app sorts them, then grouped by list
* when [groupByList] is on (stable, so each list's own order survives). A
* subtask whose parent is also shown is left out, as on the list screen.
*/
internal fun widgetRows(
tasks: List<Task>,
comparator: Comparator<Task>,
now: Instant,
zone: ZoneId,
groupByList: Boolean,
listNames: Map<Long, String>,
): List<WidgetTaskRow> {
val open = tasks.filterNot { it.isClosed }
val present = open.mapTo(HashSet()) { it.taskId }
val today = java.time.Instant.ofEpochMilli(now.toEpochMilliseconds()).atZone(zone).toLocalDate()
return open
val sorted = open
.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present }
.sortedWith(comparator)
.let { if (groupByList) it.sortedBy { task -> listNames[task.listId].orEmpty() } else it }
return sorted
.take(MAX_WIDGET_ROWS)
.map { task ->
WidgetTaskRow(
taskId = task.taskId,
listId = task.listId,
occurrenceStart = task.occurrenceStart,
title = task.title,
due = task.due,
@@ -129,7 +153,7 @@ internal fun widgetRows(
* repository's flows observe the store; a store that can't be read (permission
* revoked, provider uninstalled) reads as [TaskWidgetData.Unavailable].
*/
internal fun Context.taskWidgetData(filter: TaskFilter): Flow<TaskWidgetData> {
internal fun Context.taskWidgetData(filter: TaskFilter, groupByList: Boolean): Flow<TaskWidgetData> {
val ep = widgetEntryPoint()
val repository = ep.tasksRepository()
return combine(
@@ -137,10 +161,19 @@ internal fun Context.taskWidgetData(filter: TaskFilter): Flow<TaskWidgetData> {
repository.taskLists(),
ep.settingsPrefs().settings,
) { tasks, lists, settings ->
val listNames = lists.associate { it.id to it.name }
val data: TaskWidgetData = TaskWidgetData.Ready(
filter = filter,
listName = (filter as? TaskFilter.OfList)?.let { f -> lists.firstOrNull { it.id == f.listId }?.name },
rows = widgetRows(settings.visibleIn(filter, tasks), settings.sortOrder.comparator(), Clock.System.now(), ZoneId.systemDefault()),
listNames = listNames,
rows = widgetRows(
settings.visibleIn(filter, tasks),
settings.sortOrder.comparator(),
Clock.System.now(),
ZoneId.systemDefault(),
groupByList,
listNames,
),
groupByList = groupByList,
dynamicColor = settings.dynamicColor,
themeMode = settings.themeMode,
use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)),
+2
View File
@@ -760,4 +760,6 @@
<string name="widget_complete_task">Complete %1$s</string>
<string name="widget_config_title">Show in widget</string>
<string name="widget_config_smart_lists">Smart lists</string>
<string name="widget_config_group_by_list">Group by list</string>
<string name="widget_config_group_by_list_hint">Show each list\'s tasks under its own heading</string>
</resources>
@@ -27,7 +27,9 @@ class WidgetDataTest {
@Test
fun `every offered filter round-trips through storage`() {
val filters = WIDGET_SMART_LISTS.map { TaskFilter.Smart(it) } + TaskFilter.OfList(42)
val filters = WIDGET_SMART_LISTS.map { TaskFilter.Smart(it) } +
TaskFilter.OfList(42) +
TaskFilter.OfLists(setOf(1L, 2L, 3L))
filters.forEach { assertThat(parseWidgetFilter(it.widgetStorageValue())).isEqualTo(it) }
}
@@ -38,7 +40,10 @@ class WidgetDataTest {
@Test
fun `garbage and smart lists the widget does not offer fall back to Today`() {
listOf("", "list:", "list:-3", "list:abc", "smart:COMPLETED", "smart:nope", "whatever").forEach {
listOf(
"", "list:", "list:-3", "list:abc", "lists:", "lists:abc", "lists:-1,-2",
"smart:COMPLETED", "smart:nope", "whatever",
).forEach {
assertThat(parseWidgetFilter(it)).isEqualTo(DEFAULT_WIDGET_FILTER)
}
}
@@ -80,6 +85,8 @@ class WidgetDataTest {
TaskSorting.DEFAULT,
now,
newYork,
groupByList = false,
listNames = emptyMap(),
)
assertThat(rows.map { it.title }).containsExactly("soon", "later").inOrder()
}
@@ -89,13 +96,40 @@ class WidgetDataTest {
val parent = testTask(id = 1, title = "parent")
val child = testTask(id = 2, title = "child").copy(parentId = 1)
val orphan = testTask(id = 3, title = "orphan").copy(parentId = 99)
val rows = widgetRows(listOf(parent, child, orphan), TaskSorting.DEFAULT, now, newYork)
val rows = widgetRows(
listOf(parent, child, orphan),
TaskSorting.DEFAULT,
now,
newYork,
groupByList = false,
listNames = emptyMap(),
)
assertThat(rows.map { it.title }).containsExactly("parent", "orphan")
}
@Test
fun `rows are capped`() {
val many = (1L..(MAX_WIDGET_ROWS + 10L)).map { testTask(id = it, title = "t$it") }
assertThat(widgetRows(many, TaskSorting.DEFAULT, now, newYork)).hasSize(MAX_WIDGET_ROWS)
assertThat(
widgetRows(many, TaskSorting.DEFAULT, now, newYork, groupByList = false, listNames = emptyMap()),
).hasSize(MAX_WIDGET_ROWS)
}
@Test
fun `grouping sorts by list name, keeping each list's own order`() {
val tasks = listOf(
testTask(id = 1, listId = 2, title = "b-later", due = at("2026-07-22T09:00:00")),
testTask(id = 2, listId = 1, title = "a-soon", due = at("2026-07-20T15:00:00")),
testTask(id = 3, listId = 1, title = "a-later", due = at("2026-07-22T09:00:00")),
)
val rows = widgetRows(
tasks,
TaskSorting.DEFAULT,
now,
newYork,
groupByList = true,
listNames = mapOf(1L to "Alpha", 2L to "Beta"),
)
assertThat(rows.map { it.title }).containsExactly("a-soon", "a-later", "b-later").inOrder()
}
}
+9 -9
View File
@@ -34,17 +34,17 @@ Nothing listing-related goes in the recipe. F-Droid harvests
is fed from: title, descriptions, icon, feature graphic, screenshots and
`changelogs/<versionCode>.txt`. `title.txt` becomes the app name per locale.
## Submitting
## Status
After `v1.0.0` is tagged and `agendula_v1.0.0.apk` is live on the self-hosted
repo:
Submitted as [fdroiddata!49998](https://gitlab.com/fdroid/fdroiddata/-/merge_requests/49998)
on 2026-09-24. Its CI rebuilt v1.0.0 and verified it against our published APK
("compared built binary to supplied reference binary successfully").
1. Fork `https://gitlab.com/fdroid/fdroiddata`, copy the recipe to
`metadata/de.jeanlucmakiola.agendula.yml`.
2. `fdroid readmeta && fdroid lint de.jeanlucmakiola.agendula`, then
`fdroid build -v -l de.jeanlucmakiola.agendula` to rebuild and verify.
3. Open the merge request. Later versions are picked up from new `vX.Y.Z` tags
(`AutoUpdateMode`), so there is no per-release work in fdroiddata.
The file here is a copy of the submitted recipe. fdroiddata's copy is the one
that counts; after the merge F-Droid picks up new `vX.Y.Z` tags on its own
(`AutoUpdateMode`), so there is no per-release work there. Only a change to the
recipe itself (a new submodule, a build flag) needs an MR, pinned to a full
commit hash, never a tag.
The recipe says `License: MIT`; `:dav` is vendored MPL-2.0 (`dav/PROVENANCE.md`).
If the reviewer asks, change it to `MIT AND MPL-2.0`.
@@ -1,11 +1,3 @@
# Draft fdroiddata recipe for the OFFICIAL F-Droid repository. Submitted as
# metadata/de.jeanlucmakiola.agendula.yml in fdroiddata; see README.md here.
# Not the self-hosted control file (that is ../../fdroid-metadata/).
#
# Reproducible build + developer-signed binary: F-Droid rebuilds from source,
# compares against our APK from `Binaries`, and on a match publishes OUR binary,
# so official and self-hosted installs share one signature.
Categories:
- Task
License: MIT
@@ -22,15 +14,10 @@ RepoType: git
Repo: https://codeberg.org/jlmakiola/agendula.git
Binaries: https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo/agendula_v%v.apk
# 1.0.0 is the first release that carries every reproducibility fix
# (scripts/check_reproducible_release.sh); 0.x releases strip datastore's .so
# files host-dependently and won't verify. submodules: floret-kit is an
# included build. No key.properties on the buildserver, so the release build
# comes out unsigned, which is what F-Droid compares.
Builds:
- versionName: 1.0.0
versionCode: 10000
commit: v1.0.0
commit: 671910f54b097306ed83d69f910eb328cb07dc3d
subdir: app
submodules: true
gradle: