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..e783907da 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,14 +4,18 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier private const val BOTTOM_BAR_ANIMATION_DURATION = 700 @@ -74,6 +78,44 @@ fun PieceVisibleAnimation( modifier = modifier, ) +@Composable +fun PieceExpandCollapseAnimation( + expanded: Boolean, + modifier: Modifier = Modifier, + contentWhenExpanded: @Composable () -> Unit, + contentWhenCollapsed: @Composable () -> Unit, +) { + Box(modifier = modifier) { + AnimatedVisibility( + visible = expanded, + enter = expandVertically( + expandFrom = Alignment.Top, + animationSpec = tween(durationMillis = AI_GUIDE_MESSAGE_ANIMATION_DURATION, easing = LinearOutSlowInEasing) + ) + fadeIn(animationSpec = tween(AI_GUIDE_MESSAGE_ANIMATION_DURATION, easing = LinearOutSlowInEasing)), + exit = shrinkVertically( + shrinkTowards = Alignment.Top, + animationSpec = tween(durationMillis = AI_GUIDE_MESSAGE_ANIMATION_DURATION, easing = FastOutLinearInEasing) + ) + fadeOut(animationSpec = tween(AI_GUIDE_MESSAGE_ANIMATION_DURATION, easing = FastOutLinearInEasing)) + ) { + contentWhenExpanded() + } + + AnimatedVisibility( + visible = !expanded, + enter = expandVertically( + expandFrom = Alignment.Top, + animationSpec = tween(durationMillis = AI_GUIDE_MESSAGE_ANIMATION_DURATION, easing = LinearOutSlowInEasing) + ) + fadeIn(animationSpec = tween(AI_GUIDE_MESSAGE_ANIMATION_DURATION, easing = LinearOutSlowInEasing)), + exit = shrinkVertically( + shrinkTowards = Alignment.Top, + animationSpec = tween(durationMillis = AI_GUIDE_MESSAGE_ANIMATION_DURATION, easing = FastOutLinearInEasing) + ) + fadeOut(animationSpec = tween(AI_GUIDE_MESSAGE_ANIMATION_DURATION, easing = FastOutLinearInEasing)) + ) { + contentWhenCollapsed() + } + } +} + @Composable fun PieceGuideMessageAnimation( 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..a240afed3 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,41 @@ 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") + + return try { + val pastDateTime = LocalDateTime.parse(pastDateTimeString, formatter) + val targetTime = pastDateTime.plusHours(24) + val now = LocalDateTime.now(zoneId) + + val remainingSeconds = Duration.between(now, targetTime).seconds + if (remainingSeconds > 0) remainingSeconds else 0L // 0 이하일 경우 0으로 처리 + } catch (e: Exception) { + 0L // 파싱 실패 시 0초 반환 + } +} + +/** + * 입력 형식: 항상 "2025-11-09T13:50:53.633789" (ISO_LOCAL_DATE_TIME 형태) GetMatchInfoResponse 응답 + * 출력 형식: "yyyy.MM.dd.HH.mm.ss" 로 변환 + */ +fun formatIsoToCustomFormat(isoString: String): String { + return try { + val inputFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME + val outputFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd.HH.mm.ss") + val dateTime = LocalDateTime.parse(isoString, inputFormatter) + dateTime.format(outputFormatter) + } catch (e: Exception) { + "" // 파싱 실패 시 빈 문자열 반환 + } +} + fun formatTimeToHourMinuteSecond(totalSeconds: Long): String { val hours = totalSeconds / HOUR_IN_SECOND val minutes = (totalSeconds % HOUR_IN_SECOND) / MINUTE_IN_SECOND 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..439eaee2c 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,35 @@ 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 basicMatch = suspendRunCatching { + listOf(matchingDataSource.getMatchInfo().toDomain(MatchType.BASIC)) + }.getOrElse { throwable -> + if (throwable is HttpResponseException && throwable.status == HttpResponseStatus.NotFound) { + emptyList() + } else throw throwable + } + + val toMeMatchList = async { matchingDataSource.getToMeMatchInfoList().map { it.toDomain(MatchType.TO_ME) } } + val fromMeMatchList = async { matchingDataSource.getFromMeMatchInfoList().map { it.toDomain(MatchType.FROM_ME) } } + + basicMatch + toMeMatchList.await() + fromMeMatchList.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 +87,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/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..5ddbd3583 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,32 @@ 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, @@ -188,6 +214,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?, 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..4dad00f00 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 @@ -185,6 +185,59 @@ fun PiecePuzzleTopBar( } } +@Composable +fun PieceTimerWithCloseTopBar( + modifier: Modifier = Modifier, + title: String, + remainTime: String, + contentColor: Color, + closeButtonEnabled: Boolean, + onCloseClick: () -> Unit, +) { + 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_onboarding_camera.xml b/core/designsystem/src/main/res/drawable/ic_onboarding_camera.xml deleted file mode 100644 index 07cd43b90..000000000 --- a/core/designsystem/src/main/res/drawable/ic_onboarding_camera.xml +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/core/designsystem/src/main/res/drawable/ic_onboarding_logo.xml b/core/designsystem/src/main/res/drawable/ic_onboarding_logo.xml deleted file mode 100644 index f003f5e84..000000000 --- a/core/designsystem/src/main/res/drawable/ic_onboarding_logo.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/core/designsystem/src/main/res/drawable/ic_onboarding_screenshot.webp b/core/designsystem/src/main/res/drawable/ic_onboarding_screenshot.webp new file mode 100644 index 000000000..fdc3623a2 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/ic_onboarding_screenshot.webp differ 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/raw/onboarding_basic.lottie b/core/designsystem/src/main/res/raw/onboarding_basic.lottie new file mode 100644 index 000000000..2362ca210 Binary files /dev/null and b/core/designsystem/src/main/res/raw/onboarding_basic.lottie differ diff --git a/core/designsystem/src/main/res/raw/onboarding_greenlight.lottie b/core/designsystem/src/main/res/raw/onboarding_greenlight.lottie new file mode 100644 index 000000000..725e406b7 Binary files /dev/null and b/core/designsystem/src/main/res/raw/onboarding_greenlight.lottie differ diff --git a/core/designsystem/src/main/res/raw/onboarding_premium.lottie b/core/designsystem/src/main/res/raw/onboarding_premium.lottie new file mode 100644 index 000000000..453c8d572 Binary files /dev/null and b/core/designsystem/src/main/res/raw/onboarding_premium.lottie differ diff --git a/core/designsystem/src/main/res/raw/onboarding_talk.lottie b/core/designsystem/src/main/res/raw/onboarding_talk.lottie new file mode 100644 index 000000000..41b240570 Binary files /dev/null and b/core/designsystem/src/main/res/raw/onboarding_talk.lottie differ diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml index 127a18a47..690179565 100644 --- a/core/designsystem/src/main/res/values/strings.xml +++ b/core/designsystem/src/main/res/values/strings.xml @@ -4,12 +4,17 @@ 아직 프로필을 심사하고 있어요 - 건너뛰기 - 시작하기 - 하루 한 번,\n1:1로 만나는 특별한 인연 - 매일 밤 10시, 새로운 매칭 조각이 도착해요.\n천천히 프로필을 살펴보고, 맞춰볼지 결정해보세요. - 안심하고\n소중한 만남을 즐기세요 - 스크린샷은 제한되어 있어요.\n오직 이 공간에서만, 편안하게 인연을 찾아보세요. + 매일 밤 10시,\n가치관이 맞는 인연을\n이어줘요 + 인연을 더 만나려면? + 원한다면, 퍼즐로\n더 많은 인연을\n이어갈 수 있어요 + 만남은 어떻게 이루어지나요? + 상대가 먼저 다가오면\n‘그린라이트’,\n서로 통하면 만남이 시작돼요 + 나와 맞는 인연을 만나려면? + 정성이 담긴 프로필은\n꼭 맞는 인연을 만날\n가능성이 높아요 + 안전하게 만나고 싶어요 + 스크린샷은 제한되어 있으니,\n안심하고 인연을 찾아보세요 + 다시 볼래요 + 시작할래요! 📢 잠시 쉬어가는 안내 드려요 @@ -60,6 +65,7 @@ %d개 오픈 전 응답 대기중 + 새로운 인연 만나기 매칭에 응답해주세요! 상대방의 응답을 기다려봐요! 상대방이 매칭을 수락했어요! @@ -70,6 +76,8 @@ 사진 보기 프로필을 수정해주세요 프로필 수정하기 + 새로운 인연을 만나볼까요? + 퍼즐 %1$d개로 나와 맞는 인연을 찾아보세요. 서로의 빈 곳을 채우며 맞물리는 퍼즐처럼.\n서로의 가치관과 마음이 연결되는 순간을 만들어갑니다. @@ -280,6 +288,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..e2efa7830 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,14 @@ package com.puzzle.domain.model.match import com.puzzle.common.SECOND_IN_MILLIS +import javax.annotation.concurrent.Immutable +@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, @@ -14,29 +17,41 @@ data class MatchInfo( val matchedValueCount: Int, val matchedValueList: List, val blocked: Boolean, - val remainMatchingUpdateTimeInSec: Long = System.currentTimeMillis() / SECOND_IN_MILLIS, + val matchedDateTime: String = "", // 매칭 완료된 시간 + val isExpanded : Boolean = false ) +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 +60,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..be1df030f 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,19 @@ 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..6c1d1d65a 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.formatIsoToCustomFormat 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,13 +18,16 @@ data class GetMatchInfoResponse( val birthYear: String?, val location: String?, val job: String?, - val blocked: Boolean?, + val blocked: Boolean? = null, + 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, @@ -32,5 +37,6 @@ data class GetMatchInfoResponse( blocked = blocked ?: false, matchedValueCount = matchedValueCount ?: UNKNOWN_INT, matchedValueList = matchedValueList ?: emptyList(), + matchedDateTime = formatIsoToCustomFormat(matchedDateTime ?: UNKNOWN_STRING) ) } 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..008298e07 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,17 @@ 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 object MockMatchInfo { fun default( matchStatus: MatchStatus = MatchStatus.BEFORE_OPEN, matchId: Int = 1, matchedUserId: Int = 100, - remainMatchingUpdateTimeInSec: Long = System.currentTimeMillis() / 1000, ): MatchInfo = MatchInfo( matchId = matchId, matchedUserId = matchedUserId, + matchType = MatchType.FROM_ME, matchStatus = matchStatus, description = "상대방 소개글입니다.", nickname = "닉네임", @@ -21,6 +22,5 @@ object MockMatchInfo { matchedValueCount = 3, matchedValueList = listOf("책 좋아함", "운동 좋아함", "MBTI: INFP"), blocked = false, - remainMatchingUpdateTimeInSec = remainMatchingUpdateTimeInSec, ) } 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..e5b0a08c4 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 @@ -39,6 +39,10 @@ class SpyMatchingRepository : MatchingRepository { return matchInfoResult } + override suspend fun getInstantMatchInfoList(): List { + TODO("Not yet implemented") + } + override suspend fun getOpponentProfile(): OpponentProfile { return opponentProfile ?: throw IllegalStateException("OpponentProfile not set") } @@ -51,6 +55,10 @@ class SpyMatchingRepository : MatchingRepository { blockedNumbers.addAll(phoneNumbers) } + override suspend fun getCanFreeMatch(): Boolean { + TODO("Not yet implemented") + } + 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/main/MatchingScreenTest.kt b/feature/matching/src/androidTest/java/com/puzzle/matching/graph/main/MatchingScreenTest.kt index 7ff94a9bb..6111d2b10 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,6 +9,7 @@ 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 @@ -25,11 +26,13 @@ class MatchingScreenTest { PieceTheme { MatchingScreen( state = state, - onButtonClick = {}, - onMatchingDetailClick = {}, + onMatchingCardClick = {}, + onStoreClick = {}, + onAcceptClick = {}, + onNewMatchingCardClick = {}, onCheckMyProfileClick = {}, onEditProfileClick = {}, - onNotificationClick = {} + onNotificationClick = {}, ) } } @@ -47,7 +50,6 @@ class MatchingScreenTest { blocked: Boolean = false, matchedValueCount: Int = 0, matchedValueList: List = emptyList(), - remainMatchingUpdateTimeInSec: Long = 0L ) = MatchInfo( matchId = matchId, matchedUserId = matchedUserId, @@ -60,7 +62,7 @@ class MatchingScreenTest { blocked = blocked, matchedValueCount = matchedValueCount, matchedValueList = matchedValueList, - remainMatchingUpdateTimeInSec = remainMatchingUpdateTimeInSec + matchType = MatchType.BASIC, ) @Test @@ -134,7 +136,6 @@ class MatchingScreenTest { blocked = false, matchedValueCount = 3, matchedValueList = listOf("Value1", "Value2", "Value3"), - remainMatchingUpdateTimeInSec = 3600L ) val state = MatchingState( isLoading = false, 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..efc3668a5 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 @@ -37,9 +37,11 @@ import com.puzzle.common.ui.clickable import com.puzzle.common.ui.windowInsetsPadding import com.puzzle.designsystem.R import com.puzzle.designsystem.component.PieceLoading +import com.puzzle.designsystem.component.PieceMainTopBar 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 @@ -163,11 +165,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), + contentColor = PieceTheme.colors.black, modifier = Modifier .fillMaxWidth() .height(topBarHeight) 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..cb3a985a7 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 @@ -5,3 +5,5 @@ enum class DialogType { DECLINE_MATCHING, PROFILE_IMAGE_DETAIL, } + + 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..297c75be7 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,29 @@ 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 { + // 60분 미만일 때만 표시 + 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..7263f380b 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 @@ -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..9baddf4f2 --- /dev/null +++ b/feature/matching/src/main/java/com/puzzle/matching/graph/detail/dialog/PurchaseMatchingDialog.kt @@ -0,0 +1,158 @@ +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 + +@Composable +internal fun PurchaseContactDialog( + nickname: String?, + puzzleCount : Int, + onDismissRequest: () -> Unit, + onAcceptClick: () -> Unit, +){ + PieceDialog( + dialogTop = { + PieceDialogDefaultTop( + title = buildAnnotatedString { + withStyle(style = SpanStyle(color = PieceTheme.colors.primaryDefault)) { + append(nickname) + } + append("님과의\n인연을 이어갈까요?") + }, + subText = buildAnnotatedString { + append("퍼즐 ") + withStyle( + style = SpanStyle(color = PieceTheme.colors.primaryDefault) + ) { + append("${puzzleCount}개") + } + append("를 사용하면,\n지금 바로 연락처를 확인할 수 있어요.") + } + ) + }, + dialogBottom = { + PiecePurchaseDialogBottom( + imageId = R.drawable.ic_puzzle_white, + leftButtonText = stringResource(R.string.back), + rightButtonText = puzzleCount.toString(), + onLeftButtonClick = onDismissRequest, + onRightButtonClick = { + onDismissRequest() + onAcceptClick() + }, + ) + }, + onDismissRequest = onDismissRequest, + ) + + TrackScreenViewEvent(screenName = "match_main_accept_popup") +} + + +@Composable +internal fun PurchaseNewMatchDialog( + puzzleCount : Int, + onDismissRequest: () -> Unit, + onAcceptClick: () -> Unit, +){ + PieceDialog( + dialogTop = { + PieceDialogDefaultTop( + title = stringResource(R.string.find_new_match), + subText = stringResource(R.string.find_new_match_with_puzzle,puzzleCount) + ) + }, + dialogBottom = { + PiecePurchaseDialogBottom( + imageId = R.drawable.ic_puzzle_white, + leftButtonText = stringResource(R.string.back), + rightButtonText = puzzleCount.toString(), + onLeftButtonClick = onDismissRequest, + onRightButtonClick = { + onDismissRequest() + onAcceptClick() + }, + ) + }, + onDismissRequest = onDismissRequest, + ) + + TrackScreenViewEvent(screenName = "match_main_accept_popup") +} + +@Composable +internal fun InsufficientPuzzleDialog( + onDismissRequest: () -> Unit, + onAcceptClick: () -> 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() + }, + ) + }, + 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..139489470 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,7 +6,6 @@ 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 @@ -35,15 +34,21 @@ 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.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.dialog.AcceptMatchingDialog +import com.puzzle.matching.graph.main.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.MatchingWaitingLoadingScreen import com.puzzle.matching.graph.main.page.MatchingWaitingScreen +import kotlinx.collections.immutable.persistentHashMapOf +import kotlinx.collections.immutable.persistentListOf @Composable internal fun MatchingRoute( @@ -55,8 +60,9 @@ internal fun MatchingRoute( LifecycleStartEffect(viewModel) { viewModel.initMatchInfo() + viewModel.initFreeMatch() - onStopOrDispose { viewModel.stopTimer() } + onStopOrDispose { viewModel.stopTimerList() } } BackHandler { @@ -74,9 +80,10 @@ 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) }, 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,9 +93,10 @@ internal fun MatchingRoute( @Composable internal fun MatchingScreen( state: MatchingState, - onButtonClick: () -> Unit, + onMatchingCardClick: (MatchInfo) -> Unit, + onNewMatchingCardClick: () -> Unit, + onAcceptClick: () -> Unit, onStoreClick: () -> Unit, - onMatchingDetailClick: () -> Unit, onCheckMyProfileClick: () -> Unit, onEditProfileClick: () -> Unit, onNotificationClick: () -> Unit, @@ -97,11 +105,11 @@ internal fun MatchingScreen( if (isShowDialog) { AcceptMatchingDialog( - nickname = state.matchInfo?.nickname, + nickname = "닉네임 추가 필요.", onDismissRequest = { isShowDialog = false }, onAcceptClick = { isShowDialog = false - onButtonClick() + onAcceptClick() }, ) } @@ -136,7 +144,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 +157,7 @@ internal fun MatchingScreen( .clickable { onNotificationClick() }, ) }, - onStoreClick = { onStoreClick() }, + onStoreClick = onStoreClick, modifier = Modifier.padding(bottom = 16.dp), ) } @@ -157,7 +165,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,14 +175,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, + matchInfoList = state.matchInfoList, + remainTimeHashMap = state.formattedRemainingTimeMap, + canFreeMatch = state.canFreeMatch, + onMatchingCardClick = onMatchingCardClick, + onNewMatchingCardClick = onNewMatchingCardClick, showDialog = { isShowDialog = true }, ) } @@ -210,15 +220,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 +237,16 @@ 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(MATCHED, isExpanded = true), + sampleMatchInfo(BEFORE_OPEN, isExpanded = false), + sampleMatchInfo(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, + showDialog = {} ) } } 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..50f06154f 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,20 @@ 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.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.main.common.constant.PurchaseDialogType import com.puzzle.matching.graph.main.contract.MatchingIntent import com.puzzle.matching.graph.main.contract.MatchingState import com.puzzle.navigation.MatchingGraph @@ -26,14 +27,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.async import kotlinx.coroutines.launch import javax.inject.Inject + @HiltViewModel class MatchingViewModel @Inject constructor( private val matchingRepository: MatchingRepository, @@ -45,15 +49,21 @@ class MatchingViewModel @Inject constructor( @MainDispatcher private val dispatcher: CoroutineDispatcher, ) : BaseViewModel(MatchingState()) { - private var timerJob: Job? = null + private var timerJobMap: HashMap = hashMapOf() // matchId, timerJob + private var openMatchingId : Int? = null // matchInfo는 화면 진입시마다 초기화됨으로 열려있는 매치는 뷰모델에 저장. override suspend fun processIntent(intent: MatchingIntent) { when (intent) { - MatchingIntent.OnButtonClick -> processOnButtonClick() + is MatchingIntent.OnMatchingCardClick -> processMatchCardOpen(intent.match) + is MatchingIntent.OnNewMatchingCardClick -> processNewMatchingCardOpen() + MatchingIntent.OnAcceptClick -> processAcceptMatching() + is MatchingIntent.OnMatchingDetailClick -> navigationHelper.navigate(To(MatchingDetailRoute)) - MatchingIntent.OnEditProfileClick -> moveToProfileRegisterScreen() + MatchingIntent.OnEditProfileClick -> + navigationHelper.navigate(To(ProfileGraph.RegisterProfileRoute)) + MatchingIntent.OnCheckMyProfileClick -> navigationHelper.navigate( To(MatchingGraph.ProfilePreviewRoute) ) @@ -63,6 +73,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 +89,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,79 +98,105 @@ 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 suspend fun getMatchInfoList() = suspendRunCatching { + matchingRepository.getMatchInfoList() + }.onSuccess { matchList -> + val openedMatchExists = matchList.any { it.matchId == openMatchingId } - when (it.matchStatus) { - MatchStatus.REFUSED -> { - startTimer() - return@onSuccess - } - - MatchStatus.BEFORE_OPEN -> { - if (isNewMatching()) { - eventHelper.sendEvent( - PieceEvent.ShowSnackBar(SnackBarState.Matching("새로운 인연이 도착했어요")) - ) - } - } - - else -> Unit + val updatedList = matchList.mapIndexed { index, matchInfo -> + matchInfo.copy( + isExpanded = if (openedMatchExists) matchInfo.matchId == openMatchingId + else index == 0 + ) } - suspendRunCatching { - matchingRepository.getOpponentProfile() - }.onFailure { - errorHelper.sendError(it) - } - startTimer() + setState { copy(matchInfoList = updatedList.toImmutableList(),isLoading = false) } + + updatedList.forEach { matchInfo -> startTimer(matchInfo) } }.onFailure { - if (it is HttpResponseException) { - // 1. 회원가입하고 처음 매칭을 하는데 아직 오후 10시가 안되었을 때 - // 2. 상대방 아이디가 없어졌을 때 - if (it.status == HttpResponseStatus.NotFound) { - startTimer() - return@onFailure + errorHelper.sendError(it) + } + + private fun processMatchCardOpen(matchInfo: MatchInfo) { + if (matchInfo.isExpanded) processExpandedCardOpen(matchInfo.matchId) + else updateCardExpandedState(matchInfo.matchId) // 닫혀있다면 단순히 card만 오픈함. + } + + private fun processExpandedCardOpen(matchId: Int) { + updateCardExpandedState(matchId) // matchId 빼고 닫기. + + val clickedMatchInfo = state.value.matchInfoList.find { it.matchId == matchId } + clickedMatchInfo?.let { processMatchInfo(it) } + } + + /** 현재 matchInfoList의 확장 상태를 업데이트하는 공통 함수 (null이면 모두 닫음) */ + private fun updateCardExpandedState(expandedMatchId: Int?) { + val updatedList = state.value.matchInfoList.map { matchInfo -> + matchInfo.copy(isExpanded = matchInfo.matchId == expandedMatchId) + }.toImmutableList() + + openMatchingId = expandedMatchId + + 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(PurchaseDialogType.PURCHASE_CONTACT) + } } - } - errorHelper.sendError(it) + else -> navigationHelper.navigate(To(MatchingDetailRoute)) // todo id 담아서 보낼것. + } +// when (matchInfo.matchStatus) { +// MatchStatus.BEFORE_OPEN -> checkMatchingPiece() // matchInfo.Id 추가할것. +// MatchStatus.WAITING -> acceptMatchingInWaiting() +// MatchStatus.GREEN_LIGHT -> acceptMatchingInGreenLight() +// MatchStatus.MATCHED -> navigateToContactScreen() +// else -> Unit +// } } private suspend fun isNewMatching() = matchingRepository.isNewMatching() - private fun processOnButtonClick() { - when (currentState.matchInfo?.matchStatus) { - MatchStatus.BEFORE_OPEN -> checkMatchingPiece() - MatchStatus.WAITING -> acceptMatchingInWaiting() - MatchStatus.GREEN_LIGHT -> acceptMatchingInGreenLight() - MatchStatus.MATCHED -> navigateToContactScreen() - else -> Unit + private suspend fun processNewMatchingCardOpen() { + if (currentState.canFreeMatch) getFreeInstantMatch() + else showPurchaseNewMatchDialog() + } + + 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(PurchaseDialogType.INSUFFICIENT_PUZZLE) + } else { + showPurchaseDialog(PurchaseDialogType.PURCHASE_NEW_MATCH) } } - private fun navigateToContactScreen() = - navigationHelper.navigate(To(MatchingGraph.ContactRoute)) + private fun processAcceptMatching() { + + } private fun checkMatchingPiece() { viewModelScope.launch(dispatcher) { @@ -170,7 +214,7 @@ class MatchingViewModel @Inject constructor( suspendRunCatching { matchingRepository.acceptMatching() }.onSuccess { - setState { copy(matchInfo = matchInfo?.copy(matchStatus = MatchStatus.RESPONDED)) } + setState { copy(matchInfo = matchInfo?.copy(matchStatus = MatchStatus.RESPONDED)) } // todo 상태는 계속 User로 지속해야함. eventHelper.sendEvent( PieceEvent.ShowSnackBar(SnackBarState.Matching("인연을 수락했어요")) @@ -186,27 +230,38 @@ class MatchingViewModel @Inject constructor( }.onFailure { errorHelper.sendError(it) } } - private fun startTimer() { - timerJob?.cancel() + private fun startTimer(matchInfo: MatchInfo) { + timerJobMap[matchInfo.matchId]?.cancel() + + val remainTime = when (matchInfo.matchType) { + MatchType.BASIC -> getRemainingTimeInSec() + else -> getRemainingTimeUntil24Hours(matchInfo.matchedDateTime) + } - timerJob = viewModelScope.launch(dispatcher) { - timer.startTimer(getRemainingTimeInSec()) + timerJobMap[matchInfo.matchId] = viewModelScope.launch(dispatcher) { + timer.startTimer(remainTime) .collect { remainTimeInSec -> - setState { copy(remainWaitingTimeInSec = remainTimeInSec) } + val updatedMutable = currentState.remainWaitingTimeInSecMap.toMutableMap() + updatedMutable[matchInfo.matchId] = remainTimeInSec - if (remainTimeInSec == 0L) { - getMatchInfo() + setState { copy(remainWaitingTimeInSecMap = updatedMutable.toPersistentMap()) } - timerJob?.cancel() + if (remainTimeInSec == 0L) { + 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/card/MatchingCard.kt b/feature/matching/src/main/java/com/puzzle/matching/graph/main/card/MatchingCard.kt new file mode 100644 index 000000000..184328ec6 --- /dev/null +++ b/feature/matching/src/main/java/com/puzzle/matching/graph/main/card/MatchingCard.kt @@ -0,0 +1,538 @@ +package com.puzzle.matching.graph.main.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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState +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.unit.Dp +import androidx.compose.ui.unit.dp +import com.puzzle.analytics.LocalAnalyticsHelper +import com.puzzle.common.ui.clickable +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.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), + 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 MatchingUserExpandedCard( + matchInfo: MatchInfo, + remainTime: String, + onExpandedMatchingCardClick: () -> 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 + + Column(modifier = Modifier.semantics { contentDescription = "UserScreen" }) { + MatchingCardBackground( + matchStatus = matchInfo.matchStatus, + matchType = matchInfo.matchType, + isExpanded = true + ) { + Column(modifier = Modifier.padding(20.dp)) { + MatchStatusRow( + matchStatus = matchInfo.matchStatus, + matchInfo = matchInfo, + remainTime = remainTime + ) + + Spacer(Modifier.height(40.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { + onExpandedMatchingCardClick() + analyticsHelper.trackClickEvent( + screenName = "match_main_home", + buttonName = "userDescription", + ) + }, + ) { + 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("나와 ") + withStyle(SpanStyle(color = matchValueCountTextColor)) { + append("${matchInfo.matchedValueCount}가지 ") + } + append("생각이 닮았어요.") + }, + 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 +internal fun MatchingUserCollapsedCard( + matchInfo: MatchInfo, + remainTime: String, + onCollaspedMatchingCardClick: () -> Unit +) { + val isMatched = matchInfo.matchStatus == MATCHED + + val matchedColor = if (isMatched) PieceTheme.colors.white60 else PieceTheme.colors.dark2 + val dividerColor = if (isMatched) PieceTheme.colors.white60 else PieceTheme.colors.light1 + + MatchingCardBackground( + matchStatus = matchInfo.matchStatus, + matchType = matchInfo.matchType, + isExpanded = false + ) { + Column( + modifier = Modifier.padding(top = 20.dp, start = 20.dp, end = 20.dp, bottom = 12.dp), + verticalArrangement = Arrangement.Center + ) { + + MatchStatusRow( + matchStatus = matchInfo.matchStatus, + matchInfo = matchInfo, + remainTime = remainTime + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onCollaspedMatchingCardClick) + ) { + 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 = "${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 cardHeight = if (isExpanded) 292.dp else 86.dp + + Box( + modifier = Modifier + .height(cardHeight) + .clip(RoundedCornerShape(12.dp)) + .background(backgroundColor) + ) { + if (isMatched) { + Image( + painter = painterResource(R.drawable.bg_matched_card), + contentDescription = "bg_matched_card", + contentScale = imageContentScale, + modifier = Modifier.fillMaxSize() + ) + } + + Column(modifier = Modifier.fillMaxSize()) { + content() + } + } +} + +@Preview +@Composable +fun NewMatchingCardPreview() { + NewMatchingCard(isFreeMatching = false, onNewMatchingCardClick = {}) +} + +@Preview +@Composable +fun NewFreeMatchingCardPreview() { + NewMatchingCard(isFreeMatching = true, onNewMatchingCardClick = {}) +} + +@Preview +@Composable +private fun PreviewGreenLightCollapsedCard() { + PieceTheme { + MatchingUserCollapsedCard( + matchInfo = sampleMatchInfo(GREEN_LIGHT), + onCollaspedMatchingCardClick = {}, + remainTime = "00:00:00" + ) + } +} + +@Preview +@Composable +private fun PreviewMatchedCollapsedCard() { + PieceTheme { + MatchingUserCollapsedCard( + matchInfo = sampleMatchInfo(MATCHED), + onCollaspedMatchingCardClick = {}, + remainTime = "00:00:00" + ) + } +} + +@Preview +@Composable +private fun PreviewRespondedCollapsedCard() { + PieceTheme { + MatchingUserCollapsedCard( + matchInfo = sampleMatchInfo(RESPONDED), + onCollaspedMatchingCardClick = {}, + remainTime = "00:00:00" + ) + } +} + +@Preview +@Composable +private fun PreviewWaitingCollapsedCard() { + PieceTheme { + MatchingUserCollapsedCard( + matchInfo = sampleMatchInfo(WAITING), + onCollaspedMatchingCardClick = {}, + remainTime = "00:00:00" + ) + } +} + +@Preview +@Composable +private fun PreviewGreenLightToMeCollaspedCard() { + PieceTheme { + MatchingUserCollapsedCard( + matchInfo = sampleMatchInfo( + GREEN_LIGHT, + matchType = MatchType.TO_ME, + isExpanded = true + ), + onCollaspedMatchingCardClick = {}, + remainTime = "00:00:00" + ) + } +} + +@Preview +@Composable +private fun PreviewBeforeExpandedCard() { + PieceTheme { + MatchingUserExpandedCard( + matchInfo = sampleMatchInfo(BEFORE_OPEN, isExpanded = true), + onExpandedMatchingCardClick = {}, + remainTime = "00:00:00" + ) + } +} + +@Preview +@Composable +private fun PreviewWaitingExpandedCard() { + PieceTheme { + MatchingUserExpandedCard( + matchInfo = sampleMatchInfo(WAITING, isExpanded = true), + onExpandedMatchingCardClick = {}, + remainTime = "00:00:00" + ) + } +} + +@Preview +@Composable +private fun PreviewGreenLightExpandedCard() { + PieceTheme { + MatchingUserExpandedCard( + matchInfo = sampleMatchInfo(GREEN_LIGHT, isExpanded = true), + onExpandedMatchingCardClick = {}, + remainTime = "00:00:00" + ) + } +} + +@Preview +@Composable +private fun PreviewRespondedExpandedCard() { + PieceTheme { + MatchingUserExpandedCard( + matchInfo = sampleMatchInfo(RESPONDED, isExpanded = true), + onExpandedMatchingCardClick = {}, + remainTime = "00:00:00" + ) + } +} + +@Preview +@Composable +private fun PreviewMatchedExpandedCard() { + PieceTheme { + MatchingUserExpandedCard( + matchInfo = sampleMatchInfo(MATCHED, isExpanded = true), + onExpandedMatchingCardClick = {}, + remainTime = "00:00:00" + ) + } +} + +@Preview +@Composable +private fun PreviewGreenLightToMeExpandedCard() { + PieceTheme { + MatchingUserExpandedCard( + matchInfo = sampleMatchInfo( + GREEN_LIGHT, + matchType = MatchType.TO_ME, + isExpanded = true + ), + onExpandedMatchingCardClick = {}, + remainTime = "02:05:10" + ) + } +} + +fun sampleMatchInfo( + matchStatus: MatchStatus, + isExpanded: Boolean = false, + matchType: MatchType = MatchType.FROM_ME +) = MatchInfo( + matchId = 1, + matchedUserId = 1, + matchStatus = matchStatus, + description = "음악과 요리를 좋아하는", + nickname = "수줍은 수달", + birthYear = "02", + location = "광주광역시", + job = "학생", + blocked = false, + matchedValueCount = 7, + matchedValueList = listOf( + "바깥 데이트 스킨십도 가능", + "함께 술을 즐기고 싶어요", + "커밍아웃은 가까운 친구에게만 했어요", + ), + matchType = matchType, + isExpanded = isExpanded +) \ No newline at end of file diff --git a/feature/matching/src/main/java/com/puzzle/matching/graph/main/common/constant/PurchaseDialogType.kt b/feature/matching/src/main/java/com/puzzle/matching/graph/main/common/constant/PurchaseDialogType.kt new file mode 100644 index 000000000..935830d9b --- /dev/null +++ b/feature/matching/src/main/java/com/puzzle/matching/graph/main/common/constant/PurchaseDialogType.kt @@ -0,0 +1,7 @@ +package com.puzzle.matching.graph.main.common.constant + +enum class PurchaseDialogType { + PURCHASE_CONTACT, // 연락처 공개 유료 + PURCHASE_NEW_MATCH, // 새로운 매칭 추가 유료 + INSUFFICIENT_PUZZLE // 퍼즉 부족 +} \ 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..63f90dce5 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 sealed class MatchingIntent : UiIntent { - data object OnButtonClick : MatchingIntent() + data object OnAcceptClick : MatchingIntent() // matchId 필요할것. data object OnStoreClick : MatchingIntent() data object OnCheckMyProfileClick : MatchingIntent() - data object OnMatchingDetailClick : MatchingIntent() + data object OnMatchingDetailClick : MatchingIntent() // matchId 필요할것. 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..e401638e9 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,34 +1,56 @@ 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.main.common.constant.PurchaseDialogType +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentHashMapOf +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableMap +@Immutable data class MatchingState( val isLoading: Boolean = true, + val puzzleCount : Int = 0, val userRole: UserRole? = null, + // 10시에 한번씩 들어오는 기본 매칭 val matchInfo: MatchInfo? = null, + val matchInfoList : ImmutableList = persistentListOf(),// 다중매칭 val rejectReason: RejectReason = RejectReason(reasonImage = false, reasonValues = false), - val remainWaitingTimeInSec: Long = 0L, + val remainWaitingTimeInSecMap: ImmutableMap = persistentHashMapOf(),// + val canFreeMatch: Boolean = false, //맛보기 여부 + + val isShowDialog : Boolean = false, + val dialogType: PurchaseDialogType = PurchaseDialogType.INSUFFICIENT_PUZZLE ) : UiState { - val formattedRemainWaitingTime: String = formatTimeToHourMinuteSecond(remainWaitingTimeInSec) + val formattedRemainingTimeMap: ImmutableMap = + remainWaitingTimeInSecMap.mapValues { (_, timeInSec) -> + formatTimeToHourMinuteSecond(timeInSec) + }.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.blocked }){ MatchingStatus.WAITING - } else { - when (matchInfo.matchStatus) { - MatchStatus.REFUSED -> MatchingStatus.WAITING - MatchStatus.UNKNOWN -> MatchingStatus.LOADING - else -> MatchingStatus.USER - } + }else{ + MatchingStatus.USER } +// if (matchInfo == null || matchInfo.blocked) { +// MatchingStatus.WAITING +// } else { +// when (matchInfo.matchStatus) { +// MatchStatus.REFUSED -> MatchingStatus.WAITING +// MatchStatus.UNKNOWN -> MatchingStatus.LOADING +// else -> MatchingStatus.USER +// } +// } } else -> MatchingStatus.LOADING 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/MatchingUserScreen.kt b/feature/matching/src/main/java/com/puzzle/matching/graph/main/page/MatchingUserScreen.kt index f62382f9a..50ef9909f 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,91 @@ 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.common.ui.PieceExpandCollapseAnimation 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.card.MatchingUserCollapsedCard +import com.puzzle.matching.graph.main.card.MatchingUserExpandedCard +import com.puzzle.matching.graph.main.card.NewMatchingCard +import com.puzzle.matching.graph.main.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, + matchInfoList: ImmutableList, + remainTimeHashMap: ImmutableMap, + onMatchingCardClick : (MatchInfo) -> Unit, + onNewMatchingCardClick : () -> Unit, + canFreeMatch: Boolean, showDialog: () -> Unit, ) { - 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), - ) - } - - 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) + LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) { + items(matchInfoList) { matchInfo -> + PieceExpandCollapseAnimation( + expanded = matchInfo.isExpanded, + modifier = Modifier.fillMaxWidth(), + contentWhenExpanded = { + MatchingUserExpandedCard( + matchInfo = matchInfo, + remainTime = remainTimeHashMap[matchInfo.matchId] ?: "00:00:00", + onExpandedMatchingCardClick = { onMatchingCardClick(matchInfo) }, ) - - 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, + }, + contentWhenCollapsed = { + MatchingUserCollapsedCard( + matchInfo = matchInfo, + remainTime = remainTimeHashMap[matchInfo.matchId] ?: "00:00:00", + onCollaspedMatchingCardClick = { onMatchingCardClick(matchInfo) }, ) } - } - - HorizontalDivider( - thickness = 1.dp, - color = PieceTheme.colors.light2, ) - - 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(), - ) - } + } + item { + NewMatchingCard( + modifier = Modifier.padding(bottom = 18.dp), + isFreeMatching = canFreeMatch, + onNewMatchingCardClick = onNewMatchingCardClick + ) } } } -@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(MATCHED, isExpanded = isExpanded), + sampleMatchInfo(GREEN_LIGHT, isExpanded = true), + sampleMatchInfo(BEFORE_OPEN, isExpanded = isExpanded), + sampleMatchInfo(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, + showDialog = {} ) } } -@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..c91f3dcef 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 @@ -13,6 +13,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -29,42 +30,26 @@ 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.card.NewMatchingCard @Composable internal fun MatchingWaitingScreen( - remainTime: String, onCheckMyProfileClick: () -> Unit, + onNewMatchingCardClick : () -> Unit, + canFreeMatch : Boolean ) { - 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 { @@ -109,6 +94,12 @@ internal fun MatchingWaitingScreen( modifier = Modifier.fillMaxWidth(), ) } + + NewMatchingCard( + modifier = Modifier.padding(bottom = 18.dp), + isFreeMatching = canFreeMatch, + onNewMatchingCardClick = onNewMatchingCardClick + ) } } @@ -119,7 +110,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..0cc220373 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 @@ -54,64 +54,64 @@ 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) -// } -// -// @Test -// fun `매칭 정보를 조회했을 때, 상대방이 회원탈퇴를 했거나 첫 가입이후 10시 이전이면 타이머가 실행된다`() = -// runTest(testDispatcher) { -// // given -// val userInfo = MockUserInfo.default() -// userRepository.setUserInfo(userInfo) -// val notFoundError = HttpResponseException( -// HttpResponseStatus.NotFound, -// PieceErrorCode.NOTFOUND_USER -// ) -// matchingRepository.setMatchInfoErrorFlag(true) -// -// // when -// viewModel.initMatchInfo() -// advanceUntilIdle() -// -// // then -// 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.setMatchInfoResult(MockMatchInfo.default(matchStatus)) + + // when + viewModel.initMatchInfo() + + // then + advanceUntilIdle() + assertEquals(1, timer.startTimerCallCount) + } + + @Test + fun `매칭 정보를 조회했을 때, 상대방이 회원탈퇴를 했거나 첫 가입이후 10시 이전이면 타이머가 실행된다`() = + runTest(testDispatcher) { + // given + val userInfo = MockUserInfo.default() + userRepository.setUserInfo(userInfo) + val notFoundError = HttpResponseException( + HttpResponseStatus.NotFound, + PieceErrorCode.NOTFOUND_USER + ) + matchingRepository.setMatchInfoErrorFlag(true) + + // when + viewModel.initMatchInfo() + advanceUntilIdle() + + // then + assertEquals(1, timer.startTimerCallCount) + } } diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index 596946d6f..216849a3e 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -5,3 +5,7 @@ plugins { android { namespace = "com.puzzle.onboarding" } + +dependencies { + implementation(libs.lottie.compose) +} \ No newline at end of file diff --git a/feature/onboarding/src/main/java/com/puzzle/onboarding/OnboardingScreen.kt b/feature/onboarding/src/main/java/com/puzzle/onboarding/OnboardingScreen.kt index 9af0e6ccd..415837a13 100644 --- a/feature/onboarding/src/main/java/com/puzzle/onboarding/OnboardingScreen.kt +++ b/feature/onboarding/src/main/java/com/puzzle/onboarding/OnboardingScreen.kt @@ -2,26 +2,15 @@ package com.puzzle.onboarding import android.app.Activity import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf @@ -30,22 +19,22 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.puzzle.analytics.TrackScreenViewEvent import com.puzzle.common.event.PieceEvent import com.puzzle.common.ui.SnackBarState import com.puzzle.designsystem.R +import com.puzzle.designsystem.component.PieceOutlinedButton import com.puzzle.designsystem.component.PieceSolidButton +import com.puzzle.designsystem.component.PieceSubCloseTopBar import com.puzzle.designsystem.foundation.PieceTheme import com.puzzle.onboarding.contract.OnboardingIntent +import com.puzzle.onboarding.model.OnboardingPageData +import com.puzzle.onboarding.model.onboardingPages +import com.puzzle.onboarding.ui.PageContent import kotlinx.coroutines.launch @Composable @@ -59,7 +48,7 @@ internal fun OnboardingRoute(viewModel: OnboardingViewModel = hiltViewModel()) { } else { viewModel.eventHelper.sendEvent( PieceEvent.ShowSnackBar( - SnackBarState.TextOnly(context.getString(com.puzzle.designsystem.R.string.back_description)) + SnackBarState.TextOnly(context.getString(R.string.back_description)) ) ) } @@ -70,211 +59,104 @@ internal fun OnboardingRoute(viewModel: OnboardingViewModel = hiltViewModel()) { } @Composable -internal fun OnboardingScreen(onStartButtonClick: () -> Unit) { +private fun OnboardingScreen(onStartButtonClick: () -> Unit) { val scope = rememberCoroutineScope() + val pageCount = onboardingPages.size + val pagerState = rememberPagerState( initialPage = 0, - pageCount = { 2 }, + pageCount = { pageCount }, ) + val currentPage = pagerState.currentPage + val currentPageData = onboardingPages.getOrNull(currentPage) - TrackScreenViewEvent( - key = pagerState.currentPage, - screenName = when (pagerState.currentPage) { - 0 -> "onboarding_dailymatch" - 1 -> "onboarding_safetynotice" - else -> null - } - ) + currentPageData?.screenName?.let { screenName -> + TrackScreenViewEvent( + key = currentPage, + screenName = screenName + ) + } Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxSize() - .padding(horizontal = 20.dp) + .background(PieceTheme.colors.light3) ) { - OnboardingTopBar( - currentPage = pagerState.currentPage, - onSkipButtonClick = onStartButtonClick, - modifier = Modifier.padding(bottom = 49.dp), + + PieceSubCloseTopBar( + title = "", + contentColor = PieceTheme.colors.black, + onCloseClick = onStartButtonClick, + modifier = Modifier.padding(horizontal = 20.dp) ) HorizontalPager( state = pagerState, - modifier = Modifier - .weight(1f) - .padding(bottom = 40.dp) - ) { page -> - Column(modifier = Modifier.fillMaxSize()) { - when (page) { - 0 -> OnboardingPageContent( - imageRes = R.drawable.ic_onboarding_matching, - title = stringResource(R.string.one_day_one_matching_title), - description = stringResource(R.string.one_day_one_matching_description), - ) + userScrollEnabled = false, + modifier = Modifier.weight(1f) + ) { pageIndex -> + + val isCurrentPage = pageIndex == currentPage - 1 -> OnboardingPageContent( - imageRes = R.drawable.ic_onboarding_camera, - title = stringResource(R.string.camera_block_title), - description = stringResource(R.string.camera_block_description), - ) - } + onboardingPages.getOrNull(pageIndex)?.let { data -> + PageContent( + titleRes = data.titleRes, + pageContentType = data.contentType, + isPageActive = isCurrentPage + ) } } - OnboardingIndicator( - total = 2, - current = pagerState.currentPage, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(bottom = 30.dp), - ) - - PieceSolidButton( - label = when (pagerState.currentPage) { - 1 -> stringResource(R.string.start) - else -> stringResource(R.string.next) - }, - onClick = { - when (pagerState.currentPage) { - 1 -> onStartButtonClick() - else -> scope.launch { pagerState.animateScrollToPage(1) } - } - }, - modifier = Modifier - .padding(bottom = 10.dp, top = 12.dp) - .fillMaxWidth(), + OnboardingBottomButton( + currentPage = currentPage, + pageCount = pageCount, + currentPageData = currentPageData, + onNextClick = { scope.launch { pagerState.scrollToPage(currentPage + 1) } }, + onStartClick = onStartButtonClick, + onRestartClick = { scope.launch { pagerState.scrollToPage(0) } } ) } } @Composable -private fun OnboardingTopBar( +private fun OnboardingBottomButton( currentPage: Int, - onSkipButtonClick: () -> Unit, - modifier: Modifier = Modifier, + pageCount: Int, + currentPageData: OnboardingPageData?, + onNextClick: () -> Unit, + onStartClick: () -> Unit, + onRestartClick: () -> Unit ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .fillMaxWidth() - .height(64.dp), - ) { - Image( - painter = painterResource(R.drawable.ic_onboarding_logo), - contentDescription = null, - ) - - Spacer(modifier = Modifier.weight(1f)) - - AnimatedVisibility( - visible = currentPage == 0, - enter = fadeIn(), - exit = fadeOut(), + if (currentPage == pageCount - 1) { + Row( + modifier = Modifier + .padding(horizontal = 20.dp) + .padding(bottom = 10.dp, top = 12.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( - text = stringResource(R.string.skip), - style = PieceTheme.typography.bodyMM.copy( - textDecoration = TextDecoration.Underline - ), - color = PieceTheme.colors.dark3, - modifier = Modifier.clickable { onSkipButtonClick() } + PieceOutlinedButton( + label = stringResource(R.string.onboarding_camera_block_button1), + onClick = onRestartClick, + modifier = Modifier.weight(1f) + ) + PieceSolidButton( + label = stringResource(R.string.onboarding_camera_block_button2), + onClick = onStartClick, + modifier = Modifier.weight(1f) ) } - } -} + } else { + val labelRes = currentPageData?.buttonLabelRes ?: return -@Composable -private fun OnboardingPageContent( - imageRes: Int, - title: String, - description: String -) { - Column(modifier = Modifier.fillMaxSize()) { - Image( - painter = painterResource(imageRes), - contentDescription = null, + PieceSolidButton( + label = stringResource(labelRes), + onClick = onNextClick, modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(bottom = 66.dp), - ) - - Text( - text = title, - textAlign = TextAlign.Start, - style = PieceTheme.typography.headingLSB, - color = PieceTheme.colors.black, - modifier = Modifier.padding(bottom = 12.dp), - ) - - Text( - text = description, - textAlign = TextAlign.Start, - style = PieceTheme.typography.bodySM, - color = PieceTheme.colors.dark3, - ) - } -} - -@Composable -private fun OnboardingIndicator( - total: Int, - current: Int, - modifier: Modifier = Modifier, -) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - ) { - (0 until total).forEachIndexed { index, _ -> - if (index == current) { - Spacer( - modifier = Modifier - .size(width = 20.dp, height = 8.dp) - .clip(CircleShape) - .background(PieceTheme.colors.dark2) - ) - } else { - Spacer( - modifier = Modifier - .size(8.dp) - .clip(CircleShape) - .background(PieceTheme.colors.light1) - ) - } - } - } -} - -@Preview -@Composable -private fun OnboardingScreenPreview() { - PieceTheme { - Surface( - color = PieceTheme.colors.white, - modifier = Modifier.fillMaxSize(), - ) { - OnboardingScreen {} - } - } -} - -@Preview -@Composable -private fun OnboardingTopBarPreview() { - PieceTheme { - OnboardingTopBar( - currentPage = 0, - onSkipButtonClick = {} + .padding(horizontal = 20.dp) + .padding(bottom = 10.dp, top = 12.dp) + .fillMaxWidth(), ) } -} - - -@Preview -@Composable -private fun OnboardingIndicatorPreview() { - PieceTheme { - OnboardingIndicator(total = 2, current = 0) - } -} +} \ No newline at end of file diff --git a/feature/onboarding/src/main/java/com/puzzle/onboarding/model/OnboardingData.kt b/feature/onboarding/src/main/java/com/puzzle/onboarding/model/OnboardingData.kt new file mode 100644 index 000000000..604301ed8 --- /dev/null +++ b/feature/onboarding/src/main/java/com/puzzle/onboarding/model/OnboardingData.kt @@ -0,0 +1,69 @@ +package com.puzzle.onboarding.model + +import androidx.annotation.DrawableRes +import androidx.annotation.RawRes +import androidx.annotation.StringRes +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.puzzle.designsystem.R + +data class OnboardingPageData( + val screenName: String, + @param:StringRes val titleRes: Int, + @param:StringRes val buttonLabelRes: Int?, + val contentType: PageContentType +) + +sealed class PageContentType { + data class Lottie( + @param:RawRes val lottieRes: Int, + ) : PageContentType() + + data class Image( + @param:DrawableRes val imageRes: Int + ) : PageContentType() +} + +internal val onboardingPages = listOf( + OnboardingPageData( + screenName = "onboarding_basic", + titleRes = R.string.onboarding_basic_title, + buttonLabelRes = R.string.onboarding_basic_button, + contentType = PageContentType.Lottie( + R.raw.onboarding_basic, + ) + ), + OnboardingPageData( + screenName = "onboarding_premium", + titleRes = R.string.onboarding_premium_title, + buttonLabelRes = R.string.onboarding_premium_button, + contentType = PageContentType.Lottie( + R.raw.onboarding_premium, + ) + ), + OnboardingPageData( + screenName = "onboarding_greenlight", + titleRes = R.string.onboarding_greenlight_title, + buttonLabelRes = R.string.onboarding_greenlight_button, + contentType = PageContentType.Lottie( + R.raw.onboarding_greenlight, + ) + ), + OnboardingPageData( + screenName = "onboarding_talk", + titleRes = R.string.onboarding_talk_title, + buttonLabelRes = R.string.onboarding_talk_button, + contentType = PageContentType.Lottie( + R.raw.onboarding_talk, + ) + ), + OnboardingPageData( + screenName = "onboarding_camera_block", + titleRes = R.string.onboarding_camera_block_title, + buttonLabelRes = null, + contentType = PageContentType.Image(R.drawable.ic_onboarding_screenshot) + ) +) + +class OnboardingPageProvider : PreviewParameterProvider { + override val values: Sequence = onboardingPages.asSequence() +} \ No newline at end of file diff --git a/feature/onboarding/src/main/java/com/puzzle/onboarding/ui/PageContent.kt b/feature/onboarding/src/main/java/com/puzzle/onboarding/ui/PageContent.kt new file mode 100644 index 000000000..f69260098 --- /dev/null +++ b/feature/onboarding/src/main/java/com/puzzle/onboarding/ui/PageContent.kt @@ -0,0 +1,89 @@ +package com.puzzle.onboarding.ui + +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import com.puzzle.common.ui.PieceGuideMessageAnimation +import com.puzzle.designsystem.foundation.PieceTheme +import com.puzzle.onboarding.model.OnboardingPageData +import com.puzzle.onboarding.model.OnboardingPageProvider +import com.puzzle.onboarding.model.PageContentType +import com.puzzle.onboarding.ui.components.StopAtProgressLottie + +@Composable +internal fun PageContent( + @StringRes titleRes: Int, + pageContentType: PageContentType, + isPageActive: Boolean +) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween + ) { + + PieceGuideMessageAnimation( + visible = isPageActive, + modifier = Modifier.padding(start = 20.dp) + ) { + Text( + text = stringResource(titleRes), + textAlign = TextAlign.Start, + style = PieceTheme.typography.headingLSB, + color = PieceTheme.colors.black, + ) + } + + when (pageContentType) { + is PageContentType.Image -> { + Image( + painter = painterResource(pageContentType.imageRes), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(375f / 433f), + contentScale = ContentScale.FillWidth + ) + } + + is PageContentType.Lottie -> { + StopAtProgressLottie( + lottieRes = pageContentType.lottieRes, + targetProgress = 0.99f, + shouldPlay = isPageActive, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(375f / 433f), + ) + } + } + } +} + +@Preview +@Composable +private fun PreviewOnboardingAllPages( + @PreviewParameter(OnboardingPageProvider::class) pageData: OnboardingPageData +) { + PieceTheme { + PageContent( + titleRes = pageData.titleRes, + pageContentType = pageData.contentType, + isPageActive = true + ) + } +} \ No newline at end of file diff --git a/feature/onboarding/src/main/java/com/puzzle/onboarding/ui/components/StopAtProgressLottie.kt b/feature/onboarding/src/main/java/com/puzzle/onboarding/ui/components/StopAtProgressLottie.kt new file mode 100644 index 000000000..573399a7f --- /dev/null +++ b/feature/onboarding/src/main/java/com/puzzle/onboarding/ui/components/StopAtProgressLottie.kt @@ -0,0 +1,76 @@ +package com.puzzle.onboarding.ui.components + +import androidx.annotation.RawRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieComposition +import com.puzzle.designsystem.foundation.PieceTheme +import com.puzzle.onboarding.model.OnboardingPageData +import com.puzzle.onboarding.model.OnboardingPageProvider + +@Composable +internal fun StopAtProgressLottie( + @RawRes lottieRes: Int, + targetProgress: Float = 1.0f, + shouldPlay: Boolean, + modifier: Modifier = Modifier +) { + val composition by rememberLottieComposition( + LottieCompositionSpec.RawRes(lottieRes) + ) + + val animProgress by animateLottieCompositionAsState( + composition = composition, + iterations = 1, + isPlaying = shouldPlay, + restartOnPlay = true + ) + + var isStopped by remember { mutableStateOf(false) } + + var stoppedProgress by remember { mutableStateOf(0f) } + + LaunchedEffect(animProgress, shouldPlay) { + if (shouldPlay) { + isStopped = false + } + + if (!isStopped && animProgress >= targetProgress) { + isStopped = true + stoppedProgress = targetProgress + } + } + + val finalProgress = if (isStopped) stoppedProgress else animProgress + + LottieAnimation( + composition = composition, + progress = { finalProgress }, + contentScale = ContentScale.FillWidth, + modifier = modifier + ) +} + +@Preview +@Composable +private fun PreviewStopAtProgressLottie( + @PreviewParameter(OnboardingPageProvider::class) pageData: OnboardingPageData +) { + PieceTheme { + StopAtProgressLottie( + lottieRes = pageData.titleRes, + shouldPlay = true + ) + } +} \ No newline at end of file diff --git a/presentation/src/main/java/com/puzzle/presentation/ui/AppUiPolicy.kt b/presentation/src/main/java/com/puzzle/presentation/ui/AppUiPolicy.kt index fe627a515..ca484dff3 100644 --- a/presentation/src/main/java/com/puzzle/presentation/ui/AppUiPolicy.kt +++ b/presentation/src/main/java/com/puzzle/presentation/ui/AppUiPolicy.kt @@ -42,6 +42,7 @@ object AppUiPolicy { ) val lightStatusBarRoutes = setOf( + OnboardingRoute::class, StoreRoute::class, )