Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67e009eebf | ||
|
|
a3de9f3d93 | ||
|
|
c823243e53 | ||
|
|
d5a74213d6 |
@@ -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)),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user