diff --git a/app/src/main/java/com/poti/android/presentation/party/component/MemberSelectBottomSheet.kt b/app/src/main/java/com/poti/android/presentation/party/component/MemberSelectBottomSheet.kt new file mode 100644 index 00000000..9243f181 --- /dev/null +++ b/app/src/main/java/com/poti/android/presentation/party/component/MemberSelectBottomSheet.kt @@ -0,0 +1,120 @@ +package com.poti.android.presentation.party.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.poti.android.R +import com.poti.android.core.common.util.screenHeightDp +import com.poti.android.core.common.util.screenWidthDp +import com.poti.android.core.designsystem.component.bottomsheet.PotiBottomSheet +import com.poti.android.core.designsystem.component.button.ChipButtonType +import com.poti.android.core.designsystem.component.button.PotiChipButton +import com.poti.android.core.designsystem.theme.PotiTheme +import com.poti.android.domain.model.artist.Member + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MemberSelectBottomSheet( + title: String, + mainBtnText: String, + subBtnText: String, + onDismiss: () -> Unit, + onMainBtnClick: () -> Unit, + onSubBtnClick: () -> Unit, + allMembers: List, + selectedMembers: List, + onMemberClick: (T) -> Unit, + memberToName: (T) -> String, + memberToId: (T) -> Long, + mainEnabled: Boolean, + subEnabled: Boolean, + autoCloseSubBtn: Boolean = true, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + val selectedMemberIds = remember(selectedMembers) { + selectedMembers.map { memberToId(it) }.toSet() + } + + PotiBottomSheet( + onDismissRequest = onDismiss, + text = mainBtnText, + onClick = onMainBtnClick, + subText = subBtnText, + onSubClick = onSubBtnClick, + subEnabled = subEnabled, + enabled = mainEnabled, + sheetState = sheetState, + autoCloseSub = autoCloseSubBtn, + ) { + Text( + text = title, + modifier = Modifier + .padding(top = 12.dp, bottom = 16.dp, start = screenWidthDp(16.dp)), + color = PotiTheme.colors.black, + style = PotiTheme.typography.title18sb, + ) + + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier + .height(screenHeightDp(492.dp)) + .fillMaxWidth(), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 40.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + items = allMembers, + key = { memberToId(it) }, + ) { member -> + val isSelected = memberToId(member) in selectedMemberIds + + PotiChipButton( + text = memberToName(member), + onClick = { onMemberClick(member) }, + modifier = Modifier.fillMaxWidth(), + type = if (isSelected) ChipButtonType.SELECTED else ChipButtonType.DEFAULT, + ) + } + } + } +} + +@Preview +@Composable +private fun MemberSelectBottomSheetPreview() { + val members = (0L..20L).map { id -> + Member(id, "원영") + } + + MemberSelectBottomSheet( + title = stringResource(R.string.action_button_continue), + onDismiss = {}, + mainBtnText = stringResource(R.string.action_button_continue), + onMainBtnClick = {}, + mainEnabled = true, + subBtnText = stringResource(R.string.action_button_continue), + onSubBtnClick = {}, + subEnabled = true, + allMembers = members, + selectedMembers = members, + onMemberClick = {}, + memberToName = { it.name }, + memberToId = { it.memberId }, + ) +} diff --git a/app/src/main/java/com/poti/android/presentation/party/create/PartyArtistSelectScreen.kt b/app/src/main/java/com/poti/android/presentation/party/create/PartyArtistSelectScreen.kt index 3a1708d9..6e305b10 100644 --- a/app/src/main/java/com/poti/android/presentation/party/create/PartyArtistSelectScreen.kt +++ b/app/src/main/java/com/poti/android/presentation/party/create/PartyArtistSelectScreen.kt @@ -25,8 +25,8 @@ import com.poti.android.core.designsystem.theme.PotiTheme import com.poti.android.domain.model.artist.ArtistSearchResult import com.poti.android.presentation.party.create.component.CreateDropdownField import com.poti.android.presentation.party.create.component.ViewType -import com.poti.android.presentation.party.create.model.CreateUiEffect -import com.poti.android.presentation.party.create.model.CreateUiIntent +import com.poti.android.presentation.party.create.model.CreateUiEffect.* +import com.poti.android.presentation.party.create.model.CreateUiIntent.* import com.poti.android.presentation.party.create.model.CreateUiState @Composable @@ -39,17 +39,17 @@ fun PartyArtistSelectRoute( HandleSideEffects(viewModel.sideEffect) { effect -> when (effect) { - CreateUiEffect.NavigateToBack -> onPopBackStack() + NavigateToBack -> onPopBackStack() else -> Unit } } PartyArtistSelectScreen( uiState = uiState, - onSearchKeywordChange = { viewModel.processIntent(CreateUiIntent.OnArtistSearchKeywordChange(it)) }, - onArtistSelect = { viewModel.processIntent(CreateUiIntent.OnArtistSelect(it)) }, - onConfirmClick = { viewModel.processIntent(CreateUiIntent.OnBackToCreate) }, - onPopBackStack = { viewModel.processIntent(CreateUiIntent.OnBackToCreate) }, + onSearchKeywordChange = { viewModel.processIntent(OnArtistChange(it)) }, + onArtistSelect = { viewModel.processIntent(OnArtistSelect(it)) }, + onConfirmClick = { viewModel.processIntent(OnBackToCreate) }, + onPopBackStack = { viewModel.processIntent(OnBackToCreate) }, modifier = modifier, ) } @@ -94,7 +94,7 @@ private fun PartyArtistSelectScreen( viewType = ViewType.ARTSIT_SELECT, value = uiState.artistSearchKeyword, onValueChanged = onSearchKeywordChange, - searchResults = uiState.artistSearchResultsState.getSuccessDataOrNull() ?: emptyList(), + searchResults = uiState.artistSearchState.getSuccessDataOrNull() ?: emptyList(), resultToString = { it.name }, onItemClick = { onArtistSelect(it) }, placeholder = stringResource(R.string.create_placeholder_artist_search), diff --git a/app/src/main/java/com/poti/android/presentation/party/create/PartyCreateScreen.kt b/app/src/main/java/com/poti/android/presentation/party/create/PartyCreateScreen.kt index 1c36343b..0684dd61 100644 --- a/app/src/main/java/com/poti/android/presentation/party/create/PartyCreateScreen.kt +++ b/app/src/main/java/com/poti/android/presentation/party/create/PartyCreateScreen.kt @@ -19,6 +19,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource @@ -26,13 +28,11 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.poti.android.R import com.poti.android.core.common.extension.getSuccessDataOrNull import com.poti.android.core.common.extension.noRippleClickable import com.poti.android.core.common.util.HandleSideEffects import com.poti.android.core.common.util.screenWidthDp -import com.poti.android.core.designsystem.component.bottomsheet.MemberSelectBottomSheet import com.poti.android.core.designsystem.component.display.PotiDivider import com.poti.android.core.designsystem.component.display.PotiDividerStyle import com.poti.android.core.designsystem.component.display.PotiErrorMessage @@ -43,14 +43,16 @@ import com.poti.android.core.designsystem.component.navigation.PotiBottomButton import com.poti.android.core.designsystem.component.navigation.PotiHeaderPage import com.poti.android.core.designsystem.theme.PotiTheme import com.poti.android.domain.model.artist.MemberPriceOption +import com.poti.android.domain.model.delivery.DeliveryOption +import com.poti.android.presentation.party.component.MemberSelectBottomSheet import com.poti.android.presentation.party.create.component.CreateDeliverySetting import com.poti.android.presentation.party.create.component.CreateDropdownField import com.poti.android.presentation.party.create.component.CreateMemberSetting import com.poti.android.presentation.party.create.component.CreatePhotoUpload import com.poti.android.presentation.party.create.component.SellerNotice import com.poti.android.presentation.party.create.component.ViewType -import com.poti.android.presentation.party.create.model.CreateUiEffect -import com.poti.android.presentation.party.create.model.CreateUiIntent +import com.poti.android.presentation.party.create.model.CreateUiEffect.* +import com.poti.android.presentation.party.create.model.CreateUiIntent.* import com.poti.android.presentation.party.create.model.CreateUiState import com.poti.android.presentation.party.create.util.DateTransformation import com.poti.android.presentation.party.create.util.toImageInfosForPresigned @@ -62,105 +64,81 @@ fun PartyCreateRoute( onNavigateToDetail: (Long) -> Unit, viewModel: PartyCreateViewModel, modifier: Modifier = Modifier, - artistId: Long? = null, - artistName: String? = null, - productName: String? = null, ) { val context = LocalContext.current - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - var showBottomSheet by remember { mutableStateOf(false) } - var showDialog by remember { mutableStateOf(false) } - if (uiState.isDirty) { - BackHandler { - showDialog = true - } - } - - LaunchedEffect(Unit) { - viewModel.processIntent(CreateUiIntent.InitializeScreen(artistId, artistName, productName)) + BackHandler { + viewModel.processIntent(OnBack) } HandleSideEffects(viewModel.sideEffect) { effect -> when (effect) { - CreateUiEffect.NavigateToBack -> { - showDialog = false + NavigateToBack -> { onPopBackStack() } - CreateUiEffect.NavigateToSearch -> onNavigateToSearch() + NavigateToSearch -> onNavigateToSearch() - CreateUiEffect.ShowBottomSheet -> { - showBottomSheet = true + ConvertUris -> { + val result = uiState.imageUris.toImageInfosForPresigned(context) + viewModel.processIntent(ConvertDone(result)) } - CreateUiEffect.ShowDialog -> { - showDialog = true - } - - CreateUiEffect.ConvertUris -> { - val result = uiState.selectedImages.toImageInfosForPresigned(context) - viewModel.processIntent(CreateUiIntent.OnConvertDone(result)) - } - - is CreateUiEffect.NavigateToDetail -> { + is NavigateToDetail -> { onNavigateToDetail(effect.partyId) } } } - if (showBottomSheet) { + if (uiState.showMemberBottomSheet) { MemberSelectBottomSheet( - title = R.string.create_title_bottomsheet, - onDismiss = { showBottomSheet = false }, - mainBtnText = R.string.action_button_done, - onMainBtnClick = { - viewModel.processIntent(CreateUiIntent.OnMemberSelectDone) - showBottomSheet = false - }, - mainEnabled = uiState.isSheetTouched, - subBtnText = R.string.action_button_select_all, - onSubBtnClick = { - viewModel.processIntent(CreateUiIntent.OnAllMemberSelect) - }, + title = stringResource(R.string.create_title_bottomsheet), + mainBtnText = stringResource(R.string.action_button_done), + subBtnText = stringResource(R.string.action_button_select_all), + onDismiss = { viewModel.processIntent(OnCloseBottomSheet) }, + onMainBtnClick = { viewModel.processIntent(OnMemberSelectDone) }, + onSubBtnClick = { viewModel.processIntent(OnAllMemberSelect) }, + allMembers = uiState.rawMembers, + selectedMembers = uiState.tempSelectedMembers, + onMemberClick = { viewModel.processIntent(OnMemberSelect(it)) }, + memberToName = { it.name }, + memberToId = { it.memberId }, + mainEnabled = uiState.isMemberBottomSheetTouched, subEnabled = true, - members = uiState.sheetDisplayMemberNames, - onMemberClick = { viewModel.processIntent(CreateUiIntent.OnMemberSelect(it)) }, - selectedIndices = uiState.sheetDisplayMemberIndices, autoCloseSubBtn = false, ) } - if (showDialog) { + if (uiState.showDialog) { PotiSmallModal( - onDismissRequest = { showDialog = false }, + onDismissRequest = { viewModel.processIntent(OnCloseDialog) }, title = stringResource(R.string.create_exit_dialog_title), text = stringResource(R.string.create_exit_dialog_content), dismissBtnText = stringResource(R.string.create_exit_dialog_dismiss_text), confirmBtnText = stringResource(R.string.create_exit_dialog_confirm_text), - onDismissBtnClick = { viewModel.processIntent(CreateUiIntent.OnBackConfirm) }, - onConfirmBtnClick = { showDialog = false }, + onDismissBtnClick = { viewModel.processIntent(OnBackConfirm) }, + onConfirmBtnClick = { viewModel.processIntent(OnCloseDialog) }, ) } PartyCreateScreen( uiState = uiState, - onScrollComplete = { viewModel.processIntent(CreateUiIntent.OnScrollComplete) }, - onBackClick = { viewModel.processIntent(CreateUiIntent.OnBackClick) }, - onImageChanged = { viewModel.processIntent(CreateUiIntent.OnImagesChanged(it)) }, - onSearchArtist = { viewModel.processIntent(CreateUiIntent.OnSearchClick) }, - onProductFocusChanged = { viewModel.processIntent(CreateUiIntent.OnProductFocus(it)) }, - onProductChanged = { viewModel.processIntent(CreateUiIntent.OnProductChange(it)) }, - onProductSearchItemClick = { viewModel.processIntent(CreateUiIntent.OnProductSelect(it)) }, - onDeadlineChanged = { viewModel.processIntent(CreateUiIntent.OnDeadlineChange(it)) }, - onDescriptionChanged = { viewModel.processIntent(CreateUiIntent.OnDescriptionChange(it)) }, - onAccountNumberChanged = { viewModel.processIntent(CreateUiIntent.OnAccountNumberChange(it)) }, - onBankChanged = { viewModel.processIntent(CreateUiIntent.OnBankChange(it)) }, - onMemberPriceChanged = { viewModel.processIntent(CreateUiIntent.OnMemberPriceChange(it)) }, - onMemberEditBtnClick = { viewModel.processIntent(CreateUiIntent.OnMemberEditClick) }, - onDeliveryRadioBtnClick = { viewModel.processIntent(CreateUiIntent.OnDeliverySelect(it)) }, - onCreateBtnClick = { viewModel.processIntent(CreateUiIntent.OnCreateClick) }, + onScrollComplete = { viewModel.processIntent(ScrollComplete) }, + onBackClick = { viewModel.processIntent(OnBack) }, + onImageChanged = { viewModel.processIntent(OnImagesChanged(it)) }, + onSearchArtist = { viewModel.processIntent(OnSearchClick) }, + onProductFocusChanged = { viewModel.processIntent(OnProductFocus(it)) }, + onProductChanged = { viewModel.processIntent(OnProductChange(it)) }, + onProductSearchItemClick = { viewModel.processIntent(OnProductSelect(it)) }, + onDeadlineChanged = { viewModel.processIntent(OnDeadlineChange(it)) }, + onDescriptionChanged = { viewModel.processIntent(OnDescriptionChange(it)) }, + onAccountNumberChanged = { viewModel.processIntent(OnAccountNumberChange(it)) }, + onBankChanged = { viewModel.processIntent(OnBankChange(it)) }, + onMemberPriceChanged = { viewModel.processIntent(OnMemberPriceChange(it)) }, + onMemberEditBtnClick = { viewModel.processIntent(OnMemberEditClick) }, + onDeliveryRadioBtnClick = { viewModel.processIntent(OnDeliverySelect(it)) }, + onCreateBtnClick = { viewModel.processIntent(OnCreateClick) }, modifier = modifier, ) } @@ -181,17 +159,18 @@ private fun PartyCreateScreen( onBankChanged: (String) -> Unit, onMemberPriceChanged: (MemberPriceOption) -> Unit, onMemberEditBtnClick: () -> Unit, - onDeliveryRadioBtnClick: (Long) -> Unit, + onDeliveryRadioBtnClick: (DeliveryOption) -> Unit, onCreateBtnClick: () -> Unit, modifier: Modifier = Modifier, ) { val listState = rememberLazyListState() val dateTransformation = remember { DateTransformation() } + var listBottom by remember { mutableStateOf(0f) } + LaunchedEffect(uiState.errorIndexToScroll) { - val index = uiState.errorIndexToScroll - index?.let { - listState.animateScrollToItem(index) + uiState.errorIndexToScroll?.let { + listState.animateScrollToItem(it) } onScrollComplete() } @@ -212,7 +191,11 @@ private fun PartyCreateScreen( ) { innerPadding -> LazyColumn( state = listState, - modifier = Modifier.padding(innerPadding), + modifier = Modifier + .padding(innerPadding) + .onGloballyPositioned { coordinates -> + listBottom = coordinates.boundsInWindow().bottom + }, ) { item { Text( @@ -225,7 +208,7 @@ private fun PartyCreateScreen( ) CreatePhotoUpload( - imageUris = uiState.selectedImages, + imageUris = uiState.imageUris, onImageChanged = onImageChanged, ) @@ -267,7 +250,7 @@ private fun PartyCreateScreen( viewType = ViewType.CREATE_PARTY, value = uiState.productName, onValueChanged = onProductChanged, - searchResults = uiState.productSearchResultsState.getSuccessDataOrNull() ?: emptyList(), + searchResults = uiState.productSearchState.getSuccessDataOrNull() ?: emptyList(), onItemClick = onProductSearchItemClick, placeholder = stringResource(R.string.create_placeholder_product), label = stringResource(R.string.create_label_product), @@ -275,7 +258,7 @@ private fun PartyCreateScreen( modifier = Modifier .padding(bottom = 28.dp), fieldErrorMsg = uiState.productError?.let { stringResource(it.message) } ?: "", - selectedString = uiState.selectedProductName, + selectedString = uiState.selectedProduct, readOnly = uiState.isProductFieldReadOnly, onFocusChanged = onProductFocusChanged, ) @@ -357,11 +340,12 @@ private fun PartyCreateScreen( ) CreateMemberSetting( - neverShowHint = uiState.neverShowHint, status = uiState.memberSettingStatus, - selectedMembersOption = uiState.editOptionDisplayMembers, + selectedMembers = uiState.selectedMembers, onPriceChange = onMemberPriceChanged, onEditBtnClick = onMemberEditBtnClick, + errorMessage = uiState.memberError?.let { stringResource(it.message) } ?: "", + layoutBottom = listBottom, ) } @@ -371,9 +355,9 @@ private fun PartyCreateScreen( ) CreateDeliverySetting( - deliveryOptions = uiState.editableDeliveryOptions, - selectedOptionIds = uiState.selectedDeliveryIds, - onDeliveryOptionClick = onDeliveryRadioBtnClick, + allDeliveries = uiState.rawDeliveries, + selectedDeliveries = uiState.selectedDeliveries, + onDeliveryClick = onDeliveryRadioBtnClick, ) } diff --git a/app/src/main/java/com/poti/android/presentation/party/create/PartyCreateViewModel.kt b/app/src/main/java/com/poti/android/presentation/party/create/PartyCreateViewModel.kt index 59ecdae6..0bf209cf 100644 --- a/app/src/main/java/com/poti/android/presentation/party/create/PartyCreateViewModel.kt +++ b/app/src/main/java/com/poti/android/presentation/party/create/PartyCreateViewModel.kt @@ -1,11 +1,14 @@ package com.poti.android.presentation.party.create import androidx.core.text.isDigitsOnly +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute import com.poti.android.core.base.BaseViewModel import com.poti.android.core.common.state.ApiState import com.poti.android.domain.model.artist.ArtistSearchResult import com.poti.android.domain.model.artist.MemberPriceOption +import com.poti.android.domain.model.delivery.DeliveryOption import com.poti.android.domain.model.image.ImageInfoForPresigned import com.poti.android.domain.usecase.artist.GetMembersWithPriceUseCase import com.poti.android.domain.usecase.image.UploadImagesUseCase @@ -14,24 +17,28 @@ import com.poti.android.domain.usecase.party.GetDeliveryOptionsUseCase import com.poti.android.domain.usecase.party.SearchArtistUseCase import com.poti.android.domain.usecase.party.SearchProductUseCase import com.poti.android.presentation.party.create.model.CreateUiEffect +import com.poti.android.presentation.party.create.model.CreateUiEffect.* import com.poti.android.presentation.party.create.model.CreateUiIntent +import com.poti.android.presentation.party.create.model.CreateUiIntent.* import com.poti.android.presentation.party.create.model.CreateUiState import com.poti.android.presentation.party.create.model.FieldError import com.poti.android.presentation.party.create.model.MemberSettingStatus +import com.poti.android.presentation.party.create.navigation.PartyCreateGraph import com.poti.android.presentation.party.create.util.isTodayOrAfter import com.poti.android.presentation.party.create.util.toDashedDate import com.poti.android.presentation.party.create.util.toDateOrNull import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject +import kotlin.text.filter const val IMAGE_TYPE = "POST" @@ -44,128 +51,115 @@ class PartyCreateViewModel @Inject constructor( private val searchArtistUseCase: SearchArtistUseCase, private val searchProductUseCase: SearchProductUseCase, private val createPartyUseCase: CreatePartyUseCase, + savedStateHandle: SavedStateHandle, ) : BaseViewModel( initialState = CreateUiState(), ) { - private val artistSearchKeywordForDebounce = MutableStateFlow("") - private val productSearchKeywordForDebounce = MutableStateFlow("") + private val params = savedStateHandle.toRoute() + private val paramArtistId = params.artistId + private val paramArtistName = params.artistName + private val paramProductName = params.productName override fun processIntent(intent: CreateUiIntent) { when (intent) { - is CreateUiIntent.InitializeScreen -> { - initializeDeliveryOptions() - autoFillParams(intent.artistId, intent.artistName, intent.productName) - } + OnCloseBottomSheet -> updateState { copy(showMemberBottomSheet = false) } - CreateUiIntent.CleanScreen -> updateState { CreateUiState() } + OnCloseDialog -> updateState { copy(showDialog = false) } - CreateUiIntent.OnBackClick -> { - if (uiState.value.isDirty) { - sendEffect(CreateUiEffect.ShowDialog) - } else { - updateState { CreateUiState() } - sendEffect(CreateUiEffect.NavigateToBack) - } + OnBack -> if (shouldShowDialog()) { + updateState { copy(showDialog = true) } + } else { + updateState { copy(showDialog = false) } + sendEffect(NavigateToBack) } - is CreateUiIntent.OnBackConfirm -> { - updateState { CreateUiState() } - sendEffect(CreateUiEffect.NavigateToBack) + OnBackConfirm -> { + updateState { copy(showDialog = false) } + sendEffect(NavigateToBack) } - is CreateUiIntent.OnBackToCreate -> sendEffect(CreateUiEffect.NavigateToBack) - - CreateUiIntent.OnScrollComplete -> updateState { copy(errorIndexToScroll = null) } - - is CreateUiIntent.OnImagesChanged -> { - updateState { - copy( - isDirty = true, - selectedImages = intent.uris.toPersistentList(), - imageError = if (intent.uris.isNotEmpty()) null else this.imageError, - ) - } + OnBackToCreate -> { + sendEffect(NavigateToBack) } - is CreateUiIntent.OnArtistSelect -> { - handleArtistSelect(newArtist = intent.artist) + is OnImagesChanged -> updateState { + copy( + imageUris = intent.uris.toPersistentList(), + imageError = if (intent.uris.isNotEmpty()) null else this.imageError, + ) } - is CreateUiIntent.OnAccountNumberChange -> { - handleAccountNumberChange(newValue = intent.value) - } + OnSearchClick -> sendEffect(NavigateToSearch) - is CreateUiIntent.OnBankChange -> { - handleBankChange(newValue = intent.value) - } + is OnArtistChange -> updateState { copy(artistSearchKeyword = intent.value) } - is CreateUiIntent.OnDeadlineChange -> { - handleDeadlineChange(newValue = intent.value) - } + is OnArtistSelect -> handleArtistSelect(newArtist = intent.artist) - is CreateUiIntent.OnDeliverySelect -> { - handleDeliverySelect(newId = intent.deliveryId) + is OnProductFocus -> if (uiState.value.isProductFieldReadOnly && intent.focused) { + updateState { copy(productError = FieldError.ARTIST_EMPTY_ERROR) } } - is CreateUiIntent.OnDescriptionChange -> { - handleDescriptionChange(newValue = intent.value) + is OnProductChange -> updateState { + copy( + productName = intent.value, + productError = if (intent.value.isNotBlank()) null else this.productError, + selectedProduct = "", + ) } - is CreateUiIntent.OnMemberPriceChange -> { - handleMemberPriceChange(newOption = intent.option) - } + is OnProductSelect -> updateState { copy(productName = intent.product, selectedProduct = intent.product) } - is CreateUiIntent.OnProductFocus -> { - if (uiState.value.isProductFieldReadOnly && intent.focused) { - updateState { copy(productError = FieldError.ARTIST_EMPTY_ERROR, isDirty = true) } - } - } + is OnDeadlineChange -> handleDeadlineChange(newValue = intent.value) - is CreateUiIntent.OnProductChange -> { - handleProductChange(newValue = intent.value) + is OnDescriptionChange -> updateState { + copy( + description = intent.value, + descriptionError = if (intent.value.isNotBlank()) null else this.descriptionError, + ) } - is CreateUiIntent.OnProductSelect -> { - updateState { copy(productName = intent.product, selectedProductName = intent.product) } + is OnAccountNumberChange -> updateState { + copy( + accountNumber = intent.value.filter { it.isDigit() }, + accountNumberError = if (intent.value.isNotBlank()) null else this.accountNumberError, + ) } - CreateUiIntent.OnSearchClick -> { - sendEffect(CreateUiEffect.NavigateToSearch) + is OnBankChange -> updateState { + copy( + bank = intent.value, + bankError = if (intent.value.isNotBlank()) null else this.bankError, + ) } - CreateUiIntent.OnAllMemberSelect -> { - handleAllMemberSelect() + OnMemberEditClick -> updateState { + copy( + showMemberBottomSheet = true, + isMemberBottomSheetTouched = false, + tempSelectedMembers = this.selectedMembers, + ) } - is CreateUiIntent.OnMemberSelect -> { - handleMemberSelect(newIndex = intent.index) - } + is OnMemberSelect -> handleMemberSelect(newMember = intent.member) - CreateUiIntent.OnMemberSelectDone -> { - handleMemberSelectDone() - } + OnAllMemberSelect -> handleAllMemberSelect() - CreateUiIntent.OnMemberEditClick -> { - resetDisplayMembers() - sendEffect(CreateUiEffect.ShowBottomSheet) - } + OnMemberSelectDone -> handleMemberSelectDone() - is CreateUiIntent.OnArtistSearchKeywordChange -> { - handleArtistSearchKeywordChange(intent.value) - } + is OnMemberPriceChange -> handleMemberPriceChange(newMember = intent.member) - CreateUiIntent.OnCreateClick -> { + is OnDeliverySelect -> handleDeliverySelect(newDelivery = intent.delivery) + + OnCreateClick -> { if (uiState.value.createPartyState is ApiState.Loading) return if (validateInputs()) return - updateState { - copy(createPartyState = ApiState.Loading) - } + updateState { copy(createPartyState = ApiState.Loading) } - sendEffect(CreateUiEffect.ConvertUris) + sendEffect(ConvertUris) } - is CreateUiIntent.OnConvertDone -> { + is ConvertDone -> { if (intent.result.isEmpty()) { updateState { copy(createPartyState = ApiState.Failure("convert fail")) @@ -177,31 +171,54 @@ class PartyCreateViewModel @Inject constructor( uploadImagesAndCreateParty(intent.result) } } + + ScrollComplete -> updateState { copy(errorIndexToScroll = null) } } } init { + initializeDeliveryOptions() + autoFillParams(paramArtistId, paramArtistName, paramProductName) + viewModelScope.launch { - artistSearchKeywordForDebounce + uiState + .map { it.artistSearchKeyword } .debounce(500) .distinctUntilChanged() - .filter { it.isNotBlank() } .collectLatest { keyword -> searchArtist(keyword) } } viewModelScope.launch { - productSearchKeywordForDebounce + uiState + .map { it.productName } .debounce(500) .distinctUntilChanged() - .filter { it.isNotBlank() } .collectLatest { keyword -> - searchProdut(keyword) + searchProduct(keyword) } } } + private fun shouldShowDialog(): Boolean { + val state = uiState.value + + val hasUserInput = state.imageUris.isNotEmpty() || + state.selectedArtist?.artistId != paramArtistId || + state.selectedArtist?.name != paramArtistName || + state.productName != (paramProductName ?: "") || + state.selectedProduct.isNotEmpty() || + state.deadline.isNotEmpty() || + state.description.isNotEmpty() || + state.accountNumber.isNotEmpty() || + state.bank.isNotEmpty() || + state.selectedMembers != state.rawMembers || + state.selectedDeliveries != state.rawDeliveries + + return hasUserInput + } + private fun autoFillParams( artistId: Long?, artistName: String?, @@ -217,125 +234,89 @@ class PartyCreateViewModel @Inject constructor( ) handleArtistSelect(initialArtist) - updateState { copy(productName = productName, neverShowSearchEmptyScreen = true) } + updateState { copy(productName = productName, isAutoFilled = true) } } private fun initializeDeliveryOptions() { viewModelScope.launch { getDeliveryOptionsUseCase() .onSuccess { result -> + val deliveries = result.toPersistentList() + updateState { copy( - deliveryOptionsState = ApiState.Success(result.toPersistentList()), - editableDeliveryOptions = result.toPersistentList(), - selectedDeliveryIds = this.selectedDeliveryIds + result.first().deliveryId, + deliveriesState = ApiState.Success(deliveries), + rawDeliveries = deliveries, + selectedDeliveries = deliveries, ) } }.onFailure { e -> updateState { copy( - deliveryOptionsState = ApiState.Failure(e.message ?: "get delivery fail"), + deliveriesState = ApiState.Failure(e.message ?: "get delivery fail"), ) } } } } - private fun handleAccountNumberChange(newValue: String) { + private fun handleArtistSelect(newArtist: ArtistSearchResult) { + val needToLoadMembers = newArtist != uiState.value.selectedArtist + updateState { copy( - isDirty = true, - accountNumber = newValue.filter { it.isDigit() }, - accountNumberError = if (newValue.isNotBlank()) null else this.accountNumberError, + selectedArtist = newArtist, + artistSearchKeyword = newArtist.name, + artistError = null, + isAutoFilled = false, + productError = if (this.productError == FieldError.ARTIST_EMPTY_ERROR) null else this.productError, ) } - } - private fun handleArtistSelect(newArtist: ArtistSearchResult) { - if (newArtist == uiState.value.selectedArtist) { - updateState { copy(artistSearchKeyword = newArtist.name) } - return - } - - viewModelScope.launch { - getMembersWithPriceUseCase(newArtist.artistId) - .onSuccess { members -> - val selectedMemberIds = getAllMemberIdSet(members) - - updateState { - val errorBefore = this.memberSettingStatus == MemberSettingStatus.ERROR_NO_PRICE || this.memberSettingStatus == MemberSettingStatus.ERROR_NO_MEMBER - copy( - isDirty = true, - selectedArtist = newArtist, - artistSearchKeyword = newArtist.name, - artistError = null, - memberOptionsState = ApiState.Success(members.toPersistentList()), - editableMemberOptions = members.toPersistentList(), - selectedMemberIds = selectedMemberIds, - memberSettingStatus = MemberSettingStatus.IN_PROGRESS, - neverShowHint = errorBefore, - productError = if (this.productError == FieldError.ARTIST_EMPTY_ERROR) null else this.productError, - ) - } - } - .onFailure { e -> - updateState { - copy( - memberOptionsState = ApiState.Failure(e.message ?: "get members fail"), - ) - } - } + if (needToLoadMembers) { + viewModelScope.launch { getMembersByArtistId(newArtist.artistId) } } } - private fun getAllMemberIdSet( - members: List, - ): Set = members.map { option -> option.memberId }.toSet() + private suspend fun getMembersByArtistId(artistId: Long) = getMembersWithPriceUseCase(artistId) + .onSuccess { data -> + val members = data.toPersistentList() - private fun handleArtistSearchKeywordChange(newValue: String) { - updateState { - copy( - isDirty = true, - artistSearchKeyword = newValue, - neverShowSearchEmptyScreen = false, - ) + updateState { + copy( + membersState = ApiState.Success(members), + rawMembers = members, + selectedMembers = members, + memberSettingStatus = MemberSettingStatus.EDITABLE, + memberError = null, + ) + } + } + .onFailure { e -> + updateState { + copy( + membersState = ApiState.Failure(e.message ?: "get members fail"), + ) + } } - artistSearchKeywordForDebounce.value = newValue - } - - private suspend fun searchArtist(keyword: String) { - searchArtistUseCase(keyword = keyword) - .onSuccess { result -> - updateState { - copy( - artistSearchResultsState = ApiState.Success(result.toPersistentList()), - ) - } + private suspend fun searchArtist(keyword: String) = searchArtistUseCase(keyword = keyword) + .onSuccess { result -> + updateState { + copy( + artistSearchState = ApiState.Success(result.toPersistentList()), + ) } - .onFailure { - updateState { - copy( - artistSearchResultsState = ApiState.Failure(it.message ?: "FAIL"), - ) - } + } + .onFailure { + updateState { + copy( + artistSearchState = ApiState.Failure(it.message ?: "artist search fail"), + ) } - } - - private fun handleProductChange(newValue: String) { - updateState { - copy( - isDirty = true, - productName = newValue, - productError = if (newValue.isNotBlank()) null else this.productError, - selectedProductName = "", - ) } - productSearchKeywordForDebounce.value = newValue - } - - private suspend fun searchProdut(keyword: String) { + private suspend fun searchProduct(keyword: String) { uiState.value.selectedArtist?.let { artist -> searchProductUseCase( keyword = keyword, @@ -344,206 +325,148 @@ class PartyCreateViewModel @Inject constructor( .onSuccess { result -> updateState { copy( - productSearchResultsState = ApiState.Success(result.toPersistentList()), + productSearchState = ApiState.Success(result.toPersistentList()), ) } } .onFailure { updateState { copy( - productSearchResultsState = ApiState.Failure(it.message ?: "FAIL"), + productSearchState = ApiState.Failure(it.message ?: "product search fail"), ) } } } } - private fun handleBankChange(newValue: String) { - updateState { - copy( - isDirty = true, - bank = newValue, - bankError = if (newValue.isNotBlank()) null else this.bankError, - ) - } - } - private fun handleDeadlineChange(newValue: String) { if (!newValue.isDigitsOnly() || newValue.length > 8) return updateState { copy( - isDirty = true, deadline = newValue, deadlineError = if (newValue.isNotBlank()) null else this.deadlineError, ) } } - private fun handleDescriptionChange(newValue: String) { - updateState { - copy( - isDirty = true, - description = newValue, - descriptionError = if (newValue.isNotBlank()) null else this.descriptionError, - ) - } - } - - private fun handleDeliverySelect(newId: Long) { - val currentIds = uiState.value.selectedDeliveryIds - if (currentIds.size < 2 && newId in currentIds) return - - val newIds = if (newId in currentIds) { - currentIds - newId + private fun handleAllMemberSelect() { + val newMembers = if (uiState.value.rawMembers.size == uiState.value.tempSelectedMembers.size) { + persistentListOf() } else { - currentIds + newId + uiState.value.rawMembers } updateState { copy( - isDirty = true, - selectedDeliveryIds = newIds, + tempSelectedMembers = newMembers, + isMemberBottomSheetTouched = true, ) } } - private fun handleMemberPriceChange(newOption: MemberPriceOption) { - var currentPrice: String? = null + private fun handleMemberSelect(newMember: MemberPriceOption) { + val currentMembers = uiState.value.tempSelectedMembers - val newOptions = uiState.value.editableMemberOptions.map { option -> - if (option.memberId == newOption.memberId) { - currentPrice = option.price - newOption - } else { - option - } + val newMembers = if (currentMembers.any { it.memberId == newMember.memberId }) { + currentMembers.filter { it.memberId != newMember.memberId } + } else { + currentMembers + newMember }.toPersistentList() - val clearError = currentPrice.isNullOrBlank() && newOption.price.isNotBlank() - updateState { copy( - editableMemberOptions = newOptions, - memberSettingStatus = if (clearError) MemberSettingStatus.IN_PROGRESS else this.memberSettingStatus, + tempSelectedMembers = newMembers, + isMemberBottomSheetTouched = true, ) } } - private fun handleMemberSelect(newIndex: Int) { - val currentIndices = uiState.value.sheetDisplayMemberIndices - - val newIndices = if (newIndex in currentIndices) { - currentIndices - newIndex - } else { - currentIndices + newIndex - } + private fun handleMemberSelectDone() { + val selectedMembers = uiState.value.tempSelectedMembers.sortedBy { temp -> + uiState.value.rawMembers.indexOfFirst { raw -> + raw.memberId == temp.memberId + } + }.toPersistentList() updateState { copy( - sheetDisplayMemberIndices = newIndices, - isSheetTouched = true, + selectedMembers = selectedMembers, + memberSettingStatus = if (selectedMembers.isEmpty()) MemberSettingStatus.MEMBER_NOT_SELECTED else MemberSettingStatus.EDITABLE, + showMemberBottomSheet = false, ) } } - private fun resetDisplayMembers() { - val selectedIndices = uiState.value.editableMemberOptions.mapIndexedNotNull { index, option -> - if (option.memberId in uiState.value.selectedMemberIds) { - index + private fun handleMemberPriceChange(newMember: MemberPriceOption) { + val newMembers = uiState.value.selectedMembers.map { member -> + if (member.memberId == newMember.memberId) { + newMember } else { - null + member } - }.toSet() + }.toPersistentList() updateState { copy( - sheetDisplayMemberIndices = selectedIndices, - isSheetTouched = false, + selectedMembers = newMembers, + memberError = if (!hasInvalidPrice(newMembers)) null else this.memberError, ) } } - private fun handleAllMemberSelect() { - val newIndices = if (uiState.value.sheetDisplayMemberIndices.size == uiState.value.editableMemberOptions.size) { - setOf() - } else { - uiState.value.editableMemberOptions.indices.toSet() - } + private fun handleDeliverySelect(newDelivery: DeliveryOption) { + val currentDeliveries = uiState.value.selectedDeliveries - updateState { - copy( - sheetDisplayMemberIndices = newIndices, - isSheetTouched = true, - ) - } - } - - private fun handleMemberSelectDone() { - val newIds = uiState.value.editableMemberOptions.mapIndexedNotNull { index, option -> - if (index in uiState.value.sheetDisplayMemberIndices) { - option.memberId - } else { - null - } - }.toSet() + if (currentDeliveries.size < 2 && newDelivery in currentDeliveries) return - val newMemberOptions = clearUnselectedOptionPrices(newIds) + val newDeliveries = if (currentDeliveries.any { it.deliveryId == newDelivery.deliveryId }) { + currentDeliveries.filter { it.deliveryId != newDelivery.deliveryId } + } else { + currentDeliveries + newDelivery + }.toPersistentList() updateState { copy( - selectedMemberIds = newIds, - editableMemberOptions = newMemberOptions, - memberSettingStatus = if (newIds.isNotEmpty()) MemberSettingStatus.IN_PROGRESS else this.memberSettingStatus, + selectedDeliveries = newDeliveries, ) } } - private fun clearUnselectedOptionPrices(newIds: Set): ImmutableList { - return uiState.value.editableMemberOptions.map { option -> - if (option.memberId in newIds) { - option - } else { - option.copy(price = "") - } - }.toPersistentList() - } + private fun hasInvalidPrice(members: ImmutableList): Boolean = + members.any { it.price == "0" || it.price.isBlank() } private fun validateInputs(): Boolean { - val imageError = if (uiState.value.selectedImages.isEmpty()) FieldError.IMAGE_EMPTY_ERROR else null + val imageError = if (uiState.value.imageUris.isEmpty()) FieldError.IMAGE_EMPTY_ERROR else null val artistError = if (uiState.value.selectedArtist == null) FieldError.ARTIST_EMPTY_ERROR else null val productError = if (uiState.value.productName.isBlank()) FieldError.PRODUCT_EMPTY_ERROR else null - val deadlineError = when (val date = uiState.value.deadline.toDateOrNull()) { - null -> if (uiState.value.deadline.isBlank()) { - FieldError.DEADLINE_EMPTY_ERROR - } else { - FieldError.DEADLINE_INVALID_ERROR + val deadlineError = when (uiState.value.deadline.isBlank()) { + true -> FieldError.DEADLINE_EMPTY_ERROR + false -> { + val date = uiState.value.deadline.toDateOrNull() + when { + date == null -> FieldError.DEADLINE_INVALID_ERROR + !date.isTodayOrAfter() -> FieldError.DEADLINE_PAST_ERROR + else -> null + } } - - else -> if (!date.isTodayOrAfter()) FieldError.DEADLINE_PAST_ERROR else null } val descriptionError = if (uiState.value.description.isBlank()) FieldError.DESCRIPTION_ERROR else null val accountNumberError = if (uiState.value.accountNumber.isBlank()) FieldError.ACCOUNT_NUMBER_ERROR else null val bankError = if (uiState.value.bank.isBlank()) FieldError.BANK_ERROR else null - - val selectedMemberIds = uiState.value.selectedMemberIds - val currentSettingStatus = when { - selectedMemberIds.isEmpty() -> MemberSettingStatus.ERROR_NO_MEMBER - uiState.value.editableMemberOptions.isEmpty() -> MemberSettingStatus.DEFAULT - uiState.value.editableMemberOptions.any { option -> option.memberId in selectedMemberIds && option.price.isBlank() } -> MemberSettingStatus.ERROR_NO_PRICE - else -> uiState.value.memberSettingStatus + val memberError = when { + uiState.value.selectedMembers.isEmpty() -> FieldError.MEMBER_EMPTY_ERROR + hasInvalidPrice(uiState.value.selectedMembers) -> FieldError.MEMBER_PRICE_ERROR + else -> null } - val hasMemberOptionError = currentSettingStatus == MemberSettingStatus.ERROR_NO_MEMBER || currentSettingStatus == MemberSettingStatus.ERROR_NO_PRICE - val hasError = imageError != null || artistError != null || productError != null || deadlineError != null || descriptionError != null || - accountNumberError != null || bankError != null || hasMemberOptionError + accountNumberError != null || bankError != null || memberError != null if (hasError) { updateState { copy( - isDirty = true, imageError = imageError, artistError = artistError, productError = productError, @@ -551,8 +474,7 @@ class PartyCreateViewModel @Inject constructor( descriptionError = descriptionError, accountNumberError = accountNumberError, bankError = bankError, - memberSettingStatus = currentSettingStatus, - neverShowHint = if (hasMemberOptionError) true else this.neverShowHint, + memberError = memberError, ) } getScrollIndex()?.let { @@ -572,61 +494,49 @@ class PartyCreateViewModel @Inject constructor( uiState.value.descriptionError != null -> 4 uiState.value.accountNumberError != null -> 5 uiState.value.bankError != null -> 6 - uiState.value.memberSettingStatus == MemberSettingStatus.ERROR_NO_PRICE || uiState.value.memberSettingStatus == MemberSettingStatus.ERROR_NO_MEMBER -> 7 + uiState.value.memberError != null -> 7 else -> null } return firstErrorFieldIndex } - private suspend fun uploadPartyInfo( - urls: List, - ): Result = - createPartyUseCase( - artistId = uiState.value.selectedArtist?.artistId ?: 0L, - product = uiState.value.productName, - description = uiState.value.description, - deadline = uiState.value.deadline.toDashedDate(), - bank = uiState.value.bank, - accountNumber = uiState.value.accountNumber, - imageUrls = urls, - options = uiState.value.editableMemberOptions.filter { option -> option.memberId in uiState.value.selectedMemberIds }, - shippings = uiState.value.editableDeliveryOptions.filter { option -> option.deliveryId in uiState.value.selectedDeliveryIds }, - ) - - private suspend fun uploadImagesAndCreateParty( - imageInfos: List, - ) { - uploadImagesUseCase(IMAGE_TYPE, imageInfos) - .onSuccess { urls -> - createParty(urls) - } - .onFailure { - updateState { - copy( - createPartyState = ApiState.Failure(it.message ?: "image upload fail"), - ) - } + private suspend fun uploadPartyInfo(urls: List): Result = createPartyUseCase( + artistId = uiState.value.selectedArtist?.artistId ?: 0L, + product = uiState.value.productName, + description = uiState.value.description, + deadline = uiState.value.deadline.toDashedDate(), + bank = uiState.value.bank, + accountNumber = uiState.value.accountNumber, + imageUrls = urls, + options = uiState.value.selectedMembers, + shippings = uiState.value.selectedDeliveries, + ) + + private suspend fun uploadImagesAndCreateParty(imageInfos: List) = uploadImagesUseCase(IMAGE_TYPE, imageInfos) + .onSuccess { urls -> + createParty(urls) + } + .onFailure { + updateState { + copy( + createPartyState = ApiState.Failure(it.message ?: "image upload fail"), + ) } - } + } - private suspend fun createParty( - urls: List, - ) { - uploadPartyInfo(urls) - .onSuccess { partyId -> - updateState { - copy(createPartyState = ApiState.Success(partyId)) - } - sendEffect(CreateUiEffect.NavigateToDetail(partyId)) - updateState { CreateUiState() } + private suspend fun createParty(urls: List) = uploadPartyInfo(urls) + .onSuccess { partyId -> + updateState { + copy(createPartyState = ApiState.Success(partyId)) } - .onFailure { - updateState { - copy( - createPartyState = ApiState.Failure(it.message ?: "create party fail"), - ) - } + sendEffect(NavigateToDetail(partyId)) + } + .onFailure { + updateState { + copy( + createPartyState = ApiState.Failure(it.message ?: "create party fail"), + ) } - } + } } diff --git a/app/src/main/java/com/poti/android/presentation/party/create/component/CreateDeliverySetting.kt b/app/src/main/java/com/poti/android/presentation/party/create/component/CreateDeliverySetting.kt index 7cce88f8..72c90758 100644 --- a/app/src/main/java/com/poti/android/presentation/party/create/component/CreateDeliverySetting.kt +++ b/app/src/main/java/com/poti/android/presentation/party/create/component/CreateDeliverySetting.kt @@ -19,9 +19,9 @@ import kotlinx.collections.immutable.persistentListOf @Composable fun CreateDeliverySetting( - deliveryOptions: ImmutableList, - selectedOptionIds: Set, - onDeliveryOptionClick: (Long) -> Unit, + allDeliveries: ImmutableList, + selectedDeliveries: ImmutableList, + onDeliveryClick: (DeliveryOption) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -42,13 +42,13 @@ fun CreateDeliverySetting( Column( verticalArrangement = Arrangement.spacedBy(20.dp), ) { - deliveryOptions.forEach { option -> + allDeliveries.forEach { option -> EditOptionPrice( option = option.name, value = option.price.toString(), onValueChanged = {}, - isChecked = option.deliveryId in selectedOptionIds, - onCheckboxClick = { onDeliveryOptionClick(option.deliveryId) }, + isChecked = selectedDeliveries.any { it.deliveryId == option.deliveryId }, + onCheckboxClick = { onDeliveryClick(option) }, enabled = false, ) } @@ -64,13 +64,11 @@ private fun CreateDeliverySettingPreview() { DeliveryOption(deliveryId = 2, name = "준등기", price = 1800), ) - val selectedOptionIds = setOf(1.toLong()) - PotiTheme { CreateDeliverySetting( - deliveryOptions = deliveryOptions, - selectedOptionIds = selectedOptionIds, - onDeliveryOptionClick = {}, + allDeliveries = deliveryOptions, + selectedDeliveries = deliveryOptions, + onDeliveryClick = {}, ) } } diff --git a/app/src/main/java/com/poti/android/presentation/party/create/component/CreateMemberSetting.kt b/app/src/main/java/com/poti/android/presentation/party/create/component/CreateMemberSetting.kt index f089dd0f..706afa8a 100644 --- a/app/src/main/java/com/poti/android/presentation/party/create/component/CreateMemberSetting.kt +++ b/app/src/main/java/com/poti/android/presentation/party/create/component/CreateMemberSetting.kt @@ -20,13 +20,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.poti.android.R +import com.poti.android.core.common.util.screenWidthDp import com.poti.android.core.designsystem.component.button.PotiInlineButton import com.poti.android.core.designsystem.component.display.PotiEmptyStateInline import com.poti.android.core.designsystem.component.display.PotiErrorMessage @@ -36,39 +36,25 @@ import com.poti.android.presentation.party.create.model.MemberSettingStatus import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -private const val BOTTOM_BTN_HEIGHT_DP = 70 - @Composable fun CreateMemberSetting( status: MemberSettingStatus, - selectedMembersOption: ImmutableList, + selectedMembers: ImmutableList, onPriceChange: (MemberPriceOption) -> Unit, onEditBtnClick: () -> Unit, + layoutBottom: Float, + errorMessage: String, modifier: Modifier = Modifier, - neverShowHint: Boolean = false, ) { - var showHint by remember(status, neverShowHint) { - mutableStateOf(if (neverShowHint) false else status != MemberSettingStatus.DEFAULT) - } - - var isEditBtnInScreen by remember { mutableStateOf(false) } + var isEditBtnInLayout by remember { mutableStateOf(false) } val density = LocalDensity.current - val configuration = LocalConfiguration.current val isKeyboardVisible = WindowInsets.ime.getBottom(density) > 0 - val screenHeight = remember(configuration.screenHeightDp) { - with(density) { - configuration.screenHeightDp.dp.roundToPx() - } - } - - val bottomBtnHeight = remember { - with(density) { BOTTOM_BTN_HEIGHT_DP.dp.roundToPx() } - } + var hideHint by remember { mutableStateOf(false) } Column( modifier = modifier - .padding(vertical = 24.dp, horizontal = 16.dp), + .padding(vertical = 24.dp, horizontal = screenWidthDp(16.dp)), verticalArrangement = Arrangement.spacedBy(24.dp), ) { Row( @@ -82,37 +68,31 @@ fun CreateMemberSetting( style = PotiTheme.typography.title18sb, ) - when (status) { - MemberSettingStatus.ERROR_NO_MEMBER -> PotiErrorMessage(stringResource(R.string.create_msg_need_member)) - MemberSettingStatus.ERROR_NO_PRICE -> PotiErrorMessage(stringResource(R.string.create_msg_need_price)) - else -> Unit + if (errorMessage.isNotEmpty()) { + PotiErrorMessage(errorMessage) } } - when { - status == MemberSettingStatus.DEFAULT -> PotiEmptyStateInline(stringResource(R.string.create_placeholder_need_artist)) - selectedMembersOption.isEmpty() -> PotiEmptyStateInline(stringResource(R.string.create_placeholder_need_member)) - else -> { + when (status) { + MemberSettingStatus.ARTIST_NOT_SELECTED -> PotiEmptyStateInline(stringResource(R.string.create_placeholder_need_artist)) + MemberSettingStatus.MEMBER_NOT_SELECTED -> PotiEmptyStateInline(stringResource(R.string.create_placeholder_need_member)) + MemberSettingStatus.EDITABLE -> { Column { - selectedMembersOption.forEachIndexed { index, option -> - val isLastOption = index == selectedMembersOption.size - 1 + selectedMembers.forEachIndexed { index, option -> + val isLastOption = index == selectedMembers.size - 1 EditOptionPrice( option = option.name, value = option.price, onValueChanged = { newPrice -> - val newOption = MemberPriceOption( - memberId = option.memberId, - name = option.name, - price = newPrice, - ) + val newOption = option.copy(price = newPrice) onPriceChange(newOption) }, modifier = Modifier.padding(bottom = if (isLastOption) 0.dp else 20.dp), imeAction = if (isLastOption) ImeAction.Done else ImeAction.Next, onFocusChanged = { focused -> if (focused) { - showHint = false + hideHint = true } }, ) @@ -121,23 +101,26 @@ fun CreateMemberSetting( } } - if (status != MemberSettingStatus.DEFAULT) { + if (status != MemberSettingStatus.ARTIST_NOT_SELECTED) { Box { PotiInlineButton( text = stringResource(R.string.create_btn_member_edit), onClick = { - showHint = false + hideHint = true onEditBtnClick() }, modifier = Modifier .fillMaxWidth() .onGloballyPositioned { coordinates -> - val buttonTop = coordinates.positionInWindow().y - isEditBtnInScreen = buttonTop < screenHeight - bottomBtnHeight + val top = coordinates.positionInWindow().y + val isVisible = (top < layoutBottom) && (top > 0) + if (isEditBtnInLayout != isVisible) { + isEditBtnInLayout = isVisible + } }, ) - if (showHint && isEditBtnInScreen && !isKeyboardVisible) { + if (!hideHint && isEditBtnInLayout && !isKeyboardVisible) { HintToolTip() } } @@ -159,46 +142,34 @@ private fun CreateMemberSettingPreview() { verticalArrangement = Arrangement.spacedBy(40.dp), ) { CreateMemberSetting( - status = MemberSettingStatus.DEFAULT, - selectedMembersOption = persistentListOf(), - onPriceChange = {}, - onEditBtnClick = {}, - ) - - CreateMemberSetting( - status = MemberSettingStatus.IN_PROGRESS, - selectedMembersOption = persistentListOf(), - onPriceChange = {}, - onEditBtnClick = {}, - ) - - CreateMemberSetting( - status = MemberSettingStatus.IN_PROGRESS, - selectedMembersOption = persistentListOf( - MemberPriceOption(memberId = 1, name = "원영", price = "5000"), - MemberPriceOption(memberId = 1, name = "유진", price = ""), - MemberPriceOption(memberId = 1, name = "레이", price = "4000"), - ), + status = MemberSettingStatus.ARTIST_NOT_SELECTED, + selectedMembers = persistentListOf(), onPriceChange = {}, onEditBtnClick = {}, + layoutBottom = 0f, + errorMessage = "", ) CreateMemberSetting( - status = MemberSettingStatus.ERROR_NO_MEMBER, - selectedMembersOption = persistentListOf(), + status = MemberSettingStatus.MEMBER_NOT_SELECTED, + selectedMembers = persistentListOf(), onPriceChange = {}, onEditBtnClick = {}, + layoutBottom = 0f, + errorMessage = "", ) CreateMemberSetting( - status = MemberSettingStatus.ERROR_NO_PRICE, - selectedMembersOption = persistentListOf( + status = MemberSettingStatus.EDITABLE, + selectedMembers = persistentListOf( MemberPriceOption(memberId = 1, name = "원영", price = "5000"), MemberPriceOption(memberId = 1, name = "유진", price = ""), MemberPriceOption(memberId = 1, name = "레이", price = "4000"), ), onPriceChange = {}, onEditBtnClick = {}, + layoutBottom = 0f, + errorMessage = "모든 멤버에 가격을 설정해주세요", ) } } diff --git a/app/src/main/java/com/poti/android/presentation/party/create/model/Contracts.kt b/app/src/main/java/com/poti/android/presentation/party/create/model/Contracts.kt index 6294a6ac..3458f25d 100644 --- a/app/src/main/java/com/poti/android/presentation/party/create/model/Contracts.kt +++ b/app/src/main/java/com/poti/android/presentation/party/create/model/Contracts.kt @@ -14,13 +14,11 @@ import com.poti.android.domain.model.delivery.DeliveryOption import com.poti.android.domain.model.image.ImageInfoForPresigned import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentList enum class MemberSettingStatus { - DEFAULT, - IN_PROGRESS, - ERROR_NO_MEMBER, - ERROR_NO_PRICE, + ARTIST_NOT_SELECTED, + MEMBER_NOT_SELECTED, + EDITABLE, } enum class FieldError( @@ -35,57 +33,56 @@ enum class FieldError( DESCRIPTION_ERROR(R.string.create_error_need_description), ACCOUNT_NUMBER_ERROR(R.string.create_error_need_account_number), BANK_ERROR(R.string.create_error_need_bank), + MEMBER_EMPTY_ERROR(R.string.create_error_need_member), + MEMBER_PRICE_ERROR(R.string.create_error_need_price), } data class CreateUiState( - val isDirty: Boolean = false, - val neverShowHint: Boolean = false, - val selectedImages: ImmutableList = persistentListOf(), - val selectedArtist: ArtistSearchResult? = null, - val productName: String = "", - val productSearchResultsState: ApiState> = ApiState.Init, - val deadline: String = "", - val description: String = "", - val accountNumber: String = "", - val bank: String = "", - val memberSettingStatus: MemberSettingStatus = MemberSettingStatus.DEFAULT, - val memberOptionsState: ApiState> = ApiState.Init, - val editableMemberOptions: ImmutableList = persistentListOf(), - val selectedMemberIds: Set = setOf(), - val deliveryOptionsState: ApiState> = ApiState.Init, - val sheetDisplayMemberIndices: Set = setOf(), - val editableDeliveryOptions: ImmutableList = persistentListOf(), - val selectedDeliveryIds: Set = setOf(), + val imageUris: List = emptyList(), val imageError: FieldError? = null, + val selectedArtist: ArtistSearchResult? = null, + val artistSearchKeyword: String = "", + val artistSearchState: ApiState> = ApiState.Init, val artistError: FieldError? = null, + val selectedProduct: String = "", + val productName: String = "", + val productSearchState: ApiState> = ApiState.Init, val productError: FieldError? = null, + val deadline: String = "", val deadlineError: FieldError? = null, + val description: String = "", val descriptionError: FieldError? = null, + val accountNumber: String = "", val accountNumberError: FieldError? = null, + val bank: String = "", val bankError: FieldError? = null, - val artistSearchKeyword: String = "", - val isSheetTouched: Boolean = false, + val membersState: ApiState> = ApiState.Init, + val rawMembers: ImmutableList = persistentListOf(), + val selectedMembers: ImmutableList = persistentListOf(), + val tempSelectedMembers: ImmutableList = persistentListOf(), + val memberSettingStatus: MemberSettingStatus = MemberSettingStatus.ARTIST_NOT_SELECTED, + val showMemberBottomSheet: Boolean = false, + val isMemberBottomSheetTouched: Boolean = false, + val memberError: FieldError? = null, + val deliveriesState: ApiState> = ApiState.Init, + val rawDeliveries: ImmutableList = persistentListOf(), + val selectedDeliveries: ImmutableList = persistentListOf(), val createPartyState: ApiState = ApiState.Init, - val artistSearchResultsState: ApiState> = ApiState.Init, - val neverShowSearchEmptyScreen: Boolean = false, - val selectedProductName: String = "", + val isAutoFilled: Boolean = false, + val showDialog: Boolean = false, val errorIndexToScroll: Int? = null, ) : UiState { val isProductFieldReadOnly = selectedArtist == null - val sheetDisplayMemberNames = editableMemberOptions.map { option -> option.name } - val editOptionDisplayMembers = editableMemberOptions.filter { option -> option.memberId in selectedMemberIds }.toPersistentList() - val isArtistSearchResultsEmpty = !neverShowSearchEmptyScreen && artistSearchKeyword.isNotEmpty() && (artistSearchResultsState.getSuccessDataOrNull()?.isEmpty() ?: true) + val isArtistSearchResultsEmpty = !isAutoFilled && artistSearchKeyword.isNotEmpty() && (artistSearchState.getSuccessDataOrNull()?.isEmpty() ?: false) val isArtistSelectDoneBtnEnabled = selectedArtist != null } sealed interface CreateUiIntent : UiIntent { - data class InitializeScreen(val artistId: Long?, val artistName: String?, val productName: String?) : CreateUiIntent + data object OnCloseBottomSheet : CreateUiIntent - data object CleanScreen : CreateUiIntent + data object OnCloseDialog : CreateUiIntent - data object OnScrollComplete : CreateUiIntent - - data object OnBackClick : CreateUiIntent + data object OnBack : CreateUiIntent data object OnBackConfirm : CreateUiIntent @@ -95,7 +92,7 @@ sealed interface CreateUiIntent : UiIntent { data object OnSearchClick : CreateUiIntent - data class OnArtistSearchKeywordChange(val value: String) : CreateUiIntent + data class OnArtistChange(val value: String) : CreateUiIntent data class OnArtistSelect(val artist: ArtistSearchResult) : CreateUiIntent @@ -115,19 +112,21 @@ sealed interface CreateUiIntent : UiIntent { data object OnMemberEditClick : CreateUiIntent - data class OnMemberSelect(val index: Int) : CreateUiIntent + data class OnMemberSelect(val member: MemberPriceOption) : CreateUiIntent data object OnAllMemberSelect : CreateUiIntent data object OnMemberSelectDone : CreateUiIntent - data class OnMemberPriceChange(val option: MemberPriceOption) : CreateUiIntent + data class OnMemberPriceChange(val member: MemberPriceOption) : CreateUiIntent - data class OnDeliverySelect(val deliveryId: Long) : CreateUiIntent + data class OnDeliverySelect(val delivery: DeliveryOption) : CreateUiIntent data object OnCreateClick : CreateUiIntent - data class OnConvertDone(val result: List) : CreateUiIntent + data object ScrollComplete : CreateUiIntent + + data class ConvertDone(val result: List) : CreateUiIntent } sealed interface CreateUiEffect : UiEffect { @@ -135,10 +134,6 @@ sealed interface CreateUiEffect : UiEffect { data object NavigateToSearch : CreateUiEffect - data object ShowBottomSheet : CreateUiEffect - - data object ShowDialog : CreateUiEffect - data object ConvertUris : CreateUiEffect data class NavigateToDetail(val partyId: Long) : CreateUiEffect diff --git a/app/src/main/java/com/poti/android/presentation/party/create/navigation/PartyCreateNavigation.kt b/app/src/main/java/com/poti/android/presentation/party/create/navigation/PartyCreateNavigation.kt index dd5a4c3e..37ae419b 100644 --- a/app/src/main/java/com/poti/android/presentation/party/create/navigation/PartyCreateNavigation.kt +++ b/app/src/main/java/com/poti/android/presentation/party/create/navigation/PartyCreateNavigation.kt @@ -6,7 +6,7 @@ import androidx.compose.ui.Modifier import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable -import androidx.navigation.toRoute +import androidx.navigation.navigation import com.poti.android.core.common.extension.sharedViewModel import com.poti.android.core.navigation.Route import com.poti.android.presentation.party.create.PartyArtistSelectRoute @@ -15,13 +15,16 @@ import com.poti.android.presentation.party.create.PartyCreateViewModel import com.poti.android.presentation.party.detail.navigation.navigateToPartyDetailFromCreate import kotlinx.serialization.Serializable +@Serializable +data class PartyCreateGraph( + val artistId: Long? = null, + val artistName: String? = null, + val productName: String? = null, +) + sealed interface PartyCreateRoute : Route { @Serializable - data class Create( - val artistId: Long? = null, - val artistName: String? = null, - val productName: String? = null, - ) : PartyCreateRoute + data object Create : PartyCreateRoute @Serializable data object ArtistSelect : PartyCreateRoute @@ -32,47 +35,38 @@ fun NavController.navigateToPartyCreate( artistName: String? = null, productName: String? = null, ) { - navigate(PartyCreateRoute.Create(artistId, artistName, productName)) + navigate(PartyCreateGraph(artistId, artistName, productName)) } fun NavController.navigateToPartyArtistSelect() { navigate(PartyCreateRoute.ArtistSelect) } -fun NavController.navigateToPartyCreateFromArtistSelect() { - navigate(PartyCreateRoute.Create(null, null, null)) { - popUpTo(PartyCreateRoute.Create(null, null, null)) { - inclusive = false - } - launchSingleTop = true - } -} - fun NavGraphBuilder.partyCreateNavGraph( navController: NavController, paddingValues: PaddingValues, ) { - composable { entry -> - val viewModel: PartyCreateViewModel = entry.sharedViewModel(navController) - val params = entry.toRoute() + navigation( + startDestination = PartyCreateRoute.Create, + ) { + composable { entry -> + val viewModel: PartyCreateViewModel = entry.sharedViewModel(navController) - PartyCreateRoute( - onPopBackStack = navController::popBackStack, - onNavigateToSearch = navController::navigateToPartyArtistSelect, - onNavigateToDetail = navController::navigateToPartyDetailFromCreate, - viewModel = viewModel, - modifier = Modifier.padding(paddingValues), - artistId = params.artistId, - artistName = params.artistName, - productName = params.productName, - ) - } - composable { entry -> - val viewModel: PartyCreateViewModel = entry.sharedViewModel(navController) - PartyArtistSelectRoute( - onPopBackStack = navController::popBackStack, - viewModel = viewModel, - modifier = Modifier.padding(paddingValues), - ) + PartyCreateRoute( + onPopBackStack = navController::popBackStack, + onNavigateToSearch = navController::navigateToPartyArtistSelect, + onNavigateToDetail = navController::navigateToPartyDetailFromCreate, + viewModel = viewModel, + modifier = Modifier.padding(paddingValues), + ) + } + composable { entry -> + val viewModel: PartyCreateViewModel = entry.sharedViewModel(navController) + PartyArtistSelectRoute( + onPopBackStack = navController::popBackStack, + viewModel = viewModel, + modifier = Modifier.padding(paddingValues), + ) + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 988a44a8..3e7e9813 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -87,7 +87,7 @@ 예) 서울시 솝트구 다솝로 37 주소를 입력해주세요 연락처 - 예) 010-1234-5678 + 예) 010–1234–5678 연락처를 입력해주세요 참여가 완료되었어요 모집이 끝나면 입금이 시작돼요 @@ -96,8 +96,6 @@ 모집자 본인이 보유할 멤버는 꼭 제외해주세요! 멤버 설정 - 멤버를 1명 이상 추가해주세요 - 모든 멤버에 가격을 설정해주세요 아티스트를 먼저 선택해주세요 선택한 멤버가 없어요 멤버 편집 @@ -124,6 +122,8 @@ 설명을 입력해주세요 계좌번호를 입력해주세요 은행 정보를 입력해주세요 + 멤버를 1명 이상 추가해주세요 + 모든 멤버에 가격을 설정해주세요 멤버 편집 아티스트 검색 검색 결과가 없어요\n다른 키워드로 다시 검색해보세요