Compare commits

..
9 changed files with 215 additions and 265 deletions

No files matched your search

+4 -4
View File
@@ -7,10 +7,10 @@ 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).
### 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).
## [1.3.1] - 2026-11-09
@@ -54,9 +54,6 @@ 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)
@@ -106,7 +103,6 @@ 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,
@@ -175,9 +171,6 @@ 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,
@@ -206,9 +199,6 @@ 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 }
@@ -355,7 +345,6 @@ 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,7 +18,6 @@ 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
@@ -87,11 +86,7 @@ class TaskNotifier @Inject constructor(
),
)
}
val description = reminderBody(
task.description?.readableDescription(),
settings.reminderDescription,
lineChars = (NOTIFICATION_LINE_CHARS / context.resources.configuration.fontScale).toInt(),
)
val description = task.description?.readableDescription()?.trim()?.takeIf { it.isNotBlank() }
val tapIntent = PendingIntent.getActivity(
context,
task.taskId.toInt(),
@@ -282,96 +277,3 @@ 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,6 +149,9 @@ 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,
@@ -255,7 +258,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
}
@@ -309,15 +312,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()).withDefaultReminders() }
update { it.copy(listId = listId, parentId = null, errors = emptySet()).withDatedReminders() }
viewModelScope.launch {
val candidates = loadParents(listId, selfId = editingTaskId)
update { it.copy(parentCandidates = candidates) }
}
}
fun onStartChange(value: Instant?) = update { it.copy(start = value).withDefaultReminders() }
fun onDueChange(value: Instant?) = update { it.copy(due = value).withDefaultReminders() }
fun onStartChange(value: Instant?) = update { it.copy(start = value).withDatedReminders() }
fun onDueChange(value: Instant?) = update { it.copy(due = value).withDatedReminders() }
/**
* 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 —
@@ -335,7 +338,7 @@ class TaskEditViewModel @Inject constructor(
)
if (!it.remindersTouched) {
loadedReminders = carried(loadedReminders, switched)
return@update switched.withDefaultReminders()
return@update switched.withDatedReminders()
}
switched.copy(reminders = carried(it.reminders, switched).sortedForDisplay(switched))
}
@@ -352,6 +355,28 @@ 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
@@ -474,6 +499,9 @@ 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,7 +39,6 @@ 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
@@ -114,7 +113,6 @@ 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>()) }
@@ -180,14 +178,8 @@ internal fun NotificationsScreen(
R.string.settings_allday_reminder_time_summary,
formatMinuteOfDay(state.settings.allDayReminderMinuteOfDay, LocalUse24HourFormat.current),
),
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 },
onClick = { showAllDayTime = true },
)
Spacer(Modifier.height(24.dp))
@@ -326,17 +318,6 @@ 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(
@@ -373,24 +354,6 @@ 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,7 +6,6 @@ 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
@@ -260,7 +259,6 @@ 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,13 +470,6 @@
<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>
@@ -1,99 +0,0 @@
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()
}
}
@@ -0,0 +1,176 @@
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
}
}