Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
984da3c61c | ||
|
|
d562cf85f2 |
No files matched your search
+4
-3
@@ -8,9 +8,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).
|
||||
- With 3-button navigation, the navigation bar no longer covers the last home
|
||||
search results, and in landscape it no longer covers the home screen, the
|
||||
task list or the home top bar's buttons. The task details and editor no
|
||||
longer leave an empty gap between the open keyboard and the text (#152).
|
||||
|
||||
## [1.3.1] - 2026-11-09
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.jeanlucmakiola.agendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Pads by the start/end of a scaffold's [inner] padding and consumes it, so a
|
||||
* side navigation bar or cutout (3-button nav in landscape) is cleared once.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.sideInsetsPadding(inner: PaddingValues): Modifier {
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val sides = PaddingValues(
|
||||
start = inner.calculateStartPadding(layoutDirection),
|
||||
top = 0.dp,
|
||||
end = inner.calculateEndPadding(layoutDirection),
|
||||
bottom = 0.dp,
|
||||
)
|
||||
return this.padding(sides).consumeWindowInsets(sides)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -268,6 +269,9 @@ private fun DetailBody(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(inner)
|
||||
// Consume the bar insets so imePadding adds only what the keyboard
|
||||
// covers beyond them.
|
||||
.consumeWindowInsets(inner)
|
||||
// Shrink the scroll viewport by the keyboard so the focused add-subtask
|
||||
// field scrolls into view above the IME instead of being hidden.
|
||||
.imePadding()
|
||||
|
||||
@@ -57,6 +57,7 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -219,7 +220,9 @@ fun TaskEditScreen(
|
||||
viewModel = viewModel,
|
||||
scroll = scroll,
|
||||
onTitleBottom = { titleBottom = it },
|
||||
modifier = Modifier.padding(inner),
|
||||
// Consumed so EditContent's imePadding adds only what the keyboard
|
||||
// covers beyond the bars.
|
||||
modifier = Modifier.padding(inner).consumeWindowInsets(inner),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -29,7 +29,16 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
@@ -99,6 +108,7 @@ import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
|
||||
import de.jeanlucmakiola.agendula.ui.common.SyncPullBox
|
||||
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
|
||||
import de.jeanlucmakiola.agendula.ui.common.sideInsetsPadding
|
||||
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.time.formatDateTimeCompact
|
||||
@@ -180,7 +190,10 @@ fun ListsScreen(
|
||||
available = syncAvailable && !searchActive,
|
||||
syncing = syncing,
|
||||
onSync = viewModel::syncAll,
|
||||
modifier = Modifier.fillMaxSize().padding(top = inner.calculateTopPadding()),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = inner.calculateTopPadding())
|
||||
.sideInsetsPadding(inner),
|
||||
) {
|
||||
when (val s = state) {
|
||||
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
|
||||
@@ -513,7 +526,10 @@ private fun HomeTopBar(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.windowInsetsPadding(
|
||||
WindowInsets.systemBars.union(WindowInsets.displayCutout)
|
||||
.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
)
|
||||
.height(72.dp)
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -672,10 +688,14 @@ private fun SearchResults(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// The last result scrolls clear of the navigation bar and the keyboard.
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(results, key = { it.occurrenceKey }) { task ->
|
||||
UpcomingRow(task = task, onClick = { onOpenTask(task) })
|
||||
}
|
||||
item(key = "bottom-inset") {
|
||||
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars.union(WindowInsets.ime)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.agendula.ui.common.SyncPullBox
|
||||
import de.jeanlucmakiola.agendula.ui.common.ignoringEdgeSwipes
|
||||
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
|
||||
import de.jeanlucmakiola.agendula.ui.common.sideInsetsPadding
|
||||
import de.jeanlucmakiola.agendula.ui.lists.ListEditorSheet
|
||||
import de.jeanlucmakiola.agendula.ui.lists.listWriteFailureMessage
|
||||
import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure
|
||||
@@ -250,7 +251,7 @@ fun TaskListScreen(
|
||||
}
|
||||
},
|
||||
) { inner ->
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Box(modifier = Modifier.fillMaxSize().sideInsetsPadding(inner)) {
|
||||
SyncPullBox(
|
||||
available = syncAvailable,
|
||||
syncing = syncing,
|
||||
@@ -260,7 +261,10 @@ fun TaskListScreen(
|
||||
) {
|
||||
when (val s = state) {
|
||||
TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission
|
||||
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
|
||||
TaskListUiState.Failure -> CenteredMessage(
|
||||
stringResource(R.string.task_list_failure),
|
||||
PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding()),
|
||||
)
|
||||
is TaskListUiState.Content -> TaskListBody(
|
||||
s, filter, inner, onOpenTask, onRequestDelete, viewModel,
|
||||
onClearCompleted = if (listId != null && list?.isReadOnly != true) ({ confirmClear = true }) else null,
|
||||
|
||||
@@ -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