Compare commits

..
8 changed files with 218 additions and 73 deletions

No files matched your search

+3 -4
View File
@@ -8,10 +8,9 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
### Fixed
- 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).
- 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
@@ -1,27 +0,0 @@
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,7 +18,6 @@ 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
@@ -269,9 +268,6 @@ 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,7 +57,6 @@ 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
@@ -220,9 +219,7 @@ fun TaskEditScreen(
viewModel = viewModel,
scroll = scroll,
onTitleBottom = { titleBottom = it },
// Consumed so EditContent's imePadding adds only what the keyboard
// covers beyond the bars.
modifier = Modifier.padding(inner).consumeWindowInsets(inner),
modifier = Modifier.padding(inner),
)
}
@@ -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,
@@ -29,16 +29,7 @@ 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.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.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
@@ -108,7 +99,6 @@ 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
@@ -190,10 +180,7 @@ fun ListsScreen(
available = syncAvailable && !searchActive,
syncing = syncing,
onSync = viewModel::syncAll,
modifier = Modifier
.fillMaxSize()
.padding(top = inner.calculateTopPadding())
.sideInsetsPadding(inner),
modifier = Modifier.fillMaxSize().padding(top = inner.calculateTopPadding()),
) {
when (val s = state) {
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
@@ -526,10 +513,7 @@ private fun HomeTopBar(
Row(
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(
WindowInsets.systemBars.union(WindowInsets.displayCutout)
.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
)
.statusBarsPadding()
.height(72.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -688,14 +672,10 @@ 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,7 +115,6 @@ 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
@@ -251,7 +250,7 @@ fun TaskListScreen(
}
},
) { inner ->
Box(modifier = Modifier.fillMaxSize().sideInsetsPadding(inner)) {
Box(modifier = Modifier.fillMaxSize()) {
SyncPullBox(
available = syncAvailable,
syncing = syncing,
@@ -261,10 +260,7 @@ 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),
PaddingValues(top = inner.calculateTopPadding(), bottom = inner.calculateBottomPadding()),
)
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
is TaskListUiState.Content -> TaskListBody(
s, filter, inner, onOpenTask, onRequestDelete, viewModel,
onClearCompleted = if (listId != null && list?.isReadOnly != true) ({ confirmClear = true }) else null,
@@ -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
}
}