diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index e8f225e0a..348574715 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -11,10 +11,14 @@ android { dependencies { implementation(project(":feature:auth")) + implementation(project(":feature:characterchat")) implementation(libs.retrofit.kotlinx.serialization) implementation(libs.androidx.appcompat) implementation(libs.google.accompanist.permissions) implementation(libs.gson) implementation(libs.lottie.compose) + implementation(libs.coil.svg) + implementation(libs.eventbus) + implementation(libs.eventbus) //implementation(libs.androidsvg.aar) } diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/navigation/HomeNavigation.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/navigation/HomeNavigation.kt index 4aaedea0c..8ae186125 100644 --- a/feature/home/src/main/java/com/teamoffroad/feature/home/navigation/HomeNavigation.kt +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/navigation/HomeNavigation.kt @@ -22,6 +22,7 @@ fun NavController.navigateToHome( fun NavGraphBuilder.homeNavGraph( navigateToBack: () -> Unit, navigateToGainedCharacter: () -> Unit, + navigateToCharacterChatScreen: (Int, String) -> Unit ) { composable { backStackEntry -> val category = backStackEntry.toRoute().category @@ -30,6 +31,7 @@ fun NavGraphBuilder.homeNavGraph( category = category, completeQuests = completeQuests, navigateToGainedCharacter = navigateToGainedCharacter, + navigateToCharacterChatScreen = navigateToCharacterChatScreen ) } } diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/HomeScreen.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/HomeScreen.kt index 8e8396d48..05593fffc 100644 --- a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/HomeScreen.kt +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/HomeScreen.kt @@ -13,10 +13,9 @@ 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -25,14 +24,13 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel -import com.teamoffroad.core.designsystem.component.StaticAnimationWrapper +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.teamoffroad.core.designsystem.component.actionBarPadding import com.teamoffroad.core.designsystem.theme.HomeGradi1 import com.teamoffroad.core.designsystem.theme.HomeGradi2 @@ -62,10 +60,12 @@ fun HomeScreen( category: String?, completeQuests: List = emptyList(), navigateToGainedCharacter: () -> Unit = {}, + navigateToCharacterChatScreen: (Int, String) -> Unit ) { val context = LocalContext.current val viewModel: HomeViewModel = hiltViewModel() val isCompleteQuestDialogShown = remember { mutableStateOf(false) } + val characterName = viewModel.characterName.collectAsStateWithLifecycle() val launcher = rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) {} @@ -87,28 +87,27 @@ fun HomeScreen( if (completeQuests.isNotEmpty()) isCompleteQuestDialogShown.value = true } - StaticAnimationWrapper { - Surface( - modifier = Modifier - .background(homeGradientBackground) - .padding(bottom = 140.dp) - .navigationBarsPadding(), - color = Color.Transparent - ) { - StaticAnimationWrapper { - Column(modifier = Modifier.fillMaxWidth()) { - UsersAdventuresInformation( - context = context, - modifier = Modifier - .weight(1f) - .actionBarPadding(), - viewModel = viewModel, - navigateToGainedCharacter = navigateToGainedCharacter, - ) - Spacer(modifier = Modifier.padding(top = 12.dp)) - UsersQuestInformation(context, viewModel) - } - } + Box( + modifier = Modifier + .background(homeGradientBackground) + .fillMaxSize() + .padding(bottom = 180.dp) + ) { + Column(modifier = Modifier.fillMaxSize()) { + UsersAdventuresInformation( + //isChatting = isUserChatting, + context = context, + characterName = characterName.value, + modifier = Modifier + .weight(1f) + .actionBarPadding(), + viewModel = viewModel, + navigateToGainedCharacter = navigateToGainedCharacter, + navigateToCharacterChatScreen = navigateToCharacterChatScreen + ) + Spacer(modifier = Modifier.padding(top = 12.dp)) + UsersQuestInformation(context, viewModel) + } } @@ -116,9 +115,7 @@ fun HomeScreen( CompleteQuestDialog( isCompleteQuestDialogShown = isCompleteQuestDialogShown, completeQuests = completeQuests, - onClickCancel = { - isCompleteQuestDialogShown.value = false - }, + onClickCancel = { isCompleteQuestDialogShown.value = false }, ) } } @@ -127,9 +124,11 @@ fun HomeScreen( @Composable private fun UsersAdventuresInformation( context: Context, + characterName: String, modifier: Modifier = Modifier, viewModel: HomeViewModel, navigateToGainedCharacter: () -> Unit, + navigateToCharacterChatScreen: (Int, String) -> Unit ) { val adventuresInformationState = viewModel.getUsersAdventuresInformationState.collectAsState(initial = UiState.Loading).value @@ -157,7 +156,9 @@ private fun UsersAdventuresInformation( HomeIcons( context = context, imageUrl = imageUrl, + characterName = characterName, navigateToGainedCharacter = navigateToGainedCharacter, + navigateToCharacterChatScreen = navigateToCharacterChatScreen ) } @@ -238,7 +239,8 @@ fun HomeScreenPreview() { OffroadTheme { HomeScreen( //padding = PaddingValues(), - category = "NONE" + category = "NONE", + navigateToCharacterChatScreen = { _, _ -> } ) } } diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/HomeViewModel.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/HomeViewModel.kt index bc9f1a3c1..3088e5b20 100644 --- a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/HomeViewModel.kt +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/HomeViewModel.kt @@ -1,7 +1,14 @@ package com.teamoffroad.feature.home.presentation +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.teamoffroad.characterchat.domain.model.Chat +import com.teamoffroad.characterchat.domain.repository.CharacterChatRepository +import com.teamoffroad.characterchat.presentation.model.ChatModel +import com.teamoffroad.characterchat.presentation.model.ChatType +import com.teamoffroad.characterchat.presentation.model.TimeType +import com.teamoffroad.core.common.domain.model.NotificationEvent import com.teamoffroad.core.common.domain.repository.TokenRepository import com.teamoffroad.core.common.domain.usecase.SetAutoSignInUseCase import com.teamoffroad.feature.home.domain.model.Emblem @@ -11,20 +18,29 @@ import com.teamoffroad.feature.home.domain.repository.UserRepository import com.teamoffroad.feature.home.domain.usecase.PostFcmTokenUseCase import com.teamoffroad.feature.home.presentation.component.UiState import com.teamoffroad.feature.home.presentation.component.getErrorMessage +import com.teamoffroad.feature.home.presentation.model.CharacterChatModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import java.time.LocalDateTime import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( private val userRepository: UserRepository, + //private val characterChatRepository: CharacterChatRepository, private val setAutoSignInUseCase: SetAutoSignInUseCase, - private val tokenRepository: TokenRepository, + private val deviceTokenRepository: TokenRepository, private val fcmTokenUseCase: PostFcmTokenUseCase, ) : ViewModel() { +// private val _getCharacterChat = MutableStateFlow(CharacterChatModel("", "")) +// val getCharacterChat = _getCharacterChat.asStateFlow() private val _getUsersAdventuresInformationState = MutableStateFlow>( @@ -53,18 +69,69 @@ class HomeViewModel @Inject constructor( private val _getUserQuestsState = MutableStateFlow>(UiState.Loading) val getUserQuestsState = _getUserQuestsState.asStateFlow() +// private val _sendChatState = MutableStateFlow>(UiState.Loading) +// val sendChatState = _sendChatState.asStateFlow() + private val _circleProgressBar = MutableStateFlow(0f) val circleProgressBar = _circleProgressBar.asStateFlow() private val _linearProgressBar = MutableStateFlow(0f) val linearProgressBar = _linearProgressBar.asStateFlow() +// private val _isCharacterChatting: MutableStateFlow = MutableStateFlow(false) +// val isCharacterChatting: StateFlow = _isCharacterChatting.asStateFlow() + +// private val _isCharacterChattingLoading = MutableStateFlow(false) +// val isCharacterChattingLoading = _isCharacterChattingLoading.asStateFlow() +// +// private val _chattingText: MutableStateFlow = MutableStateFlow("") +// val chattingText: StateFlow = _chattingText.asStateFlow() + + private val _characterName = MutableStateFlow("") + val characterName = _characterName.asStateFlow() + +// var asd = MutableStateFlow("") +// init { +// //아까 CharacterChatBroadcastReceiver에서 게시한 브로드캐스트리시버를 여기서 받습니다. +// EventBus.getDefault().register(this) +// } + +// //뷰모델이 삭제될때 이벤트버스도 해제시켜줍니다. +// override fun onCleared() { +// super.onCleared() +// EventBus.getDefault().unregister(this) +// } +// +// //브로드캐스트리시버가 작동할때마다 동작하는 함수(fcm발송 > 앱이 포그라운드에 있고, 타입이 캐릭터채팅이라면 작동) +// //그런데 홈화면이 아니고 다른화면에서 이 함수가 호출되면 ui가 활성되있지 않기 때문에 ui작업을 할 수 없습니다.(함수 실행될때 로그는 찍힘) +// //그래서 데이터스토어 같은 로컬저장소에 데이터와 캐릭터 채팅확인 여부를 저장해두었다가 +// //홈화면에 들어와서 채팅확인 여부가 x라면 알림을 보여주고, 알림을 봤다면 다시 채팅확인 여부가 o로 만드는식으로 하면 될거같습니다. +// //그래서 포스트맨으로 fcm쏴보면서 요함수에서 하면 될 것 같습니다. +// @Subscribe(threadMode = ThreadMode.MAIN) +// fun onNotificationEvent(event: NotificationEvent) { +// //Log.d("characterChat data", event.toString()) +// +// // 1. 홈 화면에서 캐릭터한테 메시지가 왔을 때 +// val characterName = event.characterName +// val characterContent = event.characterContent +// if(characterName != null && characterContent != null) { +// _getCharacterChat.value = CharacterChatModel(_characterName.value, characterContent) +// updateCharacterChatting(true) +// } +// +// } + +// fun updateCharacterChatting(state: Boolean) { +// _isCharacterChatting.value = state +// } + fun getUsersAdventuresInformation(category: String) { viewModelScope.launch { runCatching { userRepository.getUsersAdventuresInformation(category) }.onSuccess { state -> _getUsersAdventuresInformationState.emit(UiState.Success(state)) + _characterName.value = state.characterName updateSelectedEmblem(state.emblemName) updateCharacterImage(state.baseImageUrl) updateMotionImageUrl(state.motionImageUrl) @@ -145,9 +212,45 @@ class HomeViewModel @Inject constructor( } } +// fun updateChattingText(text: String) { +// _chattingText.value = text +// } + +// fun sendChat() { +// val chattingText = chattingText.value +// _isCharacterChattingLoading.value = true +// +// viewModelScope.launch { +// runCatching { +// val now = LocalDateTime.now() +// val userChat = ChatModel( +// chatType = ChatType.USER, +// text = chattingText, +// date = now.toLocalDate(), +// time = Triple(TimeType.toTimeType(now.hour), now.hour, now.minute), +// ) +// characterChatRepository.saveChat(1, chattingText) +// }.onSuccess { chat -> +// // 보낸 채팅 내용 홈에 보여주어야 함 +// _sendChatState.emit(UiState.Success(chat)) +// _isCharacterChattingLoading.value = false +// +// val characterContent = chat.content +// if (characterContent != null) { +// _getCharacterChat.value = CharacterChatModel(_characterName.value, characterContent) +// updateCharacterChatting(true) +// } +// +// }.onFailure { t -> +// val errorMessage = getErrorMessage(t) +// _sendChatState.emit(UiState.Failure(errorMessage)) +// } +// } +// } + fun updateFcmToken() { viewModelScope.launch { - val deviceToken = tokenRepository.getDeviceToken().first() + val deviceToken = deviceTokenRepository.getDeviceToken().first() if (deviceToken.isBlank()) return@launch runCatching { fcmTokenUseCase.invoke(deviceToken) diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/HomeIcons.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/HomeIcons.kt index c7a42a9b7..e019d0c0b 100644 --- a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/HomeIcons.kt +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/HomeIcons.kt @@ -8,21 +8,29 @@ import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi +import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box 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.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import com.teamoffroad.core.designsystem.component.clickableWithoutRipple +import com.teamoffroad.core.designsystem.theme.ErrorNew import com.teamoffroad.feature.home.presentation.component.upload.uploadImage import com.teamoffroad.offroad.feature.home.R import kotlinx.coroutines.Dispatchers @@ -32,9 +40,12 @@ import kotlinx.coroutines.withContext @RequiresApi(Build.VERSION_CODES.TIRAMISU) @Composable fun HomeIcons( + //isChatting: MutableState, context: Context, imageUrl: String, + characterName: String, navigateToGainedCharacter: () -> Unit, + navigateToCharacterChatScreen: (Int, String) -> Unit ) { val scope = rememberCoroutineScope() @@ -59,22 +70,38 @@ fun HomeIcons( } } - Box( - contentAlignment = Alignment.TopEnd, - modifier = Modifier - .aspectRatio(48f / 144f) - .padding(top = 80.dp, end = 20.dp) - ) { - Column { - val characterChatInteractionSource = remember { MutableInteractionSource() } - Image( - painter = painterResource(id = R.drawable.ic_home_chat), - contentDescription = "chat", - modifier = Modifier - .clickableWithoutRipple(interactionSource = characterChatInteractionSource) { - + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 80.dp, end = 20.dp) + .width(48.dp) + ) { + Box { + Image( + painter = painterResource(id = R.drawable.ic_home_chat), + contentDescription = "chat", + modifier = Modifier + .clickableWithoutRipple { + navigateToCharacterChatScreen(-1, characterName) + } + ) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.TopEnd + ) { + Canvas( + modifier = Modifier + .padding(top = 6.dp, end = 6.dp) + .size(8.dp) + ) { + drawCircle( + color = ErrorNew, + style = Fill + ) } - ) + } + } val uploadInteractionSource = remember { MutableInteractionSource() } Image( @@ -107,8 +134,8 @@ fun HomeIcons( } } -private suspend fun showToast(context: Context, message: String) { +suspend fun showToast(context: Context, message: String) { withContext(Dispatchers.Main) { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } -} +} \ No newline at end of file diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/character/CharacterItem.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/character/CharacterItem.kt index 3c776500e..f33b36d36 100644 --- a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/character/CharacterItem.kt +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/character/CharacterItem.kt @@ -40,6 +40,7 @@ import com.airbnb.lottie.compose.rememberLottieAnimatable import com.airbnb.lottie.compose.rememberLottieComposition import com.teamoffroad.core.designsystem.component.OffroadTagItem import com.teamoffroad.core.designsystem.component.clickableWithoutRipple +import com.teamoffroad.core.designsystem.theme.Kakao import com.teamoffroad.core.designsystem.theme.OffroadTheme import com.teamoffroad.core.designsystem.theme.Sub import com.teamoffroad.core.designsystem.theme.Sub55 @@ -68,28 +69,22 @@ class CharacterItem { if (motionCharacterUrl == null) { Box( modifier = Modifier - .width(300.dp) - .padding(top = 140.dp) + .padding(top = 120.dp) .fillMaxHeight() .align(Alignment.BottomCenter) ) { - Image( - painter = painterResource(id = R.drawable.img_home_character), - contentDescription = "character", - modifier = Modifier.fillMaxSize() + AsyncImage( + model = ImageRequest.Builder(context) + .data(baseCharacterImage) + .decoderFactory(SvgDecoder.Factory()) + .build(), + contentDescription = "explorer", + modifier = Modifier + .aspectRatio(210f / 210f) + .fillMaxSize() + .align(Alignment.BottomCenter), + // TODO: placeholder, error일 때 ) - -// AsyncImage( -// model = ImageRequest.Builder(context) -// .data(baseCharacterImage) -// .decoderFactory(SvgDecoder.Factory()) -// .build(), -// contentDescription = "explorer", -// modifier = Modifier -// .fillMaxSize() -// .align(Alignment.BottomCenter), -// // TODO: placeholder, error일 때 -// ) } } else { val composition by rememberLottieComposition( @@ -110,14 +105,17 @@ class CharacterItem { ) } - Box { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 140.dp) + .aspectRatio(210f / 210f) + ) { LottieAnimation( composition = composition, progress = progress, - contentScale = ContentScale.FillHeight, modifier = Modifier .fillMaxSize() - .padding(top = 30.dp) .align(Alignment.BottomCenter) ) } diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/model/CharacterChatModel.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/model/CharacterChatModel.kt new file mode 100644 index 000000000..8fda6f2dd --- /dev/null +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/model/CharacterChatModel.kt @@ -0,0 +1,6 @@ +package com.teamoffroad.feature.home.presentation.model + +data class CharacterChatModel( + val characterName: String, + val characterContent: String, +) \ No newline at end of file diff --git a/feature/home/src/main/res/drawable/ic_home_accordian.xml b/feature/home/src/main/res/drawable/ic_home_accordian.xml new file mode 100644 index 000000000..c218271be --- /dev/null +++ b/feature/home/src/main/res/drawable/ic_home_accordian.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/feature/home/src/main/res/values/strings.xml b/feature/home/src/main/res/values/strings.xml index 7bf2846c3..e3f2c4318 100644 --- a/feature/home/src/main/res/values/strings.xml +++ b/feature/home/src/main/res/values/strings.xml @@ -12,6 +12,9 @@ 퀘스트 \'%s\' 외 %d개를\n클리어했어요! 마이페이지에서\n보상을 확인해보세요. 확인 - + 나 :   + 채팅 종료 + 답장하기 권한이 허용되었습니다. diff --git a/feature/main/build.gradle.kts b/feature/main/build.gradle.kts index ea828a337..4ca981581 100644 --- a/feature/main/build.gradle.kts +++ b/feature/main/build.gradle.kts @@ -29,4 +29,5 @@ dependencies { implementation(libs.androidx.constraintlayout.compose) implementation(libs.accompanist.insets) implementation(libs.eventbus) + implementation(libs.lottie.compose) } diff --git a/feature/main/src/main/java/com/teamoffroad/feature/main/CharacterChattingUiState.kt b/feature/main/src/main/java/com/teamoffroad/feature/main/CharacterChattingUiState.kt new file mode 100644 index 000000000..9bfe7d290 --- /dev/null +++ b/feature/main/src/main/java/com/teamoffroad/feature/main/CharacterChattingUiState.kt @@ -0,0 +1,11 @@ +package com.teamoffroad.feature.main + +data class CharacterChattingUiState( + val characterName: String = "", + val characterChatContent: String = "", + val isCharacterChattingExist: Boolean = false, + val isCharacterChattingLoading: Boolean = false, + val isUserWatchingCharacterChat: Boolean = false, + val isAnswerButtonClicked: Boolean = false, + val isError: Boolean = false +) diff --git a/feature/main/src/main/java/com/teamoffroad/feature/main/MainActivity.kt b/feature/main/src/main/java/com/teamoffroad/feature/main/MainActivity.kt index e44d55781..f186fa15d 100644 --- a/feature/main/src/main/java/com/teamoffroad/feature/main/MainActivity.kt +++ b/feature/main/src/main/java/com/teamoffroad/feature/main/MainActivity.kt @@ -43,7 +43,7 @@ class MainActivity : ComponentActivity() { modifier = Modifier, notificationType = notificationTypeState.value, notificationId = notificationIdState.value, - viewModel = viewModel + viewModel = viewModel, ) } } diff --git a/feature/main/src/main/java/com/teamoffroad/feature/main/MainScreen.kt b/feature/main/src/main/java/com/teamoffroad/feature/main/MainScreen.kt index 83edb3d76..1e130b020 100644 --- a/feature/main/src/main/java/com/teamoffroad/feature/main/MainScreen.kt +++ b/feature/main/src/main/java/com/teamoffroad/feature/main/MainScreen.kt @@ -2,19 +2,29 @@ package com.teamoffroad.feature.main import android.os.Build import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.teamoffroad.core.common.domain.model.FcmNotificationKey.TYPE_ANNOUNCEMENT import com.teamoffroad.core.common.util.OnBackButtonListener +import com.teamoffroad.feature.main.component.CharacterChatAnimation import com.teamoffroad.feature.main.component.MainBottomBar import com.teamoffroad.feature.main.component.MainNavHost +import com.teamoffroad.feature.main.component.UserChat import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.delay @@ -26,9 +36,13 @@ internal fun MainScreen( notificationType: String?, notificationId: String?, viewModel: MainViewModel, + //navigateToCharacterChatScreen: (Int, String) -> Unit, ) { val mainContainerSetting = remember { mutableStateOf(false) } val isMainUiState by viewModel.mainUiState.collectAsState() + val characterChatUiState = viewModel.characterChatUiState.collectAsStateWithLifecycle() + val userChatUiState = viewModel.userChatUiState.collectAsStateWithLifecycle() + val userChattingText = viewModel.userChattingText.collectAsStateWithLifecycle() if (mainContainerSetting.value) { LaunchedEffect(isMainUiState) { @@ -51,10 +65,21 @@ internal fun MainScreen( } } } + MainScreenContent( navigator = navigator, + userChattingText = userChattingText, modifier = modifier, mainContainerSetting = mainContainerSetting, + characterChatUiState = characterChatUiState, + userChatUiState = userChatUiState, + updateAnswerCharacterChatButtonState = viewModel::updateAnswerCharacterChatButtonState, + updateCharacterChatExist = viewModel::updateCharacterChatExist, + updateUserWatchingCharacterChat = viewModel::updateUserWatchingCharacterChat, + updateUserChattingText = viewModel::updateUserChattingText, + updateShowUserChatTextField = viewModel::updateShowUserChatTextField, + sendChat = viewModel::sendChat, + //navigateToCharacterChatScreen = navigateToCharacterChatScreen ) } @@ -63,7 +88,17 @@ internal fun MainScreen( private fun MainScreenContent( modifier: Modifier = Modifier, navigator: MainNavigator, - mainContainerSetting: MutableState + userChattingText: State, + mainContainerSetting: MutableState, + characterChatUiState: State, + userChatUiState: State, + updateAnswerCharacterChatButtonState: (Boolean) -> Unit, + updateCharacterChatExist: (Boolean) -> Unit, + updateUserWatchingCharacterChat: (Boolean) -> Unit, + updateUserChattingText: (String) -> Unit, + updateShowUserChatTextField: (Boolean) -> Unit, + sendChat: () -> Unit, + //navigateToCharacterChatScreen: (Int, String) -> Unit ) { val showSplash = remember { mutableStateOf(true) } LaunchedEffect(Unit) { @@ -86,6 +121,42 @@ private fun MainScreenContent( navigator.setBackButtonListenerEnabled(), ) mainContainerSetting.value = true + + if (characterChatUiState.value.isCharacterChattingExist) { + CharacterChatAnimation( + characterChatUiState = characterChatUiState, + userChatUiState = userChatUiState, + updateAnswerCharacterChatButtonState = updateAnswerCharacterChatButtonState, + updateCharacterChatExist = updateCharacterChatExist, + updateUserWatchingCharacterChat = updateUserWatchingCharacterChat, + updateShowUserChatTextField = updateShowUserChatTextField, + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 370.dp) // TODO: TextField 위치 조정 필요 + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.BottomCenter + ) { + if (userChatUiState.value.showUserChatTextField) { + UserChat( + characterChatUiState = characterChatUiState, + chattingText = userChattingText, + updateShowUserChatTextField = updateShowUserChatTextField, + updateUserWatchingCharacterChat = updateUserWatchingCharacterChat, + userChatUiState = userChatUiState, + sendChat = sendChat, + updateUserChattingText = updateUserChattingText, + updateCharacterChatExist = updateCharacterChatExist + ) + } + } + } + } } }, diff --git a/feature/main/src/main/java/com/teamoffroad/feature/main/MainViewModel.kt b/feature/main/src/main/java/com/teamoffroad/feature/main/MainViewModel.kt index fab415645..e40e80432 100644 --- a/feature/main/src/main/java/com/teamoffroad/feature/main/MainViewModel.kt +++ b/feature/main/src/main/java/com/teamoffroad/feature/main/MainViewModel.kt @@ -2,20 +2,46 @@ package com.teamoffroad.feature.main import android.util.Log import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.teamoffroad.characterchat.domain.model.Chat +import com.teamoffroad.characterchat.domain.repository.CharacterChatRepository +import com.teamoffroad.characterchat.presentation.model.ChatModel +import com.teamoffroad.characterchat.presentation.model.ChatType +import com.teamoffroad.characterchat.presentation.model.TimeType import com.teamoffroad.core.common.domain.model.NotificationEvent +import com.teamoffroad.feature.home.presentation.component.UiState +import com.teamoffroad.feature.home.presentation.component.getErrorMessage +import com.teamoffroad.feature.home.presentation.model.CharacterChatModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +import java.time.LocalDateTime import javax.inject.Inject @HiltViewModel -class MainViewModel @Inject constructor() : ViewModel() { +class MainViewModel @Inject constructor( + private val characterChatRepository: CharacterChatRepository, +) : ViewModel() { private val _mainUiState = MutableStateFlow(MainUiState()) val mainUiState = _mainUiState.asStateFlow() + private val _characterChatUiState = MutableStateFlow(CharacterChattingUiState()) + val characterChatUiState = _characterChatUiState.asStateFlow() + + private val _userChatUiState = MutableStateFlow(UserChattingUiState()) + val userChatUiState = _userChatUiState.asStateFlow() + + private val _userChattingText: MutableStateFlow = MutableStateFlow("") + val userChattingText: StateFlow = _userChattingText.asStateFlow() + + private val _sendChatState = MutableStateFlow>(UiState.Loading) + val sendChatState = _sendChatState.asStateFlow() + fun navigateToAnnouncement(announcementId: String) { _mainUiState.value = _mainUiState.value.copy( announcementId = announcementId @@ -49,5 +75,91 @@ class MainViewModel @Inject constructor() : ViewModel() { @Subscribe(threadMode = ThreadMode.MAIN) fun onNotificationEvent(event: NotificationEvent) { Log.d("characterChat data", event.toString()) + + _characterChatUiState.value = _characterChatUiState.value.copy( + isCharacterChattingLoading = true + ) + + val characterName = event.characterName + val characterContent = event.characterContent + if (characterName != null && characterContent != null) { + _characterChatUiState.value = characterChatUiState.value.copy( + characterName = characterName, + characterChatContent = characterContent, + isCharacterChattingExist = true, + isAnswerButtonClicked = false + ) + } + _characterChatUiState.value = _characterChatUiState.value.copy( + isCharacterChattingLoading = false + ) + } + + fun updateAnswerCharacterChatButtonState(state: Boolean) { + _characterChatUiState.value = _characterChatUiState.value.copy( + isAnswerButtonClicked = state + ) + } + + fun updateCharacterChatExist(state: Boolean) { + _characterChatUiState.value = _characterChatUiState.value.copy( + isCharacterChattingExist = state + ) + } + + fun updateUserWatchingCharacterChat(state: Boolean) { + _characterChatUiState.value = _characterChatUiState.value.copy( + isUserWatchingCharacterChat = state + ) + } + + fun updateUserChattingText(text: String) { + _userChattingText.value = text + } + + fun updateShowUserChatTextField(state: Boolean) { + _userChatUiState.value = _userChatUiState.value.copy( + showUserChatTextField = state + ) + } + + fun sendChat() { + _userChatUiState.value = _userChatUiState.value.copy( + chatContent = userChattingText.value, + showUserChatTextField = true + ) + _characterChatUiState.value = _characterChatUiState.value.copy( + isCharacterChattingLoading = true + ) + + viewModelScope.launch { + runCatching { + val now = LocalDateTime.now() + val userChat = ChatModel( + chatType = ChatType.USER, + text = _userChatUiState.value.chatContent, + date = now.toLocalDate(), + time = Triple(TimeType.toTimeType(now.hour), now.hour, now.minute), + ) + characterChatRepository.saveChat(1, _userChatUiState.value.chatContent) + }.onSuccess { chat -> + _sendChatState.emit(UiState.Success(chat)) + _userChatUiState.value = _userChatUiState.value.copy( + isUserChattingLoading = true, + ) + + val characterContent = chat.content + _characterChatUiState.value = _characterChatUiState.value.copy( + characterChatContent = characterContent, + isCharacterChattingExist = true, + isAnswerButtonClicked = true, + isCharacterChattingLoading = false + ) + + }.onFailure { t -> + val errorMessage = getErrorMessage(t) + _sendChatState.emit(UiState.Failure(errorMessage)) + } + } } } \ No newline at end of file diff --git a/feature/main/src/main/java/com/teamoffroad/feature/main/UserChattingUiState.kt b/feature/main/src/main/java/com/teamoffroad/feature/main/UserChattingUiState.kt new file mode 100644 index 000000000..e93a1f918 --- /dev/null +++ b/feature/main/src/main/java/com/teamoffroad/feature/main/UserChattingUiState.kt @@ -0,0 +1,8 @@ +package com.teamoffroad.feature.main + +data class UserChattingUiState( + val chatContent: String = "", + val showUserChatTextField: Boolean = false, + val isUserChattingLoading: Boolean = false, + val isError: Boolean = false +) diff --git a/feature/main/src/main/java/com/teamoffroad/feature/main/component/CharacterChat.kt b/feature/main/src/main/java/com/teamoffroad/feature/main/component/CharacterChat.kt new file mode 100644 index 000000000..de04e8c1e --- /dev/null +++ b/feature/main/src/main/java/com/teamoffroad/feature/main/component/CharacterChat.kt @@ -0,0 +1,321 @@ +package com.teamoffroad.feature.main.component + +import android.util.Log +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieComposition +import com.teamoffroad.core.designsystem.component.clickableWithoutRipple +import com.teamoffroad.core.designsystem.theme.BtnInactive +import com.teamoffroad.core.designsystem.theme.Main2 +import com.teamoffroad.core.designsystem.theme.Main3 +import com.teamoffroad.core.designsystem.theme.OffroadTheme +import com.teamoffroad.core.designsystem.theme.Sub4 +import com.teamoffroad.feature.main.CharacterChattingUiState +import com.teamoffroad.feature.main.UserChattingUiState +import com.teamoffroad.offroad.feature.home.R +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun CharacterChat( + characterChatUiState: State, + userChatUiState: State, + characterTextColor: Color = Sub4, + characterTextStyle: TextStyle = OffroadTheme.typography.textBold, + messageTextColor: Color = Main2, + messageTextStyle: TextStyle = OffroadTheme.typography.textRegular, + backgroundColor: Color = Main3, + borderColor: Color = BtnInactive, + updateAnswerCharacterChatButtonState: (Boolean) -> Unit, + updateUserWatchingCharacterChat: (Boolean) -> Unit, + updateShowUserChatTextField: (Boolean) -> Unit, + //navigateToCharacterChatScreen: (Int, String) -> Unit +) { + val checkCharacterChattingLines = remember { mutableStateOf(false) } + val hasCheckedCharacterChattingLines = remember { mutableStateOf(false) } + val isExpanded = remember { mutableStateOf(false) } + + val rotationAngle by animateFloatAsState( + targetValue = if (isExpanded.value) 180f else 0f, + animationSpec = tween(durationMillis = 300), label = "" + ) + + + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = backgroundColor, + shape = RoundedCornerShape(12.dp) + ) + .border( + width = 1.dp, + shape = RoundedCornerShape(12.dp), + color = borderColor + ) + .padding(vertical = 14.dp, horizontal = 18.dp) + .clickableWithoutRipple { + //navigateToCharacterChatScreen(-1, characterName) + } + ) { + Column { + Row { + Text( + text = "${characterChatUiState.value.characterName} : ", + modifier = Modifier, + color = characterTextColor, + style = characterTextStyle + ) + + if (characterChatUiState.value.isCharacterChattingLoading) { + Box( + modifier = Modifier + .size(width = 54.dp, height = 27.dp) + ) { + val composition by rememberLottieComposition( + LottieCompositionSpec.RawRes( + com.teamoffroad.offroad.core.designsystem.R.raw.loading_linear + ) + ) + val animationState = animateLottieCompositionAsState( + composition, + iterations = LottieConstants.IterateForever + ) + + if (animationState.isAtEnd && animationState.isPlaying) { + LaunchedEffect(Unit) { } + } + + LottieAnimation(composition, animationState.progress) + } + } else { + Text( + text = characterChatUiState.value.characterChatContent, + modifier = Modifier.weight(1f), + color = messageTextColor, + style = messageTextStyle, + onTextLayout = { textLayoutResult -> + if (!hasCheckedCharacterChattingLines.value) { + checkCharacterChattingLines.value = textLayoutResult.lineCount >= 3 + hasCheckedCharacterChattingLines.value = true + } + }, + maxLines = if (!isExpanded.value && checkCharacterChattingLines.value) 2 else Int.MAX_VALUE, + overflow = TextOverflow.Ellipsis, + ) + + if(checkCharacterChattingLines.value) { + Image( + painter = painterResource(id = R.drawable.ic_home_accordian), + contentDescription = "accordion down", + modifier = Modifier + .graphicsLayer(rotationX = rotationAngle) + .clickableWithoutRipple { + updateUserWatchingCharacterChat(true) + isExpanded.value = !isExpanded.value + } + ) + } + + } + + } + + + if (characterChatUiState.value.isCharacterChattingExist && !userChatUiState.value.showUserChatTextField) { + AnswerCharacterChat( + characterChatUiState = characterChatUiState, + updateAnswerCharacterChatButtonState = updateAnswerCharacterChatButtonState, + updateUserWatchingCharacterChat = updateUserWatchingCharacterChat, + updateShowUserChatTextField = updateShowUserChatTextField, + userChatUiState = userChatUiState + ) + } + } + + } +} + +@Composable +fun AnswerCharacterChat( + characterChatUiState: State, + userChatUiState: State, + backgroundColor: Color = Main2, + textColor: Color = Main3, + textStyle: TextStyle = OffroadTheme.typography.textContents, + updateAnswerCharacterChatButtonState: (Boolean) -> Unit, + updateUserWatchingCharacterChat: (Boolean) -> Unit, + updateShowUserChatTextField: (Boolean) -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp) + ) { + Box(contentAlignment = Alignment.CenterEnd, modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(id = R.string.home_chat_answer), + modifier = Modifier + .background( + color = backgroundColor, + shape = RoundedCornerShape(8.dp) + ) + .padding(horizontal = 14.dp, vertical = 6.dp) + .clickableWithoutRipple { + updateUserWatchingCharacterChat(true) + updateAnswerCharacterChatButtonState(true) + updateShowUserChatTextField(true) + }, + color = textColor, + style = textStyle + ) + } + } +} + +@Composable +fun CharacterChatAnimation( + characterChatUiState: State, + userChatUiState: State, + updateAnswerCharacterChatButtonState: (Boolean) -> Unit, + updateCharacterChatExist: (Boolean) -> Unit, + updateUserWatchingCharacterChat: (Boolean) -> Unit, + updateShowUserChatTextField: (Boolean) -> Unit, + //navigateToCharacterChatScreen: (Int, String) -> Unit +) { + val offsetY = remember { Animatable(-10.dp.value) } + val coroutineScope = rememberCoroutineScope() + var dragOffsetY by remember { mutableFloatStateOf(0f) } + var isSwipedUp by remember { mutableStateOf(false) } + + var isInactive by remember { mutableStateOf(false) } + var lastDragTime by remember { mutableStateOf(System.currentTimeMillis()) } + + LaunchedEffect(characterChatUiState.value.isCharacterChattingExist) { // 제자리 + coroutineScope.launch { + offsetY.animateTo( + targetValue = 0.dp.value, + animationSpec = tween(durationMillis = 500) + ) + } + } + + LaunchedEffect(dragOffsetY, characterChatUiState.value.isCharacterChattingExist) { // 3초 후 상승 + if (characterChatUiState.value.isCharacterChattingExist && !characterChatUiState.value.isCharacterChattingLoading) { + while (true) { + delay(1000) + val currentTime = System.currentTimeMillis() + if (currentTime - lastDragTime > 3000 && dragOffsetY == 0f) { + if (!characterChatUiState.value.isUserWatchingCharacterChat) { + isInactive = true + break + } + + } + } + } + } + + Box( + contentAlignment = Alignment.TopCenter, + modifier = Modifier + .offset(y = offsetY.value.dp + dragOffsetY.dp) + .padding(start = 24.dp, top = 70.dp, end = 24.dp) + .pointerInput(Unit) { + detectDragGestures( + onDrag = { _, dragAmount -> + val newDragOffsetY = dragOffsetY + dragAmount.y + if (newDragOffsetY <= 0f) dragOffsetY = newDragOffsetY + }, + onDragEnd = { + if (dragOffsetY < -50f) isSwipedUp = true + else { + coroutineScope.launch { + offsetY.animateTo( + targetValue = 0.dp.value, + animationSpec = tween(durationMillis = 300) + ) + dragOffsetY = 0f + } + } + } + ) + + + } + ) { + CharacterChat( + characterChatUiState = characterChatUiState, + updateAnswerCharacterChatButtonState = updateAnswerCharacterChatButtonState, + updateUserWatchingCharacterChat = updateUserWatchingCharacterChat, + updateShowUserChatTextField = updateShowUserChatTextField, + userChatUiState = userChatUiState, + //navigateToCharacterChatScreen = navigateToCharacterChatScreen + ) + } + + LaunchedEffect(isSwipedUp) { // 사용자가 스와이프해서 캐릭터 채팅 자연스럽게 없애기 + if (isSwipedUp) { + coroutineScope.launch { + offsetY.animateTo( + targetValue = -50.dp.value, + animationSpec = tween(durationMillis = 500) + ) + updateCharacterChatExist(false) + dragOffsetY = 0f + isSwipedUp = false + } + } + } + + LaunchedEffect(isInactive) { + if (isInactive) { + coroutineScope.launch { + offsetY.animateTo( + targetValue = -50.dp.value, + animationSpec = tween(durationMillis = 500) + ) + updateCharacterChatExist(false) + dragOffsetY = 0f + isInactive = false + } + } + } +} \ No newline at end of file diff --git a/feature/main/src/main/java/com/teamoffroad/feature/main/component/MainNavHost.kt b/feature/main/src/main/java/com/teamoffroad/feature/main/component/MainNavHost.kt index a65421c41..54d0dd2d2 100644 --- a/feature/main/src/main/java/com/teamoffroad/feature/main/component/MainNavHost.kt +++ b/feature/main/src/main/java/com/teamoffroad/feature/main/component/MainNavHost.kt @@ -41,6 +41,9 @@ internal fun MainNavHost( ) { homeNavGraph( navigateToBack = navigator::popBackStackIfNotMainTabRoute, + navigateToCharacterChatScreen = { id, characterName -> + navigator.navigateToCharacterChat(id, characterName) + }, navigateToGainedCharacter = { navigator.navigateToMyPage().also { navigator.navigateToGainedCharacter() diff --git a/feature/main/src/main/java/com/teamoffroad/feature/main/component/UserChat.kt b/feature/main/src/main/java/com/teamoffroad/feature/main/component/UserChat.kt new file mode 100644 index 000000000..61534915b --- /dev/null +++ b/feature/main/src/main/java/com/teamoffroad/feature/main/component/UserChat.kt @@ -0,0 +1,112 @@ +package com.teamoffroad.feature.main.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.teamoffroad.core.designsystem.component.clickableWithoutRipple +import com.teamoffroad.core.designsystem.theme.OffroadTheme +import com.teamoffroad.core.designsystem.theme.Sub +import com.teamoffroad.core.designsystem.theme.Sub55 +import com.teamoffroad.core.designsystem.theme.White +import com.teamoffroad.feature.main.CharacterChattingUiState +import com.teamoffroad.feature.main.UserChattingUiState +import com.teamoffroad.offroad.feature.home.R + +@Composable +fun UserChat( + characterChatUiState: State, + chattingText: State, + userChatUiState: State, + updateUserWatchingCharacterChat: (Boolean) -> Unit, + updateUserChattingText: (String) -> Unit, + updateShowUserChatTextField: (Boolean) -> Unit, + sendChat: () -> Unit, + updateCharacterChatExist: (Boolean) -> Unit, +) { + if (userChatUiState.value.showUserChatTextField) { + Column { + Box( + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(end = 20.dp), + contentAlignment = Alignment.CenterEnd + ) { + FinishChatting( + updateShowUserChatTextField = updateShowUserChatTextField, + updateUserWatchingCharacterChat = updateUserWatchingCharacterChat, + characterChatUiState = characterChatUiState, + updateCharacterChatExist = updateCharacterChatExist, + ) + } + } + + HomeUserChatTextField( + text = chattingText.value, + userChatUiState = userChatUiState, + updateShowUserChatTextField = updateShowUserChatTextField, + keyboard = true, + onValueChange = { text -> + updateUserChattingText(text) + }, + onSendClick = { + updateUserWatchingCharacterChat(true) + sendChat() // 서버에 보내기 + updateUserChattingText("") // 초기화 + }, + characterChatUiState = characterChatUiState + ) + } + } + +} + +@Composable +fun FinishChatting( + backgroundColor: Color = Sub55, + borderColor: Color = Sub, + characterChatUiState: State, + updateShowUserChatTextField: (Boolean) -> Unit, + updateUserWatchingCharacterChat: (Boolean) -> Unit, + updateCharacterChatExist: (Boolean) -> Unit, +) { + Text( + style = OffroadTheme.typography.subtitle2Semibold, + text = stringResource(id = R.string.home_chat_finish), + modifier = Modifier + .padding(bottom = 8.dp) + .background( + color = backgroundColor, + shape = RoundedCornerShape(20.dp) + ) + .border( + width = 1.dp, + shape = RoundedCornerShape(20.dp), + color = borderColor + ) + .padding(horizontal = 16.dp) + .padding(vertical = 8.dp) + .clickableWithoutRipple { + if (!characterChatUiState.value.isCharacterChattingLoading) { + updateUserWatchingCharacterChat(false) + updateShowUserChatTextField(false) + updateCharacterChatExist(false) + } + }, + color = White + ) +} \ No newline at end of file diff --git a/feature/main/src/main/java/com/teamoffroad/feature/main/component/UserChatTextField.kt b/feature/main/src/main/java/com/teamoffroad/feature/main/component/UserChatTextField.kt new file mode 100644 index 000000000..28fd5e8cd --- /dev/null +++ b/feature/main/src/main/java/com/teamoffroad/feature/main/component/UserChatTextField.kt @@ -0,0 +1,208 @@ +package com.teamoffroad.feature.main.component + +import android.graphics.Rect +import android.view.ViewTreeObserver +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieComposition +import com.teamoffroad.core.designsystem.component.clickableWithoutRipple +import com.teamoffroad.core.designsystem.theme.BtnInactive +import com.teamoffroad.core.designsystem.theme.Main2 +import com.teamoffroad.core.designsystem.theme.OffroadTheme +import com.teamoffroad.core.designsystem.theme.Transparent +import com.teamoffroad.core.designsystem.theme.White +import com.teamoffroad.feature.main.CharacterChattingUiState +import com.teamoffroad.feature.main.UserChattingUiState +import com.teamoffroad.offroad.feature.home.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeUserChatTextField( + modifier: Modifier = Modifier, + text: String = "", + userChatUiState: State, + characterChatUiState: State, + updateShowUserChatTextField: (Boolean) -> Unit, + keyboard: Boolean, + onValueChange: (String) -> Unit = {}, + onSendClick: () -> Unit = {}, +) { + val scrollState = rememberScrollState() + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + val contextView = LocalView.current + var showLottieLoading by remember { mutableStateOf(false) } + + var keyboardVisible by remember { mutableStateOf(keyboard) } + + LaunchedEffect(userChatUiState.value.showUserChatTextField) { + if (userChatUiState.value.showUserChatTextField) { + focusRequester.requestFocus() + } + } + + DisposableEffect(contextView) { + val rect = Rect() + val listener = ViewTreeObserver.OnGlobalLayoutListener { + contextView.getWindowVisibleDisplayFrame(rect) + val screenHeight = contextView.rootView.height + val keypadHeight = screenHeight - rect.bottom + keyboardVisible = keypadHeight > screenHeight * 0.15 + } + contextView.viewTreeObserver.addOnGlobalLayoutListener(listener) + onDispose { + contextView.viewTreeObserver.removeOnGlobalLayoutListener(listener) + } + } + + AnimatedVisibility( + visible = userChatUiState.value.showUserChatTextField, + ) { + Box( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .background( + color = White, + shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) + ) + .padding(horizontal = 22.dp, vertical = 4.dp), + ) { + val textFieldHeight = remember { mutableIntStateOf(0) } + + Column{ + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp, bottom = 4.dp) + ) { + Text( + text = stringResource(id = R.string.home_chat_me), + color = Main2, + style = OffroadTheme.typography.textBold, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 6.dp) + ) + Box{ + if (text.isNotBlank()) { + Box( + modifier = Modifier + .size(width = 54.dp, height = 27.dp) + ) { + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(com.teamoffroad.offroad.core.designsystem.R.raw.loading_linear)) + val animationState = animateLottieCompositionAsState(composition, iterations = LottieConstants.IterateForever) + + if (animationState.isAtEnd && animationState.isPlaying) { + LaunchedEffect(Unit) { } + } + + LottieAnimation(composition, animationState.progress) + } + } else { + Text( + text = userChatUiState.value.chatContent, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .onGloballyPositioned { layoutCoordinates -> + textFieldHeight.intValue = layoutCoordinates.size.height + }, + style = OffroadTheme.typography.textRegular, + maxLines = 2, + ) + } + } + } + + Box { + Box( + modifier = Modifier + .fillMaxWidth() + .height(with(LocalDensity.current) { textFieldHeight.intValue.toDp() }) + .align(Alignment.Center) + .padding(vertical = 10.dp) + .padding(end = 44.dp) + .background( + color = BtnInactive, + shape = RoundedCornerShape(10.dp), + ), + ) + TextField( + value = text, + onValueChange = { onValueChange(it) }, + textStyle = OffroadTheme.typography.textRegular, + modifier = Modifier + .verticalScroll(scrollState) + .padding(end = 44.dp) + .padding(horizontal = 2.dp) + .fillMaxWidth() + .focusRequester(focusRequester) + .onGloballyPositioned { layoutCoordinates -> + textFieldHeight.intValue = layoutCoordinates.size.height + }, + maxLines = 2, + colors = TextFieldDefaults.textFieldColors( + containerColor = Transparent, + focusedIndicatorColor = Transparent, + unfocusedIndicatorColor = Transparent, + focusedTextColor = Main2, + ), + shape = RoundedCornerShape(12.dp), + ) + Image( + painter = painterResource(id = com.teamoffroad.offroad.feature.characterchat.R.drawable.ic_character_chat_send), + contentDescription = "send", + modifier = Modifier + .padding(end = 2.dp) + .size(36.dp) + .align(Alignment.CenterEnd) + .clickableWithoutRipple { if (text.isNotBlank() && !characterChatUiState.value.isCharacterChattingLoading) onSendClick() }, + ) + } + } + + } + } +} diff --git a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/mapper/UserCouponMapper.kt b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/mapper/UserCouponMapper.kt index 9d5be7723..2c508b816 100644 --- a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/mapper/UserCouponMapper.kt +++ b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/mapper/UserCouponMapper.kt @@ -1,16 +1,19 @@ package com.teamoffroad.feature.mypage.data.mapper -import com.teamoffroad.feature.mypage.data.model.UserCouponsEntity -import com.teamoffroad.feature.mypage.data.model.UserCouponsEntity.CouponsEntity +import com.teamoffroad.feature.mypage.data.model.UserAvailableCouponsEntity +import com.teamoffroad.feature.mypage.data.model.UserAvailableCouponsEntity.AvailableCouponsEntity +import com.teamoffroad.feature.mypage.data.model.UserUsedCouponsEntity import com.teamoffroad.feature.mypage.data.remote.request.UseCouponRequestDto -import com.teamoffroad.feature.mypage.data.remote.response.UserCouponsResponseDto +import com.teamoffroad.feature.mypage.data.remote.response.UserAvailableCouponsResponseDto +import com.teamoffroad.feature.mypage.data.remote.response.UserUsedCouponsResponseDto import com.teamoffroad.feature.mypage.domain.model.UseCoupon -import com.teamoffroad.feature.mypage.domain.model.UserCoupons +import com.teamoffroad.feature.mypage.domain.model.UserAvailableCoupons +import com.teamoffroad.feature.mypage.domain.model.UserUsedCoupons -fun UserCouponsResponseDto.toData(): UserCouponsEntity { - return UserCouponsEntity( +fun UserAvailableCouponsResponseDto.toData(): UserAvailableCouponsEntity { + return UserAvailableCouponsEntity( coupons = this.coupons.map { coupon -> - CouponsEntity( + AvailableCouponsEntity( id = coupon.id, name = coupon.name, couponImageUrl = coupon.couponImageUrl, @@ -24,10 +27,10 @@ fun UserCouponsResponseDto.toData(): UserCouponsEntity { ) } -fun UserCouponsEntity.toDomain(): UserCoupons { - return UserCoupons( +fun UserAvailableCouponsEntity.toDomain(): UserAvailableCoupons { + return UserAvailableCoupons( coupons = this.coupons.map { coupon -> - UserCoupons.Coupons( + UserAvailableCoupons.AvailableCoupons( id = coupon.id, name = coupon.name, couponImageUrl = coupon.couponImageUrl, @@ -41,6 +44,34 @@ fun UserCouponsEntity.toDomain(): UserCoupons { ) } +fun UserUsedCouponsResponseDto.toData(): UserUsedCouponsEntity { + return UserUsedCouponsEntity( + coupons = this.coupons.map { coupon -> + UserUsedCouponsEntity.UserCouponsEntity( + name = coupon.name, + couponImageUrl = coupon.couponImageUrl, + cursorId = coupon.cursorId + ) + }, + availableCouponsCount = availableCouponsCount, + usedCouponsCount = usedCouponsCount + ) +} + +fun UserUsedCouponsEntity.toDomain(): UserUsedCoupons { + return UserUsedCoupons( + coupons = this.coupons.map { coupon -> + UserUsedCoupons.UsedCoupons( + name = coupon.name, + couponImageUrl = coupon.couponImageUrl, + cursorId = coupon.cursorId + ) + }, + availableCouponsCount = availableCouponsCount, + usedCouponsCount = usedCouponsCount + ) +} + fun UseCoupon.toData(): UseCouponRequestDto { return UseCouponRequestDto( code = this.code, diff --git a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/model/UserCouponsEntity.kt b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/model/UserAvailableCouponsEntity.kt similarity index 70% rename from feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/model/UserCouponsEntity.kt rename to feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/model/UserAvailableCouponsEntity.kt index f8c9fb8f9..c333574f7 100644 --- a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/model/UserCouponsEntity.kt +++ b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/model/UserAvailableCouponsEntity.kt @@ -1,11 +1,11 @@ package com.teamoffroad.feature.mypage.data.model -data class UserCouponsEntity( - val coupons: List, +data class UserAvailableCouponsEntity( + val coupons: List, val availableCouponsCount: Int, val usedCouponsCount: Int ) { - data class CouponsEntity( + data class AvailableCouponsEntity( val id: Int, val name: String, val couponImageUrl: String, diff --git a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/model/UserUsedCouponsEntity.kt b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/model/UserUsedCouponsEntity.kt new file mode 100644 index 000000000..d26134230 --- /dev/null +++ b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/model/UserUsedCouponsEntity.kt @@ -0,0 +1,13 @@ +package com.teamoffroad.feature.mypage.data.model + +data class UserUsedCouponsEntity( + val coupons: List, + val availableCouponsCount: Int, + val usedCouponsCount: Int +) { + data class UserCouponsEntity( + val name: String, + val couponImageUrl: String, + val cursorId: Int, + ) +} diff --git a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/remote/response/UserCouponsResponseDto.kt b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/remote/response/UserAvailableCouponsResponseDto.kt similarity index 84% rename from feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/remote/response/UserCouponsResponseDto.kt rename to feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/remote/response/UserAvailableCouponsResponseDto.kt index 11fbf9bdd..693b43a87 100644 --- a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/remote/response/UserCouponsResponseDto.kt +++ b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/remote/response/UserAvailableCouponsResponseDto.kt @@ -4,9 +4,9 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class UserCouponsResponseDto( +data class UserAvailableCouponsResponseDto( @SerialName("coupons") - val coupons: List, + val coupons: List, @SerialName("availableCouponsCount") val availableCouponsCount: Int, @@ -15,7 +15,7 @@ data class UserCouponsResponseDto( val usedCouponsCount: Int ) { @Serializable - data class CouponsResponseDto( + data class AvailableCouponsResponseDto( @SerialName("id") val id: Int, diff --git a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/remote/response/UserUsedCouponsResponseDto.kt b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/remote/response/UserUsedCouponsResponseDto.kt new file mode 100644 index 000000000..49bc83a1d --- /dev/null +++ b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/remote/response/UserUsedCouponsResponseDto.kt @@ -0,0 +1,28 @@ +package com.teamoffroad.feature.mypage.data.remote.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UserUsedCouponsResponseDto( + @SerialName("coupons") + val coupons: List, + + @SerialName("availableCouponsCount") + val availableCouponsCount: Int, + + @SerialName("usedCouponsCount") + val usedCouponsCount: Int +) { + @Serializable + data class UsedCouponsResponseDto( + @SerialName("name") + val name: String, + + @SerialName("couponImageUrl") + val couponImageUrl: String, + + @SerialName("cursorId") + val cursorId: Int, + ) +} diff --git a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/remote/service/UserCouponService.kt b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/remote/service/UserCouponService.kt index 26a1332e7..a37403780 100644 --- a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/remote/service/UserCouponService.kt +++ b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/remote/service/UserCouponService.kt @@ -3,7 +3,8 @@ package com.teamoffroad.feature.mypage.data.remote.service import com.teamoffroad.core.common.data.remote.response.BaseResponse import com.teamoffroad.feature.mypage.data.remote.request.UseCouponRequestDto import com.teamoffroad.feature.mypage.data.remote.response.UseCouponResponseDto -import com.teamoffroad.feature.mypage.data.remote.response.UserCouponsResponseDto +import com.teamoffroad.feature.mypage.data.remote.response.UserAvailableCouponsResponseDto +import com.teamoffroad.feature.mypage.data.remote.response.UserUsedCouponsResponseDto import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST @@ -11,11 +12,18 @@ import retrofit2.http.Query interface UserCouponService { @GET("users/coupons") - suspend fun getCoupons( + suspend fun getAvailableCoupons( @Query("isUsed") isUsed: Boolean, @Query("size") size: Int, @Query("cursor") cursor: Int - ): BaseResponse + ): BaseResponse + + @GET("users/coupons") + suspend fun getUsedCoupons( + @Query("isUsed") isUsed: Boolean, + @Query("size") size: Int, + @Query("cursor") cursor: Int + ): BaseResponse @POST("users/coupons") suspend fun saveCoupons( diff --git a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/repository/UserCouponRepositoryImpl.kt b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/repository/UserCouponRepositoryImpl.kt index 135f62f62..dcf0803ed 100644 --- a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/repository/UserCouponRepositoryImpl.kt +++ b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/data/repository/UserCouponRepositoryImpl.kt @@ -4,20 +4,30 @@ import com.teamoffroad.feature.mypage.data.mapper.toData import com.teamoffroad.feature.mypage.data.mapper.toDomain import com.teamoffroad.feature.mypage.data.remote.service.UserCouponService import com.teamoffroad.feature.mypage.domain.model.UseCoupon -import com.teamoffroad.feature.mypage.domain.model.UserCoupons +import com.teamoffroad.feature.mypage.domain.model.UserAvailableCoupons +import com.teamoffroad.feature.mypage.domain.model.UserUsedCoupons import com.teamoffroad.feature.mypage.domain.repository.UserCouponRepository import javax.inject.Inject class UserCouponRepositoryImpl @Inject constructor( private val userCouponService: UserCouponService, ) : UserCouponRepository { - override suspend fun fetchUserCoupons( + override suspend fun fetchUserAvailableCoupons( isUsed: Boolean, size: Int, cursor: Int - ): UserCoupons { - val response = userCouponService.getCoupons(isUsed, size, cursor).data - return response?.toData()?.toDomain() ?: UserCoupons(emptyList(), 0, 0) + ): UserAvailableCoupons { + val response = userCouponService.getAvailableCoupons(isUsed, size, cursor).data + return response?.toData()?.toDomain() ?: UserAvailableCoupons(emptyList(), 0, 0) + } + + override suspend fun fetchUserUsedCoupons( + isUsed: Boolean, + size: Int, + cursor: Int + ): UserUsedCoupons { + val response = userCouponService.getUsedCoupons(isUsed, size, cursor).data + return response?.toData()?.toDomain() ?: UserUsedCoupons(emptyList(), 0, 0) } override suspend fun saveUseCoupon(coupon: UseCoupon): Boolean { diff --git a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/domain/model/UserCoupons.kt b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/domain/model/UserAvailableCoupons.kt similarity index 73% rename from feature/mypage/src/main/java/com/teamoffroad/feature/mypage/domain/model/UserCoupons.kt rename to feature/mypage/src/main/java/com/teamoffroad/feature/mypage/domain/model/UserAvailableCoupons.kt index 58bbdf6fa..ed3b65147 100644 --- a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/domain/model/UserCoupons.kt +++ b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/domain/model/UserAvailableCoupons.kt @@ -1,11 +1,11 @@ package com.teamoffroad.feature.mypage.domain.model -data class UserCoupons( - val coupons: List, +data class UserAvailableCoupons( + val coupons: List, val availableCouponsCount: Int, val usedCouponsCount: Int ) { - data class Coupons ( + data class AvailableCoupons ( val id: Int, val name: String, val couponImageUrl: String, diff --git a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/domain/model/UserUsedCoupons.kt b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/domain/model/UserUsedCoupons.kt new file mode 100644 index 000000000..c38b9cef4 --- /dev/null +++ b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/domain/model/UserUsedCoupons.kt @@ -0,0 +1,13 @@ +package com.teamoffroad.feature.mypage.domain.model + +data class UserUsedCoupons( + val coupons: List, + val availableCouponsCount: Int, + val usedCouponsCount: Int +) { + data class UsedCoupons ( + val name: String, + val couponImageUrl: String, + val cursorId: Int + ) +} diff --git a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/domain/repository/UserCouponRepository.kt b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/domain/repository/UserCouponRepository.kt index c050cfe3e..be8ce226c 100644 --- a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/domain/repository/UserCouponRepository.kt +++ b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/domain/repository/UserCouponRepository.kt @@ -1,14 +1,21 @@ package com.teamoffroad.feature.mypage.domain.repository import com.teamoffroad.feature.mypage.domain.model.UseCoupon -import com.teamoffroad.feature.mypage.domain.model.UserCoupons +import com.teamoffroad.feature.mypage.domain.model.UserAvailableCoupons +import com.teamoffroad.feature.mypage.domain.model.UserUsedCoupons interface UserCouponRepository { - suspend fun fetchUserCoupons( + suspend fun fetchUserAvailableCoupons( isUsed: Boolean, size: Int, cursor: Int - ): UserCoupons + ): UserAvailableCoupons + + suspend fun fetchUserUsedCoupons( + isUsed: Boolean, + size: Int, + cursor: Int + ): UserUsedCoupons suspend fun saveUseCoupon(coupon: UseCoupon): Boolean } \ No newline at end of file diff --git a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/domain/usecase/UserCouponListUseCase.kt b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/domain/usecase/UserCouponListUseCase.kt index eef7a331e..9fc665e4a 100644 --- a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/domain/usecase/UserCouponListUseCase.kt +++ b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/domain/usecase/UserCouponListUseCase.kt @@ -1,14 +1,19 @@ package com.teamoffroad.feature.mypage.domain.usecase import com.teamoffroad.feature.mypage.domain.model.UseCoupon -import com.teamoffroad.feature.mypage.domain.model.UserCoupons +import com.teamoffroad.feature.mypage.domain.model.UserAvailableCoupons +import com.teamoffroad.feature.mypage.domain.model.UserUsedCoupons import com.teamoffroad.feature.mypage.domain.repository.UserCouponRepository class UserCouponListUseCase( private val userCouponRepository: UserCouponRepository, ) { - suspend fun fetchUserCoupons(isUsed: Boolean, size: Int, cursor: Int): UserCoupons { - return userCouponRepository.fetchUserCoupons(isUsed, size, cursor) + suspend fun fetchUserAvailableCoupons(isUsed: Boolean, size: Int, cursor: Int): UserAvailableCoupons { + return userCouponRepository.fetchUserAvailableCoupons(isUsed, size, cursor) + } + + suspend fun fetchUserUsedCoupons(isUsed: Boolean, size: Int, cursor: Int): UserUsedCoupons { + return userCouponRepository.fetchUserUsedCoupons(isUsed, size, cursor) } suspend fun saveUseCoupon(coupon: UseCoupon): Boolean { diff --git a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/GainedCouponScreen.kt b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/GainedCouponScreen.kt index 1aa75b0b3..52ff86f51 100644 --- a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/GainedCouponScreen.kt +++ b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/GainedCouponScreen.kt @@ -36,12 +36,10 @@ internal fun GainedCouponScreen( backgroundColor: Color = Main1, viewModel: GainedCouponViewModel = hiltViewModel(), ) { - - val uiState = viewModel.getUserCouponListState.collectAsState().value - LaunchedEffect(Unit) { - viewModel.getUserCoupons(true, START_CURSOR_ID) - viewModel.getUserCoupons(false, START_CURSOR_ID) + viewModel.initCoupons() + viewModel.getUserAvailableCoupons(false, START_CURSOR_ID) + viewModel.getUserUsedCoupons(true, START_CURSOR_ID) } Box( @@ -70,8 +68,8 @@ internal fun GainedCouponScreen( viewModel.userAvailableCoupons.collectAsState().value, viewModel.userUsedCoupons.collectAsState().value, navigateToAvailableCouponDetail, - viewModel::getUserCoupons, - uiState, + viewModel::getUserAvailableCoupons, + viewModel::getUserUsedCoupons ) } } diff --git a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/GainedCouponViewModel.kt b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/GainedCouponViewModel.kt index 96e3b4e8f..c46908b48 100644 --- a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/GainedCouponViewModel.kt +++ b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/GainedCouponViewModel.kt @@ -1,8 +1,10 @@ package com.teamoffroad.feature.mypage.presentation +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.teamoffroad.feature.mypage.domain.model.UserCoupons +import com.teamoffroad.feature.mypage.domain.model.UserAvailableCoupons +import com.teamoffroad.feature.mypage.domain.model.UserUsedCoupons import com.teamoffroad.feature.mypage.domain.repository.UserCouponRepository import com.teamoffroad.feature.mypage.presentation.component.getErrorMessage import com.teamoffroad.feature.mypage.presentation.model.UiState @@ -17,9 +19,13 @@ class GainedCouponViewModel @Inject constructor( private val userCouponRepository: UserCouponRepository, ) : ViewModel() { - private val _getUserCouponListState = - MutableStateFlow>(UiState.Loading) - val getUserCouponListState = _getUserCouponListState.asStateFlow() + private val _getUserAvailableCouponListState = + MutableStateFlow>(UiState.Loading) + val getUserCouponListState = _getUserAvailableCouponListState.asStateFlow() + + private val _getUserUsedCouponListState = + MutableStateFlow>(UiState.Loading) + val getUserUsedCouponListState = _getUserUsedCouponListState.asStateFlow() private val _availableCouponsCount = MutableStateFlow(0) val availableCouponsCount = _availableCouponsCount.asStateFlow() @@ -27,44 +33,54 @@ class GainedCouponViewModel @Inject constructor( private val _usedCouponsCount = MutableStateFlow(0) val usedCouponsCount = _usedCouponsCount.asStateFlow() - private val _userAvailableCoupons = MutableStateFlow>(emptyList()) + private val _userAvailableCoupons = MutableStateFlow>(emptyList()) val userAvailableCoupons = _userAvailableCoupons.asStateFlow() - private val _userUsedCoupons = MutableStateFlow>(emptyList()) + private val _userUsedCoupons = MutableStateFlow>(emptyList()) val userUsedCoupons = _userUsedCoupons.asStateFlow() - private var isLoadable = true - - fun getUserCoupons(isUsed: Boolean, cursorId: Int) { - if (!isLoadable) return - _getUserCouponListState.value = when (cursorId) { - START_CURSOR_ID -> UiState.Loading - else -> UiState.AdditionalLoading + fun getUserAvailableCoupons(isUsed: Boolean, cursorId: Int) { + viewModelScope.launch { + runCatching { + userCouponRepository.fetchUserAvailableCoupons(isUsed, COUPON_SIZE, cursorId) + }.onSuccess { coupons -> + _getUserAvailableCouponListState.emit(UiState.Success(coupons)) + _availableCouponsCount.emit(coupons.availableCouponsCount) + Log.d("viewmodel availableCoupons", coupons.coupons.toString()) + applyAvailableCoupons(isUsed, coupons.coupons) + }.onFailure { throwable -> + _getUserAvailableCouponListState.emit(UiState.Failure(getErrorMessage(throwable))) + } } + } + fun getUserUsedCoupons(isUsed: Boolean, cursorId: Int) { viewModelScope.launch { runCatching { - userCouponRepository.fetchUserCoupons(isUsed, COUPON_SIZE, cursorId) + userCouponRepository.fetchUserUsedCoupons(isUsed, COUPON_SIZE, cursorId) }.onSuccess { coupons -> - if (coupons.coupons.isEmpty() && cursorId != START_CURSOR_ID) { - isLoadable = false - } - _getUserCouponListState.emit(UiState.Success(coupons)) - _availableCouponsCount.emit(coupons.availableCouponsCount) + _getUserUsedCouponListState.emit(UiState.Success(coupons)) _usedCouponsCount.emit(coupons.usedCouponsCount) - applyCoupons(isUsed, coupons.coupons) + Log.d("viewmodel usedCoupons", coupons.coupons.toString()) + applyUsedCoupons(isUsed, coupons.coupons) }.onFailure { throwable -> - _getUserCouponListState.emit(UiState.Failure(getErrorMessage(throwable))) + _getUserAvailableCouponListState.emit(UiState.Failure(getErrorMessage(throwable))) } } } - private fun applyCoupons(isUsed: Boolean, coupons: List) { - if (isUsed) { - _userUsedCoupons.value += coupons - } else { - _userAvailableCoupons.value += coupons - } + private fun applyAvailableCoupons(isUsed: Boolean, coupons: List) { + _userAvailableCoupons.value += coupons + } + + private fun applyUsedCoupons(isUsed: Boolean, coupons: List) { + _userUsedCoupons.value += coupons + + } + + fun initCoupons() { + _userUsedCoupons.value = emptyList() + _userAvailableCoupons.value = emptyList() } diff --git a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/component/GainedCouponItems.kt b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/component/GainedCouponItems.kt index 8478e703b..82f863a70 100644 --- a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/component/GainedCouponItems.kt +++ b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/component/GainedCouponItems.kt @@ -35,8 +35,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +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.teamoffroad.core.designsystem.component.AdaptationImage -import com.teamoffroad.core.designsystem.component.CircularLoadingAnimationLine import com.teamoffroad.core.designsystem.component.clickableWithoutRipple import com.teamoffroad.core.designsystem.theme.Black25 import com.teamoffroad.core.designsystem.theme.Black55 @@ -46,18 +49,18 @@ import com.teamoffroad.core.designsystem.theme.Main2 import com.teamoffroad.core.designsystem.theme.OffroadTheme import com.teamoffroad.core.designsystem.theme.Stroke import com.teamoffroad.core.designsystem.theme.White -import com.teamoffroad.feature.mypage.domain.model.UserCoupons -import com.teamoffroad.feature.mypage.presentation.model.UiState +import com.teamoffroad.feature.mypage.domain.model.UserAvailableCoupons +import com.teamoffroad.feature.mypage.domain.model.UserUsedCoupons +import com.teamoffroad.feature.mypage.presentation.GainedCouponViewModel.Companion.COUPON_SIZE import com.teamoffroad.offroad.feature.mypage.R import com.teamoffroad.offroad.feature.mypage.R.drawable @Composable fun AvailableCouponItems( availableCouponsCount: Int, - coupons: List, + coupons: List, navigateToAvailableCouponDetail: (Int, String, String, String, Int) -> Unit, getUserCoupons: (Boolean, Int) -> Unit, - uiState: UiState = UiState.Loading, ) { if (availableCouponsCount == 0) { Column( @@ -84,10 +87,9 @@ fun AvailableCouponItems( ) } } else { - CouponGrid( + AvailableCouponGrid( coupons = coupons, getUserCoupons = getUserCoupons, - uiState = uiState, ) { coupon -> AvailableCouponItem(coupon, navigateToAvailableCouponDetail) } @@ -96,7 +98,7 @@ fun AvailableCouponItems( @Composable private fun AvailableCouponItem( - coupon: UserCoupons.Coupons, + coupon: UserAvailableCoupons.AvailableCoupons, navigateToAvailableCouponDetail: (Int, String, String, String, Int) -> Unit, ) { Box( @@ -159,7 +161,7 @@ private fun AvailableCouponItem( @Composable fun UsedCouponItems( usedCouponsCount: Int, - coupons: List, + coupons: List, getUserCoupons: (Boolean, Int) -> Unit, ) { if (usedCouponsCount == 0) { @@ -187,7 +189,7 @@ fun UsedCouponItems( ) } } else { - CouponGrid(coupons, getUserCoupons) { coupon -> + UsedCouponGrid(coupons, getUserCoupons) { coupon -> UsedCouponItem(coupon) } } @@ -195,7 +197,7 @@ fun UsedCouponItems( @Composable private fun UsedCouponItem( - coupon: UserCoupons.Coupons, + coupon: UserUsedCoupons.UsedCoupons, ) { Box( modifier = Modifier @@ -232,11 +234,10 @@ private fun UsedCouponItem( } @Composable -fun CouponGrid( - coupons: List, +fun AvailableCouponGrid( + coupons: List, getUserCoupons: (Boolean, Int) -> Unit, - uiState: UiState = UiState.Loading, - couponContent: @Composable (UserCoupons.Coupons) -> Unit, + couponContent: @Composable (UserAvailableCoupons.AvailableCoupons) -> Unit, ) { val gridState = rememberLazyGridState() var showLottieLoading by remember { mutableStateOf(false) } @@ -265,12 +266,92 @@ fun CouponGrid( items(coupons.size) { index -> couponContent(coupons[index]) } - item(span = { GridItemSpan(2) }) { - CircularLoadingAnimationLine(isLoading = uiState is UiState.AdditionalLoading) + + if (coupons.size >= COUPON_SIZE) { + if (showLottieLoading) { + item(span = { GridItemSpan(2) }) { + LoadingIndicator { + val lastItem = coupons.lastOrNull() + if (lastItem != null) { + getUserCoupons(false, lastItem.cursorId) + } + showLottieLoading = false + } + } + } + } + } +} + +@Composable +fun UsedCouponGrid( + coupons: List, + getUserCoupons: (Boolean, Int) -> Unit, + couponContent: @Composable (UserUsedCoupons.UsedCoupons) -> Unit, +) { + val gridState = rememberLazyGridState() + var showLottieLoading by remember { mutableStateOf(false) } + + LaunchedEffect(gridState, coupons) { + snapshotFlow { gridState.layoutInfo.visibleItemsInfo } + .collect { visibleItems -> + val lastVisibleItem = visibleItems.lastOrNull() + if (lastVisibleItem != null && lastVisibleItem.index == coupons.size - 1 && !showLottieLoading) { + showLottieLoading = true + } + } + } + + LazyVerticalGrid( + state = gridState, + columns = GridCells.Fixed(2), + modifier = Modifier + .fillMaxSize() + .background(ListBg) + .padding(horizontal = 24.dp), + contentPadding = PaddingValues(vertical = 18.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(coupons.size) { index -> + couponContent(coupons[index]) + } + + if (coupons.size >= COUPON_SIZE) { + if (showLottieLoading) { + item(span = { GridItemSpan(2) }) { + LoadingIndicator { + val lastItem = coupons.lastOrNull() + if (lastItem != null) { + getUserCoupons(false, lastItem.cursorId) + } + showLottieLoading = false + } + } + } } } } +@Composable +private fun LoadingIndicator(onAnimationEnd: () -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp) + .size(38.dp), + contentAlignment = Alignment.Center + ) { + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(com.teamoffroad.offroad.core.designsystem.R.raw.loading_circle)) + val animationState = animateLottieCompositionAsState(composition, iterations = 1) + + if (animationState.isAtEnd && animationState.isPlaying) { + LaunchedEffect(Unit) { onAnimationEnd() } + } + + LottieAnimation(composition, animationState.progress) + } +} @Composable private fun GainedCouponLockedCover() { diff --git a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/component/GainedCouponViewPager.kt b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/component/GainedCouponViewPager.kt index 2d574c3ef..e5343245c 100644 --- a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/component/GainedCouponViewPager.kt +++ b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/component/GainedCouponViewPager.kt @@ -1,5 +1,6 @@ package com.teamoffroad.feature.mypage.presentation.component +import android.util.Log import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -29,8 +30,8 @@ import com.teamoffroad.core.designsystem.theme.Main1 import com.teamoffroad.core.designsystem.theme.Main2 import com.teamoffroad.core.designsystem.theme.OffroadTheme import com.teamoffroad.core.designsystem.theme.Sub -import com.teamoffroad.feature.mypage.domain.model.UserCoupons -import com.teamoffroad.feature.mypage.presentation.model.UiState +import com.teamoffroad.feature.mypage.domain.model.UserAvailableCoupons +import com.teamoffroad.feature.mypage.domain.model.UserUsedCoupons import com.teamoffroad.offroad.feature.mypage.R import kotlinx.coroutines.launch @@ -38,12 +39,15 @@ import kotlinx.coroutines.launch fun GainedCouponViewPager( availableCouponsCount: Int, usedCouponsCount: Int, - availableCoupons: List, - usedCoupons: List, + availableCoupons: List, + usedCoupons: List, navigateToAvailableCouponDetail: (Int, String, String, String, Int) -> Unit, - getUserCoupons: (Boolean, Int) -> Unit, - uiState: UiState, + getUserAvailableCoupons: (Boolean, Int) -> Unit, + getUserUsedCoupons: (Boolean, Int) -> Unit, ) { + Log.d("viewpager availableCoupons", availableCoupons.toString()) + Log.d("viewpager usedCoupons", usedCoupons.toString()) + val tabTitles = listOf( stringResource(id = R.string.my_page_gained_coupon_available), stringResource(id = R.string.my_page_gained_coupon_used) @@ -105,13 +109,13 @@ fun GainedCouponViewPager( availableCouponsCount, coupons = availableCoupons, navigateToAvailableCouponDetail = navigateToAvailableCouponDetail, - getUserCoupons, + getUserAvailableCoupons, ) 1 -> UsedCouponItems( usedCouponsCount, coupons = usedCoupons, - getUserCoupons, + getUserUsedCoupons, ) } diff --git a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/component/UseAvailableCouponDialog.kt b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/component/UseAvailableCouponDialog.kt index 4f42e2752..c2bba3413 100644 --- a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/component/UseAvailableCouponDialog.kt +++ b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/component/UseAvailableCouponDialog.kt @@ -68,7 +68,7 @@ fun UseAvailableCouponDialog( Dialog( onDismissRequest = { onClickCancel() }, properties = DialogProperties( - dismissOnClickOutside = true, + dismissOnClickOutside = false, dismissOnBackPress = true, ) ) { diff --git a/feature/mypage/src/main/res/values/strings.xml b/feature/mypage/src/main/res/values/strings.xml index 8a58e3e60..7a92c8f0a 100644 --- a/feature/mypage/src/main/res/values/strings.xml +++ b/feature/mypage/src/main/res/values/strings.xml @@ -25,7 +25,7 @@ 사용 실패 다시 한번 확인해 주세요. 사용 완료 - 쿠폰 사용이 완료되었요! + 쿠폰 사용이 완료되었어요! 사용 가능한 쿠폰이 없어요.\n퀘스트를 클리어하고 쿠폰을 획득해 보세요! 사용 완료한 쿠폰이 없어요.\n획득한 쿠폰을 사용해 보세요! 대표 캐릭터로 선택하기 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 53df0fd36..7c385f2eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -239,4 +239,3 @@ firebase = [ "firebase-messaging", "firebase-remoteConfig" ] -