diff --git a/foliary/src/commonMain/composeResources/values/strings.xml b/foliary/src/commonMain/composeResources/values/strings.xml index 4af99b5..66b2c17 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 6145d04..72117a2 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,5 @@ 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 b7c92c1..616ef08 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 efb48f0..d8cc95d 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 204a3d0..a42af86 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 e72d685..45f91e5 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 @@ -22,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 @@ -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 c06e843..a36b7aa 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