Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

목표 상세 완료 다이얼로그 #83

Merged
merged 14 commits into from
Feb 21, 2025
2 changes: 1 addition & 1 deletion feature/goal/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ android {
dependencies {
implementation(projects.core.data)

implementation(libs.compose.cloudy)
implementation(libs.lottie)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package com.chipichipi.dobedobe.feature.goal
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
Expand All @@ -27,12 +26,12 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
Expand All @@ -46,10 +45,13 @@ import com.chipichipi.dobedobe.core.designsystem.component.ThemePreviews
import com.chipichipi.dobedobe.core.designsystem.icon.DobeDobeIcons
import com.chipichipi.dobedobe.core.designsystem.theme.DobeDobeTheme
import com.chipichipi.dobedobe.core.model.Goal
import com.chipichipi.dobedobe.feature.goal.component.GoalCompleteDialog
import com.chipichipi.dobedobe.feature.goal.component.GoalToggleChip
import com.chipichipi.dobedobe.feature.goal.component.GoalTopAppBar
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel

@Composable
Expand All @@ -62,7 +64,7 @@ internal fun DetailGoalRoute(
viewModel: DetailGoalViewModel = koinViewModel(),
) {
val uiState: DetailGoalUiState by viewModel.uiState.collectAsStateWithLifecycle()
val lifecycle = LocalLifecycleOwner.current.lifecycle
val (visibleCompleteDialog, setVisibleCompleteDialog) = rememberSaveable { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
val onBack = {
if (viewModel.isGoalChanged) {
Expand All @@ -75,18 +77,18 @@ internal fun DetailGoalRoute(
onBack()
}

LaunchedEffect(Unit) {
viewModel.deleteGoalEvent
.onEach {
sendSnackBarEvent(GoalSnackBarType.DELETE)
navigateToBack()
}
.flowWithLifecycle(lifecycle)
.launchIn(this)
}
DetailGoalEventEffect(
viewModel = viewModel,
onShowCompleteDialog = { setVisibleCompleteDialog(true) },
sendSnackBarEvent = sendSnackBarEvent,
navigateToBack = navigateToBack,
onShowSnackbar = onShowSnackbar,
)

DetailGoalScreen(
uiState = uiState,
visibleCompleteDialog = visibleCompleteDialog,
onDismissCompleteDialog = { setVisibleCompleteDialog(false) },
onShowSnackbar = onShowSnackbar,
navigateToBack = onBack,
navigateToEditMode = navigateToEditMode,
Expand All @@ -95,17 +97,61 @@ internal fun DetailGoalRoute(
onRemoveGoal = viewModel::removeGoal,
modifier = modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures {
focusManager.clearFocus()
}
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
focusManager.clearFocus()
},
)
}

@Composable
private fun DetailGoalEventEffect(
viewModel: DetailGoalViewModel,
onShowCompleteDialog: () -> Unit,
sendSnackBarEvent: (GoalSnackBarType) -> Unit,
navigateToBack: () -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean,
) {
val lifecycle = LocalLifecycleOwner.current.lifecycle
val coroutineScope = rememberCoroutineScope()
val snackBarMsg = stringResource(R.string.feature_detail_goal_snackbar_message)

LaunchedEffect(Unit) {
viewModel.goalUiEvent
.onEach { event ->
when (event) {
is DetailGoalUiEvent.UndoGoal -> {
coroutineScope.launch {
onShowSnackbar(
snackBarMsg,
null,
)
}
}

is DetailGoalUiEvent.CompleteGoal -> {
onShowCompleteDialog()
coroutineScope.coroutineContext.cancelChildren()
}

is DetailGoalUiEvent.Delete -> {
sendSnackBarEvent(GoalSnackBarType.DELETE)
navigateToBack()
}
}
}
.flowWithLifecycle(lifecycle)
.launchIn(this)
}
}

@Composable
private fun DetailGoalScreen(
uiState: DetailGoalUiState,
visibleCompleteDialog: Boolean,
onDismissCompleteDialog: () -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean,
navigateToBack: () -> Unit,
navigateToEditMode: () -> Unit,
Expand All @@ -114,7 +160,7 @@ private fun DetailGoalScreen(
onRemoveGoal: (Long) -> Unit,
modifier: Modifier = Modifier,
) {
val (visibleDialog, setVisibleDialog) = rememberSaveable { mutableStateOf(false) }
val (visibleDeleteDialog, setVisibleDeleteDialog) = rememberSaveable { mutableStateOf(false) }

Scaffold(
modifier = modifier,
Expand All @@ -124,7 +170,7 @@ private fun DetailGoalScreen(
navigateToBack = navigateToBack,
actions = {
TextButton(
onClick = { if (uiState.isSuccess) setVisibleDialog(true) },
onClick = { if (uiState.isSuccess) setVisibleDeleteDialog(true) },
colors = ButtonDefaults.textButtonColors().copy(
contentColor = DobeDobeTheme.colors.red,
),
Expand Down Expand Up @@ -155,12 +201,6 @@ private fun DetailGoalScreen(
val goal = uiState.goal
DetailGoalContent(
goal = goal,
visibleDialog = visibleDialog,
onDismissDialog = { setVisibleDialog(false) },
onConfirmDialog = {
setVisibleDialog(false)
onRemoveGoal(goal.id)
},
onShowSnackbar = onShowSnackbar,
onTogglePinned = { onTogglePinned(goal.id) },
onToggleCompleted = { onToggleCompleted(goal.id) },
Expand All @@ -172,6 +212,23 @@ private fun DetailGoalScreen(
.padding(horizontal = 24.dp)
.padding(top = 24.dp, bottom = 32.dp),
)

if (visibleDeleteDialog) {
GoalDeleteDialog(
onConfirm = {
setVisibleDeleteDialog(false)
onRemoveGoal(goal.id)
},
onDismiss = { setVisibleDeleteDialog(false) },
)
}

if (visibleCompleteDialog) {
GoalCompleteDialog(
onDismissRequest = onDismissCompleteDialog,
characterType = uiState.characterType,
)
}
}
}
}
Expand All @@ -180,9 +237,6 @@ private fun DetailGoalScreen(
@Composable
private fun DetailGoalContent(
goal: Goal,
visibleDialog: Boolean,
onConfirmDialog: () -> Unit,
onDismissDialog: () -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean,
onTogglePinned: () -> Unit,
onToggleCompleted: () -> Unit,
Expand Down Expand Up @@ -230,11 +284,6 @@ private fun DetailGoalContent(
onTogglePinned = onTogglePinned,
)
}
GoalDeleteDialog(
visible = visibleDialog,
onConfirm = onConfirmDialog,
onDismiss = onDismissDialog,
)
}

@Composable
Expand Down Expand Up @@ -305,12 +354,10 @@ private fun GoalToggleChipGroup(

@Composable
private fun GoalDeleteDialog(
visible: Boolean,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
if (!visible) return
DobeDobeDialog(
title = stringResource(R.string.feature_detail_goal_delete_dialog_title),
primaryText = stringResource(R.string.feature_detail_goal_delete_dialog_primary),
Expand All @@ -327,7 +374,6 @@ private fun GoalDeleteDialog(
private fun DeleteDialogPreview() {
DobeDobeTheme {
GoalDeleteDialog(
visible = true,
onConfirm = {},
onDismiss = {},
)
Expand All @@ -341,9 +387,6 @@ private fun DetailGoalContentPreview() {
DobeDobeBackground {
DetailGoalContent(
goal = Goal.todo("test"),
visibleDialog = false,
onConfirmDialog = {},
onDismissDialog = {},
onShowSnackbar = { _, _ -> false },
onTogglePinned = {},
onToggleCompleted = {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,39 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import com.chipichipi.dobedobe.core.data.repository.GoalRepository
import com.chipichipi.dobedobe.core.data.repository.UserRepository
import com.chipichipi.dobedobe.core.model.CharacterType
import com.chipichipi.dobedobe.core.model.Goal
import com.chipichipi.dobedobe.feature.goal.navigation.GoalRoute
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

internal class DetailGoalViewModel(
savedStateHandle: SavedStateHandle,
private val goalRepository: GoalRepository,
userRepository: UserRepository,
) : ViewModel() {
private var originalGoal: Goal? = null

val uiState: StateFlow<DetailGoalUiState> = savedStateHandle.getGoalFlow()
.map(DetailGoalUiState::Success)
.combine(userRepository.userData) { goal, user ->
DetailGoalUiState.Success(
goal = goal,
characterType = user.characterType,
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
Expand All @@ -39,13 +51,14 @@ internal class DetailGoalViewModel(
is DetailGoalUiState.Loading, DetailGoalUiState.Error -> false
}

private val _deleteGoalEvent = Channel<Unit>(capacity = Channel.BUFFERED)
val deleteGoalEvent: Flow<Unit> = _deleteGoalEvent.receiveAsFlow()
private val _goalUiEvent = Channel<DetailGoalUiEvent>(capacity = Channel.CONFLATED)
val goalUiEvent: Flow<DetailGoalUiEvent> = _goalUiEvent.receiveAsFlow()

init {
viewModelScope.launch {
originalGoal = savedStateHandle.getGoalFlow().first()
}
observeGoalCompletion()
}

fun togglePinned(id: Long) {
Expand All @@ -64,7 +77,23 @@ internal class DetailGoalViewModel(
viewModelScope.launch {
goalRepository.removeGoal(id)
.onSuccess {
_deleteGoalEvent.send(Unit)
_goalUiEvent.send(DetailGoalUiEvent.Delete)
}
}
}

private fun observeGoalCompletion() {
viewModelScope.launch {
uiState
.mapNotNull { (it as? DetailGoalUiState.Success)?.goal?.isCompleted }
.distinctUntilChanged()
.drop(1)
.collectLatest { isCompleted ->
if (isCompleted) {
_goalUiEvent.send(DetailGoalUiEvent.CompleteGoal)
} else {
_goalUiEvent.send(DetailGoalUiEvent.UndoGoal)
}
}
}
}
Expand All @@ -81,10 +110,19 @@ sealed interface DetailGoalUiState {

data class Success(
val goal: Goal,
val characterType: CharacterType,
) : DetailGoalUiState

data object Error : DetailGoalUiState

val isSuccess: Boolean
get() = this is Success
}

sealed interface DetailGoalUiEvent {
data object Delete : DetailGoalUiEvent

data object CompleteGoal : DetailGoalUiEvent

data object UndoGoal : DetailGoalUiEvent
}
Loading