Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d30c0e0a4 | ||
|
|
d9dc358883 |
No files matched your search
+4
-4
@@ -7,10 +7,10 @@ All notable changes to this project are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- Removing a task's last date also removes its reminders, so the task can be
|
||||
saved again. Reminders you picked yourself come back when you set a new date
|
||||
(#159).
|
||||
### 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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -149,9 +149,6 @@ class TaskEditViewModel @Inject constructor(
|
||||
private var loadedDue: Instant? = null
|
||||
private var loadedAllDay = false
|
||||
|
||||
/** The reminders the user picked before clearing the task's last date, and whether they were all-day ones. */
|
||||
private var setAside: Pair<List<TaskReminder>, Boolean>? = null
|
||||
|
||||
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
||||
fun bindNew(
|
||||
presetListId: Long? = null,
|
||||
@@ -258,7 +255,7 @@ class TaskEditViewModel @Inject constructor(
|
||||
isOccurrence = task.isOccurrence,
|
||||
lists = lists,
|
||||
parentCandidates = loadParents(task.listId, selfId = taskId),
|
||||
).let { if (it.start == null && it.due == null) it.withoutReminders() else it },
|
||||
),
|
||||
).also { baseline = it.toForm() }
|
||||
editingOccurrence = task.occurrenceStart
|
||||
}
|
||||
@@ -312,15 +309,15 @@ class TaskEditViewModel @Inject constructor(
|
||||
/** Switching list invalidates the parent (it lived in the old list); reload candidates. */
|
||||
fun onListChange(listId: Long) {
|
||||
if (_state.value.listId == listId) return // re-picking the same list keeps the parent
|
||||
update { it.copy(listId = listId, parentId = null, errors = emptySet()).withDatedReminders() }
|
||||
update { it.copy(listId = listId, parentId = null, errors = emptySet()).withDefaultReminders() }
|
||||
viewModelScope.launch {
|
||||
val candidates = loadParents(listId, selfId = editingTaskId)
|
||||
update { it.copy(parentCandidates = candidates) }
|
||||
}
|
||||
}
|
||||
|
||||
fun onStartChange(value: Instant?) = update { it.copy(start = value).withDatedReminders() }
|
||||
fun onDueChange(value: Instant?) = update { it.copy(due = value).withDatedReminders() }
|
||||
fun onStartChange(value: Instant?) = update { it.copy(start = value).withDefaultReminders() }
|
||||
fun onDueChange(value: Instant?) = update { it.copy(due = value).withDefaultReminders() }
|
||||
/**
|
||||
* All-day and timed values use different conventions (UTC midnight vs. a real
|
||||
* instant in the device zone), so the switch has to move the timestamps too —
|
||||
@@ -338,7 +335,7 @@ class TaskEditViewModel @Inject constructor(
|
||||
)
|
||||
if (!it.remindersTouched) {
|
||||
loadedReminders = carried(loadedReminders, switched)
|
||||
return@update switched.withDatedReminders()
|
||||
return@update switched.withDefaultReminders()
|
||||
}
|
||||
switched.copy(reminders = carried(it.reminders, switched).sortedForDisplay(switched))
|
||||
}
|
||||
@@ -355,28 +352,6 @@ class TaskEditViewModel @Inject constructor(
|
||||
return carried
|
||||
}
|
||||
|
||||
/**
|
||||
* [withDefaultReminders] for a task that may have lost its dates: with none
|
||||
* left its reminders have nothing to count from and go (#159). Those the user
|
||||
* picked are set aside and come back with the next date; untouched ones come
|
||||
* back from [loadedReminders] anyway.
|
||||
*/
|
||||
private fun TaskEditUiState.withDatedReminders(): TaskEditUiState {
|
||||
if (start == null && due == null) {
|
||||
if (reminders.isEmpty()) return this
|
||||
if (remindersTouched) setAside = reminders to isAllDay
|
||||
return withoutReminders()
|
||||
}
|
||||
val aside = setAside ?: return withDefaultReminders()
|
||||
setAside = null
|
||||
val restored = if (aside.second == isAllDay) {
|
||||
aside.first
|
||||
} else {
|
||||
StoredReminders.switched(aside.first, toForm(), settings, nowMillis(), ZoneId.systemDefault())
|
||||
}
|
||||
return copy(reminders = (reminders + restored).distinct().sortedForDisplay(this), remindersChosen = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* The reminders the task will be saved with while the user has not
|
||||
* touched them: those it was loaded with, plus the defaults of the dates
|
||||
@@ -499,9 +474,6 @@ private fun List<TaskReminder>.sortedForDisplay(state: TaskEditUiState): List<Ta
|
||||
sortedWith(taskReminderDisplayOrder(state.isAllDay, state.allDayMinuteOfDay))
|
||||
|
||||
/** Snapshot the editable state as a validated [TaskForm]. */
|
||||
/** No reminders at all, stored as such. */
|
||||
private fun TaskEditUiState.withoutReminders(): TaskEditUiState = copy(reminders = emptyList(), remindersChosen = true)
|
||||
|
||||
private fun TaskEditUiState.toForm(): TaskForm = TaskForm(
|
||||
title = title,
|
||||
listId = listId ?: 0L,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
package de.jeanlucmakiola.agendula.ui.edit
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.domain.Priority
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.lang.reflect.Proxy
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Reminders on a task that loses or regains its dates in the editor (#159). */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TaskEditViewModelTest {
|
||||
|
||||
private val due = Clock.System.now() + 3.days
|
||||
private val reminder = TaskReminder(30)
|
||||
private val repository = FakeRepository()
|
||||
|
||||
@BeforeEach fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||
|
||||
@AfterEach fun tearDown() = Dispatchers.resetMain()
|
||||
|
||||
private fun editing(start: Instant? = null, due: Instant? = this.due): TaskEditViewModel {
|
||||
repository.task = task(start, due)
|
||||
repository.reminders = listOf(reminder)
|
||||
return TaskEditViewModel(repository, settingsPrefs()).apply { bindEdit(TASK_ID) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearing the last date drops the reminders and the task saves`() {
|
||||
val vm = editing()
|
||||
vm.onDueChange(null)
|
||||
vm.save()
|
||||
|
||||
assertThat(vm.state.value.errors).isEmpty()
|
||||
assertThat(repository.saved?.reminders).isEmpty()
|
||||
assertThat(repository.saved?.remindersChosen).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day switch or another list does not bring them back`() {
|
||||
val vm = editing()
|
||||
vm.onDueChange(null)
|
||||
vm.onAllDayChange(true)
|
||||
vm.onListChange(LIST_ID + 1)
|
||||
|
||||
assertThat(vm.state.value.reminders).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `untouched reminders come back with a new date`() {
|
||||
val vm = editing()
|
||||
vm.onDueChange(null)
|
||||
vm.onDueChange(due + 1.days)
|
||||
|
||||
assertThat(vm.state.value.reminders).contains(reminder)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `picked reminders come back with a new date`() {
|
||||
val vm = editing()
|
||||
val picked = TaskReminder(120)
|
||||
vm.addReminder(picked)
|
||||
vm.onDueChange(null)
|
||||
vm.onDueChange(due + 1.days)
|
||||
|
||||
assertThat(vm.state.value.reminders).containsExactly(reminder, picked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearing one of two dates keeps the reminders`() {
|
||||
val vm = editing(start = due - 1.days)
|
||||
vm.onDueChange(null)
|
||||
|
||||
assertThat(vm.state.value.reminders).containsExactly(reminder)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a task stored without dates opens without its reminders`() {
|
||||
val vm = editing(due = null)
|
||||
|
||||
assertThat(vm.state.value.reminders).isEmpty()
|
||||
assertThat(vm.state.value.isDirty).isFalse()
|
||||
}
|
||||
|
||||
private fun task(start: Instant?, due: Instant?) = Task(
|
||||
taskId = TASK_ID,
|
||||
listId = LIST_ID,
|
||||
title = "Task",
|
||||
description = null,
|
||||
location = null,
|
||||
url = null,
|
||||
priority = Priority.NONE,
|
||||
status = TaskStatus.NEEDS_ACTION,
|
||||
percentComplete = null,
|
||||
start = start,
|
||||
due = due,
|
||||
isAllDay = false,
|
||||
timeZone = null,
|
||||
completedAt = null,
|
||||
listColor = 0,
|
||||
taskColor = null,
|
||||
listName = null,
|
||||
accountName = null,
|
||||
parentId = null,
|
||||
isRecurring = false,
|
||||
distanceFromCurrent = null,
|
||||
created = null,
|
||||
lastModified = null,
|
||||
)
|
||||
|
||||
private class FakeRepository : TasksRepository by unsupported() {
|
||||
var task: Task? = null
|
||||
var reminders: List<TaskReminder> = emptyList()
|
||||
var saved: TaskForm? = null
|
||||
|
||||
override fun taskLists() = flowOf(emptyList<TaskList>())
|
||||
override fun tasks(filter: TaskFilter): Flow<List<Task>> = flowOf(emptyList())
|
||||
override fun taskDetail(taskId: Long, occurrenceStart: Instant?) =
|
||||
flowOf(task?.let { TaskDetail(it, emptyList()) })
|
||||
override suspend fun remindersFor(taskId: Long) = reminders
|
||||
override suspend fun updateTask(
|
||||
taskId: Long,
|
||||
form: TaskForm,
|
||||
expectedLastModified: Instant?,
|
||||
occurrenceStart: Instant?,
|
||||
scope: RecurringScope,
|
||||
) {
|
||||
saved = form
|
||||
}
|
||||
}
|
||||
|
||||
private class MemoryDataStore : DataStore<Preferences> {
|
||||
override val data = MutableStateFlow(emptyPreferences())
|
||||
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences) =
|
||||
transform(data.value).also { data.value = it }
|
||||
}
|
||||
|
||||
private fun settingsPrefs() = SettingsPrefs(MemoryDataStore(), ProviderResolver(unsupported<ProviderEnvironment>()))
|
||||
|
||||
private companion object {
|
||||
const val TASK_ID = 1L
|
||||
const val LIST_ID = 7L
|
||||
|
||||
/** Calls the test does not expect answer false or null. */
|
||||
inline fun <reified T> unsupported(): T =
|
||||
Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, method, _ ->
|
||||
if (method.returnType == Boolean::class.javaPrimitiveType) false else null
|
||||
} as T
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user