From 3267ccf938cdf2d204f01bf36b0423716333b04a Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:05:28 +0000 Subject: [PATCH 1/3] Add undo snackbar on Today & completion on Upcoming Support undoing task completion via snackbar action on Today screen and enable task completion checkbox on Upcoming screen. Closes #134 --- .../composeResources/values/strings.xml | 2 + .../foliary/feature/today/TodayScreen.kt | 95 ++++++++++++++----- .../foliary/feature/today/TodayViewModel.kt | 2 + .../feature/upcoming/UpcomingScreen.kt | 6 +- .../feature/upcoming/UpcomingViewModel.kt | 6 ++ .../foliary/feature/today/TodayScreenTest.kt | 36 +++++++ .../feature/today/TodayViewModelTest.kt | 9 +- .../feature/upcoming/UpcomingScreenTest.kt | 24 +++++ .../feature/upcoming/UpcomingViewModelTest.kt | 30 ++++++ 9 files changed, 184 insertions(+), 26 deletions(-) diff --git a/foliary/src/commonMain/composeResources/values/strings.xml b/foliary/src/commonMain/composeResources/values/strings.xml index 4af99b57..66b2c17d 100644 --- a/foliary/src/commonMain/composeResources/values/strings.xml +++ b/foliary/src/commonMain/composeResources/values/strings.xml @@ -70,6 +70,8 @@ Create a new task Completed today (%1$d) Toggle completed tasks + Task marked as done + Undo diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/today/TodayScreen.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/today/TodayScreen.kt index 6145d04c..e4cbe840 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/today/TodayScreen.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/today/TodayScreen.kt @@ -23,8 +23,13 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -37,7 +42,6 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.composables.icons.lucide.ChevronDown import com.composables.icons.lucide.Lucide -import dev.appoutlet.foliary.core.navigation.Navigator import dev.appoutlet.foliary.core.ui.component.button.FoliaryAddButton import dev.appoutlet.foliary.core.ui.component.button.FoliarySecondaryButton import dev.appoutlet.foliary.core.ui.component.layout.FoliaryEmptyContent @@ -64,7 +68,10 @@ import foliary.foliary.generated.resources.today_empty_button import foliary.foliary.generated.resources.today_empty_description import foliary.foliary.generated.resources.today_empty_title import foliary.foliary.generated.resources.today_title +import foliary.foliary.generated.resources.today_undo_snackbar_action +import foliary.foliary.generated.resources.today_undo_snackbar_message import foliary.foliary.generated.resources.today_welcome +import kotlinx.coroutines.channels.Channel import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel @@ -72,22 +79,73 @@ import org.koin.compose.viewmodel.koinViewModel @Composable fun TodayScreen(lazyListState: LazyListState) { val viewModel = koinViewModel() + val snackbarHostState = remember { SnackbarHostState() } + val undoChannel = remember { Channel(Channel.CONFLATED) } - Screen( - screenName = "TodayScreen", - viewModelProvider = { viewModel }, - onAction = ::onAction - ) { viewData -> - when (viewData) { - TodayViewData.Idle -> {} - TodayViewData.Loading -> LoadingIndicator() - is TodayViewData.Loaded, - is TodayViewData.Empty, - is TodayViewData.Celebration -> TodayScreenContent( - lazyListState = lazyListState, - viewData = viewData, - onEvent = viewModel::onEvent, + TodayUndoSnackbar( + events = undoChannel, + hostState = snackbarHostState, + message = stringResource(Res.string.today_undo_snackbar_message), + actionLabel = stringResource(Res.string.today_undo_snackbar_action), + onUndo = { taskId -> viewModel.onEvent(TodayEvent.MarkTaskAsNotCompleted(taskId)) } + ) + + Box(modifier = Modifier.fillMaxSize()) { + Screen( + screenName = "TodayScreen", + viewModelProvider = { viewModel }, + onAction = { action, navigator -> + when (action) { + TodayAction.NavigateToCreateTask -> navigator.navigate(CreateTaskNavKey()) + TodayAction.NavigateToSignIn -> navigator.setRoot(SignInNavKey) + is TodayAction.NavigateToTaskDetail -> navigator.navigate(TaskDetailNavKey(action.taskId)) + is TodayAction.ShowUndoSnackbar -> undoChannel.trySend(action) + } + } + ) { viewData -> + when (viewData) { + TodayViewData.Idle -> {} + TodayViewData.Loading -> LoadingIndicator() + is TodayViewData.Loaded, + is TodayViewData.Empty, + is TodayViewData.Celebration -> TodayScreenContent( + lazyListState = lazyListState, + viewData = viewData, + onEvent = viewModel::onEvent, + ) + } + } + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)) + .padding(horizontal = 16.dp) + ) + } +} + +@Composable +internal fun TodayUndoSnackbar( + events: Channel, + hostState: SnackbarHostState, + message: String, + actionLabel: String, + onUndo: (String) -> Unit, + duration: SnackbarDuration = SnackbarDuration.Long, +) { + LaunchedEffect(events) { + for (event in events) { + hostState.currentSnackbarData?.dismiss() + val result = hostState.showSnackbar( + message = message, + actionLabel = actionLabel, + duration = duration, ) + if (result == SnackbarResult.ActionPerformed) { + onUndo(event.taskId) + } } } } @@ -317,13 +375,6 @@ private fun TodayScreenCelebrationContent( } } -private fun onAction(action: TodayAction, navigator: Navigator) { - when (action) { - TodayAction.NavigateToCreateTask -> navigator.navigate(CreateTaskNavKey()) - TodayAction.NavigateToSignIn -> navigator.setRoot(SignInNavKey) - is TodayAction.NavigateToTaskDetail -> navigator.navigate(TaskDetailNavKey(action.taskId)) - } -} private const val RotationCollapsed = -90f private const val RotationExpanded = 0f diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/today/TodayViewModel.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/today/TodayViewModel.kt index b7c92c14..616ef08f 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/today/TodayViewModel.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/today/TodayViewModel.kt @@ -99,6 +99,7 @@ class TodayViewModel( private fun onMarkTaskAsCompleted(taskId: String) = intent { celebrationPending = true taskRepository.markCompleted(Uuid.parse(taskId)) + postSideEffect(TodayAction.ShowUndoSnackbar(taskId)) } private fun onMarkTaskAsNotCompleted(taskId: String) = intent { @@ -142,6 +143,7 @@ sealed interface TodayAction : Action { data object NavigateToCreateTask : TodayAction data object NavigateToSignIn : TodayAction data class NavigateToTaskDetail(val taskId: String) : TodayAction + data class ShowUndoSnackbar(val taskId: String) : TodayAction } sealed interface TodayEvent { diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/upcoming/UpcomingScreen.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/upcoming/UpcomingScreen.kt index efb48f0c..d8cc95d7 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/upcoming/UpcomingScreen.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/upcoming/UpcomingScreen.kt @@ -235,7 +235,11 @@ private fun UpcomingTaskItem( .padding(horizontal = 16.dp, vertical = 4.dp) .align(Alignment.Center), task = task, - onCompletedChange = {}, + onCompletedChange = { isCompleted -> + if (isCompleted) { + onEvent(UpcomingEvent.MarkTaskAsCompleted(task.id)) + } + }, onClick = { onEvent(UpcomingEvent.OnTaskClick(task.id)) }, onPillClick = { clickedPill -> when (clickedPill) { diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/upcoming/UpcomingViewModel.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/upcoming/UpcomingViewModel.kt index 204a3d0d..a42af86d 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/upcoming/UpcomingViewModel.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/upcoming/UpcomingViewModel.kt @@ -29,9 +29,14 @@ class UpcomingViewModel( UpcomingEvent.OnAddTaskClick -> onAddTaskClick() is UpcomingEvent.OnTaskClick -> onTaskClick(event.taskId) is UpcomingEvent.OnUrlPillClick -> onUrlPillClick(event.taskId) + is UpcomingEvent.MarkTaskAsCompleted -> onMarkTaskAsCompleted(event.taskId) } } + private fun onMarkTaskAsCompleted(taskId: String) = intent { + taskRepository.markCompleted(Uuid.parse(taskId)) + } + private fun onAddTaskClick() = intent { postSideEffect(UpcomingAction.NavigateToCreateTask) } @@ -72,6 +77,7 @@ sealed interface UpcomingEvent { data object OnAddTaskClick : UpcomingEvent data class OnTaskClick(val taskId: String) : UpcomingEvent data class OnUrlPillClick(val taskId: String) : UpcomingEvent + data class MarkTaskAsCompleted(val taskId: String) : UpcomingEvent } sealed interface UpcomingAction : Action { diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/today/TodayScreenTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/today/TodayScreenTest.kt index e72d6858..8ef7c6e7 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/today/TodayScreenTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/today/TodayScreenTest.kt @@ -1,6 +1,10 @@ package dev.appoutlet.foliary.feature.today import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.remember import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithContentDescription @@ -10,6 +14,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.v2.runComposeUiTest import dev.appoutlet.foliary.core.ui.component.task.FoliaryTaskCardViewData import dev.appoutlet.foliary.core.ui.component.task.fixture +import kotlinx.coroutines.channels.Channel import foliary.foliary.generated.resources.Res import foliary.foliary.generated.resources.today_add_task_a11y import foliary.foliary.generated.resources.today_celebration_button @@ -316,4 +321,35 @@ class TodayScreenTest { event shouldBe TodayEvent.OnUrlPillClick(task.id) } + + @Test + fun `should display undo snackbar and invoke onUndo when action is tapped`() = runComposeUiTest { + val channel = Channel(Channel.CONFLATED) + var undoneTaskId: String? = null + + setContent { + val hostState = remember { SnackbarHostState() } + + TodayUndoSnackbar( + events = channel, + hostState = hostState, + message = "Task marked as done", + actionLabel = "Undo", + onUndo = { undoneTaskId = it }, + duration = SnackbarDuration.Indefinite, + ) + + SnackbarHost(hostState = hostState) + } + + channel.trySend(TodayAction.ShowUndoSnackbar("task-123")) + mainClock.advanceTimeBy(1000) + waitForIdle() + + onNodeWithText("Task marked as done").assertIsDisplayed() + onNodeWithText("Undo").assertIsDisplayed().performClick() + waitForIdle() + + undoneTaskId shouldBe "task-123" + } } diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/today/TodayViewModelTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/today/TodayViewModelTest.kt index c06e8436..a36b7aa3 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/today/TodayViewModelTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/today/TodayViewModelTest.kt @@ -144,6 +144,7 @@ class TodayViewModelTest : ViewModelTest() { @@ -112,6 +115,33 @@ class UpcomingViewModelTest : ViewModelTest Date: Thu, 22 Oct 2026 09:57:54 +0000 Subject: [PATCH 2/3] Fix detekt import ordering in TodayScreenTest Correct import order in TodayScreenTest to pass detekt linter. Closes #134 --- .../foliary/feature/signin/SignInScreen.kt | 13 ++++--------- .../appoutlet/foliary/feature/today/TodayScreen.kt | 1 - .../foliary/feature/today/TodayScreenTest.kt | 2 +- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/signin/SignInScreen.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/signin/SignInScreen.kt index ee4503eb..004fba00 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/signin/SignInScreen.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/signin/SignInScreen.kt @@ -72,16 +72,14 @@ fun SignInScreen() { ) { paddingValues -> Box(modifier = Modifier.padding(paddingValues).fillMaxSize()) { Column( - modifier = Modifier - .fillMaxSize() + modifier = Modifier.widthInCompact(480.dp) .align(Alignment.Center) .padding(horizontal = 24.dp) .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.spacedBy(24.dp) ) { SignInHeader() - Spacer(Modifier.height(24.dp)) SignInForm(viewData = viewData, onEvent = viewModel::onEvent) } } @@ -91,10 +89,7 @@ fun SignInScreen() { @Composable private fun SignInHeader() { - Column( - modifier = Modifier.widthInCompact(480.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { Box( modifier = Modifier .size(64.dp) @@ -135,7 +130,7 @@ private fun SignInForm( onEvent: (SignInEvent) -> Unit ) { FoliaryCard( - modifier = Modifier.widthInCompact(480.dp).fillMaxWidth() + modifier = Modifier.fillMaxWidth() ) { AnimatedContent( targetState = viewData, diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/today/TodayScreen.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/today/TodayScreen.kt index e4cbe840..72117a2f 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/today/TodayScreen.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/today/TodayScreen.kt @@ -375,6 +375,5 @@ private fun TodayScreenCelebrationContent( } } - private const val RotationCollapsed = -90f private const val RotationExpanded = 0f diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/today/TodayScreenTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/today/TodayScreenTest.kt index 8ef7c6e7..45f91e59 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/today/TodayScreenTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/feature/today/TodayScreenTest.kt @@ -14,7 +14,6 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.v2.runComposeUiTest import dev.appoutlet.foliary.core.ui.component.task.FoliaryTaskCardViewData import dev.appoutlet.foliary.core.ui.component.task.fixture -import kotlinx.coroutines.channels.Channel import foliary.foliary.generated.resources.Res import foliary.foliary.generated.resources.today_add_task_a11y import foliary.foliary.generated.resources.today_celebration_button @@ -27,6 +26,7 @@ import foliary.foliary.generated.resources.today_empty_title import foliary.foliary.generated.resources.today_title import foliary.foliary.generated.resources.today_welcome import io.kotest.matchers.shouldBe +import kotlinx.coroutines.channels.Channel import org.jetbrains.compose.resources.getString import kotlin.test.Test From 89f7f8d105ccc8f38eefb3aa5c334402ee8766a0 Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Thu, 22 Oct 2026 11:31:57 +0100 Subject: [PATCH 3/3] Revert unrelated SignIn screen layout changes --- .../foliary/feature/signin/SignInScreen.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/signin/SignInScreen.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/signin/SignInScreen.kt index 004fba00..ee4503eb 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/signin/SignInScreen.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/feature/signin/SignInScreen.kt @@ -72,14 +72,16 @@ fun SignInScreen() { ) { paddingValues -> Box(modifier = Modifier.padding(paddingValues).fillMaxSize()) { Column( - modifier = Modifier.widthInCompact(480.dp) + modifier = Modifier + .fillMaxSize() .align(Alignment.Center) .padding(horizontal = 24.dp) .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp) + verticalArrangement = Arrangement.Center, ) { SignInHeader() + Spacer(Modifier.height(24.dp)) SignInForm(viewData = viewData, onEvent = viewModel::onEvent) } } @@ -89,7 +91,10 @@ fun SignInScreen() { @Composable private fun SignInHeader() { - Column(horizontalAlignment = Alignment.CenterHorizontally) { + Column( + modifier = Modifier.widthInCompact(480.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { Box( modifier = Modifier .size(64.dp) @@ -130,7 +135,7 @@ private fun SignInForm( onEvent: (SignInEvent) -> Unit ) { FoliaryCard( - modifier = Modifier.fillMaxWidth() + modifier = Modifier.widthInCompact(480.dp).fillMaxWidth() ) { AnimatedContent( targetState = viewData,