diff --git a/data/src/main/java/com/nexters/boolti/data/datasource/GiftDataSource.kt b/data/src/main/java/com/nexters/boolti/data/datasource/GiftDataSource.kt index 91de3ab4..05e85c4f 100644 --- a/data/src/main/java/com/nexters/boolti/data/datasource/GiftDataSource.kt +++ b/data/src/main/java/com/nexters/boolti/data/datasource/GiftDataSource.kt @@ -32,4 +32,7 @@ internal class GiftDataSource @Inject constructor( suspend fun cancelGift(giftUuid: String): Boolean = service.cancelGift(GiftCancelRequest(giftUuid = giftUuid)) + + suspend fun cancelRegisteredGift(giftUuid: String): Boolean = + service.cancelReceiveGift(GiftCancelRequest(giftUuid = giftUuid)) } \ No newline at end of file diff --git a/data/src/main/java/com/nexters/boolti/data/network/api/GiftService.kt b/data/src/main/java/com/nexters/boolti/data/network/api/GiftService.kt index f66570c7..e4ad8a6f 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/api/GiftService.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/api/GiftService.kt @@ -35,4 +35,7 @@ internal interface GiftService { @POST("app/api/v1/order/cancel-gift") suspend fun cancelGift(@Body request: GiftCancelRequest): Boolean + + @POST("app/api/v1/order/cancel-receive-gift") + suspend fun cancelReceiveGift(@Body request: GiftCancelRequest): Boolean } \ No newline at end of file diff --git a/data/src/main/java/com/nexters/boolti/data/network/response/TicketGroupDto.kt b/data/src/main/java/com/nexters/boolti/data/network/response/TicketGroupDto.kt index 2520cc98..76773cca 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/response/TicketGroupDto.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/response/TicketGroupDto.kt @@ -27,6 +27,8 @@ internal data class TicketsDto( val ticketName: String? = null, @SerialName("ticketCount") val ticketCount: Int? = null, + @SerialName("giftUuid") + val giftUuid: String? = null, ) { fun toDomain(): TicketGroup = TicketGroup( userId = userId ?: "", @@ -53,7 +55,9 @@ internal data class TicketsDto( csTicketId = "", showDate = LocalDateTime.MIN, ) - } + }, + isGift = giftUuid != null, + giftUuid = giftUuid, ) } @@ -91,6 +95,8 @@ internal data class TicketGroupDto( val tickets: List? = null, @SerialName("userId") val userId: String? = null, + @SerialName("giftUuid") + val giftUuid: String? = null, ) { fun toDomain(): TicketGroup = TicketGroup( userId = userId ?: "", @@ -111,6 +117,8 @@ internal data class TicketGroupDto( tickets = tickets?.map { it.toDomain(showDate = showDate?.toLocalDateTime() ?: LocalDateTime.MIN) } ?: emptyList(), + isGift = giftUuid != null, + giftUuid = giftUuid, ) @Serializable diff --git a/data/src/main/java/com/nexters/boolti/data/repository/GiftRepositoryImpl.kt b/data/src/main/java/com/nexters/boolti/data/repository/GiftRepositoryImpl.kt index 40bf9a98..9f955134 100644 --- a/data/src/main/java/com/nexters/boolti/data/repository/GiftRepositoryImpl.kt +++ b/data/src/main/java/com/nexters/boolti/data/repository/GiftRepositoryImpl.kt @@ -54,4 +54,8 @@ internal class GiftRepositoryImpl @Inject constructor( override fun cancelGift(giftUuid: String): Flow = flow { emit(dataSource.cancelGift(giftUuid)) } + + override fun cancelRegisteredGift(giftUuid: String): Flow = flow { + emit(dataSource.cancelRegisteredGift(giftUuid)) + } } diff --git a/domain/src/main/java/com/nexters/boolti/domain/model/Ticket.kt b/domain/src/main/java/com/nexters/boolti/domain/model/Ticket.kt index c33ab8e7..551ed221 100644 --- a/domain/src/main/java/com/nexters/boolti/domain/model/Ticket.kt +++ b/domain/src/main/java/com/nexters/boolti/domain/model/Ticket.kt @@ -31,6 +31,8 @@ data class TicketGroup( val hostName: String = "", val hostPhoneNumber: String = "", val tickets: List = emptyList(), + val giftUuid: String? = null, + val isGift: Boolean = false, ) { data class Ticket( val ticketId: String = "", diff --git a/domain/src/main/java/com/nexters/boolti/domain/repository/GiftRepository.kt b/domain/src/main/java/com/nexters/boolti/domain/repository/GiftRepository.kt index 5e54be48..3198c28a 100644 --- a/domain/src/main/java/com/nexters/boolti/domain/repository/GiftRepository.kt +++ b/domain/src/main/java/com/nexters/boolti/domain/repository/GiftRepository.kt @@ -22,4 +22,6 @@ interface GiftRepository { fun getGiftPaymentInfo(giftId: String): Flow fun cancelGift(giftUuid: String): Flow + + fun cancelRegisteredGift(giftUuid: String): Flow } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c95a3317..8506ef01 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] minSdk = "26" targetSdk = "34" -versionCode = "23" -versionName = "1.7.2" +versionCode = "24" +versionName = "1.8.0" packageName = "com.nexters.boolti" compileSdk = "34" targetJvm = "17" diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailEvent.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailEvent.kt index 6737459b..e4d44557 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailEvent.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailEvent.kt @@ -4,4 +4,7 @@ sealed interface TicketDetailEvent { data object ManagerCodeValid : TicketDetailEvent data object OnRefresh : TicketDetailEvent data object NotFound : TicketDetailEvent + data object GiftRefunded : TicketDetailEvent + data object FailedToRefund : TicketDetailEvent + data object NetworkError : TicketDetailEvent } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailScreen.kt index 587b32cc..90aed4df 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailScreen.kt @@ -6,7 +6,6 @@ import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -39,7 +38,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -51,7 +49,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -75,6 +73,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -93,9 +92,9 @@ import com.nexters.boolti.presentation.component.BtBackAppBar import com.nexters.boolti.presentation.component.DottedDivider import com.nexters.boolti.presentation.component.InstagramIndicator import com.nexters.boolti.presentation.component.ShowInquiry -import com.nexters.boolti.presentation.component.ToastSnackbarHost import com.nexters.boolti.presentation.extension.toDp import com.nexters.boolti.presentation.extension.toPx +import com.nexters.boolti.presentation.screen.LocalSnackbarController import com.nexters.boolti.presentation.screen.MainDestination import com.nexters.boolti.presentation.screen.qr.QrCoverView import com.nexters.boolti.presentation.theme.BooltiTheme @@ -112,8 +111,6 @@ import com.nexters.boolti.presentation.util.TicketShape import com.nexters.boolti.presentation.util.UrlParser import com.nexters.boolti.presentation.util.asyncImageBlurModel import com.nexters.boolti.presentation.util.rememberQrBitmapPainter -import kotlinx.coroutines.launch -import java.time.LocalDate fun NavGraphBuilder.TicketDetailScreen( navigateTo: (String) -> Unit, @@ -136,7 +133,7 @@ fun NavGraphBuilder.TicketDetailScreen( } } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun TicketDetailScreen( modifier: Modifier = Modifier, @@ -148,10 +145,10 @@ private fun TicketDetailScreen( val scrollState = rememberScrollState() var showEnterCodeDialog by remember { mutableStateOf(false) } var showTicketNotFoundDialog by remember { mutableStateOf(false) } + var showRefundGiftTicket by rememberSaveable { mutableStateOf(false) } - val snackbarHostState = remember { SnackbarHostState() } + val snackbarHostController = LocalSnackbarController.current val clipboardManager = LocalClipboardManager.current - val scope = rememberCoroutineScope() val context = LocalContext.current var contentWidth by remember { mutableFloatStateOf(0f) } @@ -169,15 +166,27 @@ private fun TicketDetailScreen( val pullToRefreshState = rememberPullToRefreshState() val entranceSuccessMsg = stringResource(R.string.message_ticket_validated) + val failedToRefundMsg = stringResource(R.string.refund_failed_to_registered_gift) + val networkErrorMsg = stringResource(R.string.error_network) + val giftRefundedString = stringResource(R.string.refund_complete_ticket_refund) LaunchedEffect(viewModel.event) { viewModel.event.collect { when (it) { - TicketDetailEvent.ManagerCodeValid -> snackbarHostState.showSnackbar( + TicketDetailEvent.ManagerCodeValid -> snackbarHostController.showMessage( entranceSuccessMsg ) TicketDetailEvent.OnRefresh -> showEnterCodeDialog = false TicketDetailEvent.NotFound -> showTicketNotFoundDialog = true + TicketDetailEvent.FailedToRefund -> snackbarHostController.showMessage( + failedToRefundMsg + ) + + TicketDetailEvent.NetworkError -> snackbarHostController.showMessage(networkErrorMsg) + TicketDetailEvent.GiftRefunded -> { + snackbarHostController.showMessage(giftRefundedString) + onBackClicked() + } } } } @@ -194,12 +203,6 @@ private fun TicketDetailScreen( onClickBack = onBackClicked, ) }, - snackbarHost = { - ToastSnackbarHost( - modifier = Modifier.padding(bottom = 54.dp), - hostState = snackbarHostState, - ) - } ) { innerPadding -> if (pullToRefreshState.isRefreshing) { viewModel.refresh().invokeOnCompletion { @@ -247,7 +250,11 @@ private fun TicketDetailScreen( // 배경 블러된 이미지 Box(contentAlignment = Alignment.BottomCenter) { AsyncImage( - model = asyncImageBlurModel(context, ticketGroup.poster, radius = 24), + model = asyncImageBlurModel( + context, + ticketGroup.poster, + radius = 24 + ), modifier = Modifier .width(contentWidth.toDp()) .aspectRatio(317 / 570f) @@ -260,7 +267,12 @@ private fun TicketDetailScreen( .fillMaxWidth() .aspectRatio(317 / 125f) .background( - brush = Brush.verticalGradient(listOf(Black.copy(alpha = 0f), Black)), + brush = Brush.verticalGradient( + listOf( + Black.copy(alpha = 0f), + Black + ) + ), ) ) } @@ -306,16 +318,15 @@ private fun TicketDetailScreen( ) { Notice(notice = ticketGroup.ticketNotice) - val copySuccessMessage = stringResource(id = R.string.ticketing_address_copied_message) + val copySuccessMessage = + stringResource(id = R.string.ticketing_address_copied_message) Inquiry( hostName = ticketGroup.hostName, hostPhoneNumber = ticketGroup.hostPhoneNumber, onClickCopyPlace = { clipboardManager.setText(AnnotatedString(ticketGroup.streetAddress)) if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { - scope.launch { - snackbarHostState.showSnackbar(copySuccessMessage) - } + snackbarHostController.showMessage(copySuccessMessage) } }, onClickNavigateToShowDetail = { @@ -344,7 +355,10 @@ private fun TicketDetailScreen( Spacer(modifier = Modifier.size(20.dp)) RefundPolicySection(uiState.refundPolicy) - if (currentTicket.ticketState == TicketState.Ready) { + if ( + currentTicket.ticketState == TicketState.Ready && + uiState.isShowDate + ) { Text( modifier = Modifier .padding(top = 20.dp, bottom = 60.dp) @@ -356,6 +370,19 @@ private fun TicketDetailScreen( textDecoration = TextDecoration.Underline, ) } + + if (uiState.isRefundableGift) { + Text( + modifier = Modifier + .padding(top = 20.dp, bottom = 60.dp) + .align(Alignment.CenterHorizontally) + .clickable { showRefundGiftTicket = true }, + text = stringResource(R.string.cancel_registered_gift_button), + style = MaterialTheme.typography.bodySmall, + color = Grey50, + textDecoration = TextDecoration.Underline, + ) + } } PullToRefreshContainer( modifier = Modifier.align(Alignment.TopCenter), @@ -365,28 +392,13 @@ private fun TicketDetailScreen( } if (showEnterCodeDialog) { - if (LocalDate.now().toEpochDay() < uiState.ticketGroup.showDate.toLocalDate().toEpochDay()) { - // 아직 공연일 아님 - BTDialog( - showCloseButton = false, - onClickPositiveButton = { showEnterCodeDialog = false }, - onDismiss = { showEnterCodeDialog = false }, - ) { - Text( - text = stringResource(R.string.error_show_not_today), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } else if (currentTicket.ticketState == TicketState.Ready) { - ManagerCodeDialog( - managerCode = managerCodeState.code, - onManagerCodeChanged = viewModel::setManagerCode, - error = managerCodeState.error, - onDismiss = { showEnterCodeDialog = false }, - onClickConfirm = { viewModel.requestEntrance(it) } - ) - } + ManagerCodeDialog( + managerCode = managerCodeState.code, + onManagerCodeChanged = viewModel::setManagerCode, + error = managerCodeState.error, + onDismiss = { showEnterCodeDialog = false }, + onClickConfirm = { viewModel.requestEntrance(it) } + ) } if (showTicketNotFoundDialog) { @@ -405,6 +417,26 @@ private fun TicketDetailScreen( ) } } + + if (showRefundGiftTicket) { + BTDialog( + enableDismiss = false, + onClickPositiveButton = { + viewModel.refundGiftTicket() + showRefundGiftTicket = false + }, + onDismiss = { + showRefundGiftTicket = false + } + ) { + Text( + text = stringResource(R.string.refund_registered_ticket_dialog), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } } @Composable @@ -433,7 +465,6 @@ private fun Title(showName: String = "") { } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun QrCodes( modifier: Modifier = Modifier, diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailUiState.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailUiState.kt index 546779f8..adea5d49 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailUiState.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailUiState.kt @@ -2,10 +2,15 @@ package com.nexters.boolti.presentation.screen.ticket.detail import com.nexters.boolti.domain.model.LegacyTicket import com.nexters.boolti.domain.model.TicketGroup +import java.time.LocalDate data class TicketDetailUiState( val legacyTicket: LegacyTicket = LegacyTicket(), val refundPolicy: List = emptyList(), val ticketGroup: TicketGroup = TicketGroup(), val currentPage: Int = 0, -) +) { + val isShowDate: Boolean = LocalDate.now() == ticketGroup.showDate.toLocalDate() + val isRefundableGift: Boolean = ticketGroup.isGift && + LocalDate.now() < ticketGroup.showDate.toLocalDate() +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailViewModel.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailViewModel.kt index 81e1db0a..817a09e6 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailViewModel.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.nexters.boolti.domain.exception.ManagerCodeException import com.nexters.boolti.domain.exception.TicketException +import com.nexters.boolti.domain.repository.GiftRepository import com.nexters.boolti.domain.repository.TicketRepository import com.nexters.boolti.domain.request.ManagerCodeRequest import com.nexters.boolti.domain.usecase.GetRefundPolicyUsecase @@ -27,8 +28,10 @@ import javax.inject.Inject class TicketDetailViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val repository: TicketRepository, + private val giftRepository: GiftRepository, private val getRefundPolicyUsecase: GetRefundPolicyUsecase, ) : BaseViewModel() { + // 실제로는 reservationId가 들어온다. api 변경에 따른 수정 private val ticketId: String = requireNotNull(savedStateHandle["ticketId"]) { "TicketDetailViewModel 에 ticketId 가 전달되지 않았습니다." } @@ -90,6 +93,18 @@ class TicketDetailViewModel @Inject constructor( } } + fun refundGiftTicket() { + val giftUuid = uiState.value.ticketGroup.giftUuid ?: return + + giftRepository.cancelRegisteredGift(giftUuid = giftUuid) + .onEach { isSuccessful -> + if (isSuccessful) event(TicketDetailEvent.GiftRefunded) + else event(TicketDetailEvent.FailedToRefund) + } + .catch { event(TicketDetailEvent.NetworkError) } + .launchIn(viewModelScope + recordExceptionHandler) + } + fun setManagerCode(code: String) { _managerCodeState.update { it.copy(code = code, error = null) } } diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index d5210869..314ec156 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -81,6 +81,8 @@ %d/%d자 + 네트워크 오류가 발생했어요 + 지금 불티에 로그인하고\n당신의 공연을 시작해 보세요! 카카오톡으로 시작하기 @@ -210,7 +212,7 @@ 선물이 등록되었어요 로그인 후 선물 등록이 가능합니다.\n로그인해 주세요. 등록하기 - 선물을 등록하면\n선물 취소 및 환불이 불가합니다.\n등록하시겠습니까? + 선물을 등록하시겠습니까? 본인이 결제한 선물입니다.\n선물을 등록하면 다른 분께 보낼 수 없습니다. 등록하시겠습니까? 선물 등록에 실패했어요 선물 등록 중 오류가 발생했습니다.\n웹 링크에서 선물 상태를 확인 후 다시 시도해 주세요 @@ -240,6 +242,7 @@ 입장 코드 입장 코드 입력하기 + 받은 선물 취소하기 입장 코드로 입장 확인 입장 코드는 주최자 계정의 마이 > QR 스캔 > 해당 공연 스캐너에서 확인 가능해요. 입장 코드를 입력해 주세요 @@ -378,6 +381,9 @@ 환불 수단 취소/환불 규정을 확인했습니다 "알 수 없는 오류로 환불에 실패했어요" + 취소 시 선물을 보낸 분께 알림이 발송되며 결제가 자동 취소됩니다. 취소하시겠습니까? + 받은 선물을 취소했어요 + 받은 선물 취소 중 오류가 발생했어요 신고하기