diff --git a/core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt b/core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt index d19f1fa7b..b01e9df6a 100644 --- a/core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt +++ b/core/billing/src/main/java/com/puzzle/billing/data/BillingHelperImpl.kt @@ -177,5 +177,4 @@ class BillingHelperImpl @Inject constructor( Log.d("BillingClient", "BillingClient release called, but it wasn't ready") } } - } \ No newline at end of file diff --git a/core/common-ui/src/main/java/com/puzzle/common/ui/Animation.kt b/core/common-ui/src/main/java/com/puzzle/common/ui/Animation.kt index ef571e343..9f6460bdb 100644 --- a/core/common-ui/src/main/java/com/puzzle/common/ui/Animation.kt +++ b/core/common-ui/src/main/java/com/puzzle/common/ui/Animation.kt @@ -4,6 +4,9 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SizeTransform import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically @@ -61,6 +64,21 @@ fun PiecePageTransitionAnimation( modifier = modifier, ) +@Composable +fun PieceCardTransitionAnimation( + targetState: S, + modifier: Modifier = Modifier, + content: @Composable AnimatedContentScope.(targetState: S) -> Unit +) = AnimatedContent( + targetState = targetState, + transitionSpec = { + (EnterTransition.None togetherWith ExitTransition.None) + .using(SizeTransform(clip = false)) + }, + content = content, + modifier = modifier, +) + @Composable fun PieceVisibleAnimation( visible: Boolean, diff --git a/core/common/src/main/java/com/puzzle/common/TimeUtil.kt b/core/common/src/main/java/com/puzzle/common/TimeUtil.kt index ff9c52841..e7d948a9d 100644 --- a/core/common/src/main/java/com/puzzle/common/TimeUtil.kt +++ b/core/common/src/main/java/com/puzzle/common/TimeUtil.kt @@ -67,6 +67,33 @@ fun getRemainingTimeInSec(): Long { return Duration.between(now, targetTime).seconds } +/** + * 주어진 과거 시각("yyyy.MM.dd.HH.mm.ss")으로부터 + * 24시간이 지날 때까지 남은 초를 계산합니다. + */ +fun getRemainingTimeUntil24Hours(pastDateTimeString: String): Long { + val zoneId = ZoneId.of(SEOUL_TIME_ZONE) + val formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd.HH.mm.ss") + + val pastDateTime = LocalDateTime.parse(pastDateTimeString, formatter) + val targetTime = pastDateTime.plusHours(24) + val now = LocalDateTime.now(zoneId) + + val remainingSeconds = Duration.between(now, targetTime).seconds + return if (remainingSeconds > 0) remainingSeconds else 0L // 0 이하일 경우 0으로 처리 +} + +/** + * 입력 형식: 항상 "2025-11-09T13:50:53.633789" + * 출력 형식: "yyyy.MM.dd.HH.mm.ss" 로 변환 + */ +fun formatIsoToDate(isoString: String): String { + val inputFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME + val outputFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd.HH.mm.ss") + val dateTime = LocalDateTime.parse(isoString, inputFormatter) + return dateTime.format(outputFormatter) +} + fun formatTimeToHourMinuteSecond(totalSeconds: Long): String { val hours = totalSeconds / HOUR_IN_SECOND val minutes = (totalSeconds % HOUR_IN_SECOND) / MINUTE_IN_SECOND diff --git a/core/common/src/test/kotlin/com/puzzle/common/TimeUtilTest.kt b/core/common/src/test/kotlin/com/puzzle/common/TimeUtilTest.kt index 182fd816c..2de000112 100644 --- a/core/common/src/test/kotlin/com/puzzle/common/TimeUtilTest.kt +++ b/core/common/src/test/kotlin/com/puzzle/common/TimeUtilTest.kt @@ -6,7 +6,11 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource +import java.time.Clock import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException class TimeUtilTest { @@ -89,4 +93,68 @@ class TimeUtilTest { fun `잘못된 형식의 생일 날짜는 false를 반환한다`(input: String) { assertFalse(input.isValidBirthDateFormat()) } + + /** + * 입력 형식: "2025-11-09T13:50:53.633789" + * 출력 형식: "yyyy.MM.dd.HH.mm.ss" (ISO 형식) + */ + @Test + fun `올바른 ISO 형식을 변환할 수 있다`() { + // given + val input = "2025-11-09T13:50:53.633789" + + // when + val result = formatIsoToDate(input) + + // then + assertEquals("2025.11.09.13.50.53", result) + } + + @ParameterizedTest + @ValueSource( + strings = [ + "2025-11-09 13:50:53", // 공백 + "2025/11/09T13:50:53", // 슬래시 + "abcd", // 문자열 + "2025-13-40T99:99:99", // 알맞지 않은 날짜 + "", // 빈 문자열 + " ", // 공백만 + "-1999-13-40T99:99:99", // 음수 + ] + ) + fun `잘못된 형식을 변환하려고 하면 DateTimeParseException이 발생한다`(input: String) { + assertThrows { + formatIsoToDate(input) + } + } + + @Test + fun `이미 24시간이 지난 경우 0을 반환한다`() { + val fixedNow = LocalDateTime.of(2025, 1, 1, 12, 0, 0) + + val past = fixedNow.minusHours(30) // 30시간 전 + val pastString = past.format(DateTimeFormatter.ofPattern("yyyy.MM.dd.HH.mm.ss")) + + val result = getRemainingTimeUntil24Hours(pastString) + + assertEquals(0L, result) + } + + @ParameterizedTest + @ValueSource( + strings = [ + "2025-11-09 13:50:53", // 공백 + "2025/11/09T13:50:53", // 슬래시 + "abcd", // 문자열 + "2025-13-40T99:99:99", // 알맞지 않은 날짜 + "", // 빈 문자열 + " ", // 공백만 + "-1999-13-40T99:99:99", // 음수 + ] + ) + fun `잘못된 형식을 넣으면 DateTimeParseException을 발생시킨다`(input: String) { + assertThrows { + getRemainingTimeUntil24Hours(input) + } + } } \ No newline at end of file diff --git a/core/data/src/main/java/com/puzzle/data/repository/MatchingRepositoryImpl.kt b/core/data/src/main/java/com/puzzle/data/repository/MatchingRepositoryImpl.kt index 7457557be..afb4c153f 100644 --- a/core/data/src/main/java/com/puzzle/data/repository/MatchingRepositoryImpl.kt +++ b/core/data/src/main/java/com/puzzle/data/repository/MatchingRepositoryImpl.kt @@ -1,17 +1,19 @@ package com.puzzle.data.repository +import com.puzzle.common.suspendRunCatching import com.puzzle.datastore.datasource.matching.LocalMatchingDataSource +import com.puzzle.domain.model.error.HttpResponseException +import com.puzzle.domain.model.error.HttpResponseStatus import com.puzzle.domain.model.match.MatchInfo +import com.puzzle.domain.model.match.MatchType import com.puzzle.domain.model.profile.Contact import com.puzzle.domain.model.profile.OpponentProfile -import com.puzzle.domain.model.profile.OpponentProfileBasic -import com.puzzle.domain.model.profile.OpponentValuePick -import com.puzzle.domain.model.profile.OpponentValueTalk import com.puzzle.domain.repository.MatchingRepository import com.puzzle.network.model.profile.ContactResponse import com.puzzle.network.source.matching.MatchingDataSource import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.supervisorScope import javax.inject.Inject class MatchingRepositoryImpl @Inject constructor( @@ -33,14 +35,37 @@ class MatchingRepositoryImpl @Inject constructor( return response.contacts.map(ContactResponse::toDomain) } - override suspend fun getMatchInfo(): MatchInfo = matchingDataSource.getMatchInfo() - .toDomain() + override suspend fun getCanFreeMatch(): Boolean = + matchingDataSource.getCanFreeMatch().canFreeMatch + + override suspend fun getNewInstantMatch(): Unit = + matchingDataSource.getNewInstantMatch() + + override suspend fun getBasicMatchInfo() = matchingDataSource.getMatchInfo() + .toDomain(matchType = MatchType.BASIC) + + override suspend fun getMatchInfoList(): List = coroutineScope { + val basicMatchDeferred = async { + suspendRunCatching { + listOf(matchingDataSource.getMatchInfo().toDomain(MatchType.BASIC)) + }.getOrElse { throwable -> + if (throwable is HttpResponseException && throwable.status == HttpResponseStatus.NotFound) { + emptyList() + } else throw throwable + } + } + + val toMeMatchListDeferred = async { matchingDataSource.getToMeMatchInfoList().map { it.toDomain(MatchType.TO_ME) } } + val fromMeMatchListDeferred = async { matchingDataSource.getFromMeMatchInfoList().map { it.toDomain(MatchType.FROM_ME) } } + + basicMatchDeferred.await() + toMeMatchListDeferred.await() + fromMeMatchListDeferred.await() + } override suspend fun getOpponentProfile(): OpponentProfile = coroutineScope { - val valueTalksDeferred = async { getOpponentValueTalks() } - val valuePicksDeferred = async { getOpponentValuePicks() } - val profileBasicDeferred = async { getOpponentProfileBasic() } - val profileImageDeferred = async { getOpponentProfileImage() } + val valueTalksDeferred = async { matchingDataSource.getOpponentValueTalks().toDomain() } + val valuePicksDeferred = async { matchingDataSource.getOpponentValuePicks().toDomain() } + val profileBasicDeferred = async { matchingDataSource.getOpponentProfileBasic().toDomain() } + val profileImageDeferred = async { matchingDataSource.getOpponentProfileImage().toDomain() } val valuePicks = valuePicksDeferred.await() val valueTalks = valueTalksDeferred.await() @@ -64,18 +89,6 @@ class MatchingRepositoryImpl @Inject constructor( ) } - private suspend fun getOpponentValueTalks(): List = - matchingDataSource.getOpponentValueTalks().toDomain() - - private suspend fun getOpponentValuePicks(): List = - matchingDataSource.getOpponentValuePicks().toDomain() - - private suspend fun getOpponentProfileBasic(): OpponentProfileBasic = - matchingDataSource.getOpponentProfileBasic().toDomain() - - private suspend fun getOpponentProfileImage(): String = - matchingDataSource.getOpponentProfileImage().toDomain() - override suspend fun checkMatchingPiece() = matchingDataSource.checkMatchingPiece() override suspend fun acceptMatching() = matchingDataSource.acceptMatching() diff --git a/core/data/src/test/java/com/puzzle/data/source/matching/FakeMatchingDataSource.kt b/core/data/src/test/java/com/puzzle/data/source/matching/FakeMatchingDataSource.kt index 7b8ab309b..6334bd7a6 100644 --- a/core/data/src/test/java/com/puzzle/data/source/matching/FakeMatchingDataSource.kt +++ b/core/data/src/test/java/com/puzzle/data/source/matching/FakeMatchingDataSource.kt @@ -1,5 +1,6 @@ package com.puzzle.data.source.matching +import com.puzzle.network.model.matching.GetCanFreeMatchResponse import com.puzzle.network.model.matching.GetContactsResponse import com.puzzle.network.model.matching.GetMatchInfoResponse import com.puzzle.network.model.matching.GetOpponentProfileBasicResponse @@ -14,6 +15,43 @@ class FakeMatchingDataSource : MatchingDataSource { private var opponentValueTalksData: GetOpponentValueTalksResponse? = null private var opponentProfileImageData: GetOpponentProfileImageResponse? = null + override suspend fun getCanFreeMatch(): GetCanFreeMatchResponse = GetCanFreeMatchResponse(false) + override suspend fun getToMeMatchInfoList(): List = + listOf( + GetMatchInfoResponse( + matchId = 1234, + matchStatus = "WAITING", + description = "안녕하세요, 저는 음악과 여행을 좋아하는 사람입니다.", + nickname = "여행하는음악가", + birthYear = "1995", + location = "서울특별시", + job = "음악 프로듀서", + matchedUserId = 1, + isBlocked = false, + matchedValueCount = 3, + matchedValueList = listOf("음악", "여행", "독서") + ) + ) + + override suspend fun getFromMeMatchInfoList(): List = + listOf( + GetMatchInfoResponse( + matchId = 1234, + matchStatus = "WAITING", + description = "안녕하세요, 저는 음악과 여행을 좋아하는 사람입니다.", + nickname = "여행하는음악가", + birthYear = "1995", + location = "서울특별시", + job = "음악 프로듀서", + matchedUserId = 1, + isBlocked = false, + matchedValueCount = 3, + matchedValueList = listOf("음악", "여행", "독서") + ) + ) + + override suspend fun getNewInstantMatch() {} + fun setOpponentProfileData( basicData: GetOpponentProfileBasicResponse, valuePicksData: GetOpponentValuePicksResponse, @@ -68,7 +106,7 @@ class FakeMatchingDataSource : MatchingDataSource { location = "서울특별시", job = "음악 프로듀서", matchedUserId = 1, - blocked = false, + isBlocked = false, matchedValueCount = 3, matchedValueList = listOf("음악", "여행", "독서") ) diff --git a/core/designsystem/src/main/java/com/puzzle/designsystem/component/Button.kt b/core/designsystem/src/main/java/com/puzzle/designsystem/component/Button.kt index 7e65eb22f..0ff06d7ba 100644 --- a/core/designsystem/src/main/java/com/puzzle/designsystem/component/Button.kt +++ b/core/designsystem/src/main/java/com/puzzle/designsystem/component/Button.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.puzzle.designsystem.R @@ -123,6 +124,7 @@ fun PieceSubButton( ) { Text( text = label, + textAlign = TextAlign.Center, style = PieceTheme.typography.bodyMSB, ) } diff --git a/core/designsystem/src/main/java/com/puzzle/designsystem/component/Dialog.kt b/core/designsystem/src/main/java/com/puzzle/designsystem/component/Dialog.kt index 8dc69dee2..6cf9d7d46 100644 --- a/core/designsystem/src/main/java/com/puzzle/designsystem/component/Dialog.kt +++ b/core/designsystem/src/main/java/com/puzzle/designsystem/component/Dialog.kt @@ -83,6 +83,58 @@ fun PieceDialogDefaultTop( } } +@Composable +fun PieceDialogDefaultTop( + title: AnnotatedString, + subText: AnnotatedString, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(top = 40.dp, bottom = 12.dp), + ) { + Text( + text = title, + color = PieceTheme.colors.black, + textAlign = TextAlign.Center, + style = PieceTheme.typography.headingMSB, + ) + + Text( + text = subText, + color = PieceTheme.colors.dark2, + textAlign = TextAlign.Center, + style = PieceTheme.typography.bodySM, + ) + } +} + +@Composable +fun PieceDialogDefaultTop( + title: String, + subText: AnnotatedString, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(top = 40.dp, bottom = 12.dp), + ) { + Text( + text = title, + color = PieceTheme.colors.black, + textAlign = TextAlign.Center, + style = PieceTheme.typography.headingMSB, + ) + + Text( + text = subText, + color = PieceTheme.colors.dark2, + textAlign = TextAlign.Center, + style = PieceTheme.typography.bodySM, + ) + } +} + @Composable fun PieceDialogDefaultTop( title: String, @@ -188,6 +240,35 @@ fun PieceDialogBottom( } } +@Composable +fun PiecePurchaseDialogBottom( + imageId: Int, + leftButtonText: String, + rightButtonText: String, + onLeftButtonClick: () -> Unit, + onRightButtonClick: () -> Unit, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 20.dp), + ) { + PieceOutlinedButton( + label = leftButtonText, + onClick = onLeftButtonClick, + modifier = Modifier.weight(1f), + ) + + PieceIconButton( + label = rightButtonText, + imageId = imageId, + onClick = onRightButtonClick, + modifier = Modifier.weight(1f), + ) + } +} + @Composable fun PieceImageDialog( imageUri: Any?, @@ -310,3 +391,15 @@ private fun PreviewPieceImageDialog() { ) } } + +@Preview +@Composable +fun PiecePurchaseDialogBottomPreview() { + PiecePurchaseDialogBottom( + imageId = R.drawable.ic_puzzle_white, + leftButtonText = "뒤로", + rightButtonText = "3", + onLeftButtonClick = {}, + onRightButtonClick = {}, + ) +} \ No newline at end of file diff --git a/core/designsystem/src/main/java/com/puzzle/designsystem/component/TopBar.kt b/core/designsystem/src/main/java/com/puzzle/designsystem/component/TopBar.kt index f829e47d2..8059c9649 100644 --- a/core/designsystem/src/main/java/com/puzzle/designsystem/component/TopBar.kt +++ b/core/designsystem/src/main/java/com/puzzle/designsystem/component/TopBar.kt @@ -71,7 +71,7 @@ fun PieceSubBackTopBar( if (isShowBackButton) { Image( painter = painterResource(R.drawable.ic_arrow_left), - contentDescription = "뒤로 가기 버튼", + contentDescription = "뒤로가기 버튼", modifier = Modifier .size(32.dp) .clickable { onBackClick() } @@ -185,6 +185,59 @@ fun PiecePuzzleTopBar( } } +@Composable +fun PieceTimerWithCloseTopBar( + title: String, + remainTime: String, + contentColor: Color, + closeButtonEnabled: Boolean, + onCloseClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .height(60.dp), + ) { + if (!remainTime.isNotEmpty()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterStart) + ) { + Image( + painter = painterResource(R.drawable.ic_clock), + contentDescription = null, + colorFilter = ColorFilter.tint(PieceTheme.colors.error) + ) + Text( + text = remainTime, + style = PieceTheme.typography.bodySM, + color = PieceTheme.colors.error, + ) + } + } + + Text( + text = title, + style = PieceTheme.typography.headingSSB, + color = contentColor, + modifier = Modifier.align(Alignment.Center), + ) + + if (closeButtonEnabled) { + Image( + painter = painterResource(R.drawable.ic_close), + contentDescription = "닫기 버튼", + colorFilter = ColorFilter.tint(contentColor), + modifier = Modifier + .size(32.dp) + .clickable { onCloseClick() } + .align(Alignment.CenterEnd), + ) + } + } +} + @Preview @Composable fun PreviewPieceMainTopBar() { @@ -226,6 +279,23 @@ fun PreviewPieceMainTopBarWithRightComponent() { } } +@Preview +@Composable +fun PreviewPieceMainTopBarWithRightComponent2() { + PieceTheme { + PieceTimerWithCloseTopBar( + remainTime = "01:01", + title = "title", + onCloseClick = {}, + contentColor = PieceTheme.colors.black, + closeButtonEnabled = true, + modifier = Modifier + .padding(vertical = 20.dp) + .background(PieceTheme.colors.white) + ) + } +} + @Preview @Composable fun PreviewPieceSubBackTopBar() { diff --git a/core/designsystem/src/main/java/com/puzzle/designsystem/foundation/Color.kt b/core/designsystem/src/main/java/com/puzzle/designsystem/foundation/Color.kt index f23cd9daa..6b9f56ecc 100644 --- a/core/designsystem/src/main/java/com/puzzle/designsystem/foundation/Color.kt +++ b/core/designsystem/src/main/java/com/puzzle/designsystem/foundation/Color.kt @@ -19,6 +19,7 @@ private val Light1 = Color(0xFFCBD1D9) private val Light2 = Color(0xFFE8EBF0) private val Light3 = Color(0xFFF4F6FA) private val White = Color(0xFFFFFFFF) +private val White60 = Color(0x99FFFFFF) private val Error = Color(0xFFFF3059) @@ -38,5 +39,6 @@ data class PieceColors( val light2: Color = Light2, val light3: Color = Light3, val white: Color = White, + val white60: Color = White60, val error: Color = Error, ) diff --git a/core/designsystem/src/main/res/drawable/badge_free.xml b/core/designsystem/src/main/res/drawable/badge_free.xml new file mode 100644 index 000000000..8d8e11f8e --- /dev/null +++ b/core/designsystem/src/main/res/drawable/badge_free.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/bg_matched_card.png b/core/designsystem/src/main/res/drawable/bg_matched_card.png new file mode 100644 index 000000000..9e96b6007 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/bg_matched_card.png differ diff --git a/core/designsystem/src/main/res/drawable/ic_check_matched.xml b/core/designsystem/src/main/res/drawable/ic_check_matched.xml new file mode 100644 index 000000000..8bd1bdd85 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_check_matched.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_clock.xml b/core/designsystem/src/main/res/drawable/ic_clock.xml new file mode 100644 index 000000000..a7828f03d --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_clock.xml @@ -0,0 +1,18 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_plus_circle_gray.xml b/core/designsystem/src/main/res/drawable/ic_plus_circle_gray.xml new file mode 100644 index 000000000..30f12a654 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_plus_circle_gray.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_puzzle_white.xml b/core/designsystem/src/main/res/drawable/ic_puzzle_white.xml new file mode 100644 index 000000000..74dbef4c9 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_puzzle_white.xml @@ -0,0 +1,12 @@ + + + diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml index 127a18a47..cb8a7d2a1 100644 --- a/core/designsystem/src/main/res/values/strings.xml +++ b/core/designsystem/src/main/res/values/strings.xml @@ -60,6 +60,7 @@ %d개 오픈 전 응답 대기중 + 새로운 인연 만나기 매칭에 응답해주세요! 상대방의 응답을 기다려봐요! 상대방이 매칭을 수락했어요! @@ -71,6 +72,25 @@ 프로필을 수정해주세요 프로필 수정하기 + 나와 + %d가지 + 생각이 닮았어요. + 새로운 인연 + 이 곧 도착할 거예요! + 내 운명의 상대를 찾을지도 몰라요.\n놓치지 않도록 푸쉬 알림을 켜주세요! + + + 진중한 만남 + 을 이어가기 위해\n프로필을 살펴보고 있어요. + + + 님과의\n인연을 이어갈까요? + 퍼즐 + %1$d개 + 를 사용하면,\n지금 바로 연락처를 확인할 수 있어요. + 새로운 인연을 만나볼까요? + 로 나와 맞는 인연을 찾아보세요 + 서로의 빈 곳을 채우며 맞물리는 퍼즐처럼.\n서로의 가치관과 마음이 연결되는 순간을 만들어갑니다. 카카오로 시작하기 @@ -280,6 +300,9 @@ OOO님과 연락처가 공개되었어요.\n대화를 시작해보세요! + 구매하기 + 앗, 퍼즐이 부족해요! + 스토어에서 퍼즐을 구매하시겠어요? 퍼즐 %1$s개를 구매했어요. 지금 바로 새로운 인연을 만나보세요! 홈으로 diff --git a/core/domain/src/main/java/com/puzzle/domain/model/match/MatchInfo.kt b/core/domain/src/main/java/com/puzzle/domain/model/match/MatchInfo.kt index 4f6dbe4a2..ce8a991ca 100644 --- a/core/domain/src/main/java/com/puzzle/domain/model/match/MatchInfo.kt +++ b/core/domain/src/main/java/com/puzzle/domain/model/match/MatchInfo.kt @@ -1,11 +1,13 @@ package com.puzzle.domain.model.match import com.puzzle.common.SECOND_IN_MILLIS +import javax.annotation.concurrent.Immutable data class MatchInfo( - val matchId: Int, + val matchId: Int, // 식별자로 사용 val matchedUserId: Int, val matchStatus: MatchStatus, + val matchType: MatchType, val description: String, val nickname: String, val birthYear: String, @@ -13,30 +15,42 @@ data class MatchInfo( val job: String, val matchedValueCount: Int, val matchedValueList: List, - val blocked: Boolean, - val remainMatchingUpdateTimeInSec: Long = System.currentTimeMillis() / SECOND_IN_MILLIS, + val isBlocked: Boolean, + val matchedDateTime: String, + val isExpanded : Boolean ) +enum class MatchType { + /** 기본 10시마다 들어오는 매치 */ + BASIC, + + /** 나에 의해 생성된 매치 */ + TO_ME, + + /** 상대에 의해 생성된 매치 */ + FROM_ME +} + enum class MatchStatus { - // 자신이 매칭 조각 열람 전 + /** 자신이 매칭 조각 열람 전 */ BEFORE_OPEN, - // 자신은 매칭조각 열람, 상대는 매칭 수락 안함(열람했는지도 모름) + /** 자신은 매칭조각 열람, 상대는 매칭 수락 안함(열람했는지도 모름) */ WAITING, - // 자신은 수락, 상대는 모름 + /** 자신은 수락, 상대는 모름 */ RESPONDED, - // 자신은 열람만, 상대는 수락 + /** 상대만 수락 */ GREEN_LIGHT, - // 둘다 수락 + /** 둘 다 수락 */ MATCHED, - // 내가 거절했을 때 + /** 내가 거절했을 때 */ REFUSED, - // 오류 + /** 오류 */ UNKNOWN; companion object { @@ -45,3 +59,4 @@ enum class MatchStatus { } } } + diff --git a/core/domain/src/main/java/com/puzzle/domain/repository/MatchingRepository.kt b/core/domain/src/main/java/com/puzzle/domain/repository/MatchingRepository.kt index f300b2b22..dd0dd547c 100644 --- a/core/domain/src/main/java/com/puzzle/domain/repository/MatchingRepository.kt +++ b/core/domain/src/main/java/com/puzzle/domain/repository/MatchingRepository.kt @@ -7,13 +7,18 @@ import com.puzzle.domain.model.profile.OpponentProfile interface MatchingRepository { suspend fun refuseMatch() suspend fun reportUser(userId: Int, reason: String) - suspend fun getOpponentContacts(): List suspend fun blockUser(matchId: Int) suspend fun blockContacts(phoneNumbers: List) - suspend fun getMatchInfo(): MatchInfo + suspend fun getCanFreeMatch() : Boolean + suspend fun getNewInstantMatch() : Unit + suspend fun getOpponentContacts(): List suspend fun getOpponentProfile(): OpponentProfile suspend fun checkMatchingPiece() suspend fun acceptMatching() suspend fun isFirstMatching(): Boolean suspend fun isNewMatching(): Boolean + suspend fun getMatchInfoList(): List + + suspend fun getBasicMatchInfo() : MatchInfo + } diff --git a/core/network/src/main/java/com/puzzle/network/api/PieceApi.kt b/core/network/src/main/java/com/puzzle/network/api/PieceApi.kt index 9be0227b6..63e960d8a 100644 --- a/core/network/src/main/java/com/puzzle/network/api/PieceApi.kt +++ b/core/network/src/main/java/com/puzzle/network/api/PieceApi.kt @@ -8,6 +8,7 @@ import com.puzzle.network.model.auth.VerifyAuthCodeRequest import com.puzzle.network.model.auth.VerifyAuthCodeResponse import com.puzzle.network.model.auth.WithdrawRequest import com.puzzle.network.model.matching.BlockContactsRequest +import com.puzzle.network.model.matching.GetCanFreeMatchResponse import com.puzzle.network.model.matching.GetContactsResponse import com.puzzle.network.model.matching.GetMatchInfoResponse import com.puzzle.network.model.matching.GetOpponentProfileBasicResponse @@ -162,6 +163,18 @@ interface PieceApi { @POST("/api/matches/accept") suspend fun acceptMatching(): ApiResponse + @GET("/api/matches/instants/free/today") + suspend fun getCanFreeMatch(): ApiResponse + + @GET("/api/matches/instants/to-me") + suspend fun getToMeMatchInfoList(): ApiResponse> + + @GET("/api/matches/instants/from-me") + suspend fun getFromMeMatchInfoList(): ApiResponse> + + @POST("/api/matches/instants/new") + suspend fun getNewInstantMatch(): ApiResponse + @GET("/api/settings/infos") suspend fun getSettingInfos(): ApiResponse diff --git a/core/network/src/main/java/com/puzzle/network/model/matching/GetCanFreeMatchResponse.kt b/core/network/src/main/java/com/puzzle/network/model/matching/GetCanFreeMatchResponse.kt new file mode 100644 index 000000000..a36caef2b --- /dev/null +++ b/core/network/src/main/java/com/puzzle/network/model/matching/GetCanFreeMatchResponse.kt @@ -0,0 +1,8 @@ +package com.puzzle.network.model.matching + +import kotlinx.serialization.Serializable + +@Serializable +data class GetCanFreeMatchResponse( + val canFreeMatch: Boolean +) diff --git a/core/network/src/main/java/com/puzzle/network/model/matching/GetMatchInfoResponse.kt b/core/network/src/main/java/com/puzzle/network/model/matching/GetMatchInfoResponse.kt index 6221a9e3e..cfa6c313d 100644 --- a/core/network/src/main/java/com/puzzle/network/model/matching/GetMatchInfoResponse.kt +++ b/core/network/src/main/java/com/puzzle/network/model/matching/GetMatchInfoResponse.kt @@ -1,7 +1,9 @@ package com.puzzle.network.model.matching +import com.puzzle.common.formatIsoToDate import com.puzzle.domain.model.match.MatchInfo import com.puzzle.domain.model.match.MatchStatus +import com.puzzle.domain.model.match.MatchType import com.puzzle.network.model.UNKNOWN_INT import com.puzzle.network.model.UNKNOWN_STRING import kotlinx.serialization.Serializable @@ -16,21 +18,25 @@ data class GetMatchInfoResponse( val birthYear: String?, val location: String?, val job: String?, - val blocked: Boolean?, + val isBlocked : Boolean? = null, val matchedValueCount: Int?, val matchedValueList: List?, + val matchedDateTime: String? = null ) { - fun toDomain() = MatchInfo( + fun toDomain(matchType: MatchType) = MatchInfo( matchId = matchId ?: UNKNOWN_INT, matchedUserId = matchedUserId ?: UNKNOWN_INT, + matchType = matchType, matchStatus = MatchStatus.create(matchStatus), description = description ?: "", nickname = nickname ?: UNKNOWN_STRING, birthYear = birthYear ?: UNKNOWN_STRING, location = location ?: UNKNOWN_STRING, job = job ?: UNKNOWN_STRING, - blocked = blocked ?: false, + isBlocked = isBlocked ?: false, matchedValueCount = matchedValueCount ?: UNKNOWN_INT, matchedValueList = matchedValueList ?: emptyList(), + matchedDateTime = formatIsoToDate(matchedDateTime ?: UNKNOWN_STRING), + isExpanded = false ) } diff --git a/core/network/src/main/java/com/puzzle/network/source/matching/MatchingDataSource.kt b/core/network/src/main/java/com/puzzle/network/source/matching/MatchingDataSource.kt index 94df2451c..577ffae3e 100644 --- a/core/network/src/main/java/com/puzzle/network/source/matching/MatchingDataSource.kt +++ b/core/network/src/main/java/com/puzzle/network/source/matching/MatchingDataSource.kt @@ -1,5 +1,6 @@ package com.puzzle.network.source.matching +import com.puzzle.network.model.matching.GetCanFreeMatchResponse import com.puzzle.network.model.matching.GetContactsResponse import com.puzzle.network.model.matching.GetMatchInfoResponse import com.puzzle.network.model.matching.GetOpponentProfileBasicResponse @@ -12,7 +13,11 @@ interface MatchingDataSource { suspend fun reportUser(userId: Int, reason: String) suspend fun blockUser(matchId: Int) suspend fun blockContacts(phoneNumbers: List) + suspend fun getCanFreeMatch() : GetCanFreeMatchResponse suspend fun getMatchInfo(): GetMatchInfoResponse + suspend fun getToMeMatchInfoList() : List + suspend fun getFromMeMatchInfoList() : List + suspend fun getNewInstantMatch() : Unit suspend fun getContacts(): GetContactsResponse suspend fun getOpponentValueTalks(): GetOpponentValueTalksResponse suspend fun getOpponentValuePicks(): GetOpponentValuePicksResponse diff --git a/core/network/src/main/java/com/puzzle/network/source/matching/MatchingDataSourceImpl.kt b/core/network/src/main/java/com/puzzle/network/source/matching/MatchingDataSourceImpl.kt index 7d622cca0..eb3614989 100644 --- a/core/network/src/main/java/com/puzzle/network/source/matching/MatchingDataSourceImpl.kt +++ b/core/network/src/main/java/com/puzzle/network/source/matching/MatchingDataSourceImpl.kt @@ -2,6 +2,7 @@ package com.puzzle.network.source.matching import com.puzzle.network.api.PieceApi import com.puzzle.network.model.matching.BlockContactsRequest +import com.puzzle.network.model.matching.GetCanFreeMatchResponse import com.puzzle.network.model.matching.GetContactsResponse import com.puzzle.network.model.matching.GetMatchInfoResponse import com.puzzle.network.model.matching.GetOpponentProfileBasicResponse @@ -28,9 +29,20 @@ class MatchingDataSourceImpl @Inject constructor( override suspend fun blockContacts(phoneNumbers: List) = pieceApi.blockContacts(BlockContactsRequest(phoneNumbers)).unwrapData() + override suspend fun getCanFreeMatch(): GetCanFreeMatchResponse = + pieceApi.getCanFreeMatch().unwrapData() + override suspend fun getMatchInfo(): GetMatchInfoResponse = pieceApi.getMatchInfo().unwrapData() + override suspend fun getToMeMatchInfoList(): List = + pieceApi.getToMeMatchInfoList().unwrapData() + + override suspend fun getFromMeMatchInfoList(): List = + pieceApi.getFromMeMatchInfoList().unwrapData() + + override suspend fun getNewInstantMatch() : Unit = pieceApi.getNewInstantMatch().unwrapData() + override suspend fun getContacts(): GetContactsResponse = pieceApi.getMatchesContacts().unwrapData() diff --git a/core/testing/src/main/java/com/puzzle/testing/domain/model/match/MockMatchInfo.kt b/core/testing/src/main/java/com/puzzle/testing/domain/model/match/MockMatchInfo.kt index eae504c42..7283d0f39 100644 --- a/core/testing/src/main/java/com/puzzle/testing/domain/model/match/MockMatchInfo.kt +++ b/core/testing/src/main/java/com/puzzle/testing/domain/model/match/MockMatchInfo.kt @@ -2,16 +2,21 @@ package com.puzzle.testing.domain.model.match import com.puzzle.domain.model.match.MatchInfo import com.puzzle.domain.model.match.MatchStatus +import com.puzzle.domain.model.match.MatchType +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter object MockMatchInfo { fun default( matchStatus: MatchStatus = MatchStatus.BEFORE_OPEN, matchId: Int = 1, matchedUserId: Int = 100, - remainMatchingUpdateTimeInSec: Long = System.currentTimeMillis() / 1000, + matchType: MatchType = MatchType.BASIC, ): MatchInfo = MatchInfo( matchId = matchId, matchedUserId = matchedUserId, + matchType = matchType, matchStatus = matchStatus, description = "상대방 소개글입니다.", nickname = "닉네임", @@ -20,7 +25,15 @@ object MockMatchInfo { job = "개발자", matchedValueCount = 3, matchedValueList = listOf("책 좋아함", "운동 좋아함", "MBTI: INFP"), - blocked = false, - remainMatchingUpdateTimeInSec = remainMatchingUpdateTimeInSec, + matchedDateTime = get23HoursAgoFormattedTime(), + isExpanded = false, + isBlocked = false, ) + + private fun get23HoursAgoFormattedTime(zoneId: String = "Asia/Seoul"): String { + val now = LocalDateTime.now(ZoneId.of(zoneId)) + val time23HoursAgo = now.minusHours(23) + val formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd.HH.mm.ss") + return time23HoursAgo.format(formatter) + } } diff --git a/core/testing/src/main/java/com/puzzle/testing/domain/repository/SpyMatchingRepository.kt b/core/testing/src/main/java/com/puzzle/testing/domain/repository/SpyMatchingRepository.kt index 0f5d3116c..07ce1160d 100644 --- a/core/testing/src/main/java/com/puzzle/testing/domain/repository/SpyMatchingRepository.kt +++ b/core/testing/src/main/java/com/puzzle/testing/domain/repository/SpyMatchingRepository.kt @@ -9,6 +9,7 @@ import com.puzzle.testing.domain.model.match.MockMatchInfo class SpyMatchingRepository : MatchingRepository { private var matchInfoErrorFlag: Boolean = false private var matchInfoResult: MatchInfo = MockMatchInfo.default() + private var matchInfoListResult: List = listOf(matchInfoResult) private var opponentProfile: OpponentProfile? = null private var isFirstMatchingValue: Boolean = false private var isNewMatchingValue: Boolean = true @@ -18,6 +19,10 @@ class SpyMatchingRepository : MatchingRepository { matchInfoResult = result } + fun setMatchInfoListResult(result : List){ + matchInfoListResult = result + } + fun setMatchInfoErrorFlag(flag: Boolean) { matchInfoErrorFlag = flag } @@ -34,11 +39,6 @@ class SpyMatchingRepository : MatchingRepository { this.isNewMatchingValue = value } - override suspend fun getMatchInfo(): MatchInfo { - if (matchInfoErrorFlag) throw IllegalStateException("MatchInfo throw Error") - return matchInfoResult - } - override suspend fun getOpponentProfile(): OpponentProfile { return opponentProfile ?: throw IllegalStateException("OpponentProfile not set") } @@ -51,6 +51,10 @@ class SpyMatchingRepository : MatchingRepository { blockedNumbers.addAll(phoneNumbers) } + override suspend fun getMatchInfoList(): List = matchInfoListResult + override suspend fun getBasicMatchInfo(): MatchInfo = matchInfoResult + override suspend fun getCanFreeMatch(): Boolean = false + override suspend fun getNewInstantMatch() {} override suspend fun refuseMatch() {} override suspend fun reportUser(userId: Int, reason: String) {} override suspend fun getOpponentContacts(): List = emptyList() diff --git a/feature/matching/src/androidTest/java/com/puzzle/matching/graph/detail/MatchingDetailScreenTest.kt b/feature/matching/src/androidTest/java/com/puzzle/matching/graph/detail/MatchingDetailScreenTest.kt index ad813cb66..a9d25cb4c 100644 --- a/feature/matching/src/androidTest/java/com/puzzle/matching/graph/detail/MatchingDetailScreenTest.kt +++ b/feature/matching/src/androidTest/java/com/puzzle/matching/graph/detail/MatchingDetailScreenTest.kt @@ -39,7 +39,7 @@ class MatchingDetailScreenTest { } @Test - fun 이미_매칭을_수락한_상태라면_매칭_거절하기가_보이지_않는다() { + fun 이미_매칭을_수락한_상태라면_인연_거절하기가_보이지_않는다() { // given val state = MatchingDetailState( matchStatus = RESPONDED, @@ -50,12 +50,12 @@ class MatchingDetailScreenTest { setMatchingDetailScreenContent(state) // then - composeTestRule.onNodeWithText("매칭 거절하기") + composeTestRule.onNodeWithText("인연 거절하기") .assertIsNotDisplayed() } @Test - fun 이미_매칭을_수락한_상태라면_매칭_수락하기_버튼이_활성화_되지_않는다() { + fun 이미_매칭을_수락한_상태라면_인연_수락하기_버튼이_활성화_되지_않는다() { // given val state = MatchingDetailState( matchStatus = RESPONDED, @@ -66,12 +66,12 @@ class MatchingDetailScreenTest { setMatchingDetailScreenContent(state) // then - composeTestRule.onNodeWithText("매칭 수락하기") + composeTestRule.onNodeWithText("인연 수락하기") .assertIsNotEnabled() } @Test - fun 이미_매칭이_성사된_상태라면_매칭_거절하기가_보이지_않는다() { + fun 이미_매칭이_성사된_상태라면_인연_거절하기가_보이지_않는다() { // given val state = MatchingDetailState( matchStatus = MATCHED, @@ -85,12 +85,12 @@ class MatchingDetailScreenTest { composeTestRule.onNodeWithContentDescription("매칭 상대 프로필 이미지 확인하기") .performClick() - composeTestRule.onAllNodesWithText("매칭 수락하기") + composeTestRule.onAllNodesWithText("인연 수락하기") .assertAll(isNotEnabled()) } @Test - fun 이미_매칭이_성사된_상태라면_매칭_수락하기_버튼이_활성화_되지_않는다() { + fun 이미_매칭이_성사된_상태라면_인연_수락하기_버튼이_활성화_되지_않는다() { // given val state = MatchingDetailState( matchStatus = MATCHED, @@ -104,7 +104,7 @@ class MatchingDetailScreenTest { composeTestRule.onNodeWithContentDescription("매칭 상대 프로필 이미지 확인하기") .performClick() - composeTestRule.onAllNodesWithText("매칭 수락하기") + composeTestRule.onAllNodesWithText("인연 수락하기") .assertAll(isNotEnabled()) } } diff --git a/feature/matching/src/androidTest/java/com/puzzle/matching/graph/main/MatchingScreenTest.kt b/feature/matching/src/androidTest/java/com/puzzle/matching/graph/main/MatchingScreenTest.kt index 7ff94a9bb..c8df7edc9 100644 --- a/feature/matching/src/androidTest/java/com/puzzle/matching/graph/main/MatchingScreenTest.kt +++ b/feature/matching/src/androidTest/java/com/puzzle/matching/graph/main/MatchingScreenTest.kt @@ -9,9 +9,11 @@ import com.puzzle.designsystem.foundation.PieceTheme import com.puzzle.domain.model.match.MatchInfo import com.puzzle.domain.model.match.MatchStatus import com.puzzle.domain.model.match.MatchStatus.WAITING +import com.puzzle.domain.model.match.MatchType import com.puzzle.domain.model.user.RejectReason import com.puzzle.domain.model.user.UserRole import com.puzzle.matching.graph.main.contract.MatchingState +import kotlinx.collections.immutable.persistentListOf import org.junit.Rule import org.junit.Test @@ -25,11 +27,14 @@ class MatchingScreenTest { PieceTheme { MatchingScreen( state = state, - onButtonClick = {}, - onMatchingDetailClick = {}, + onMatchingCardClick = {}, + onStoreClick = {}, + onAcceptClick = {}, + onDisMissClick = {}, + onNewMatchingCardClick = {}, onCheckMyProfileClick = {}, onEditProfileClick = {}, - onNotificationClick = {} + onNotificationClick = {}, ) } } @@ -44,10 +49,10 @@ class MatchingScreenTest { birthYear: String = "2000", location: String = "Seoul", job: String = "Developer", - blocked: Boolean = false, + isBlocked: Boolean = false, matchedValueCount: Int = 0, + matchedDateTime : String = "2025-11-09T13:50:53.633789", matchedValueList: List = emptyList(), - remainMatchingUpdateTimeInSec: Long = 0L ) = MatchInfo( matchId = matchId, matchedUserId = matchedUserId, @@ -57,10 +62,12 @@ class MatchingScreenTest { birthYear = birthYear, location = location, job = job, - blocked = blocked, + isBlocked = isBlocked, matchedValueCount = matchedValueCount, matchedValueList = matchedValueList, - remainMatchingUpdateTimeInSec = remainMatchingUpdateTimeInSec + matchedDateTime = matchedDateTime, + matchType = MatchType.BASIC, + isExpanded = false ) @Test @@ -72,7 +79,7 @@ class MatchingScreenTest { setMatchingScreenContent(state) // then - composeTestRule.onNodeWithContentDescription("LoadingScreen") + composeTestRule.onNodeWithContentDescription("MatchingCardsLoadingScreen") .assertIsDisplayed() } @@ -93,12 +100,12 @@ class MatchingScreenTest { } @Test - fun 프로필심사를_마쳤지만_매칭이_되지_않았을_경우_대기화면이_표시된다() { + fun 프로필심사를_마쳤지만_매칭이_없는경우_대기화면이_표시된다() { // given val state = MatchingState( isLoading = false, userRole = UserRole.USER, - matchInfo = null + matchInfoList = persistentListOf() ) // when @@ -112,11 +119,11 @@ class MatchingScreenTest { @Test fun 상대방을_차단하였을_경우_대기화면이_표시된다() { // given - val matchInfo = mockMatchInfo(blocked = true) + val matchInfo = mockMatchInfo(isBlocked = true) val state = MatchingState( isLoading = false, userRole = UserRole.USER, - matchInfo = matchInfo + matchInfoList = persistentListOf(matchInfo) ) // when @@ -131,22 +138,21 @@ class MatchingScreenTest { fun 매칭정보가_있을_경우_매칭화면이_표시된다() { // given val matchInfo = mockMatchInfo( - blocked = false, + isBlocked = false, matchedValueCount = 3, matchedValueList = listOf("Value1", "Value2", "Value3"), - remainMatchingUpdateTimeInSec = 3600L ) val state = MatchingState( isLoading = false, userRole = UserRole.USER, - matchInfo = matchInfo + matchInfoList = persistentListOf(matchInfo) ) // when setMatchingScreenContent(state) // then - composeTestRule.onNodeWithContentDescription("UserScreen") + composeTestRule.onNodeWithContentDescription("MatchingUserScreen") .assertIsDisplayed() } @@ -163,7 +169,7 @@ class MatchingScreenTest { setMatchingScreenContent(state) // then - composeTestRule.onNodeWithText("얼굴이 잘나온 사진으로 변경해주세요") + composeTestRule.onNodeWithText("얼굴이 잘 보이는 사진으로 변경해주세요") .assertIsDisplayed() } @@ -200,7 +206,7 @@ class MatchingScreenTest { composeTestRule.onNodeWithText("가치관 talk을 좀 더 정성스럽게 써주세요") .assertIsDisplayed() - composeTestRule.onNodeWithText("얼굴이 잘나온 사진으로 변경해주세요") + composeTestRule.onNodeWithText("얼굴이 잘 보이는 사진으로 변경해주세요") .assertIsDisplayed() } } diff --git a/feature/matching/src/main/java/com/puzzle/matching/graph/detail/MatchingDetailScreen.kt b/feature/matching/src/main/java/com/puzzle/matching/graph/detail/MatchingDetailScreen.kt index 2e566a69b..0b4234187 100644 --- a/feature/matching/src/main/java/com/puzzle/matching/graph/detail/MatchingDetailScreen.kt +++ b/feature/matching/src/main/java/com/puzzle/matching/graph/detail/MatchingDetailScreen.kt @@ -39,12 +39,16 @@ import com.puzzle.designsystem.R import com.puzzle.designsystem.component.PieceLoading import com.puzzle.designsystem.component.PieceOutlinedButton import com.puzzle.designsystem.component.PieceRoundingSolidButton -import com.puzzle.designsystem.component.PieceSubCloseTopBar +import com.puzzle.designsystem.component.PieceTimerWithCloseTopBar import com.puzzle.designsystem.foundation.PieceTheme import com.puzzle.domain.model.match.MatchStatus import com.puzzle.domain.model.profile.OpponentProfile import com.puzzle.matching.graph.detail.bottomsheet.MatchingDetailMoreBottomSheet +import com.puzzle.matching.graph.detail.common.constant.BasicDialogType.AcceptMatching +import com.puzzle.matching.graph.detail.common.constant.BasicDialogType.DeclineMatching +import com.puzzle.matching.graph.detail.common.constant.BasicDialogType.ProfileImageDetail import com.puzzle.matching.graph.detail.common.constant.DialogType +import com.puzzle.matching.graph.detail.common.constant.PurchaseDialogType import com.puzzle.matching.graph.detail.contract.MatchingDetailIntent import com.puzzle.matching.graph.detail.contract.MatchingDetailState import com.puzzle.matching.graph.detail.contract.MatchingDetailState.MatchingDetailPage @@ -96,11 +100,11 @@ internal fun MatchingDetailScreen( onAcceptClick: () -> Unit, ) { var isShowDialog by remember { mutableStateOf(false) } - var dialogType by remember { mutableStateOf(DialogType.ACCEPT_MATCHING) } + var dialogType : DialogType by remember { mutableStateOf(AcceptMatching) } if (isShowDialog) { when (dialogType) { - DialogType.ACCEPT_MATCHING -> AcceptMatchingDialog( + AcceptMatching -> AcceptMatchingDialog( nickname = state.profile?.nickname.orEmpty(), onDismissRequest = { isShowDialog = false }, onAcceptClick = { @@ -109,7 +113,7 @@ internal fun MatchingDetailScreen( } ) - DialogType.DECLINE_MATCHING -> DeclineMatchingDialog( + DeclineMatching -> DeclineMatchingDialog( nickname = state.profile?.nickname.orEmpty(), onDismissRequest = { isShowDialog = false }, onDeclineClick = { @@ -118,7 +122,7 @@ internal fun MatchingDetailScreen( } ) - DialogType.PROFILE_IMAGE_DETAIL -> ProfileImageDialog( + ProfileImageDetail-> ProfileImageDialog( imageUri = state.profile?.imageUrl.orEmpty(), matchStatus = state.matchStatus, entryRoute = when (state.currentPage) { @@ -127,8 +131,12 @@ internal fun MatchingDetailScreen( MatchingDetailPage.ValueTalkPage -> "value_talk" }, onDismissRequest = { isShowDialog = false }, - onApproveClick = { dialogType = DialogType.ACCEPT_MATCHING } + onApproveClick = { dialogType = AcceptMatching } ) + + PurchaseDialogType.InsufficientPuzzle -> {} + PurchaseDialogType.PurchaseNewMatch -> {} + is PurchaseDialogType.PurchaseContact -> {} } } @@ -155,7 +163,7 @@ internal fun MatchingDetailScreen( state = state, onMoreClick = onMoreClick, onDeclineClick = { - dialogType = DialogType.DECLINE_MATCHING + dialogType = DeclineMatching isShowDialog = true }, modifier = Modifier @@ -163,11 +171,12 @@ internal fun MatchingDetailScreen( .padding(top = topBarHeight, bottom = bottomBarHeight), ) - PieceSubCloseTopBar( + PieceTimerWithCloseTopBar( title = state.currentPage.title, - contentColor = PieceTheme.colors.black, + remainTime = state.displayRemainTime, onCloseClick = onCloseClick, - closeButtonEnabled = !(isShowDialog && dialogType == DialogType.PROFILE_IMAGE_DETAIL), + closeButtonEnabled = !(isShowDialog && dialogType == ProfileImageDetail), + contentColor = PieceTheme.colors.black, modifier = Modifier .fillMaxWidth() .height(topBarHeight) @@ -182,18 +191,18 @@ internal fun MatchingDetailScreen( .padding(horizontal = 20.dp), ) - if (!isShowDialog || dialogType != DialogType.PROFILE_IMAGE_DETAIL) { + if (!isShowDialog || dialogType != ProfileImageDetail) { MatchingDetailBottomBar( currentPage = state.currentPage, matchStatus = state.matchStatus, onNextPageClick = onNextPageClick, onPreviousPageClick = onPreviousPageClick, onShowPicturesClick = { - dialogType = DialogType.PROFILE_IMAGE_DETAIL + dialogType = ProfileImageDetail isShowDialog = true }, onAcceptClick = { - dialogType = DialogType.ACCEPT_MATCHING + dialogType = AcceptMatching isShowDialog = true }, modifier = Modifier diff --git a/feature/matching/src/main/java/com/puzzle/matching/graph/detail/MatchingDetailViewModel.kt b/feature/matching/src/main/java/com/puzzle/matching/graph/detail/MatchingDetailViewModel.kt index 4fa5d8907..96de2c7d0 100644 --- a/feature/matching/src/main/java/com/puzzle/matching/graph/detail/MatchingDetailViewModel.kt +++ b/feature/matching/src/main/java/com/puzzle/matching/graph/detail/MatchingDetailViewModel.kt @@ -5,19 +5,28 @@ import androidx.lifecycle.viewModelScope import com.puzzle.common.base.BaseViewModel import com.puzzle.common.event.EventHelper import com.puzzle.common.event.PieceEvent +import com.puzzle.common.getRemainingTimeInSec +import com.puzzle.common.getRemainingTimeUntil24Hours import com.puzzle.common.suspendRunCatching import com.puzzle.common.ui.BottomSheetContent import com.puzzle.common.ui.SnackBarState import com.puzzle.domain.model.error.ErrorHelper +import com.puzzle.domain.model.match.MatchInfo +import com.puzzle.domain.model.match.MatchType +import com.puzzle.domain.model.timer.Timer import com.puzzle.domain.repository.MatchingRepository import com.puzzle.matching.graph.detail.contract.MatchingDetailIntent import com.puzzle.matching.graph.detail.contract.MatchingDetailState import com.puzzle.navigation.MatchingGraph import com.puzzle.navigation.NavigationEvent import com.puzzle.navigation.NavigationHelper +import com.puzzle.navigation.Route import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toPersistentMap +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import javax.inject.Inject +import kotlin.collections.set @HiltViewModel class MatchingDetailViewModel @Inject constructor( @@ -25,8 +34,11 @@ class MatchingDetailViewModel @Inject constructor( private val navigationHelper: NavigationHelper, private val eventHelper: EventHelper, private val errorHelper: ErrorHelper, + private val timer: Timer, ) : BaseViewModel(MatchingDetailState()) { + private var timerJob : Job? = null + init { initMatchDetailInfo() } @@ -46,8 +58,9 @@ class MatchingDetailViewModel @Inject constructor( val matchInfoJob = launch { suspendRunCatching { - matchingRepository.getMatchInfo() + matchingRepository.getBasicMatchInfo() }.onSuccess { response -> + startTimer(response) setState { copy( matchId = response.matchId, @@ -149,6 +162,27 @@ class MatchingDetailViewModel @Inject constructor( }.onFailure { errorHelper.sendError(it) } } + private fun startTimer(matchInfo: MatchInfo) { + timerJob?.cancel() + + val remainTime = when (matchInfo.matchType) { + MatchType.BASIC -> getRemainingTimeInSec() + else -> getRemainingTimeUntil24Hours(matchInfo.matchedDateTime) + } + + timerJob = viewModelScope.launch { + timer.startTimer(remainTime) + .collect { remainTimeInSec -> + setState { copy(remainWaitingTimeInSec = remainTimeInSec) } + + if (remainTimeInSec == 0L) { + timerJob?.cancel() + navigationHelper.navigate(NavigationEvent.TopLevelTo(MatchingGraph.MatchingRoute)) + } + } + } + } + private fun showBottomSheet(content: @Composable () -> Unit) { eventHelper.sendEvent(PieceEvent.ShowBottomSheet(BottomSheetContent(content))) } diff --git a/feature/matching/src/main/java/com/puzzle/matching/graph/detail/common/constant/DialogType.kt b/feature/matching/src/main/java/com/puzzle/matching/graph/detail/common/constant/DialogType.kt index 7584df3b3..c08bef52b 100644 --- a/feature/matching/src/main/java/com/puzzle/matching/graph/detail/common/constant/DialogType.kt +++ b/feature/matching/src/main/java/com/puzzle/matching/graph/detail/common/constant/DialogType.kt @@ -1,7 +1,15 @@ package com.puzzle.matching.graph.detail.common.constant -enum class DialogType { - ACCEPT_MATCHING, - DECLINE_MATCHING, - PROFILE_IMAGE_DETAIL, +sealed interface DialogType + +sealed interface BasicDialogType : DialogType { + data object AcceptMatching : BasicDialogType + data object DeclineMatching : BasicDialogType + data object ProfileImageDetail : BasicDialogType } + +sealed interface PurchaseDialogType : DialogType{ + data class PurchaseContact(val nickName: String) : PurchaseDialogType + data object PurchaseNewMatch : PurchaseDialogType + data object InsufficientPuzzle : PurchaseDialogType +} \ No newline at end of file diff --git a/feature/matching/src/main/java/com/puzzle/matching/graph/detail/contract/MatchingDetailState.kt b/feature/matching/src/main/java/com/puzzle/matching/graph/detail/contract/MatchingDetailState.kt index 9040e4759..ce2041dab 100644 --- a/feature/matching/src/main/java/com/puzzle/matching/graph/detail/contract/MatchingDetailState.kt +++ b/feature/matching/src/main/java/com/puzzle/matching/graph/detail/contract/MatchingDetailState.kt @@ -1,17 +1,26 @@ package com.puzzle.matching.graph.detail.contract +import com.puzzle.common.HOUR_IN_SECOND import com.puzzle.common.base.UiState +import com.puzzle.common.formatTimeToHourMinuteSecond +import com.puzzle.common.formatTimeToMinuteSecond import com.puzzle.domain.model.match.MatchStatus +import com.puzzle.domain.model.match.MatchType import com.puzzle.domain.model.profile.OpponentProfile data class MatchingDetailState( val isLoading: Boolean = false, val matchId: Int = 0, val userId: Int = 0, + val matchType: MatchType = MatchType.BASIC, val matchStatus: MatchStatus? = null, val currentPage: MatchingDetailPage = MatchingDetailPage.BasicInfoPage, + val remainWaitingTimeInSec : Long = 0L, val profile: OpponentProfile? = null, ) : UiState { + val displayRemainTime = if (remainWaitingTimeInSec < HOUR_IN_SECOND) { + formatTimeToMinuteSecond(remainWaitingTimeInSec) + } else "" enum class MatchingDetailPage(val title: String) { BasicInfoPage(title = ""), diff --git a/feature/matching/src/main/java/com/puzzle/matching/graph/detail/dialog/AcceptMatchingDialog.kt b/feature/matching/src/main/java/com/puzzle/matching/graph/detail/dialog/AcceptMatchingDialog.kt index 1f4fc521d..c2c914405 100644 --- a/feature/matching/src/main/java/com/puzzle/matching/graph/detail/dialog/AcceptMatchingDialog.kt +++ b/feature/matching/src/main/java/com/puzzle/matching/graph/detail/dialog/AcceptMatchingDialog.kt @@ -5,11 +5,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview import com.puzzle.analytics.TrackScreenViewEvent import com.puzzle.designsystem.R import com.puzzle.designsystem.component.PieceDialog import com.puzzle.designsystem.component.PieceDialogBottom import com.puzzle.designsystem.component.PieceDialogDefaultTop +import com.puzzle.designsystem.component.PiecePurchaseDialogBottom import com.puzzle.designsystem.foundation.PieceTheme @Composable @@ -25,7 +27,7 @@ internal fun AcceptMatchingDialog( withStyle(style = SpanStyle(color = PieceTheme.colors.primaryDefault)) { append(nickname) } - append("님과의\n인연을 이어갈까요?") + append(stringResource(R.string.purchase_contact_title)) }, subText = stringResource(R.string.matching_accept_subtext), ) @@ -46,3 +48,15 @@ internal fun AcceptMatchingDialog( TrackScreenViewEvent(screenName = "match_main_accept_popup") } + +@Preview(showBackground = true) +@Composable +private fun PreviewAcceptMatchingDialog() { + PieceTheme { + AcceptMatchingDialog( + nickname = "감성적인 여우", + onDismissRequest = {}, + onAcceptClick = {}, + ) + } +} diff --git a/feature/matching/src/main/java/com/puzzle/matching/graph/detail/dialog/DeclineMatchingDialog.kt b/feature/matching/src/main/java/com/puzzle/matching/graph/detail/dialog/DeclineMatchingDialog.kt index d3dd370ce..bf6a22abc 100644 --- a/feature/matching/src/main/java/com/puzzle/matching/graph/detail/dialog/DeclineMatchingDialog.kt +++ b/feature/matching/src/main/java/com/puzzle/matching/graph/detail/dialog/DeclineMatchingDialog.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview import com.puzzle.analytics.TrackScreenViewEvent import com.puzzle.designsystem.R import com.puzzle.designsystem.component.PieceDialog @@ -47,3 +48,13 @@ internal fun DeclineMatchingDialog( TrackScreenViewEvent(screenName = "match_detail_reject_popup") } + +@Preview +@Composable +fun DeclineMatchingDialogPreview() { + DeclineMatchingDialog( + nickname = "닉네임", + onDismissRequest = {}, + onDeclineClick = {}, + ) +} \ No newline at end of file diff --git a/feature/matching/src/main/java/com/puzzle/matching/graph/detail/dialog/PurchaseMatchingDialog.kt b/feature/matching/src/main/java/com/puzzle/matching/graph/detail/dialog/PurchaseMatchingDialog.kt new file mode 100644 index 000000000..df6511230 --- /dev/null +++ b/feature/matching/src/main/java/com/puzzle/matching/graph/detail/dialog/PurchaseMatchingDialog.kt @@ -0,0 +1,167 @@ +package com.puzzle.matching.graph.detail.dialog + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import com.puzzle.analytics.TrackScreenViewEvent +import com.puzzle.designsystem.R +import com.puzzle.designsystem.component.PieceDialog +import com.puzzle.designsystem.component.PieceDialogBottom +import com.puzzle.designsystem.component.PieceDialogDefaultTop +import com.puzzle.designsystem.component.PiecePurchaseDialogBottom +import com.puzzle.designsystem.foundation.PieceTheme +import com.puzzle.matching.graph.detail.common.constant.PurchaseDialogType + +@Composable +internal fun PurchaseContactDialog( + nickname: String?, + puzzleCount : Int = 3, + onDismissRequest: () -> Unit, + onAcceptClick: (PurchaseDialogType) -> Unit, +){ + PieceDialog( + dialogTop = { + PieceDialogDefaultTop( + title = buildAnnotatedString { + withStyle(style = SpanStyle(color = PieceTheme.colors.primaryDefault)) { + append(nickname) + } + append(stringResource(R.string.purchase_contact_title)) + }, + subText = buildAnnotatedString { + append("${stringResource(R.string.purchase_puzzle)} ") + withStyle( + style = SpanStyle(color = PieceTheme.colors.primaryDefault) + ) { + append(stringResource(R.string.purchase_puzzle_count, puzzleCount)) + } + append(stringResource(R.string.purchase_contact_subtext)) + } + ) + }, + dialogBottom = { + PiecePurchaseDialogBottom( + imageId = R.drawable.ic_puzzle_white, + leftButtonText = stringResource(R.string.back), + rightButtonText = puzzleCount.toString(), + onLeftButtonClick = onDismissRequest, + onRightButtonClick = { + onDismissRequest() + onAcceptClick(PurchaseDialogType.PurchaseContact(nickname ?: "")) + }, + ) + }, + onDismissRequest = onDismissRequest, + ) + + TrackScreenViewEvent(screenName = "match_main_accept_popup") +} + + +@Composable +internal fun PurchaseNewMatchDialog( + puzzleCount : Int = 2, + onDismissRequest: () -> Unit, + onAcceptClick: (PurchaseDialogType) -> Unit, +){ + PieceDialog( + dialogTop = { + PieceDialogDefaultTop( + title = stringResource(R.string.purchase_find_new_match_title), + subText = buildAnnotatedString { + append("${stringResource(R.string.purchase_puzzle)} ") + withStyle( + style = SpanStyle(color = PieceTheme.colors.primaryDefault) + ) { + append(stringResource(R.string.purchase_puzzle_count, puzzleCount)) + } + append(stringResource(R.string.purchase_find_new_match_subtext)) + } + ) + }, + dialogBottom = { + PiecePurchaseDialogBottom( + imageId = R.drawable.ic_puzzle_white, + leftButtonText = stringResource(R.string.back), + rightButtonText = puzzleCount.toString(), + onLeftButtonClick = onDismissRequest, + onRightButtonClick = { + onDismissRequest() + onAcceptClick(PurchaseDialogType.PurchaseNewMatch) + }, + ) + }, + onDismissRequest = onDismissRequest, + ) + + TrackScreenViewEvent(screenName = "match_main_accept_popup") +} + +@Composable +internal fun InsufficientPuzzleDialog( + onDismissRequest: () -> Unit, + onAcceptClick: (PurchaseDialogType) -> Unit, +){ + PieceDialog( + dialogTop = { + PieceDialogDefaultTop( + title = stringResource(R.string.store_insufficient_puzzle), + subText = stringResource(R.string.store_purchase_puzzle_in_store), + ) + }, + dialogBottom = { + PieceDialogBottom( + leftButtonText = stringResource(R.string.back), + rightButtonText = stringResource(R.string.store_purchase), + onLeftButtonClick = onDismissRequest, + onRightButtonClick = { + onDismissRequest() + onAcceptClick(PurchaseDialogType.InsufficientPuzzle) + }, + ) + }, + onDismissRequest = onDismissRequest, + ) + + TrackScreenViewEvent(screenName = "match_main_accept_popup") +} + +@Preview(showBackground = true) +@Composable +private fun PreviewPurchaseContactDialog() { + PieceTheme { + PurchaseContactDialog( + nickname = "수줍은 수달", + puzzleCount = 3, + onDismissRequest = {}, + onAcceptClick = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewPurchaseNewMatchDialog() { + PieceTheme { + PurchaseNewMatchDialog( + puzzleCount = 2, + onDismissRequest = {}, + onAcceptClick = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewInsufficientPuzzleDialog() { + PieceTheme { + InsufficientPuzzleDialog( + onDismissRequest = {}, + onAcceptClick = {}, + ) + } +} + diff --git a/feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingScreen.kt b/feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingScreen.kt index 980cb60a9..ca8777adb 100644 --- a/feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingScreen.kt +++ b/feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingScreen.kt @@ -6,13 +6,11 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -35,15 +33,26 @@ import com.puzzle.designsystem.component.PieceMainTopBar import com.puzzle.designsystem.component.PiecePuzzleTopBar import com.puzzle.designsystem.foundation.PieceTheme import com.puzzle.domain.model.match.MatchInfo -import com.puzzle.domain.model.match.MatchStatus.WAITING -import com.puzzle.matching.graph.detail.dialog.AcceptMatchingDialog +import com.puzzle.domain.model.match.MatchStatus.BEFORE_OPEN +import com.puzzle.domain.model.match.MatchStatus.GREEN_LIGHT +import com.puzzle.domain.model.match.MatchStatus.MATCHED +import com.puzzle.matching.graph.detail.common.constant.PurchaseDialogType +import com.puzzle.matching.graph.detail.common.constant.PurchaseDialogType.InsufficientPuzzle +import com.puzzle.matching.graph.detail.common.constant.PurchaseDialogType.PurchaseNewMatch +import com.puzzle.matching.graph.detail.common.constant.PurchaseDialogType.PurchaseContact +import com.puzzle.matching.graph.detail.dialog.InsufficientPuzzleDialog +import com.puzzle.matching.graph.detail.dialog.PurchaseContactDialog +import com.puzzle.matching.graph.detail.dialog.PurchaseNewMatchDialog +import com.puzzle.matching.graph.main.component.card.sampleMatchInfo import com.puzzle.matching.graph.main.contract.MatchingIntent import com.puzzle.matching.graph.main.contract.MatchingState import com.puzzle.matching.graph.main.contract.MatchingState.MatchingStatus -import com.puzzle.matching.graph.main.page.MatchingLoadingScreen +import com.puzzle.matching.graph.main.page.MatchingCardsLoadingScreen import com.puzzle.matching.graph.main.page.MatchingPendingScreen import com.puzzle.matching.graph.main.page.MatchingUserScreen import com.puzzle.matching.graph.main.page.MatchingWaitingScreen +import kotlinx.collections.immutable.persistentHashMapOf +import kotlinx.collections.immutable.persistentListOf @Composable internal fun MatchingRoute( @@ -55,8 +64,9 @@ internal fun MatchingRoute( LifecycleStartEffect(viewModel) { viewModel.initMatchInfo() + viewModel.initFreeMatch() - onStopOrDispose { viewModel.stopTimer() } + onStopOrDispose { viewModel.stopTimerList() } } BackHandler { @@ -74,9 +84,11 @@ internal fun MatchingRoute( MatchingScreen( state = state, - onButtonClick = { viewModel.onIntent(MatchingIntent.OnButtonClick) }, + onMatchingCardClick = { viewModel.onIntent(MatchingIntent.OnMatchingCardClick(it))}, + onNewMatchingCardClick = { viewModel.onIntent(MatchingIntent.OnNewMatchingCardClick) }, + onAcceptClick = { viewModel.onIntent(MatchingIntent.OnAcceptClick(it)) }, + onDisMissClick = { viewModel.onIntent(MatchingIntent.OnDisMissClick) }, onStoreClick = { viewModel.onIntent(MatchingIntent.OnStoreClick) }, - onMatchingDetailClick = { viewModel.onIntent(MatchingIntent.OnMatchingDetailClick) }, onCheckMyProfileClick = { viewModel.onIntent(MatchingIntent.OnCheckMyProfileClick) }, onEditProfileClick = { viewModel.onIntent(MatchingIntent.OnEditProfileClick) }, onNotificationClick = { viewModel.onIntent(MatchingIntent.OnNotificationClick) }, @@ -86,31 +98,36 @@ internal fun MatchingRoute( @Composable internal fun MatchingScreen( state: MatchingState, - onButtonClick: () -> Unit, + onMatchingCardClick: (MatchInfo) -> Unit, + onNewMatchingCardClick: () -> Unit, + onAcceptClick: (PurchaseDialogType) -> Unit, + onDisMissClick : () -> Unit, onStoreClick: () -> Unit, - onMatchingDetailClick: () -> Unit, onCheckMyProfileClick: () -> Unit, onEditProfileClick: () -> Unit, onNotificationClick: () -> Unit, ) { - var isShowDialog by remember { mutableStateOf(false) } - - if (isShowDialog) { - AcceptMatchingDialog( - nickname = state.matchInfo?.nickname, - onDismissRequest = { isShowDialog = false }, - onAcceptClick = { - isShowDialog = false - onButtonClick() - }, - ) + + if (state.isShowDialog) { + when(state.dialogType){ + InsufficientPuzzle -> + InsufficientPuzzleDialog(onDismissRequest = onDisMissClick, onAcceptClick = onAcceptClick) + PurchaseNewMatch-> + PurchaseNewMatchDialog(onDismissRequest = onDisMissClick, onAcceptClick = onAcceptClick) + is PurchaseContact -> + PurchaseContactDialog( + nickname = state.dialogType.nickName, + onDismissRequest = onDisMissClick, + onAcceptClick = onAcceptClick + ) + } } Column( modifier = Modifier .fillMaxSize() .background(PieceTheme.colors.black) - .blur(isBlur = isShowDialog) + .blur(isBlur = state.isShowDialog) .padding(horizontal = 20.dp) ) { // TopBar @@ -136,7 +153,7 @@ internal fun MatchingScreen( MatchingStatus.USER, MatchingStatus.WAITING -> { PiecePuzzleTopBar( - count = 22, + count = state.puzzleCount, chipColor = PieceTheme.colors.white.copy(alpha = 0.1f), contentColor = PieceTheme.colors.white, rightComponent = { @@ -149,7 +166,7 @@ internal fun MatchingScreen( .clickable { onNotificationClick() }, ) }, - onStoreClick = { onStoreClick() }, + onStoreClick = onStoreClick, modifier = Modifier.padding(bottom = 16.dp), ) } @@ -157,7 +174,7 @@ internal fun MatchingScreen( // Body when (state.matchingStatus) { - MatchingStatus.LOADING -> MatchingLoadingScreen() + MatchingStatus.LOADING -> MatchingCardsLoadingScreen() MatchingStatus.PENDING -> MatchingPendingScreen( isImageRejected = state.rejectReason.reasonImage, isDescriptionRejected = state.rejectReason.reasonValues, @@ -167,15 +184,16 @@ internal fun MatchingScreen( MatchingStatus.WAITING -> MatchingWaitingScreen( onCheckMyProfileClick = onCheckMyProfileClick, - remainTime = state.formattedRemainWaitingTime, + onNewMatchingCardClick = onNewMatchingCardClick, + canFreeMatch = state.canFreeMatch ) MatchingStatus.USER -> MatchingUserScreen( - matchInfo = state.matchInfo!!, - remainTime = state.formattedRemainWaitingTime, - onButtonClick = onButtonClick, - onMatchingDetailClick = onMatchingDetailClick, - showDialog = { isShowDialog = true }, + matchInfoList = state.matchInfoList, + remainTimeHashMap = state.formattedRemainingTimeMap, + canFreeMatch = state.canFreeMatch, + onMatchingCardClick = onMatchingCardClick, + onNewMatchingCardClick = onNewMatchingCardClick, ) } } @@ -210,15 +228,16 @@ private fun PreviewMatchingWaitingScreen() { PieceTheme { MatchingWaitingScreen( onCheckMyProfileClick = {}, - remainTime = " 00:00:00 ", + onNewMatchingCardClick = {}, + canFreeMatch = false ) } } @Preview @Composable -private fun PreviewMatchingLoadingScreen() { - PieceTheme { MatchingLoadingScreen() } +private fun PreviewMatchingWaitingLoadingScreen() { + PieceTheme { MatchingCardsLoadingScreen() } } @Preview @@ -226,27 +245,15 @@ private fun PreviewMatchingLoadingScreen() { private fun PreviewMatchingUserScreen() { PieceTheme { MatchingUserScreen( - matchInfo = MatchInfo( - matchId = 1, - matchedUserId = 1, - matchStatus = WAITING, - description = "음악과 요리를 좋아하는", - nickname = "수줍은 수달", - birthYear = "02", - location = "광주광역시", - job = "학생", - blocked = false, - matchedValueCount = 7, - matchedValueList = listOf( - "바깥 데이트 스킨십도 가능", - "함께 술을 즐기고 싶어요", - "커밍아웃은 가까운 친구에게만 했어요", - ), + matchInfoList = persistentListOf( + sampleMatchInfo(0,MATCHED, isExpanded = true), + sampleMatchInfo(1, BEFORE_OPEN, isExpanded = false), + sampleMatchInfo(2, GREEN_LIGHT, isExpanded = false), ), - onButtonClick = {}, - onMatchingDetailClick = {}, - showDialog = {}, - remainTime = " 00:00:00 ", + remainTimeHashMap = persistentHashMapOf(3 to "00:00:01", 1 to "00:10:02"), + onMatchingCardClick = {}, + onNewMatchingCardClick = {}, + canFreeMatch = false, ) } } diff --git a/feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt b/feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt index 5ee5f2a1a..7f6a8b7d4 100644 --- a/feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt +++ b/feature/matching/src/main/java/com/puzzle/matching/graph/main/MatchingViewModel.kt @@ -5,19 +5,23 @@ import com.puzzle.common.base.BaseViewModel import com.puzzle.common.event.EventHelper import com.puzzle.common.event.PieceEvent import com.puzzle.common.getRemainingTimeInSec +import com.puzzle.common.getRemainingTimeUntil24Hours import com.puzzle.common.suspendRunCatching import com.puzzle.common.ui.MainDispatcher -import com.puzzle.common.ui.SnackBarState import com.puzzle.domain.model.error.ErrorHelper -import com.puzzle.domain.model.error.HttpResponseException -import com.puzzle.domain.model.error.HttpResponseStatus +import com.puzzle.domain.model.match.MatchInfo import com.puzzle.domain.model.match.MatchStatus +import com.puzzle.domain.model.match.MatchType import com.puzzle.domain.model.timer.Timer +import com.puzzle.domain.model.timer.Timer.Companion.TIMEOUT_FLAG import com.puzzle.domain.model.user.ProfileStatus import com.puzzle.domain.model.user.UserRole import com.puzzle.domain.repository.MatchingRepository import com.puzzle.domain.repository.UserRepository -import com.puzzle.matching.BuildConfig +import com.puzzle.matching.graph.detail.common.constant.PurchaseDialogType +import com.puzzle.matching.graph.detail.common.constant.PurchaseDialogType.InsufficientPuzzle +import com.puzzle.matching.graph.detail.common.constant.PurchaseDialogType.PurchaseNewMatch +import com.puzzle.matching.graph.detail.common.constant.PurchaseDialogType.PurchaseContact import com.puzzle.matching.graph.main.contract.MatchingIntent import com.puzzle.matching.graph.main.contract.MatchingState import com.puzzle.navigation.MatchingGraph @@ -26,14 +30,17 @@ import com.puzzle.navigation.NavigationEvent.To import com.puzzle.navigation.NavigationHelper import com.puzzle.navigation.NotificationRoute import com.puzzle.navigation.ProfileGraph -import com.puzzle.navigation.SettingGraph import com.puzzle.navigation.StoreRoute import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentMap import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject + @HiltViewModel class MatchingViewModel @Inject constructor( private val matchingRepository: MatchingRepository, @@ -45,15 +52,19 @@ class MatchingViewModel @Inject constructor( @MainDispatcher private val dispatcher: CoroutineDispatcher, ) : BaseViewModel(MatchingState()) { - private var timerJob: Job? = null + private var timerJobMap: ConcurrentHashMap = ConcurrentHashMap() // matchId, timerJob + private var getMatchInfoJob : Job? = null + private var openMatchingId: Int? = null override suspend fun processIntent(intent: MatchingIntent) { when (intent) { - MatchingIntent.OnButtonClick -> processOnButtonClick() - is MatchingIntent.OnMatchingDetailClick -> - navigationHelper.navigate(To(MatchingDetailRoute)) + is MatchingIntent.OnMatchingCardClick -> processMatchCardOpen(intent.match) + is MatchingIntent.OnNewMatchingCardClick -> processNewMatchingCardOpen() + is MatchingIntent.OnAcceptClick -> processAcceptDialog(intent.dialogType) + is MatchingIntent.OnDisMissClick -> setState { copy(isShowDialog = false) } + MatchingIntent.OnEditProfileClick -> + navigationHelper.navigate(To(ProfileGraph.RegisterProfileRoute)) - MatchingIntent.OnEditProfileClick -> moveToProfileRegisterScreen() MatchingIntent.OnCheckMyProfileClick -> navigationHelper.navigate( To(MatchingGraph.ProfilePreviewRoute) ) @@ -63,6 +74,14 @@ class MatchingViewModel @Inject constructor( } } + internal fun initFreeMatch() = viewModelScope.launch(dispatcher) { + suspendRunCatching { + matchingRepository.getCanFreeMatch() + }.onSuccess { + setState { copy(canFreeMatch = it) } + }.onFailure { errorHelper.sendError(it) } + } + internal fun initMatchInfo() = viewModelScope.launch(dispatcher) { suspendRunCatching { userRepository.getUserInfo() @@ -71,7 +90,7 @@ class MatchingViewModel @Inject constructor( when { userInfo.profileStatus == ProfileStatus.REJECTED -> getRejectReason() - userInfo.userRole == UserRole.USER -> getMatchInfo() + userInfo.userRole == UserRole.USER -> getMatchInfoList() userInfo.userRole == UserRole.BANNED -> { eventHelper.sendEvent(PieceEvent.ShowBannedDialog) return@launch @@ -80,133 +99,177 @@ class MatchingViewModel @Inject constructor( else -> Unit } - setState { copy(isLoading = false) } - }.onFailure { errorHelper.sendError(it) } - - } - - private fun moveToProfileRegisterScreen() { - navigationHelper.navigate(To(ProfileGraph.RegisterProfileRoute)) } private suspend fun getRejectReason() { suspendRunCatching { userRepository.getRejectReason() }.onSuccess { - setState { copy(rejectReason = it) } + setState { copy(rejectReason = it, isLoading = false) } }.onFailure { errorHelper.sendError(it) } } - private suspend fun getMatchInfo() = suspendRunCatching { - matchingRepository.getMatchInfo() - }.onSuccess { - setState { copy(matchInfo = it) } + private fun getMatchInfoList() { + getMatchInfoJob?.cancel() - when (it.matchStatus) { - MatchStatus.REFUSED -> { - startTimer() - return@onSuccess - } - - MatchStatus.BEFORE_OPEN -> { - if (isNewMatching()) { - eventHelper.sendEvent( - PieceEvent.ShowSnackBar(SnackBarState.Matching("새로운 인연이 도착했어요")) + getMatchInfoJob = viewModelScope.launch(dispatcher) { + suspendRunCatching { + matchingRepository.getMatchInfoList() + }.onSuccess { matchList -> + val openedMatchExists = matchList.any { it.matchId == openMatchingId } + + val updatedList = matchList.mapIndexed { index, matchInfo -> + matchInfo.copy( + isExpanded = if (openedMatchExists) matchInfo.matchId == openMatchingId + else index == 0 ) } - } - else -> Unit - } + setState { copy(matchInfoList = updatedList.toImmutableList(), isLoading = false) } - suspendRunCatching { - matchingRepository.getOpponentProfile() - }.onFailure { - errorHelper.sendError(it) - } - startTimer() - }.onFailure { - if (it is HttpResponseException) { - // 1. 회원가입하고 처음 매칭을 하는데 아직 오후 10시가 안되었을 때 - // 2. 상대방 아이디가 없어졌을 때 - if (it.status == HttpResponseStatus.NotFound) { - startTimer() - return@onFailure + updatedList.forEach { matchInfo -> startTimer(matchInfo) } + }.onFailure { + errorHelper.sendError(it) } } + } - errorHelper.sendError(it) + private fun processMatchCardOpen(matchInfo: MatchInfo) { + if (matchInfo.isExpanded) processExpandedCardOpen(matchInfo.matchId) + else updateCardExpandedState(matchInfo.matchId) // 닫혀있다면 단순히 card만 오픈함. } - private suspend fun isNewMatching() = matchingRepository.isNewMatching() + private fun processExpandedCardOpen(matchId: Int) { + updateCardExpandedState(matchId) - private fun processOnButtonClick() { - when (currentState.matchInfo?.matchStatus) { - MatchStatus.BEFORE_OPEN -> checkMatchingPiece() - MatchStatus.WAITING -> acceptMatchingInWaiting() - MatchStatus.GREEN_LIGHT -> acceptMatchingInGreenLight() - MatchStatus.MATCHED -> navigateToContactScreen() - else -> Unit - } + val clickedMatchInfo = state.value.matchInfoList.find { it.matchId == matchId } + clickedMatchInfo?.let { processMatchInfo(it) } } - private fun navigateToContactScreen() = - navigationHelper.navigate(To(MatchingGraph.ContactRoute)) + private fun updateCardExpandedState(expandedMatchId: Int?) { + val updatedList = state.value.matchInfoList.map { matchInfo -> + matchInfo.copy(isExpanded = matchInfo.matchId == expandedMatchId) + }.toImmutableList() - private fun checkMatchingPiece() { - viewModelScope.launch(dispatcher) { - suspendRunCatching { - matchingRepository.checkMatchingPiece() - }.onSuccess { - setState { copy(matchInfo = matchInfo?.copy(matchStatus = MatchStatus.WAITING)) } - }.onFailure { errorHelper.sendError(it) } + openMatchingId = expandedMatchId - navigationHelper.navigate(To(MatchingDetailRoute)) + setState { copy(matchInfoList = updatedList) } + } + + private fun processMatchInfo(matchInfo: MatchInfo) { + when (matchInfo.matchStatus) { + MatchStatus.MATCHED -> { + when (matchInfo.matchType) { + MatchType.BASIC -> + navigationHelper.navigate(To(MatchingGraph.ContactRoute)) + + MatchType.TO_ME, MatchType.FROM_ME -> + showPurchaseDialog(PurchaseContact(nickName = matchInfo.nickname)) + } + } + + else -> navigationHelper.navigate(To(MatchingDetailRoute)) // todo id 담아서 보낼것. } + + // 다중매칭에 관한 id 조회 api가 추가될때 open +// when (matchInfo.matchStatus) { +// MatchStatus.BEFORE_OPEN -> checkMatchingPiece() +// MatchStatus.WAITING -> acceptMatchingInWaiting() +// MatchStatus.GREEN_LIGHT -> acceptMatchingInGreenLight() +// MatchStatus.MATCHED -> navigateToContactScreen() +// else -> Unit +// } } - private fun acceptMatchingInWaiting() = viewModelScope.launch { - suspendRunCatching { - matchingRepository.acceptMatching() - }.onSuccess { - setState { copy(matchInfo = matchInfo?.copy(matchStatus = MatchStatus.RESPONDED)) } + //private suspend fun isNewMatching() = matchingRepository.isNewMatching() - eventHelper.sendEvent( - PieceEvent.ShowSnackBar(SnackBarState.Matching("인연을 수락했어요")) - ) - }.onFailure { errorHelper.sendError(it) } + private suspend fun processNewMatchingCardOpen() { + if (currentState.canFreeMatch) getFreeInstantMatch() + else showPurchaseNewMatchDialog() } - private fun acceptMatchingInGreenLight() = viewModelScope.launch { - suspendRunCatching { - matchingRepository.acceptMatching() - }.onSuccess { - setState { copy(matchInfo = matchInfo?.copy(matchStatus = MatchStatus.MATCHED)) } - }.onFailure { errorHelper.sendError(it) } + private suspend fun getFreeInstantMatch() = suspendRunCatching { + matchingRepository.getNewInstantMatch() + }.onSuccess { + navigationHelper.navigate(To(MatchingDetailRoute)) //todo id를 담아서 보내야할것. + }.onFailure { errorHelper.sendError(it) } + + private fun showPurchaseNewMatchDialog() { + if (currentState.puzzleCount < 2) showPurchaseDialog(InsufficientPuzzle) + else showPurchaseDialog(PurchaseNewMatch) } - private fun startTimer() { - timerJob?.cancel() + private fun processAcceptDialog(purchaseDialogType: PurchaseDialogType) = + when (purchaseDialogType) { + InsufficientPuzzle -> navigationHelper.navigate(To(StoreRoute)) + PurchaseNewMatch -> {} + is PurchaseContact -> {} + } - timerJob = viewModelScope.launch(dispatcher) { - timer.startTimer(getRemainingTimeInSec()) +// private fun checkMatchingPiece() { +// viewModelScope.launch(dispatcher) { +// suspendRunCatching { +// matchingRepository.checkMatchingPiece() +// }.onSuccess { +// setState { copy(matchInfo = matchInfo?.copy(matchStatus = MatchStatus.WAITING)) } +// }.onFailure { errorHelper.sendError(it) } +// +// navigationHelper.navigate(To(MatchingDetailRoute)) +// } +// } +// +// private fun acceptMatchingInWaiting() = viewModelScope.launch { +// suspendRunCatching { +// matchingRepository.acceptMatching() +// }.onSuccess { +// setState { copy(matchInfo = matchInfo?.copy(matchStatus = MatchStatus.RESPONDED)) } // todo 상태는 계속 User로 지속해야함. +// +// eventHelper.sendEvent( +// PieceEvent.ShowSnackBar(SnackBarState.Matching("인연을 수락했어요")) +// ) +// }.onFailure { errorHelper.sendError(it) } +// } +// +// private fun acceptMatchingInGreenLight() = viewModelScope.launch { +// suspendRunCatching { +// matchingRepository.acceptMatching() +// }.onSuccess { +// setState { copy(matchInfo = matchInfo?.copy(matchStatus = MatchStatus.MATCHED)) } +// }.onFailure { errorHelper.sendError(it) } +// } + + private fun startTimer(matchInfo: MatchInfo) { + timerJobMap[matchInfo.matchId]?.cancel() + + val remainTime = when (matchInfo.matchType) { + MatchType.BASIC -> getRemainingTimeInSec() + else -> getRemainingTimeUntil24Hours(matchInfo.matchedDateTime) + } + + timerJobMap[matchInfo.matchId] = viewModelScope.launch(dispatcher) { + timer.startTimer(remainTime) .collect { remainTimeInSec -> - setState { copy(remainWaitingTimeInSec = remainTimeInSec) } + val updateMap = currentState.remainWaitingTimeInSecMap.put(matchInfo.matchId,remainTimeInSec) - if (remainTimeInSec == 0L) { - getMatchInfo() + setState { copy(remainWaitingTimeInSecMap = updateMap) } - timerJob?.cancel() + if (remainTimeInSec == TIMEOUT_FLAG) { + timerJobMap[matchInfo.matchId]?.cancel() + timerJobMap.remove(matchInfo.matchId) + getMatchInfoList() } } } } - internal fun stopTimer() = timerJob?.cancel() + internal fun stopTimerList() = timerJobMap.values.forEach(Job::cancel) + + private fun showPurchaseDialog(purchaseDialogType: PurchaseDialogType) = + setState { copy(isShowDialog = true, dialogType = purchaseDialogType) } override fun onCleared() { super.onCleared() - timerJob?.cancel() + timerJobMap.values.forEach(Job::cancel) } } diff --git a/feature/matching/src/main/java/com/puzzle/matching/graph/main/component/card/MatchInfoPreviewProvider.kt b/feature/matching/src/main/java/com/puzzle/matching/graph/main/component/card/MatchInfoPreviewProvider.kt new file mode 100644 index 000000000..bf9012fe0 --- /dev/null +++ b/feature/matching/src/main/java/com/puzzle/matching/graph/main/component/card/MatchInfoPreviewProvider.kt @@ -0,0 +1,28 @@ +package com.puzzle.matching.graph.main.component.card + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.puzzle.domain.model.match.MatchInfo +import com.puzzle.domain.model.match.MatchStatus.GREEN_LIGHT +import com.puzzle.domain.model.match.MatchStatus.WAITING +import com.puzzle.domain.model.match.MatchStatus.BEFORE_OPEN +import com.puzzle.domain.model.match.MatchStatus.RESPONDED +import com.puzzle.domain.model.match.MatchStatus.MATCHED +import com.puzzle.domain.model.match.MatchType + + +class MatchInfoPreviewProvider : PreviewParameterProvider { + + override val values: Sequence = sequenceOf( + sampleMatchInfo(matchStatus = GREEN_LIGHT), + sampleMatchInfo(matchStatus = MATCHED), + sampleMatchInfo(matchStatus = RESPONDED), + sampleMatchInfo(matchStatus = WAITING), + sampleMatchInfo(matchStatus = GREEN_LIGHT, matchType = MatchType.TO_ME), + sampleMatchInfo(matchStatus = BEFORE_OPEN, isExpanded = true), + sampleMatchInfo(matchStatus = WAITING, isExpanded = true), + sampleMatchInfo(matchStatus = GREEN_LIGHT, isExpanded = true), + sampleMatchInfo(matchStatus = RESPONDED, isExpanded = true), + sampleMatchInfo(matchStatus = MATCHED, isExpanded = true), + sampleMatchInfo(matchStatus = GREEN_LIGHT, matchType = MatchType.TO_ME, isExpanded = true) + ) +} diff --git a/feature/matching/src/main/java/com/puzzle/matching/graph/main/component/card/MatchingCard.kt b/feature/matching/src/main/java/com/puzzle/matching/graph/main/component/card/MatchingCard.kt new file mode 100644 index 000000000..613381139 --- /dev/null +++ b/feature/matching/src/main/java/com/puzzle/matching/graph/main/component/card/MatchingCard.kt @@ -0,0 +1,456 @@ +package com.puzzle.matching.graph.main.component.card + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.puzzle.analytics.LocalAnalyticsHelper +import com.puzzle.common.ui.PieceCardTransitionAnimation +import com.puzzle.common.ui.clickable +import com.puzzle.designsystem.R +import com.puzzle.designsystem.foundation.PieceTheme +import com.puzzle.domain.model.match.MatchInfo +import com.puzzle.domain.model.match.MatchStatus +import com.puzzle.domain.model.match.MatchStatus.BEFORE_OPEN +import com.puzzle.domain.model.match.MatchStatus.GREEN_LIGHT +import com.puzzle.domain.model.match.MatchStatus.MATCHED +import com.puzzle.domain.model.match.MatchStatus.RESPONDED +import com.puzzle.domain.model.match.MatchStatus.WAITING +import com.puzzle.domain.model.match.MatchType + +@Composable +internal fun NewMatchingCard( + modifier: Modifier = Modifier, + isFreeMatching: Boolean, + onNewMatchingCardClick: () -> Unit +) { + Row( + modifier = modifier + .clickable(onClick = onNewMatchingCardClick) + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(PieceTheme.colors.white) + .padding(horizontal = 16.dp, vertical = 20.dp) + .semantics { contentDescription = "NewMatchingCard" }, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.ic_plus_circle_gray), + contentDescription = null, + ) + + Text( + text = stringResource(R.string.find_new_matching), + style = PieceTheme.typography.bodyMSB, + color = PieceTheme.colors.dark2, + modifier = Modifier.padding(horizontal = 12.dp), + ) + + Spacer(modifier = Modifier.weight(1f)) + + if (isFreeMatching) { + Image( + painter = painterResource(R.drawable.badge_free), + contentDescription = null, + ) + } + } +} + +@Composable +internal fun MatchingUserCard( + matchInfo: MatchInfo, + remainTime: String, + onCardClick: () -> Unit +) { + val analyticsHelper = LocalAnalyticsHelper.current + val isMatched = matchInfo.matchStatus == MATCHED + + val matchedColor = + if (isMatched) PieceTheme.colors.white60 else PieceTheme.colors.dark2 + val matchValueCountTextColor = + if (isMatched) PieceTheme.colors.white else PieceTheme.colors.black + val dividerColor = + if (isMatched) PieceTheme.colors.white60 else PieceTheme.colors.light1 + + PieceCardTransitionAnimation( + targetState = matchInfo.isExpanded, + ) { isExpanded -> + Column( + modifier = Modifier + .semantics { + contentDescription = + if (isExpanded) "ExpandedMatchingUserCard" else "CollapsedMatchingUserCard" + } + ) { + MatchingCardBackground( + matchStatus = matchInfo.matchStatus, + matchType = matchInfo.matchType, + isExpanded = isExpanded + ) { + Column( + modifier = Modifier.padding( + top = 20.dp, + start = 20.dp, + end = 20.dp, + bottom = if (isExpanded) 20.dp else 12.dp + ) + ) { + // 상단 매칭 상태 row (공통) + MatchStatusRow( + matchStatus = matchInfo.matchStatus, + matchInfo = matchInfo, + remainTime = remainTime + ) + + if (isExpanded) { + Spacer(Modifier.height(40.dp)) + + ExpandedContent( + matchInfo = matchInfo, + matchedColor = matchedColor, + matchValueCountTextColor = matchValueCountTextColor, + dividerColor = dividerColor, + onClick = { + onCardClick() + analyticsHelper.trackClickEvent( + screenName = "match_main_home", + buttonName = "expand_match_button", + ) + } + ) + + Spacer(Modifier.height(60.dp)) + } else { + CollapsedContent( + matchInfo = matchInfo, + matchedColor = matchedColor, + dividerColor = dividerColor, + onClick = { + onCardClick() + analyticsHelper.trackClickEvent( + screenName = "match_main_home", + buttonName = "collapsed_match_button", + ) + } + ) + } + } + } + } + } +} + +@Composable +private fun ExpandedContent( + matchInfo: MatchInfo, + matchedColor: Color, + matchValueCountTextColor: Color, + dividerColor: Color, + onClick: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + ) { + Text( + text = matchInfo.description, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = PieceTheme.typography.headingLSB, + color = matchedColor, + ) + + Text( + text = "${matchInfo.nickname},", + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = PieceTheme.typography.headingLSB, + color = matchedColor, + ) + + Text( + text = buildAnnotatedString { + append("${stringResource(R.string.match_prefix)} ") + withStyle(SpanStyle(color = matchValueCountTextColor)) { + append(stringResource(R.string.match_middle, matchInfo.matchedValueCount)) + } + append(" ${stringResource(R.string.match_suffix)}") + }, + maxLines = 1, + style = PieceTheme.typography.headingLSB, + color = matchedColor, + ) + + UserInfoRow( + birthYear = matchInfo.birthYear, + location = matchInfo.location, + job = matchInfo.job, + textColor = matchedColor, + dividerColor = dividerColor, + topPadding = 12.dp + ) + } +} + +@Composable +private fun CollapsedContent( + matchInfo: MatchInfo, + matchedColor: Color, + dividerColor: Color, + onClick: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + ) { + UserInfoRow( + birthYear = matchInfo.birthYear, + location = matchInfo.location, + job = matchInfo.job, + textColor = matchedColor, + dividerColor = dividerColor, + topPadding = 4.dp + ) + } +} + +@Composable +private fun MatchStatusRow( + matchStatus: MatchStatus, + matchInfo: MatchInfo, + remainTime: String +) { + val (imageRes, tag) = when (matchStatus) { + BEFORE_OPEN -> R.drawable.ic_matching_loading to stringResource(R.string.before_open) + WAITING -> R.drawable.ic_matching_loading to stringResource(R.string.waiting_for_response) + RESPONDED -> R.drawable.ic_matching_check to stringResource(R.string.responded) + GREEN_LIGHT -> R.drawable.ic_matching_heart to stringResource(R.string.green_light) + MATCHED -> R.drawable.ic_check_matched to stringResource(R.string.matched) + else -> R.drawable.ic_matching_loading to stringResource(R.string.before_open) + } + + val isMatched = matchInfo.matchStatus == MATCHED + + val titleTextColor = if (isMatched) PieceTheme.colors.white else PieceTheme.colors.black + val timerColor = if (isMatched) PieceTheme.colors.white60 else PieceTheme.colors.subDefault + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Image( + painter = painterResource(imageRes), + contentDescription = null, + ) + + if (matchInfo.isExpanded) { + Text( + text = tag, + style = PieceTheme.typography.bodySSB, + color = titleTextColor + ) + } else { + Text( + text = matchInfo.nickname, + style = PieceTheme.typography.headingSSB, + color = titleTextColor + ) + } + + Spacer(Modifier.weight(1f)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(R.drawable.ic_clock), + contentDescription = null, + colorFilter = ColorFilter.tint(timerColor) + ) + Text( + text = remainTime, + style = PieceTheme.typography.bodySM, + color = timerColor + ) + } + } +} + +@Composable +private fun UserInfoRow( + birthYear: String, + location: String, + job: String, + textColor: Color, + dividerColor: Color, + topPadding: Dp +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = topPadding), + ) { + Text( + text = stringResource(R.string.birth_year_format, birthYear), + style = PieceTheme.typography.bodyMM, + color = textColor, + modifier = Modifier.align(Alignment.CenterVertically) + ) + + VerticalDivider( + thickness = 1.dp, + color = dividerColor, + modifier = Modifier.height(12.dp) + ) + + Text( + text = location, + style = PieceTheme.typography.bodyMM, + color = textColor, + modifier = Modifier.align(Alignment.CenterVertically) + ) + + VerticalDivider( + thickness = 1.dp, + color = dividerColor, + modifier = Modifier.height(12.dp) + ) + + Text( + text = job, + style = PieceTheme.typography.bodyMM, + color = textColor, + modifier = Modifier.align(Alignment.CenterVertically) + ) + } +} + +@Composable +private fun MatchingCardBackground( + matchStatus: MatchStatus, + matchType: MatchType, + isExpanded: Boolean, + content: @Composable ColumnScope.() -> Unit +) { + val isMatched = matchStatus == MATCHED + val isGreenLightTome = matchStatus == GREEN_LIGHT && matchType == MatchType.TO_ME + + val backgroundColor = + if (isGreenLightTome) PieceTheme.colors.primaryLight else PieceTheme.colors.white + + val imageContentScale = if (isExpanded) ContentScale.FillHeight else ContentScale.FillWidth + val minHeight = if (isExpanded) 292.dp else 86.dp + + Box( + modifier = Modifier + .heightIn(min = minHeight) + .clip(RoundedCornerShape(12.dp)) + .background(backgroundColor) + ) { + if (isMatched) { + Image( + painter = painterResource(R.drawable.bg_matched_card), + contentDescription = "bg_matched_card", + contentScale = imageContentScale, + modifier = Modifier.matchParentSize() + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + ) { + content() + } + } +} + +@Preview +@Composable +private fun NewMatchingCardPreview() { + PieceTheme { + NewMatchingCard(isFreeMatching = false, onNewMatchingCardClick = {}) + } +} + +@Preview +@Composable +private fun NewFreeMatchingCardPreview() { + PieceTheme { + NewMatchingCard(isFreeMatching = true, onNewMatchingCardClick = {}) + } +} + +@Preview(showBackground = true) +@Composable +private fun MatchingUserCardPreview( + @PreviewParameter(MatchInfoPreviewProvider::class) matchInfo: MatchInfo +) { + PieceTheme { + MatchingUserCard( + matchInfo = matchInfo, + remainTime = "00:00:00", + onCardClick = {} + ) + } +} + +internal fun sampleMatchInfo( + matchId: Int = 0, + matchStatus: MatchStatus, + isExpanded: Boolean = false, + matchType: MatchType = MatchType.FROM_ME +) = MatchInfo( + matchId = matchId, + matchedUserId = 1, + matchStatus = matchStatus, + description = "음악과 요리를 좋아하는", + nickname = "수줍은 수달", + birthYear = "02", + location = "광주광역시", + job = "학생", + isBlocked = false, + matchedValueCount = 7, + matchedValueList = listOf( + "바깥 데이트 스킨십도 가능", + "함께 술을 즐기고 싶어요", + "커밍아웃은 가까운 친구에게만 했어요", + ), + matchType = matchType, + matchedDateTime = "", + isExpanded = isExpanded +) \ No newline at end of file diff --git a/feature/matching/src/main/java/com/puzzle/matching/graph/main/contract/MatchingIntent.kt b/feature/matching/src/main/java/com/puzzle/matching/graph/main/contract/MatchingIntent.kt index 312420790..14a91a9b2 100644 --- a/feature/matching/src/main/java/com/puzzle/matching/graph/main/contract/MatchingIntent.kt +++ b/feature/matching/src/main/java/com/puzzle/matching/graph/main/contract/MatchingIntent.kt @@ -1,12 +1,16 @@ package com.puzzle.matching.graph.main.contract import com.puzzle.common.base.UiIntent +import com.puzzle.domain.model.match.MatchInfo +import com.puzzle.matching.graph.detail.common.constant.PurchaseDialogType sealed class MatchingIntent : UiIntent { - data object OnButtonClick : MatchingIntent() + data class OnAcceptClick(val dialogType: PurchaseDialogType) : MatchingIntent() + data object OnDisMissClick : MatchingIntent() data object OnStoreClick : MatchingIntent() data object OnCheckMyProfileClick : MatchingIntent() - data object OnMatchingDetailClick : MatchingIntent() data object OnEditProfileClick : MatchingIntent() data object OnNotificationClick : MatchingIntent() + data object OnNewMatchingCardClick: MatchingIntent() + data class OnMatchingCardClick(val match : MatchInfo) : MatchingIntent() } diff --git a/feature/matching/src/main/java/com/puzzle/matching/graph/main/contract/MatchingState.kt b/feature/matching/src/main/java/com/puzzle/matching/graph/main/contract/MatchingState.kt index 111edd4e2..a5be3cd44 100644 --- a/feature/matching/src/main/java/com/puzzle/matching/graph/main/contract/MatchingState.kt +++ b/feature/matching/src/main/java/com/puzzle/matching/graph/main/contract/MatchingState.kt @@ -1,33 +1,44 @@ package com.puzzle.matching.graph.main.contract +import androidx.compose.runtime.Immutable import com.puzzle.common.base.UiState import com.puzzle.common.formatTimeToHourMinuteSecond import com.puzzle.domain.model.match.MatchInfo -import com.puzzle.domain.model.match.MatchStatus import com.puzzle.domain.model.user.RejectReason import com.puzzle.domain.model.user.UserRole +import com.puzzle.matching.graph.detail.common.constant.PurchaseDialogType +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentHashMapOf +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableMap +@Immutable data class MatchingState( val isLoading: Boolean = true, + val canFreeMatch: Boolean = false, + val puzzleCount : Int = 0, val userRole: UserRole? = null, - val matchInfo: MatchInfo? = null, + val matchInfoList : ImmutableList = persistentListOf(), val rejectReason: RejectReason = RejectReason(reasonImage = false, reasonValues = false), - val remainWaitingTimeInSec: Long = 0L, + val remainWaitingTimeInSecMap: PersistentMap = persistentHashMapOf(),// + val isShowDialog : Boolean = false, + val dialogType: PurchaseDialogType = PurchaseDialogType.InsufficientPuzzle ) : UiState { - val formattedRemainWaitingTime: String = formatTimeToHourMinuteSecond(remainWaitingTimeInSec) + val formattedRemainingTimeMap = + remainWaitingTimeInSecMap + .mapValues { formatTimeToHourMinuteSecond(it.value) } + .toImmutableMap() val matchingStatus: MatchingStatus = when { isLoading -> MatchingStatus.LOADING userRole == UserRole.PENDING -> MatchingStatus.PENDING userRole == UserRole.USER -> { - if (matchInfo == null || matchInfo.blocked) { + if (matchInfoList.isEmpty() || matchInfoList.all { it.isBlocked }){ MatchingStatus.WAITING - } else { - when (matchInfo.matchStatus) { - MatchStatus.REFUSED -> MatchingStatus.WAITING - MatchStatus.UNKNOWN -> MatchingStatus.LOADING - else -> MatchingStatus.USER - } + }else{ + MatchingStatus.USER } } diff --git a/feature/matching/src/main/java/com/puzzle/matching/graph/main/page/MatchingLoadingScreen.kt b/feature/matching/src/main/java/com/puzzle/matching/graph/main/page/MatchingLoadingScreen.kt index 919b8591f..d09fc908d 100644 --- a/feature/matching/src/main/java/com/puzzle/matching/graph/main/page/MatchingLoadingScreen.kt +++ b/feature/matching/src/main/java/com/puzzle/matching/graph/main/page/MatchingLoadingScreen.kt @@ -1,5 +1,6 @@ package com.puzzle.matching.graph.main.page +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -12,18 +13,21 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.puzzle.designsystem.R import com.puzzle.designsystem.foundation.PieceTheme @Composable -internal fun MatchingLoadingScreen() { +internal fun MatchingWaitingLoadingScreen() { Column(modifier = Modifier.semantics { contentDescription = "LoadingScreen" }) { Spacer( modifier = Modifier @@ -198,9 +202,80 @@ internal fun MatchingLoadingScreen() { } } +@Composable +internal fun MatchingCardsLoadingScreen() { + Column(modifier = Modifier.semantics { contentDescription = "MatchingCardsLoadingScreen" }) { + Column( + verticalArrangement = Arrangement.spacedBy(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(top = 8.dp, bottom = 12.dp) + .clip(RoundedCornerShape(12.dp)) + .background(PieceTheme.colors.white) + .padding(20.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 20.dp), + ) { + Spacer( + modifier = Modifier + .size(width = 80.dp, height = 20.dp) + .clip(RoundedCornerShape(4.dp)) + .background(PieceTheme.colors.light2) + ) + Spacer( + modifier = Modifier + .size(width = 80.dp, height = 20.dp) + .clip(RoundedCornerShape(4.dp)) + .background(PieceTheme.colors.light2) + ) + } + + Column { + Spacer( + modifier = Modifier + .padding(bottom = 12.dp) + .fillMaxWidth() + .height(100.dp) + .clip(RoundedCornerShape(4.dp)) + .background(PieceTheme.colors.light2) + ) + + Spacer( + modifier = Modifier + .padding(bottom = 80.dp) + .size(width = 180.dp, height = 20.dp) + .clip(RoundedCornerShape(4.dp)) + .background(PieceTheme.colors.light2) + ) + } + } + + repeat(4) { + Spacer( + Modifier + .padding(bottom = 12.dp) + .fillMaxWidth() + .height(78.dp) + .clip(RoundedCornerShape(12.dp)) + .background(PieceTheme.colors.white) + ) + } + } +} @Preview @Composable private fun PreviewMatchingLoadingScreen() { - PieceTheme { MatchingLoadingScreen() } + PieceTheme { MatchingWaitingLoadingScreen() } +} + +@Preview +@Composable +private fun PreviewMatchingLoadingCardsScreen() { + PieceTheme { MatchingCardsLoadingScreen() } } diff --git a/feature/matching/src/main/java/com/puzzle/matching/graph/main/page/MatchingPendingScreen.kt b/feature/matching/src/main/java/com/puzzle/matching/graph/main/page/MatchingPendingScreen.kt index eebf2a971..858168cc0 100644 --- a/feature/matching/src/main/java/com/puzzle/matching/graph/main/page/MatchingPendingScreen.kt +++ b/feature/matching/src/main/java/com/puzzle/matching/graph/main/page/MatchingPendingScreen.kt @@ -76,9 +76,9 @@ internal fun MatchingPendingScreen( Text( text = buildAnnotatedString { withStyle(SpanStyle(color = PieceTheme.colors.primaryDefault)) { - append("진중한 만남") + append(stringResource(R.string.match_pending_title)) } - append("을 이어가기 위해\n프로필을 살펴보고 있어요.") + append(stringResource(R.string.match_pending_subtitle)) }, style = PieceTheme.typography.headingMSB, color = PieceTheme.colors.black, diff --git a/feature/matching/src/main/java/com/puzzle/matching/graph/main/page/MatchingUserScreen.kt b/feature/matching/src/main/java/com/puzzle/matching/graph/main/page/MatchingUserScreen.kt index f62382f9a..ec7a17dd4 100644 --- a/feature/matching/src/main/java/com/puzzle/matching/graph/main/page/MatchingUserScreen.kt +++ b/feature/matching/src/main/java/com/puzzle/matching/graph/main/page/MatchingUserScreen.kt @@ -1,442 +1,81 @@ package com.puzzle.matching.graph.main.page -import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.puzzle.analytics.LocalAnalyticsHelper -import com.puzzle.common.ui.clickable -import com.puzzle.common.ui.verticalScrollbar -import com.puzzle.designsystem.R -import com.puzzle.designsystem.component.PieceSolidButton import com.puzzle.designsystem.foundation.PieceTheme import com.puzzle.domain.model.match.MatchInfo -import com.puzzle.domain.model.match.MatchStatus import com.puzzle.domain.model.match.MatchStatus.BEFORE_OPEN import com.puzzle.domain.model.match.MatchStatus.GREEN_LIGHT import com.puzzle.domain.model.match.MatchStatus.MATCHED -import com.puzzle.domain.model.match.MatchStatus.RESPONDED -import com.puzzle.domain.model.match.MatchStatus.WAITING +import com.puzzle.matching.graph.main.component.card.MatchingUserCard +import com.puzzle.matching.graph.main.component.card.NewMatchingCard +import com.puzzle.matching.graph.main.component.card.sampleMatchInfo +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentHashMapOf +import kotlinx.collections.immutable.persistentListOf @Composable internal fun MatchingUserScreen( - matchInfo: MatchInfo, - remainTime: String, - onButtonClick: () -> Unit, - onMatchingDetailClick: () -> Unit, - showDialog: () -> Unit, + matchInfoList: ImmutableList, + remainTimeHashMap: ImmutableMap, + onMatchingCardClick: (MatchInfo) -> Unit, + onNewMatchingCardClick: () -> Unit, + canFreeMatch: Boolean, ) { - val analyticsHelper = LocalAnalyticsHelper.current - val listState = rememberLazyListState() - - Column(modifier = Modifier.semantics { contentDescription = "UserScreen" }) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(PieceTheme.colors.white.copy(alpha = 0.1f)), - ) { - Text( - text = buildAnnotatedString { - append(stringResource(R.string.precious_connection_start)) - withStyle(style = SpanStyle(color = PieceTheme.colors.subDefault)) { - append(remainTime) - } - append(stringResource(R.string.time_remaining)) - }, - style = PieceTheme.typography.bodySM, - color = PieceTheme.colors.light1, - modifier = Modifier.padding(vertical = 12.dp), + LazyColumn( + modifier = Modifier.semantics { contentDescription = "MatchingUserScreen" }, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items( + items = matchInfoList, + key = { it.matchId } + ) { matchInfo -> + MatchingUserCard( + matchInfo = matchInfo, + remainTime = remainTimeHashMap[matchInfo.matchId] ?: "00:00:00", + onCardClick = { onMatchingCardClick(matchInfo) } ) } - - Column( - verticalArrangement = Arrangement.spacedBy(20.dp), - modifier = Modifier - .padding(top = 8.dp, bottom = 30.dp) - .clip(RoundedCornerShape(12.dp)) - .background(PieceTheme.colors.white) - .padding(20.dp), - ) { - MatchStatusRow(matchInfo.matchStatus) - - Column( - modifier = Modifier - .fillMaxWidth() - .clickable { - when (matchInfo.matchStatus) { - BEFORE_OPEN -> onButtonClick() - else -> onMatchingDetailClick() - } - - analyticsHelper.trackClickEvent( - screenName = "match_main_home", - buttonName = "userDescription", - ) - }, - ) { - Text( - text = matchInfo.description, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = PieceTheme.typography.headingLSB, - color = PieceTheme.colors.black, - ) - - Text( - text = stringResource(R.string.nickname_format, matchInfo.nickname), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = PieceTheme.typography.headingLSB, - color = PieceTheme.colors.black, - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(top = 12.dp), - ) { - Text( - text = "${matchInfo.birthYear}년생", - style = PieceTheme.typography.bodyMM, - color = PieceTheme.colors.dark2, - ) - - VerticalDivider( - thickness = 1.dp, - color = PieceTheme.colors.light2, - modifier = Modifier.height(12.dp) - ) - - Text( - text = matchInfo.location, - style = PieceTheme.typography.bodyMM, - color = PieceTheme.colors.dark2, - ) - - VerticalDivider( - thickness = 1.dp, - color = PieceTheme.colors.light2, - modifier = Modifier.height(12.dp) - ) - - Text( - text = matchInfo.job, - style = PieceTheme.typography.bodyMM, - color = PieceTheme.colors.dark2, - ) - } - } - - HorizontalDivider( - thickness = 1.dp, - color = PieceTheme.colors.light2, + item { + NewMatchingCard( + modifier = Modifier.padding(bottom = 18.dp), + isFreeMatching = canFreeMatch, + onNewMatchingCardClick = onNewMatchingCardClick ) - - Column( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - ) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - text = stringResource(R.string.same_value_as_me), - style = PieceTheme.typography.bodyMM, - color = PieceTheme.colors.black, - ) - - Text( - text = "${matchInfo.matchedValueCount}개", - style = PieceTheme.typography.bodyMM, - color = PieceTheme.colors.primaryDefault, - ) - } - - LazyColumn( - state = listState, - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier - .padding(top = 12.dp) - .fillMaxWidth() - .weight(1f) - .heightIn(max = 191.dp) - .verticalScrollbar( - state = listState, - color = PieceTheme.colors.light2 - ) - .padding(bottom = 20.dp), - ) { - items(items = matchInfo.matchedValueList) { value -> ValueTag(value) } - - item { - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(20.dp), - ) - } - } - - val label = when (matchInfo.matchStatus) { - BEFORE_OPEN -> stringResource(R.string.check_matching_pieces) - WAITING, GREEN_LIGHT -> stringResource(R.string.accept_matching) - RESPONDED -> stringResource(R.string.responded) - MATCHED -> stringResource(R.string.check_contact) - else -> "" - } - PieceSolidButton( - label = label, - enabled = matchInfo.matchStatus != RESPONDED, - onClick = { - when (matchInfo.matchStatus) { - BEFORE_OPEN -> { - analyticsHelper.trackClickEvent( - screenName = "match_main_home", - buttonName = "checkRelationShip", - ) - onButtonClick() - } - - MATCHED -> onButtonClick() - else -> showDialog() - } - }, - modifier = Modifier.fillMaxWidth(), - ) - } } } } -@Composable -private fun MatchStatusRow( - matchStatus: MatchStatus, -) { - val (imageRes, tag, description) = when (matchStatus) { - BEFORE_OPEN -> Triple( - R.drawable.ic_matching_loading, - stringResource(R.string.before_open), - stringResource(R.string.check_the_matching_pieces) - ) - - WAITING -> Triple( - R.drawable.ic_matching_loading, - stringResource(R.string.waiting_for_response), - stringResource(R.string.please_respond_to_matching) - ) - - RESPONDED -> Triple( - R.drawable.ic_matching_check, - stringResource(R.string.responded), - stringResource(R.string.waiting_for_other_response) - ) - - GREEN_LIGHT -> Triple( - R.drawable.ic_matching_heart, - stringResource(R.string.green_light), - stringResource(R.string.other_accepted_matching) - ) - - MATCHED -> Triple( - R.drawable.ic_matching_check, - stringResource(R.string.matched), - stringResource(R.string.connected_with_other) - ) - - else -> Triple( - R.drawable.ic_matching_loading, - stringResource(R.string.before_open), - stringResource(R.string.check_the_matching_pieces) - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), - ) { - Image( - painter = painterResource(imageRes), - contentDescription = null, - ) - - Text( - text = tag, - style = PieceTheme.typography.bodySSB, - color = PieceTheme.colors.dark2 - ) - - Text( - text = description, - style = PieceTheme.typography.bodySM, - color = PieceTheme.colors.dark3 - ) - } -} - -@Composable -private fun ValueTag(value: String) { - Box( - modifier = Modifier - .wrapContentSize() - .clip(RoundedCornerShape(4.dp)) - .background(PieceTheme.colors.primaryLight) - .padding(vertical = 6.dp, horizontal = 12.dp), - ) { - Text( - text = value, - style = PieceTheme.typography.bodyMR, - color = PieceTheme.colors.black, - ) - } -} - -@Preview +@Preview(showBackground = true, backgroundColor = 0xFF000000) @Composable private fun PreviewMatchingUserScreen() { + var isExpanded by remember { mutableStateOf(false) } PieceTheme { MatchingUserScreen( - matchInfo = MatchInfo( - matchId = 1, - matchedUserId = 1, - matchStatus = WAITING, - description = "음악과 요리를 좋아하는", - nickname = "수줍은 수달", - birthYear = "02", - location = "광주광역시", - job = "학생", - blocked = false, - matchedValueCount = 7, - matchedValueList = listOf( - "바깥 데이트 스킨십도 가능", - "함께 술을 즐기고 싶어요", - "커밍아웃은 가까운 친구에게만 했어요", - ) + matchInfoList = persistentListOf( + sampleMatchInfo(1, MATCHED, isExpanded = isExpanded), + sampleMatchInfo(2, GREEN_LIGHT, isExpanded = true), + sampleMatchInfo(3, BEFORE_OPEN, isExpanded = isExpanded), + sampleMatchInfo(4, MATCHED, isExpanded = true) ), - onButtonClick = {}, - onMatchingDetailClick = {}, - showDialog = {}, - remainTime = " 00:00:00 " + remainTimeHashMap = persistentHashMapOf(3 to "00:00:01", 1 to "00:10:02"), + onMatchingCardClick = { isExpanded = !isExpanded }, + onNewMatchingCardClick = {}, + canFreeMatch = true, ) } } -@Preview -@Composable -private fun PreviewMatchingUserScreen2() { - PieceTheme { - MatchingUserScreen( - matchInfo = MatchInfo( - matchId = 1, - matchedUserId = 1, - matchStatus = RESPONDED, - description = "음악과 요리를 좋아하는", - nickname = "수줍은 수달", - birthYear = "02", - location = "광주광역시", - job = "학생", - blocked = false, - matchedValueCount = 7, - matchedValueList = listOf( - "바깥 데이트 스킨십도 가능", - "함께 술을 즐기고 싶어요", - "커밍아웃은 가까운 친구에게만 했어요", - ) - ), - onButtonClick = {}, - onMatchingDetailClick = {}, - showDialog = {}, - remainTime = " 00:00:00 " - ) - } -} - -@Preview -@Composable -private fun PreviewMatchingUserScreen3() { - PieceTheme { - MatchingUserScreen( - matchInfo = MatchInfo( - matchId = 1, - matchedUserId = 1, - matchStatus = GREEN_LIGHT, - description = "음악과 요리를 좋아하는", - nickname = "수줍은 수달", - birthYear = "02", - location = "광주광역시", - job = "학생", - blocked = false, - matchedValueCount = 7, - matchedValueList = listOf( - "바깥 데이트 스킨십도 가능", - "함께 술을 즐기고 싶어요", - "커밍아웃은 가까운 친구에게만 했어요", - ) - ), - onButtonClick = {}, - onMatchingDetailClick = {}, - showDialog = {}, - remainTime = " 00:00:00 " - ) - } -} - -@Preview -@Composable -private fun PreviewMatchingUserScreen4() { - PieceTheme { - MatchingUserScreen( - matchInfo = MatchInfo( - matchId = 1, - matchedUserId = 1, - matchStatus = BEFORE_OPEN, - description = "음악과 요리를 좋아하는", - nickname = "수줍은 수달", - birthYear = "02", - location = "광주광역시", - job = "학생", - blocked = false, - matchedValueCount = 7, - matchedValueList = listOf( - "바깥 데이트 스킨십도 가능", - "함께 술을 즐기고 싶어요", - "커밍아웃은 가까운 친구에게만 했어요", - ) - ), - onButtonClick = {}, - onMatchingDetailClick = {}, - showDialog = {}, - remainTime = " 00:00:00 ", - ) - } -} diff --git a/feature/matching/src/main/java/com/puzzle/matching/graph/main/page/MatchingWaitingScreen.kt b/feature/matching/src/main/java/com/puzzle/matching/graph/main/page/MatchingWaitingScreen.kt index 43afc694d..61e51b0ac 100644 --- a/feature/matching/src/main/java/com/puzzle/matching/graph/main/page/MatchingWaitingScreen.kt +++ b/feature/matching/src/main/java/com/puzzle/matching/graph/main/page/MatchingWaitingScreen.kt @@ -2,7 +2,6 @@ package com.puzzle.matching.graph.main.page import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -29,49 +28,33 @@ import androidx.compose.ui.unit.dp import com.puzzle.designsystem.R import com.puzzle.designsystem.component.PieceSolidButton import com.puzzle.designsystem.foundation.PieceTheme +import com.puzzle.matching.graph.main.component.card.NewMatchingCard @Composable internal fun MatchingWaitingScreen( - remainTime: String, + canFreeMatch : Boolean, onCheckMyProfileClick: () -> Unit, + onNewMatchingCardClick : () -> Unit, ) { - Column(modifier = Modifier.semantics { contentDescription = "WaitingScreen" }) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(PieceTheme.colors.white.copy(alpha = 0.1f)), - ) { - Text( - text = buildAnnotatedString { - append(stringResource(R.string.precious_connection_start)) - withStyle(style = SpanStyle(color = PieceTheme.colors.subDefault)) { - append(remainTime) - } - append(stringResource(R.string.time_remaining)) - }, - style = PieceTheme.typography.bodySM, - color = PieceTheme.colors.light1, - modifier = Modifier.padding(vertical = 12.dp), - ) - } - + Column( + modifier = Modifier + .semantics { contentDescription = "WaitingScreen" } + .verticalScroll(rememberScrollState()) + ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .padding(top = 8.dp, bottom = 30.dp) + .padding(top = 8.dp, bottom = 12.dp) .clip(RoundedCornerShape(12.dp)) .background(PieceTheme.colors.white) - .padding(20.dp) - .verticalScroll(rememberScrollState()), + .padding(20.dp), ) { Text( text = buildAnnotatedString { withStyle(SpanStyle(color = PieceTheme.colors.primaryDefault)) { - append("새로운 인연") + append(stringResource(R.string.new_match_middle)) } - append("이 곧 도착할 거예요!") + append(stringResource(R.string.new_match_suffix)) }, style = PieceTheme.typography.headingMSB, color = PieceTheme.colors.black, @@ -80,7 +63,7 @@ internal fun MatchingWaitingScreen( ) Text( - text = "내 운명의 상대를 찾을지도 몰라요.\n놓치지 않도록 푸쉬 알림을 켜주세요!", + text = stringResource(R.string.new_match_body), style = PieceTheme.typography.bodySM, color = PieceTheme.colors.dark3, textAlign = TextAlign.Center, @@ -109,6 +92,12 @@ internal fun MatchingWaitingScreen( modifier = Modifier.fillMaxWidth(), ) } + + NewMatchingCard( + modifier = Modifier.padding(bottom = 18.dp), + isFreeMatching = canFreeMatch, + onNewMatchingCardClick = onNewMatchingCardClick + ) } } @@ -119,7 +108,8 @@ private fun PreviewMatchingWaitingScreen() { PieceTheme { MatchingWaitingScreen( onCheckMyProfileClick = {}, - remainTime = " 20:20:20 " + onNewMatchingCardClick = {}, + canFreeMatch = true ) } } diff --git a/feature/matching/src/test/java/com/puzzle/matching/graph/main/MatchingViewModelTest.kt b/feature/matching/src/test/java/com/puzzle/matching/graph/main/MatchingViewModelTest.kt index ee8ead29a..43fcdfd91 100644 --- a/feature/matching/src/test/java/com/puzzle/matching/graph/main/MatchingViewModelTest.kt +++ b/feature/matching/src/test/java/com/puzzle/matching/graph/main/MatchingViewModelTest.kt @@ -2,10 +2,8 @@ package com.puzzle.matching.graph.main import com.puzzle.common.event.EventHelper import com.puzzle.domain.model.error.ErrorHelper -import com.puzzle.domain.model.error.HttpResponseException -import com.puzzle.domain.model.error.HttpResponseStatus -import com.puzzle.domain.model.error.PieceErrorCode import com.puzzle.domain.model.match.MatchStatus +import com.puzzle.domain.model.match.MatchType import com.puzzle.domain.model.user.ProfileStatus import com.puzzle.domain.model.user.UserRole import com.puzzle.navigation.NavigationHelper @@ -20,6 +18,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest @@ -53,48 +52,54 @@ class MatchingViewModelTest { ) } -// Android 기능 Block으로 인해 테스트 일시 주석 처리 -// @ParameterizedTest -// @CsvSource( -// "BANNED,APPROVED", -// "USER,REJECTED" -// ) -// fun `UserRole이 BANNED이거나 ProfileStatus가 REJECTED인 경우 타이머는 실행되지 않는다`( -// roleString: String, -// profileStatusString: String -// ) = runTest(testDispatcher) { -// // given -// val userInfo = MockUserInfo.default( -// userRole = UserRole.valueOf(roleString), -// profileStatus = ProfileStatus.valueOf(profileStatusString) -// ) -// userRepository.setUserInfo(userInfo) -// matchingRepository.setMatchInfoResult(MockMatchInfo.default(MatchStatus.REFUSED)) -// -// // when -// viewModel.initMatchInfo() -// -// // then -// assertEquals(0, timer.startTimerCallCount) -// } -// -// @ParameterizedTest -// @EnumSource(value = MatchStatus::class) -// fun `ProfileStatus가 APPROVED일 경우 타이머가 실행된다`(matchStatus: MatchStatus) = -// runTest(testDispatcher) { -// // given -// val userInfo = MockUserInfo.default() -// userRepository.setUserInfo(userInfo) -// matchingRepository.setMatchInfoResult(MockMatchInfo.default(matchStatus)) -// -// // when -// viewModel.initMatchInfo() -// -// // then -// advanceUntilIdle() -// assertEquals(1, timer.startTimerCallCount) -// } -// + @ParameterizedTest + @CsvSource( + "BANNED,APPROVED", + "USER,REJECTED" + ) + fun `UserRole이 BANNED이거나 ProfileStatus가 REJECTED인 경우 타이머는 실행되지 않는다`( + roleString: String, + profileStatusString: String + ) = runTest(testDispatcher) { + // given + val userInfo = MockUserInfo.default( + userRole = UserRole.valueOf(roleString), + profileStatus = ProfileStatus.valueOf(profileStatusString) + ) + userRepository.setUserInfo(userInfo) + matchingRepository.setMatchInfoResult(MockMatchInfo.default(MatchStatus.REFUSED)) + + // when + viewModel.initMatchInfo() + + // then + assertEquals(0, timer.startTimerCallCount) + } + + @ParameterizedTest + @EnumSource(value = MatchStatus::class) + fun `ProfileStatus가 APPROVED일 경우 여러개의 매칭에 대한 타이머가 각각 실행된다`(matchStatus: MatchStatus) = + runTest(testDispatcher) { + // given + val userInfo = MockUserInfo.default() + userRepository.setUserInfo(userInfo) + matchingRepository.setMatchInfoListResult( + listOf( + MockMatchInfo.default(matchId = 1,matchStatus = matchStatus, matchType = MatchType.BASIC), + MockMatchInfo.default(matchId = 2,matchStatus = matchStatus, matchType = MatchType.TO_ME ), + MockMatchInfo.default(matchId = 3,matchStatus = matchStatus, matchType = MatchType.FROM_ME ) + ) + ) + + // when + viewModel.initMatchInfo() + + // then + advanceUntilIdle() + + assertTrue(timer.startTimerCallCount == 3) + } + // @Test // fun `매칭 정보를 조회했을 때, 상대방이 회원탈퇴를 했거나 첫 가입이후 10시 이전이면 타이머가 실행된다`() = // runTest(testDispatcher) {