Add setting for the description shown in reminders (#156)

This commit is contained in:
makiolaj committed 2026-11-12 15:55:20 +01:00
1 parent 3e34c60577
commit d9dc358883
7 files changed
+165 -2

No files matched your search

+5
View File
@@ -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
@@ -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<TaskSection> = 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")
@@ -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<String>()
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>): 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
}
@@ -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<Long>()) }
@@ -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)
@@ -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) }
+7
View File
@@ -470,6 +470,13 @@
<string name="settings_exact_alarms_blocked">Blocked — tap to allow exact reminders</string>
<string name="settings_reliable_delivery">Reliable delivery</string>
<string name="settings_snooze_duration">Snooze duration</string>
<string name="settings_reminder_description">Description in reminders</string>
<string name="settings_reminder_description_hidden">Hidden</string>
<string name="settings_reminder_description_short">Short</string>
<string name="settings_reminder_description_full">Full</string>
<string name="settings_reminder_description_hidden_summary">Only the title and due time</string>
<string name="settings_reminder_description_short_summary">Up to three lines of the description</string>
<string name="settings_reminder_description_full_summary">The whole description</string>
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Agendula so they arrive on time.</string>
<string name="settings_reliable_delivery_exempt">Exempt from battery optimisation — reminders arrive on time.</string>
<string name="settings_default_list">Default list</string>
@@ -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…")
}
}