Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d30c0e0a4 | ||
|
|
d9dc358883 |
No files matched your search
@@ -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,11 @@ class TaskNotifier @Inject constructor(
|
||||
),
|
||||
)
|
||||
}
|
||||
val description = task.description?.readableDescription()?.trim()?.takeIf { it.isNotBlank() }
|
||||
val description = reminderBody(
|
||||
task.description?.readableDescription(),
|
||||
settings.reminderDescription,
|
||||
lineChars = (NOTIFICATION_LINE_CHARS / context.resources.configuration.fontScale).toInt(),
|
||||
)
|
||||
val tapIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
task.taskId.toInt(),
|
||||
@@ -277,3 +282,96 @@ 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, at the default font size. */
|
||||
private const val NOTIFICATION_LINE_CHARS = 40
|
||||
|
||||
/** The description a reminder shows, as [mode] asks; null when there is none to show. */
|
||||
internal fun reminderBody(
|
||||
description: String?,
|
||||
mode: ReminderDescription,
|
||||
lineChars: Int = NOTIFICATION_LINE_CHARS,
|
||||
): String? {
|
||||
val text = description?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
return when (mode) {
|
||||
ReminderDescription.HIDDEN -> null
|
||||
ReminderDescription.SHORT -> shortDescription(text, lineChars = lineChars)
|
||||
ReminderDescription.FULL -> text
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [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 perLine = lineChars.coerceAtLeast(2)
|
||||
val lines = description.lines().map { it.trimEnd() }.filter { it.isNotBlank() }
|
||||
val kept = mutableListOf<String>()
|
||||
var rowsLeft = maxLines
|
||||
for ((index, line) in lines.withIndex()) {
|
||||
val width = displayWidth(line)
|
||||
val rows = (width + perLine - 1) / perLine
|
||||
val last = index == lines.lastIndex
|
||||
// The ellipsis after a line that ends the budget needs a cell of its own.
|
||||
val fits = rows < rowsLeft || (rows == rowsLeft && (last || width < rows * perLine))
|
||||
if (fits) {
|
||||
kept += line
|
||||
rowsLeft -= rows
|
||||
if (rowsLeft == 0 && !last) return ellipsize(kept)
|
||||
continue
|
||||
}
|
||||
if (rowsLeft > 0) kept += cutAtWord(line, rowsLeft * perLine - 1)
|
||||
return ellipsize(kept)
|
||||
}
|
||||
return kept.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun ellipsize(lines: List<String>): String =
|
||||
lines.joinToString("\n").trimEnd { it.isWhitespace() || it in ".,;:" } + "\u2026"
|
||||
|
||||
/** [line] cut to at most [max] cells, at a word break when there is one, never inside a character. */
|
||||
private fun cutAtWord(line: String, max: Int): String {
|
||||
var end = 0
|
||||
var width = 0
|
||||
while (end < line.length) {
|
||||
val codePoint = line.codePointAt(end)
|
||||
width += cellsOf(codePoint)
|
||||
if (width > max) break
|
||||
end += Character.charCount(codePoint)
|
||||
}
|
||||
if (end >= line.length || line[end].isWhitespace()) return line.substring(0, end)
|
||||
val cut = line.substring(0, end)
|
||||
val space = cut.indexOfLast { it.isWhitespace() }
|
||||
return if (space > end / 2) cut.substring(0, space) else cut
|
||||
}
|
||||
|
||||
/** How many cells [text] takes, wide characters (CJK, emoji) counting two. */
|
||||
private fun displayWidth(text: String): Int {
|
||||
var width = 0
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
val codePoint = text.codePointAt(i)
|
||||
width += cellsOf(codePoint)
|
||||
i += Character.charCount(codePoint)
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
private fun cellsOf(codePoint: Int): Int = when {
|
||||
Character.isSupplementaryCodePoint(codePoint) -> 2
|
||||
Character.UnicodeScript.of(codePoint) in WIDE_SCRIPTS -> 2
|
||||
else -> 1
|
||||
}
|
||||
|
||||
private val WIDE_SCRIPTS = setOf(
|
||||
Character.UnicodeScript.HAN,
|
||||
Character.UnicodeScript.HIRAGANA,
|
||||
Character.UnicodeScript.KATAKANA,
|
||||
Character.UnicodeScript.HANGUL,
|
||||
)
|
||||
@@ -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>()) }
|
||||
@@ -178,9 +180,15 @@ internal fun NotificationsScreen(
|
||||
R.string.settings_allday_reminder_time_summary,
|
||||
formatMinuteOfDay(state.settings.allDayReminderMinuteOfDay, LocalUse24HourFormat.current),
|
||||
),
|
||||
position = Position.Bottom,
|
||||
position = Position.Middle,
|
||||
onClick = { showAllDayTime = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_reminder_description),
|
||||
summary = reminderDescriptionLabel(state.settings.reminderDescription),
|
||||
position = Position.Bottom,
|
||||
onClick = { showDescription = true },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
val batteryExempt = rememberBatteryOptimizationExempt(context)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.agendula.data.appname.LauncherName
|
||||
import de.jeanlucmakiola.agendula.data.appname.LauncherNameManager
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ReminderDescription
|
||||
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||
@@ -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) }
|
||||
|
||||
@@ -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">A short preview 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,99 @@
|
||||
package de.jeanlucmakiola.agendula.data.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ReminderDescription
|
||||
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…")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a line that fills the budget exactly leaves room for the ellipsis`() {
|
||||
val full = "a".repeat(120)
|
||||
val short = shortDescription("$full\nmore", maxLines = 3, lineChars = 40)
|
||||
assertThat(short).isEqualTo("a".repeat(119) + "…")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cut never splits an emoji`() {
|
||||
val short = shortDescription("x".repeat(118) + "😀" + "y".repeat(10), maxLines = 3, lineChars = 40)
|
||||
assertThat(short).isEqualTo("x".repeat(118) + "…")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a word that ends right at the cut is kept`() {
|
||||
val short = shortDescription("aaaa bbbb cccc", maxLines = 1, lineChars = 10)
|
||||
assertThat(short).isEqualTo("aaaa bbbb…")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tabs count as word breaks`() {
|
||||
val short = shortDescription("Meeting\tagenda\tnotes", maxLines = 1, lineChars = 12)
|
||||
assertThat(short).isEqualTo("Meeting…")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no space is left before the ellipsis`() {
|
||||
assertThat(shortDescription("see list :\nmore", maxLines = 1, lineChars = 40)).isEqualTo("see list…")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wide characters take two cells`() {
|
||||
val short = shortDescription("中".repeat(30), maxLines = 1, lineChars = 20)
|
||||
assertThat(short).isEqualTo("中".repeat(9) + "…")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the setting picks what the reminder shows`() {
|
||||
val long = List(60) { "word" }.joinToString(" ")
|
||||
assertThat(reminderBody(long, ReminderDescription.HIDDEN)).isNull()
|
||||
assertThat(reminderBody(long, ReminderDescription.FULL)).isEqualTo(long)
|
||||
assertThat(reminderBody(long, ReminderDescription.SHORT)).isEqualTo(shortDescription(long))
|
||||
assertThat(reminderBody(" ", ReminderDescription.FULL)).isNull()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user