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