Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions foliary/src/commonMain/composeResources/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@
<string name="today_celebration_button">Create a new task</string>
<string name="today_completed_header">Completed today (%1$d)</string>
<string name="today_completed_header_a11y">Toggle completed tasks</string>
<string name="today_undo_snackbar_message">Task marked as done</string>
<string name="today_undo_snackbar_action">Undo</string>
<!-- endregion -->

<!-- region Create Task -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -64,30 +68,84 @@ 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

@Composable
fun TodayScreen(lazyListState: LazyListState) {
val viewModel = koinViewModel<TodayViewModel>()
val snackbarHostState = remember { SnackbarHostState() }
val undoChannel = remember { Channel<TodayAction.ShowUndoSnackbar>(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<TodayAction.ShowUndoSnackbar>,
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)
}
}
}
}
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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<TodayAction.ShowUndoSnackbar>(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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ class TodayViewModelTest : ViewModelTest<TodayViewModel, TodayViewData, TodayAct
)

viewModel.onEvent(TodayEvent.MarkTaskAsCompleted(pendingTask.id.toString()))
expectSideEffect(TodayAction.ShowUndoSnackbar(pendingTask.id.toString()))
delay(1.milliseconds)
tasksFlow.value = listOf(completedTask)

Expand Down Expand Up @@ -189,6 +190,7 @@ class TodayViewModelTest : ViewModelTest<TodayViewModel, TodayViewData, TodayAct
)

viewModel.onEvent(TodayEvent.MarkTaskAsCompleted(pendingTask.id.toString()))
expectSideEffect(TodayAction.ShowUndoSnackbar(pendingTask.id.toString()))
delay(1.milliseconds)
tasksFlow.value = listOf(completedTask)

Expand Down Expand Up @@ -257,7 +259,7 @@ class TodayViewModelTest : ViewModelTest<TodayViewModel, TodayViewData, TodayAct
}

@Test
fun `should call markCompleted when MarkTaskAsCompleted is emitted`() {
fun `should call markCompleted and emit ShowUndoSnackbar side effect when MarkTaskAsCompleted is emitted`() {
val taskId = Uuid.parse("550e8400-e29b-41d4-a716-446655440000")

every { mockAuthenticationRepository.currentUser() } returns UserInfo.fixture()
Expand All @@ -269,14 +271,15 @@ class TodayViewModelTest : ViewModelTest<TodayViewModel, TodayViewData, TodayAct
expectState(TodayViewData.Empty(userName = "Messias", completedTasks = emptyList()))

viewModel.onEvent(TodayEvent.MarkTaskAsCompleted(taskId.toString()))
delay(1.milliseconds)

expectSideEffect(TodayAction.ShowUndoSnackbar(taskId.toString()))
}

verifySuspend { mockTaskRepository.markCompleted(taskId) }
}

@Test
fun `should call markNotCompleted when MarkTaskAsNotCompleted is emitted`() {
fun `should call markNotCompleted without emitting ShowUndoSnackbar when MarkTaskAsNotCompleted is emitted`() {
val taskId = Uuid.parse("550e8400-e29b-41d4-a716-446655440000")

every { mockAuthenticationRepository.currentUser() } returns UserInfo.fixture()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,28 @@ class UpcomingScreenTest {

event shouldBe UpcomingEvent.OnUrlPillClick(task.id)
}

@Test
fun `should emit MarkTaskAsCompleted event when task card checkbox is clicked`() = runComposeUiTest {
var event: UpcomingEvent? = null
val task = FoliaryTaskCardViewData.fixture(id = "task-complete-id", isCompleted = false)
val group = UpcomingViewData.TaskGroup.fixture(
label = UpcomingViewData.GroupLabel.Today,
tasks = listOf(task)
)

setContent {
UpcomingScreenContent(
lazyListState = rememberLazyListState(),
viewData = UpcomingViewData.Loaded(listOf(group)),
onEvent = { event = it },
)
}

onNodeWithTag("FoliaryTaskCard:Checkbox", useUnmergedTree = true)
.assertIsDisplayed()
.performClick()

event shouldBe UpcomingEvent.MarkTaskAsCompleted("task-complete-id")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verifySuspend
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlin.test.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.uuid.Uuid

class UpcomingViewModelTest : ViewModelTest<UpcomingViewModel, UpcomingViewData, UpcomingAction>() {
Expand Down Expand Up @@ -112,6 +115,33 @@ class UpcomingViewModelTest : ViewModelTest<UpcomingViewModel, UpcomingViewData,
}
}

@Test
fun `should call markCompleted when MarkTaskAsCompleted is emitted and update state when pending flow re-emits`() {
val taskId = Uuid.parse("550e8400-e29b-41d4-a716-446655440000")
val task = Task.fixture(id = taskId)
val tasksFlow = MutableStateFlow(listOf(task))
val loadedViewData = UpcomingViewData.Loaded(listOf(UpcomingViewData.TaskGroup.fixture()))

fakeUpcomingViewDataMapper.result = loadedViewData
every { mockTaskRepository.findAllPendingTasks() } returns tasksFlow
everySuspend { mockTaskRepository.markCompleted(taskId) } returns Unit

test {
expectState(UpcomingViewData.Loading)
expectState(loadedViewData)

fakeUpcomingViewDataMapper.result = UpcomingViewData.Empty

viewModel.onEvent(UpcomingEvent.MarkTaskAsCompleted(taskId.toString()))
delay(1.milliseconds)
tasksFlow.value = emptyList()

expectState(UpcomingViewData.Empty)
}

verifySuspend { mockTaskRepository.markCompleted(taskId) }
}

private class FakeUpcomingViewDataMapper(
timeProvider: DefaultTimeProvider = DefaultTimeProvider(),
) : UpcomingViewDataMapper(
Expand Down
Loading