From d9dc3588835820c9a13e8460bbd5fc2341fffa48 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 12 Nov 2026 15:55:20 +0100 Subject: [PATCH] Add setting for the description shown in reminders (#156) --- CHANGELOG.md | 5 ++ .../agendula/data/prefs/SettingsPrefs.kt | 11 ++++ .../agendula/data/reminders/TaskNotifier.kt | 50 ++++++++++++++++- .../ui/settings/NotificationsScreen.kt | 39 +++++++++++++- .../agendula/ui/settings/SettingsViewModel.kt | 2 + app/src/main/res/values/strings.xml | 7 +++ .../data/reminders/ShortDescriptionTest.kt | 53 +++++++++++++++++++ 7 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ShortDescriptionTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 26e197b..9b7ad45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Added +- A "Description in reminders" setting under Notifications sets how much of a + task's description a reminder shows: none, a short preview of up to three + lines (the new default), or all of it as before (#156). + ## [1.3.1] - 2026-11-09 ### Changed diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt index dd67d3b..8de33df 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt @@ -54,6 +54,9 @@ fun TimeFormatPref.is24Hour(systemIs24Hour: Boolean): Boolean = when (this) { TimeFormatPref.TWENTY_FOUR_HOUR -> true } +/** How much of a task's description a due reminder shows when expanded. */ +enum class ReminderDescription { HIDDEN, SHORT, FULL } + const val DEFAULT_SNOOZE_MINUTES = 10 /** Done starts folded away, to keep the open tasks in focus. */ val DEFAULT_COLLAPSED_SECTIONS: Set = setOf(TaskSection.COMPLETED) @@ -103,6 +106,7 @@ data class Settings( val sortOrder: TaskSortOrder = TaskSortOrder.DUE, /** How long a reminder's "Snooze" action puts it off. */ val snoozeMinutes: Int = DEFAULT_SNOOZE_MINUTES, + val reminderDescription: ReminderDescription = ReminderDescription.SHORT, val timeFormat: TimeFormatPref = TimeFormatPref.AUTO, /** The first day of the week; `null` follows the locale. */ val weekStart: DayOfWeek? = null, @@ -171,6 +175,9 @@ class SettingsPrefs @Inject constructor( sortOrder = p[SORT_ORDER]?.let { runCatching { TaskSortOrder.valueOf(it) }.getOrNull() } ?: TaskSortOrder.DUE, snoozeMinutes = p[SNOOZE_MINUTES]?.takeIf { it > 0 } ?: DEFAULT_SNOOZE_MINUTES, + reminderDescription = p[REMINDER_DESCRIPTION] + ?.let { runCatching { ReminderDescription.valueOf(it) }.getOrNull() } + ?: ReminderDescription.SHORT, timeFormat = p[TIME_FORMAT]?.let { runCatching { TimeFormatPref.valueOf(it) }.getOrNull() } ?: TimeFormatPref.AUTO, autofocusTitle = p[AUTOFOCUS_TITLE] ?: true, @@ -199,6 +206,9 @@ class SettingsPrefs @Inject constructor( suspend fun setSnoozeMinutes(minutes: Int) = dataStore.edit { it[SNOOZE_MINUTES] = minutes.coerceAtLeast(1) } + suspend fun setReminderDescription(pref: ReminderDescription) = + dataStore.edit { it[REMINDER_DESCRIPTION] = pref.name } + suspend fun setSyncIntervalMinutes(minutes: Int) = dataStore.edit { it[SYNC_INTERVAL] = minutes } suspend fun setPushEnabled(enabled: Boolean) = dataStore.edit { it[PUSH_ENABLED] = enabled } @@ -345,6 +355,7 @@ class SettingsPrefs @Inject constructor( val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields") val SORT_ORDER = stringPreferencesKey("sort_order") val SNOOZE_MINUTES = intPreferencesKey("snooze_minutes") + val REMINDER_DESCRIPTION = stringPreferencesKey("reminder_description") val TIME_FORMAT = stringPreferencesKey("time_format") val WEEK_START = stringPreferencesKey("week_start") val AUTOFOCUS_TITLE = booleanPreferencesKey("autofocus_title") diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/TaskNotifier.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/TaskNotifier.kt index d7f70e6..aac4f0f 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/TaskNotifier.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/TaskNotifier.kt @@ -18,6 +18,7 @@ import androidx.core.content.ContextCompat import androidx.core.content.edit import de.jeanlucmakiola.agendula.MainActivity import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.prefs.ReminderDescription import de.jeanlucmakiola.agendula.data.prefs.Settings import de.jeanlucmakiola.agendula.domain.readableDescription import de.jeanlucmakiola.agendula.data.prefs.is24Hour @@ -86,7 +87,13 @@ class TaskNotifier @Inject constructor( ), ) } - val description = task.description?.readableDescription()?.trim()?.takeIf { it.isNotBlank() } + val description = task.description?.readableDescription()?.trim()?.takeIf { it.isNotBlank() }?.let { + when (settings.reminderDescription) { + ReminderDescription.HIDDEN -> null + ReminderDescription.SHORT -> shortDescription(it) + ReminderDescription.FULL -> it + } + } val tapIntent = PendingIntent.getActivity( context, task.taskId.toInt(), @@ -277,3 +284,44 @@ internal fun expandedText(dueLine: String?, description: String?): String? = whe dueLine == null -> description else -> "$dueLine\n\n$description" } + +/** Rough characters per line of an expanded notification on a phone. */ +private const val NOTIFICATION_LINE_CHARS = 40 + +/** + * [description] cut to about [maxLines] lines of an expanded notification, + * ending in an ellipsis when anything was left out. Blank lines are dropped, + * and a long line counts as the lines it wraps to. + */ +internal fun shortDescription( + description: String, + maxLines: Int = 3, + lineChars: Int = NOTIFICATION_LINE_CHARS, +): String { + val lines = description.lines().map { it.trimEnd() }.filter { it.isNotBlank() } + val kept = mutableListOf() + var used = 0 + for ((index, line) in lines.withIndex()) { + val rows = (line.length + lineChars - 1) / lineChars + if (used + rows <= maxLines) { + kept += line + used += rows + if (used == maxLines && index < lines.lastIndex) return ellipsize(kept) + continue + } + val room = (maxLines - used) * lineChars + if (room > 1) kept += cutAtWord(line, room - 1) + return ellipsize(kept) + } + return kept.joinToString("\n") +} + +private fun ellipsize(lines: List): String = + lines.joinToString("\n").trimEnd().trimEnd('.', ',', ';', ':') + "\u2026" + +/** [line] cut to at most [max] characters, at a word break when there is one. */ +private fun cutAtWord(line: String, max: Int): String { + val cut = line.take(max) + val space = cut.lastIndexOf(' ') + return if (space > max / 2) cut.substring(0, space) else cut +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/NotificationsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/NotificationsScreen.kt index 8728d8c..98e0bfe 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/NotificationsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/NotificationsScreen.kt @@ -39,6 +39,7 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.core.net.toUri import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.prefs.ReminderDescription import de.jeanlucmakiola.agendula.data.prefs.SNOOZE_PRESETS import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier import de.jeanlucmakiola.agendula.domain.TaskReminderOrder @@ -113,6 +114,7 @@ internal fun NotificationsScreen( var showOffset by remember { mutableStateOf(false) } var showAllDay by remember { mutableStateOf(false) } var showSnooze by remember { mutableStateOf(false) } + var showDescription by remember { mutableStateOf(false) } var showAllDayTime by remember { mutableStateOf(false) } var perListExpanded by remember { mutableStateOf(false) } var expandedLists by remember { mutableStateOf(emptySet()) } @@ -197,9 +199,15 @@ internal fun NotificationsScreen( GroupedRow( title = stringResource(R.string.settings_snooze_duration), summary = snoozeLabel(state.settings.snoozeMinutes), - position = Position.Bottom, + position = Position.Middle, onClick = { showSnooze = true }, ) + GroupedRow( + title = stringResource(R.string.settings_reminder_description), + summary = reminderDescriptionLabel(state.settings.reminderDescription), + position = Position.Bottom, + onClick = { showDescription = true }, + ) // Per-list overrides: the whole section folds behind one header. Each // list may keep, drop, or replace the global default for its tasks. @@ -318,6 +326,17 @@ internal fun NotificationsScreen( onDismiss = { showSnooze = false }, ) } + if (showDescription) { + OptionPicker( + title = stringResource(R.string.settings_reminder_description), + options = ReminderDescription.entries, + selected = state.settings.reminderDescription, + label = { reminderDescriptionLabel(it) }, + summary = { reminderDescriptionSummary(it) }, + onSelect = viewModel::setReminderDescription, + onDismiss = { showDescription = false }, + ) + } if (showAllDayTime) { val minute = state.settings.allDayReminderMinuteOfDay TimePickerAlert( @@ -354,6 +373,24 @@ private fun snoozeLabel(minutes: Int): String = if (minutes % 60 == 0) pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60) else pluralStringResource(R.plurals.duration_minutes, minutes, minutes) +@Composable +private fun reminderDescriptionLabel(pref: ReminderDescription): String = stringResource( + when (pref) { + ReminderDescription.HIDDEN -> R.string.settings_reminder_description_hidden + ReminderDescription.SHORT -> R.string.settings_reminder_description_short + ReminderDescription.FULL -> R.string.settings_reminder_description_full + }, +) + +@Composable +private fun reminderDescriptionSummary(pref: ReminderDescription): String = stringResource( + when (pref) { + ReminderDescription.HIDDEN -> R.string.settings_reminder_description_hidden_summary + ReminderDescription.SHORT -> R.string.settings_reminder_description_short_summary + ReminderDescription.FULL -> R.string.settings_reminder_description_full_summary + }, +) + /** Which list's override picker is open, and for which kind of task. */ private data class OverrideTarget(val listId: Long, val allDay: Boolean) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt index 3be4765..83d1c6f 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt @@ -9,6 +9,7 @@ import de.jeanlucmakiola.agendula.data.di.IoDispatcher 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.prefs.ReminderDescription import de.jeanlucmakiola.agendula.data.prefs.TimeFormatPref import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier @@ -259,6 +260,7 @@ class SettingsViewModel @Inject constructor( fun setTimeFormat(pref: TimeFormatPref) = viewModelScope.launch { prefs.setTimeFormat(pref) } fun setWeekStart(day: DayOfWeek?) = viewModelScope.launch { prefs.setWeekStart(day) } fun setSnoozeMinutes(minutes: Int) = viewModelScope.launch { prefs.setSnoozeMinutes(minutes) } + fun setReminderDescription(pref: ReminderDescription) = viewModelScope.launch { prefs.setReminderDescription(pref) } fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) } fun setBottomAddBar(enabled: Boolean) = viewModelScope.launch { prefs.setBottomAddBar(enabled) } fun setDefaultStartToday(enabled: Boolean) = viewModelScope.launch { prefs.setDefaultStartToday(enabled) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eb37419..09571ce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -470,6 +470,13 @@ Blocked — tap to allow exact reminders Reliable delivery Snooze duration + Description in reminders + Hidden + Short + Full + Only the title and due time + Up to three lines of the description + The whole description Android may delay reminders to save battery. Exempt Agendula so they arrive on time. Exempt from battery optimisation — reminders arrive on time. Default list diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ShortDescriptionTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ShortDescriptionTest.kt new file mode 100644 index 0000000..e92a6aa --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ShortDescriptionTest.kt @@ -0,0 +1,53 @@ +package de.jeanlucmakiola.agendula.data.reminders + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class ShortDescriptionTest { + + @Test + fun `short text is left alone`() { + assertThat(shortDescription("Buy oat milk")).isEqualTo("Buy oat milk") + } + + @Test + fun `three short lines fit`() { + assertThat(shortDescription("one\ntwo\nthree")).isEqualTo("one\ntwo\nthree") + } + + @Test + fun `lines past the third are cut with an ellipsis`() { + assertThat(shortDescription("one\ntwo\nthree\nfour\nfive")).isEqualTo("one\ntwo\nthree…") + } + + @Test + fun `blank lines take no room`() { + assertThat(shortDescription("one\n\n\ntwo\n \nthree")).isEqualTo("one\ntwo\nthree") + } + + @Test + fun `a long paragraph is cut at a word within the line budget`() { + val text = List(40) { "word" }.joinToString(" ") + val short = shortDescription(text, maxLines = 3, lineChars = 40) + assertThat(short).endsWith("word…") + assertThat(short.length).isAtMost(3 * 40) + } + + @Test + fun `a wrapping line counts as the lines it takes`() { + val long = "a".repeat(70) + val short = shortDescription("$long\nsecond\nthird", maxLines = 3, lineChars = 40) + assertThat(short).isEqualTo("$long\nsecond…") + } + + @Test + fun `a word with no break is cut hard`() { + val short = shortDescription("x".repeat(200), maxLines = 3, lineChars = 40) + assertThat(short).isEqualTo("x".repeat(119) + "…") + } + + @Test + fun `trailing punctuation gives way to the ellipsis`() { + assertThat(shortDescription("one.\ntwo,\nthree.\nfour")).isEqualTo("one.\ntwo,\nthree…") + } +}