Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6355f6c941 | ||
|
|
e2275745da | ||
|
|
3e34c60577 | ||
|
|
caf4357cee | ||
|
|
97164feb39 | ||
|
|
8a521d32f9 | ||
|
|
b68582177b | ||
|
|
05f43d6f76 | ||
|
|
7498ffb37e |
No files matched your search
@@ -7,6 +7,11 @@ 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).
|
||||
|
||||
## [1.3.1] - 2026-11-09
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -763,4 +763,95 @@
|
||||
<string name="widget_config_background">الخلفية</string>
|
||||
<string name="widget_config_background_solid">سادة</string>
|
||||
<string name="widget_config_theme">السمة</string>
|
||||
<string name="app_name_tasks_alias">مهام</string>
|
||||
<string name="list_where">أين</string>
|
||||
<string name="settings_backup_subtitle">التصدير و الاستيراد</string>
|
||||
<string name="error_reminder_without_date">اضبط تاريخ بدء أو استحقاق لاستخدام تذكير.</string>
|
||||
<string name="reminder_at_start">في وقت البدء</string>
|
||||
<string name="reminder_at_due_time">في وقت الاستحقاق</string>
|
||||
<string name="reminder_on_start_date">عند موعد البدء</string>
|
||||
<string name="reminder_on_due_date">عند موعد الاستحقاق</string>
|
||||
<string name="reminder_at_time">%1$s عند %2$s</string>
|
||||
<string name="reminder_anchor_start">من البدء</string>
|
||||
<string name="reminder_anchor_due">من تاريخ الاستحقاق</string>
|
||||
<string name="reminder_time">الوقت</string>
|
||||
<string name="reminder_benefit_delivery_title">تنبيه عندما يكون الأمر مهمًا</string>
|
||||
<string name="reminder_benefit_reversible_title">يمكنك تغييره في أي وقت</string>
|
||||
<string name="settings_app_name_hint">اعرض Agendula كـ ”مهام“ في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد. يُغلَق Agendula عندما تقوم بالتبديل، لذا أعِد فتحه من المشغّل.</string>
|
||||
<string name="settings_allday_reminder_time_summary">العد التنازلي لمهام التي تستمر طوال اليوم عند %1$s</string>
|
||||
<string name="settings_store_permission_denied_hint">مهام التطبيق الآخر تبقى غير قابلة للوصول حتى تسمح بالوصول. اضغط لفتح إعدادات التطبيق.</string>
|
||||
<string name="settings_copy_counting">جارٍ حساب ما هناك لنسخه…</string>
|
||||
<string name="settings_copy_confirm_empty">%1$s لا يحتوي على مهام لنسخها.</string>
|
||||
<string name="settings_copy_nothing">لم يكن هناك شيء للنسخ</string>
|
||||
<string name="export_hint">ملف .ics واحد لكل قائمة، قابل للقراءة بواسطة تطبيقات المهام والتقويم الأخرى. القوائم الموضوع عليها بعلامة صح تذهب إلى مجلد تختاره أنت، أو إلى ملف مضغوط (zip) واحد.</string>
|
||||
<string name="export_no_lists">لا توجد قوائم للتصدير</string>
|
||||
<string name="export_to_folder">الحفظ إلى مجلد</string>
|
||||
<string name="export_to_zip">حفظ كملف مضغوط (zip)</string>
|
||||
<plurals name="export_done">
|
||||
<item quantity="zero">تم تصدير (%1$d) لا قوائم</item>
|
||||
<item quantity="one">تم تصدير قائمة واحدة (%1$d)</item>
|
||||
<item quantity="two">تم تصدير قائمتان (%1$d)</item>
|
||||
<item quantity="few">تم تصدير %1$d قوائم</item>
|
||||
<item quantity="many">تم تصدير %1$d قائمة</item>
|
||||
<item quantity="other">تم تصدير %1$d قائمة</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_minutes_before_start">
|
||||
<item quantity="zero">(%1$d) لا دقائق قبل البدء</item>
|
||||
<item quantity="one">%1$d دقيقة واحده قبل البدء</item>
|
||||
<item quantity="two">%1$d دقيقتان قبل البدء</item>
|
||||
<item quantity="few">%1$d دقائق قبل البدء</item>
|
||||
<item quantity="many">%1$d دقيقة قبل البدء</item>
|
||||
<item quantity="other">%1$d دقيقة قبل البدء</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_hours_before_start">
|
||||
<item quantity="zero">(%1$d) لا ساعات قبل البدء</item>
|
||||
<item quantity="one">%1$d ساعة واحده قبل البدء</item>
|
||||
<item quantity="two">%1$d ساعتان قبل البدء</item>
|
||||
<item quantity="few">%1$d ساعات قبل البدء</item>
|
||||
<item quantity="many">%1$d ساعة قبل البدء</item>
|
||||
<item quantity="other">%1$d ساعة قبل البدء</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_days_before_start">
|
||||
<item quantity="zero">(%1$d) لا أيام قبل البدء</item>
|
||||
<item quantity="one">%1$d يوم واحد قبل البدء</item>
|
||||
<item quantity="two">%1$d يومان قبل البدء</item>
|
||||
<item quantity="few">%1$d أيام قبل البدء</item>
|
||||
<item quantity="many">%1$d يوم قبل البدء</item>
|
||||
<item quantity="other">%1$d يوم قبل البدء</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_weeks_before_start">
|
||||
<item quantity="zero">(%1$d) لا أسابيع قبل البدء</item>
|
||||
<item quantity="one">%1$d أسبوع واحد قبل البدء</item>
|
||||
<item quantity="two">%1$d أسبوعان قبل البدء</item>
|
||||
<item quantity="few">%1$d أسابيع قبل البدء</item>
|
||||
<item quantity="many">%1$d أسبوع قبل البدء</item>
|
||||
<item quantity="other">%1$d أسبوع قبل البدء</item>
|
||||
</plurals>
|
||||
<string name="settings_accounts_subtitle">مزامنة مهامك مع خادم CalDAV</string>
|
||||
<string name="accounts_add">إضافة حسابًا</string>
|
||||
<string name="accounts_sync_interval">المزامنة تلقائيًا</string>
|
||||
<string name="accounts_sync_interval_manual">فقط عند تقوم بالمزامنة</string>
|
||||
<string name="accounts_push_via">تصل التغييرات فورًا، عبر %1$s</string>
|
||||
<string name="widget_unavailable">افتح Agendula لإنهاء الإعداد</string>
|
||||
<string name="widget_today_all_done">انتهى كل شيء</string>
|
||||
<string name="widget_config_title">إظهار في الأداة المصغرة</string>
|
||||
<string name="widget_config_group_by_list">تجميع حسب القائمة</string>
|
||||
<string name="widget_config_group_by_list_hint">إظهار مهام كل قائمة ضمن عنوانها الخاص</string>
|
||||
<string name="widget_config_background_solid_hint">يملأ الأداة المصغّرة بلون سطح السمة</string>
|
||||
<string name="widget_config_background_tinted_hint">أكثر نعومة، تعبئة لونية متناغمة</string>
|
||||
<string name="widget_config_background_transparent">شفافة</string>
|
||||
<string name="widget_config_background_transparent_hint">لا تعبئة — تبقى المهام قابلة للقراءة في حاوياتها الخاصة</string>
|
||||
<string name="widget_config_row_color_neutral_hint">يستخدم كل صف نفس التعبئة اللونية المتناغمة</string>
|
||||
<string name="widget_config_row_color_by_list">حسب القائمة</string>
|
||||
<string name="widget_config_row_color_by_list_hint">كل صف متناغم باللون الخاص بقائمته</string>
|
||||
<string name="widget_today_description">حلقة تقدم لمهام اليوم</string>
|
||||
<string name="widget_up_next_description">مهمتك الوحيدة الأقرب لموعد الاستحقاق</string>
|
||||
<string name="widget_lists_label">قوائم</string>
|
||||
<string name="widget_lists_description">مربع اختصار لكل قائمة، مع عدد مرّات فتحه</string>
|
||||
<string name="widget_week_label">أسبوع</string>
|
||||
<string name="widget_week_description">شريط من سبعة أيام لعدد المهام</string>
|
||||
<string name="widget_config_background_tinted">ملونة</string>
|
||||
<string name="settings_theme_black">أسود</string>
|
||||
<string name="settings_theme_black_summary">داكن مع خلفيات سوداء نقية، لشاشات AMOLED</string>
|
||||
<string name="settings_app_name">اسم التطبيق</string>
|
||||
</resources>
|
||||
@@ -320,4 +320,18 @@
|
||||
<string name="reminder_at_due">V čase úkolu</string>
|
||||
<string name="reminder_on_the_day">V daný den</string>
|
||||
<string name="settings_list_reminders_title">Upozornění pro jednotlivé seznamy</string>
|
||||
<string name="shortcut_new_task">Nový úkol</string>
|
||||
<string name="shortcut_today">Dnes</string>
|
||||
<string name="qs_tile_new_task">Nový úkol</string>
|
||||
<string name="sort_title">Seřadit podle</string>
|
||||
<string name="view_options_title">Řazení a filtrování</string>
|
||||
<string name="view_filter_tags">Filtrovat podle štítku</string>
|
||||
<string name="view_filter_clear">Zrušit filtr</string>
|
||||
<string name="sort_due">Termín splnění</string>
|
||||
<string name="sort_title_alpha">Název</string>
|
||||
<string name="sort_created">Nedávno přidané</string>
|
||||
<string name="clear_completed_action">Zrušit</string>
|
||||
<string name="undo">Zpět</string>
|
||||
<string name="section_today">Dnes</string>
|
||||
<string name="section_upcoming">Nadcházející</string>
|
||||
</resources>
|
||||
@@ -28,4 +28,11 @@
|
||||
<string name="qs_tile_new_task">Новая задача</string>
|
||||
<string name="lists_reorder">Переопределить список</string>
|
||||
<string name="lists_reorder_done">Завершить переопределение</string>
|
||||
<string name="app_name_tasks_alias">Задачи</string>
|
||||
<string name="lists_reorder_handle">Перетащите, чтобы изменить порядок</string>
|
||||
<string name="sort_title">Сортировать по</string>
|
||||
<string name="view_options_title">Сортировать и фильтровать</string>
|
||||
<string name="view_filter_tags">Фильтр по тегу</string>
|
||||
<string name="view_filter_clear">Очистить фильтр</string>
|
||||
<string name="sort_due">Срок выполнения</string>
|
||||
</resources>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user