diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c599588c..20457580 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ - + @@ -17,13 +17,14 @@ android:networkSecurityConfig="@xml/network_security_config" android:roundIcon="@mipmap/ic_falling_launcher_round" android:supportsRtl="true" - android:theme="@style/Theme.THT"> + android:theme="@style/Theme.THT" + android:usesCleartextTraffic="true"> + android:screenOrientation="portrait" + android:theme="@style/Theme.App.Starting" + tools:ignore="LockedOrientationActivity"> @@ -33,9 +34,10 @@ (), Fragmen ) true } + R.id.menu_heart -> { showFragment(LikeFragment.TAG) true } + R.id.menu_chat -> { showFragment(ChatFragment.TAG) true } + R.id.menu_my -> { showFragment(MyPageFragment.TAG) true } + else -> false } } @@ -101,6 +106,15 @@ class HomeActivity : BaseActivity(), Fragmen } } + fun showBottomNav() { + binding.bnvHome.visibility = View.VISIBLE + } + + fun hideBottomNav() { + binding.bnvHome.visibility = View.GONE + } + + override fun addFragmentBackStack(tag: String, bundle: Bundle?) { binding.root.hideSoftInput() with(supportFragmentManager) { diff --git a/app/src/main/java/com/tht/tht/navigation/BottomNavigationProviderImpl.kt b/app/src/main/java/com/tht/tht/navigation/BottomNavigationProviderImpl.kt new file mode 100644 index 00000000..b9b1df7c --- /dev/null +++ b/app/src/main/java/com/tht/tht/navigation/BottomNavigationProviderImpl.kt @@ -0,0 +1,15 @@ +package com.tht.tht.navigation + +import android.content.Context +import com.tht.tht.HomeActivity +import tht.core.navigation.BottomNavigationProvider + +class BottomNavigationProviderImpl : BottomNavigationProvider { + override fun show(context: Context) { + (context as HomeActivity).showBottomNav() + } + + override fun hide(context: Context) { + (context as HomeActivity).hideBottomNav() + } +} diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 925f0e35..07cd4838 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -2,5 +2,6 @@ tht-talk.co.kr + 3.34.157.62 diff --git a/build.gradle.kts b/build.gradle.kts index c55c233b..1b61a4c3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -43,6 +43,7 @@ subprojects { // run use ./gradlew assembleRelease -PcomposeCompilerReports=true --rerun-tasks tasks.withType().configureEach { kotlinOptions { + jvmTarget = "17" if (project.findProperty("composeCompilerReports") == "true") { val metricsOutputDir = "${rootProject.file(".").absolutePath}/compose-report/compose-metrics" val reportOutputDir = "${rootProject.file(".").absolutePath}/compose-report/compose-reports" @@ -56,3 +57,10 @@ subprojects { } } } + +//configurations.all { +// resolutionStrategy { +// force("org.jetbrains.kotlin:kotlin-stdlib:1.9.0") +// force ("org.jetbrains.kotlin:kotlin-reflect:1.9.0") +// } +//} diff --git a/core/compose-ui/src/main/java/com/example/compose_ui/component/image/ThtImage.kt b/core/compose-ui/src/main/java/com/example/compose_ui/component/image/ThtImage.kt index 81f66885..fe5807d8 100644 --- a/core/compose-ui/src/main/java/com/example/compose_ui/component/image/ThtImage.kt +++ b/core/compose-ui/src/main/java/com/example/compose_ui/component/image/ThtImage.kt @@ -2,11 +2,14 @@ package com.example.compose_ui.component.image import androidx.compose.foundation.Image import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp import coil.compose.AsyncImagePainter import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest @@ -32,7 +35,9 @@ fun ThtImage( else -> { Image( - modifier = modifier.size(size), + modifier = modifier + .size(size) + .clip(RoundedCornerShape(12.dp)), painter = painter, contentDescription = null, contentScale = ContentScale.Crop, diff --git a/core/compose-ui/src/main/java/com/example/compose_ui/component/text/headline/Headline.kt b/core/compose-ui/src/main/java/com/example/compose_ui/component/text/headline/Headline.kt index 99e55c69..db7c5ba0 100644 --- a/core/compose-ui/src/main/java/com/example/compose_ui/component/text/headline/Headline.kt +++ b/core/compose-ui/src/main/java/com/example/compose_ui/component/text/headline/Headline.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit import com.example.compose_ui.component.font.rememberPretendardFontStyle import com.example.compose_ui.extensions.dpTextUnit @@ -104,7 +105,9 @@ fun ThtHeadline4( fontWeight: FontWeight, color: Color, textAlign: TextAlign = TextAlign.Center, - shadow: Shadow? = null + shadow: Shadow? = null, + maxLines: Int = 1, + overflow: TextOverflow = TextOverflow.Ellipsis ) { Text( modifier = modifier, @@ -116,6 +119,8 @@ fun ThtHeadline4( shadow = shadow ), color = color, + overflow = overflow, + maxLines = maxLines, ) } diff --git a/core/navigation/src/main/java/tht/core/navigation/NavigationProvider.kt b/core/navigation/src/main/java/tht/core/navigation/NavigationProvider.kt new file mode 100644 index 00000000..e1a44ae7 --- /dev/null +++ b/core/navigation/src/main/java/tht/core/navigation/NavigationProvider.kt @@ -0,0 +1,8 @@ +package tht.core.navigation + +import android.content.Context + +interface BottomNavigationProvider { + fun show(context: Context) + fun hide(context: Context) +} diff --git a/data/src/main/java/com/tht/tht/data/constant/THTApiConstant.kt b/data/src/main/java/com/tht/tht/data/constant/THTApiConstant.kt index 1e823994..231d7414 100644 --- a/data/src/main/java/com/tht/tht/data/constant/THTApiConstant.kt +++ b/data/src/main/java/com/tht/tht/data/constant/THTApiConstant.kt @@ -49,6 +49,10 @@ object THTApiConstant { object Chat { const val CHAT_LIST = "/chat/rooms" + + const val CHAT_DETAIL_INFORMATION = "/chat/room/{chat-room-idx}" + + const val CHAT_DETAIL_HISTORY = "/chat/history" } object Setting { diff --git a/data/src/main/java/com/tht/tht/data/di/UseCaseModule.kt b/data/src/main/java/com/tht/tht/data/di/UseCaseModule.kt index 648672f7..16095b34 100644 --- a/data/src/main/java/com/tht/tht/data/di/UseCaseModule.kt +++ b/data/src/main/java/com/tht/tht/data/di/UseCaseModule.kt @@ -1,6 +1,8 @@ package com.tht.tht.data.di import com.tht.tht.domain.chat.repository.ChatRepository +import com.tht.tht.domain.chat.usecase.GetChatDetailInformationUseCase +import com.tht.tht.domain.chat.usecase.GetChatHistoryUseCase import com.tht.tht.domain.chat.usecase.GetChatListUseCase import com.tht.tht.domain.email.repository.EmailRepository import com.tht.tht.domain.email.usecase.SendInquiryEmailUseCase @@ -33,18 +35,19 @@ import com.tht.tht.domain.signup.usecase.RemoveSignupUserUseCase import com.tht.tht.domain.signup.usecase.RequestAuthenticationUseCase import com.tht.tht.domain.signup.usecase.RequestPhoneVerifyUseCase import com.tht.tht.domain.signup.usecase.RequestSignupUseCase +import com.tht.tht.domain.tohot.DailyTopicRepository import com.tht.tht.domain.tohot.DailyUserCardRepository +import com.tht.tht.domain.tohot.FetchDailyTopicListUseCase import com.tht.tht.domain.tohot.FetchDailyUserCardUseCase import com.tht.tht.domain.tohot.FetchToHotStateUseCase +import com.tht.tht.domain.tohot.SelectTopicUseCase import com.tht.tht.domain.token.repository.TokenRepository import com.tht.tht.domain.token.token.CheckAndRefreshThtAccessTokenUseCase import com.tht.tht.domain.token.token.CheckThtAccessTokenExpiredUseCase import com.tht.tht.domain.token.token.FetchThtAccessTokenUseCase +import com.tht.tht.domain.token.token.FetchThtUserUuidUseCase import com.tht.tht.domain.token.token.RefreshFcmTokenUseCase import com.tht.tht.domain.token.token.RefreshThtAccessTokenUseCase -import com.tht.tht.domain.tohot.DailyTopicRepository -import com.tht.tht.domain.tohot.FetchDailyTopicListUseCase -import com.tht.tht.domain.tohot.SelectTopicUseCase import com.tht.tht.domain.user.BlockUserUseCase import com.tht.tht.domain.user.LogoutUseCase import com.tht.tht.domain.user.ReportUserUseCase @@ -98,6 +101,13 @@ object UseCaseModule { dispatcher ) + @Provides + fun provideFetchUserUuidUseCase( + repository: TokenRepository, + ): FetchThtUserUuidUseCase = FetchThtUserUuidUseCase( + repository, + ) + @Provides fun provideFetchTermsUseCase( repository: SignupRepository, @@ -246,6 +256,18 @@ object UseCaseModule { ): GetChatListUseCase = GetChatListUseCase(repository) + @Provides + fun provideGetChatDetailInformationUseCase( + repository: ChatRepository + ): GetChatDetailInformationUseCase = + GetChatDetailInformationUseCase(repository) + + @Provides + fun provideGetChatHistoryUseCase( + repository: ChatRepository + ): GetChatHistoryUseCase = + GetChatHistoryUseCase(repository) + @Provides fun provideFetchThtAccessTokenUseCase( repository: TokenRepository diff --git a/data/src/main/java/com/tht/tht/data/local/dao/TokenDao.kt b/data/src/main/java/com/tht/tht/data/local/dao/TokenDao.kt index 6806329b..333dc926 100644 --- a/data/src/main/java/com/tht/tht/data/local/dao/TokenDao.kt +++ b/data/src/main/java/com/tht/tht/data/local/dao/TokenDao.kt @@ -8,11 +8,13 @@ interface TokenDao { fun updateFcmToken(token: String) - fun updateThtToken(token: String, accessTokenExpiresIn: Long, phone: String) + fun updateThtToken(token: String, accessTokenExpiresIn: Long, phone: String, userUuid: String?) fun fetchThtToken(): AccessTokenEntity fun fetchPhone(): String? + fun fetchUserUuid(): String? + fun clear() } diff --git a/data/src/main/java/com/tht/tht/data/local/dao/TokenDaoImpl.kt b/data/src/main/java/com/tht/tht/data/local/dao/TokenDaoImpl.kt index b46c570d..bbd76c75 100644 --- a/data/src/main/java/com/tht/tht/data/local/dao/TokenDaoImpl.kt +++ b/data/src/main/java/com/tht/tht/data/local/dao/TokenDaoImpl.kt @@ -38,16 +38,18 @@ class TokenDaoImpl @Inject constructor( sp.edit { putString(FCM_TOKEN_KEY, token) } } - override fun updateThtToken(token: String, accessTokenExpiresIn: Long, phone: String) { + override fun updateThtToken(token: String, accessTokenExpiresIn: Long, phone: String, userUuid: String?) { sp.edit { putString(THT_TOKEN_KEY, token) } sp.edit { putLong(THT_TOKEN_EXPIRES_KEY, accessTokenExpiresIn) } sp.edit { putString(THT_PHONE_KEY, phone) } + sp.edit { putString(THT_USER_UUID, userUuid) } } override fun fetchThtToken(): AccessTokenEntity { return AccessTokenEntity( accessToken = sp.getString(THT_TOKEN_KEY, null), - expiredTime = sp.getLong(THT_TOKEN_EXPIRES_KEY, 0L) + expiredTime = sp.getLong(THT_TOKEN_EXPIRES_KEY, 0L), + userUuid = sp.getString(THT_USER_UUID, null), ) } @@ -55,6 +57,10 @@ class TokenDaoImpl @Inject constructor( return sp.getString(THT_PHONE_KEY, null) } + override fun fetchUserUuid(): String? { + return sp.getString(THT_USER_UUID, null) + } + override fun clear() { sp.edit { remove(FCM_TOKEN_KEY) @@ -72,5 +78,7 @@ class TokenDaoImpl @Inject constructor( private const val THT_TOKEN_EXPIRES_KEY = "tht_token_expires_key" private const val THT_PHONE_KEY = "tht_phone_key" + + private const val THT_USER_UUID = "tht_user_uuid" } } diff --git a/data/src/main/java/com/tht/tht/data/local/datasource/TokenDataSource.kt b/data/src/main/java/com/tht/tht/data/local/datasource/TokenDataSource.kt index f14f3c61..8417eb13 100644 --- a/data/src/main/java/com/tht/tht/data/local/datasource/TokenDataSource.kt +++ b/data/src/main/java/com/tht/tht/data/local/datasource/TokenDataSource.kt @@ -8,11 +8,13 @@ interface TokenDataSource { suspend fun updateFcmToken(token: String) - suspend fun updateThtToken(token: String, accessTokenExpiresIn: Long, phone: String) + suspend fun updateThtToken(token: String, accessTokenExpiresIn: Long, phone: String, userUuid: String?) suspend fun fetchThtToken(): AccessTokenEntity suspend fun fetchPhone(): String? + suspend fun fetchUserUuid(): String? + suspend fun clear() } diff --git a/data/src/main/java/com/tht/tht/data/local/datasource/TokenDataSourceImpl.kt b/data/src/main/java/com/tht/tht/data/local/datasource/TokenDataSourceImpl.kt index dbee886f..0300e762 100644 --- a/data/src/main/java/com/tht/tht/data/local/datasource/TokenDataSourceImpl.kt +++ b/data/src/main/java/com/tht/tht/data/local/datasource/TokenDataSourceImpl.kt @@ -24,9 +24,9 @@ class TokenDataSourceImpl @Inject constructor( } } - override suspend fun updateThtToken(token: String, accessTokenExpiresIn: Long, phone: String) { + override suspend fun updateThtToken(token: String, accessTokenExpiresIn: Long, phone: String, userUuid: String?) { withContext(dispatcher) { - tokenDao.updateThtToken(token, accessTokenExpiresIn, phone) + tokenDao.updateThtToken(token, accessTokenExpiresIn, phone, userUuid) } } @@ -40,6 +40,10 @@ class TokenDataSourceImpl @Inject constructor( return tokenDao.fetchPhone() } + override suspend fun fetchUserUuid(): String? { + return tokenDao.fetchUserUuid() + } + override suspend fun clear() { return withContext(dispatcher) { tokenDao.clear() diff --git a/data/src/main/java/com/tht/tht/data/local/entity/AccessTokenEntity.kt b/data/src/main/java/com/tht/tht/data/local/entity/AccessTokenEntity.kt index 9c8d1d35..2238a89e 100644 --- a/data/src/main/java/com/tht/tht/data/local/entity/AccessTokenEntity.kt +++ b/data/src/main/java/com/tht/tht/data/local/entity/AccessTokenEntity.kt @@ -2,5 +2,6 @@ package com.tht.tht.data.local.entity data class AccessTokenEntity( val accessToken: String?, - val expiredTime: Long + val expiredTime: Long, + val userUuid: String?, ) diff --git a/data/src/main/java/com/tht/tht/data/local/entity/SignupUserEntity.kt b/data/src/main/java/com/tht/tht/data/local/entity/SignupUserEntity.kt index 3b09c39b..37062cf0 100644 --- a/data/src/main/java/com/tht/tht/data/local/entity/SignupUserEntity.kt +++ b/data/src/main/java/com/tht/tht/data/local/entity/SignupUserEntity.kt @@ -22,7 +22,7 @@ data class SignupUserEntity( val snsType: String, val snsUniqueId: String, val height: Int, - val smoke: String, - val drink: String, - val religion: String + val smoke: String?, + val drink: String?, + val religion: String? ) : java.io.Serializable diff --git a/data/src/main/java/com/tht/tht/data/local/mapper/Mapper.kt b/data/src/main/java/com/tht/tht/data/local/mapper/Mapper.kt index 12139ba3..3c6162d0 100644 --- a/data/src/main/java/com/tht/tht/data/local/mapper/Mapper.kt +++ b/data/src/main/java/com/tht/tht/data/local/mapper/Mapper.kt @@ -131,6 +131,7 @@ fun RegionCodeResponse.toModel(): RegionCodeModel { fun AccessTokenEntity.toModel(): AccessTokenModel { return AccessTokenModel( accessToken = accessToken, - expiredTime = expiredTime + expiredTime = expiredTime, + userUuid = userUuid, ) } diff --git a/data/src/main/java/com/tht/tht/data/remote/datasource/chat/ChatDataSource.kt b/data/src/main/java/com/tht/tht/data/remote/datasource/chat/ChatDataSource.kt index 24bf4fd9..2792cf5f 100644 --- a/data/src/main/java/com/tht/tht/data/remote/datasource/chat/ChatDataSource.kt +++ b/data/src/main/java/com/tht/tht/data/remote/datasource/chat/ChatDataSource.kt @@ -1,8 +1,14 @@ package com.tht.tht.data.remote.datasource.chat +import com.tht.tht.data.remote.response.chat.ChatDetailInformationResponse +import com.tht.tht.data.remote.response.chat.ChatHistoryResponse import com.tht.tht.data.remote.response.chat.ChatListResponse interface ChatDataSource { suspend fun getChatList(): List + + suspend fun getChatDetailInformation(roomIdx: Long): ChatDetailInformationResponse + + suspend fun getChatHistory(roomIdx: Long, chatIdx: String?, size: String): List } diff --git a/data/src/main/java/com/tht/tht/data/remote/datasource/chat/ChatDataSourceImpl.kt b/data/src/main/java/com/tht/tht/data/remote/datasource/chat/ChatDataSourceImpl.kt index d5697d14..659d72ff 100644 --- a/data/src/main/java/com/tht/tht/data/remote/datasource/chat/ChatDataSourceImpl.kt +++ b/data/src/main/java/com/tht/tht/data/remote/datasource/chat/ChatDataSourceImpl.kt @@ -1,6 +1,8 @@ package com.tht.tht.data.remote.datasource.chat import com.tht.tht.data.remote.mapper.toUnwrap +import com.tht.tht.data.remote.response.chat.ChatDetailInformationResponse +import com.tht.tht.data.remote.response.chat.ChatHistoryResponse import com.tht.tht.data.remote.response.chat.ChatListResponse import com.tht.tht.data.remote.service.chat.ChatService import javax.inject.Inject @@ -12,4 +14,12 @@ class ChatDataSourceImpl @Inject constructor( override suspend fun getChatList(): List { return chatService.getChatList().toUnwrap { it } } + + override suspend fun getChatDetailInformation(roomIdx: Long): ChatDetailInformationResponse { + return chatService.getChatDetailInformation(roomIdx).toUnwrap { it } + } + + override suspend fun getChatHistory(roomIdx: Long, chatIdx: String?, size: String): List { + return chatService.getChatHistory(roomIdx.toString(), chatIdx, size = size).toUnwrap { it } + } } diff --git a/data/src/main/java/com/tht/tht/data/remote/mapper/AccessTokenRefreshResponseMapper.kt b/data/src/main/java/com/tht/tht/data/remote/mapper/AccessTokenRefreshResponseMapper.kt index e1a6b5f8..66efe14c 100644 --- a/data/src/main/java/com/tht/tht/data/remote/mapper/AccessTokenRefreshResponseMapper.kt +++ b/data/src/main/java/com/tht/tht/data/remote/mapper/AccessTokenRefreshResponseMapper.kt @@ -6,6 +6,7 @@ import com.tht.tht.domain.token.model.AccessTokenModel fun AccessTokenRefreshResponse.toAccessTokenModel(): AccessTokenModel { return AccessTokenModel( accessToken = accessToken, - expiredTime = accessTokenExpiresIn + expiredTime = accessTokenExpiresIn, + userUuid = userUuid, ) } diff --git a/data/src/main/java/com/tht/tht/data/remote/mapper/LoginMapper.kt b/data/src/main/java/com/tht/tht/data/remote/mapper/LoginMapper.kt index 0676c23d..b49bd22e 100644 --- a/data/src/main/java/com/tht/tht/data/remote/mapper/LoginMapper.kt +++ b/data/src/main/java/com/tht/tht/data/remote/mapper/LoginMapper.kt @@ -6,6 +6,7 @@ import com.tht.tht.domain.token.model.FcmTokenLoginResponseModel fun FcmTokenLoginResponse.toModel(): FcmTokenLoginResponseModel { return FcmTokenLoginResponseModel( accessToken = accessToken, - accessTokenExpiresIn = accessTokenExpiresIn + accessTokenExpiresIn = accessTokenExpiresIn, + userUuid = userUuid, ) } diff --git a/data/src/main/java/com/tht/tht/data/remote/mapper/SignupMapper.kt b/data/src/main/java/com/tht/tht/data/remote/mapper/SignupMapper.kt index 47df854d..d892208d 100644 --- a/data/src/main/java/com/tht/tht/data/remote/mapper/SignupMapper.kt +++ b/data/src/main/java/com/tht/tht/data/remote/mapper/SignupMapper.kt @@ -82,7 +82,8 @@ fun SignupUserModel.toRemoteRequest(): SignupRequest { fun SignupResponse.toModel(): SignupResponseModel { return SignupResponseModel( accessToken = accessToken, - accessTokenExpiresIn = accessTokenExpiresIn + accessTokenExpiresIn = accessTokenExpiresIn, + userUuid = userUuid, ) } diff --git a/data/src/main/java/com/tht/tht/data/remote/mapper/chat/ChatDetailInformationMapper.kt b/data/src/main/java/com/tht/tht/data/remote/mapper/chat/ChatDetailInformationMapper.kt new file mode 100644 index 00000000..70c2f18a --- /dev/null +++ b/data/src/main/java/com/tht/tht/data/remote/mapper/chat/ChatDetailInformationMapper.kt @@ -0,0 +1,12 @@ +package com.tht.tht.data.remote.mapper.chat + +import com.tht.tht.data.remote.response.chat.ChatDetailInformationResponse +import com.tht.tht.domain.chat.model.ChatDetailInformationModel + +fun ChatDetailInformationResponse.toModel() = ChatDetailInformationModel( + chatRoomIdx = chatRoomIdx, + talkSubject = talkSubject, + talkIssue = talkIssue, + startDate = startDate, + isChatAble = isChatAble +) diff --git a/data/src/main/java/com/tht/tht/data/remote/mapper/chat/ChatHistoryMapper.kt b/data/src/main/java/com/tht/tht/data/remote/mapper/chat/ChatHistoryMapper.kt new file mode 100644 index 00000000..3055c500 --- /dev/null +++ b/data/src/main/java/com/tht/tht/data/remote/mapper/chat/ChatHistoryMapper.kt @@ -0,0 +1,13 @@ +package com.tht.tht.data.remote.mapper.chat + +import com.tht.tht.data.remote.response.chat.ChatHistoryResponse +import com.tht.tht.domain.chat.model.ChatHistoryModel + +fun ChatHistoryResponse.toModel() = ChatHistoryModel( + chatIdx = chatIdx, + sender = sender, + senderUuid = senderUuid, + msg = msg, + imgUrl = imgUrl, + dateTime = dateTime, +) diff --git a/data/src/main/java/com/tht/tht/data/remote/response/chat/ChatDetailInformationResponse.kt b/data/src/main/java/com/tht/tht/data/remote/response/chat/ChatDetailInformationResponse.kt new file mode 100644 index 00000000..65442741 --- /dev/null +++ b/data/src/main/java/com/tht/tht/data/remote/response/chat/ChatDetailInformationResponse.kt @@ -0,0 +1,11 @@ +package com.tht.tht.data.remote.response.chat + +import com.google.gson.annotations.SerializedName + +data class ChatDetailInformationResponse( + @SerializedName("chatRoomIdx") val chatRoomIdx: Long, + @SerializedName("talkSubject") val talkSubject: String, + @SerializedName("talkIssue") val talkIssue: String, + @SerializedName("startDate") val startDate: String, + @SerializedName("isChatAble") val isChatAble: Boolean +) diff --git a/data/src/main/java/com/tht/tht/data/remote/response/chat/ChatHistoryResponse.kt b/data/src/main/java/com/tht/tht/data/remote/response/chat/ChatHistoryResponse.kt new file mode 100644 index 00000000..83b9401c --- /dev/null +++ b/data/src/main/java/com/tht/tht/data/remote/response/chat/ChatHistoryResponse.kt @@ -0,0 +1,12 @@ +package com.tht.tht.data.remote.response.chat + +import com.google.gson.annotations.SerializedName + +data class ChatHistoryResponse( + @SerializedName("chatIdx") val chatIdx: String, + @SerializedName("sender") val sender: String, + @SerializedName("senderUuid") val senderUuid: String, + @SerializedName("msg") val msg: String, + @SerializedName("imgUrl") val imgUrl: String, + @SerializedName("dateTime") val dateTime: String, +) diff --git a/data/src/main/java/com/tht/tht/data/remote/response/login/FcmTokenLoginResponse.kt b/data/src/main/java/com/tht/tht/data/remote/response/login/FcmTokenLoginResponse.kt index 4113ce20..2d54a68a 100644 --- a/data/src/main/java/com/tht/tht/data/remote/response/login/FcmTokenLoginResponse.kt +++ b/data/src/main/java/com/tht/tht/data/remote/response/login/FcmTokenLoginResponse.kt @@ -6,5 +6,7 @@ data class FcmTokenLoginResponse( @SerializedName("accessToken") val accessToken: String, @SerializedName("accessTokenExpiresIn") - val accessTokenExpiresIn: Long + val accessTokenExpiresIn: Long, + @SerializedName("userUuid") + val userUuid: String ) diff --git a/data/src/main/java/com/tht/tht/data/remote/response/signup/SignupResponse.kt b/data/src/main/java/com/tht/tht/data/remote/response/signup/SignupResponse.kt index 3b7aa923..2a35a4d5 100644 --- a/data/src/main/java/com/tht/tht/data/remote/response/signup/SignupResponse.kt +++ b/data/src/main/java/com/tht/tht/data/remote/response/signup/SignupResponse.kt @@ -6,5 +6,7 @@ data class SignupResponse( @SerializedName("accessToken") val accessToken: String, @SerializedName("accessTokenExpiresIn") - val accessTokenExpiresIn: Long + val accessTokenExpiresIn: Long, + @SerializedName("userUuid") + val userUuid: String ) diff --git a/data/src/main/java/com/tht/tht/data/remote/response/user/AccessTokenRefreshResponse.kt b/data/src/main/java/com/tht/tht/data/remote/response/user/AccessTokenRefreshResponse.kt index 43b0c653..cf981864 100644 --- a/data/src/main/java/com/tht/tht/data/remote/response/user/AccessTokenRefreshResponse.kt +++ b/data/src/main/java/com/tht/tht/data/remote/response/user/AccessTokenRefreshResponse.kt @@ -6,5 +6,7 @@ data class AccessTokenRefreshResponse( @SerializedName("accessToken") val accessToken: String, @SerializedName("accessTokenExpiresIn") - val accessTokenExpiresIn: Long + val accessTokenExpiresIn: Long, + @SerializedName("userUuid") + val userUuid: String, ) diff --git a/data/src/main/java/com/tht/tht/data/remote/service/chat/ChatService.kt b/data/src/main/java/com/tht/tht/data/remote/service/chat/ChatService.kt index 03b506a1..21737086 100644 --- a/data/src/main/java/com/tht/tht/data/remote/service/chat/ChatService.kt +++ b/data/src/main/java/com/tht/tht/data/remote/service/chat/ChatService.kt @@ -2,10 +2,26 @@ package com.tht.tht.data.remote.service.chat import com.tht.tht.data.constant.THTApiConstant import com.tht.tht.data.remote.response.base.ThtResponse +import com.tht.tht.data.remote.response.chat.ChatDetailInformationResponse +import com.tht.tht.data.remote.response.chat.ChatHistoryResponse import com.tht.tht.data.remote.response.chat.ChatListResponse import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query interface ChatService { @GET(THTApiConstant.Chat.CHAT_LIST) suspend fun getChatList(): ThtResponse> + + @GET(THTApiConstant.Chat.CHAT_DETAIL_INFORMATION) + suspend fun getChatDetailInformation( + @Path(value = "chat-room-idx") roomIdx: Long + ): ThtResponse + + @GET(THTApiConstant.Chat.CHAT_DETAIL_HISTORY) + suspend fun getChatHistory( + @Query(value = "roomNo") roomIdx: String, + @Query(value = "chatIdx") chatIdx:String?, + @Query(value = "size") size: String, + ): ThtResponse> } diff --git a/data/src/main/java/com/tht/tht/data/repository/ChatRepositoryImpl.kt b/data/src/main/java/com/tht/tht/data/repository/ChatRepositoryImpl.kt index cd8ea94c..a7c39832 100644 --- a/data/src/main/java/com/tht/tht/data/repository/ChatRepositoryImpl.kt +++ b/data/src/main/java/com/tht/tht/data/repository/ChatRepositoryImpl.kt @@ -2,6 +2,8 @@ package com.tht.tht.data.repository import com.tht.tht.data.remote.datasource.chat.ChatDataSource import com.tht.tht.data.remote.mapper.chat.toModel +import com.tht.tht.domain.chat.model.ChatDetailInformationModel +import com.tht.tht.domain.chat.model.ChatHistoryModel import com.tht.tht.domain.chat.model.ChatListModel import com.tht.tht.domain.chat.repository.ChatRepository import javax.inject.Inject @@ -13,4 +15,12 @@ class ChatRepositoryImpl @Inject constructor( override suspend fun getChatList(): List { return chatDataSource.getChatList().map { it.toModel() } } + + override suspend fun getChatDetailInformation(roomIdx: Long): ChatDetailInformationModel { + return chatDataSource.getChatDetailInformation(roomIdx).toModel() + } + + override suspend fun getChatHistory(roomIdx: Long, chatIdx: String?, size: String): List { + return chatDataSource.getChatHistory(roomIdx, chatIdx, size).map { it.toModel() } + } } diff --git a/data/src/main/java/com/tht/tht/data/repository/SignupRepositoryImpl.kt b/data/src/main/java/com/tht/tht/data/repository/SignupRepositoryImpl.kt index 253fa4b4..77c054b0 100644 --- a/data/src/main/java/com/tht/tht/data/repository/SignupRepositoryImpl.kt +++ b/data/src/main/java/com/tht/tht/data/repository/SignupRepositoryImpl.kt @@ -16,6 +16,7 @@ import com.tht.tht.domain.signup.model.TermsModel import com.tht.tht.domain.signup.repository.SignupRepository import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject class SignupRepositoryImpl @Inject constructor( diff --git a/data/src/main/java/com/tht/tht/data/repository/TokenRepositoryImpl.kt b/data/src/main/java/com/tht/tht/data/repository/TokenRepositoryImpl.kt index 2b23d164..f3021ed6 100644 --- a/data/src/main/java/com/tht/tht/data/repository/TokenRepositoryImpl.kt +++ b/data/src/main/java/com/tht/tht/data/repository/TokenRepositoryImpl.kt @@ -20,8 +20,8 @@ class TokenRepositoryImpl @Inject constructor( tokenDataSource.updateFcmToken(token) } - override suspend fun updateThtToken(token: String, accessTokenExpiresIn: Long, phone: String) { - tokenDataSource.updateThtToken(token, accessTokenExpiresIn, phone) + override suspend fun updateThtToken(token: String, accessTokenExpiresIn: Long, phone: String, userUuid: String?) { + tokenDataSource.updateThtToken(token, accessTokenExpiresIn, phone, userUuid) } override suspend fun fetchThtToken(): AccessTokenModel { @@ -32,6 +32,10 @@ class TokenRepositoryImpl @Inject constructor( return tokenDataSource.fetchPhone() } + override suspend fun fetchUserUuid(): String? { + return tokenDataSource.fetchUserUuid() + } + override suspend fun refreshAccessToken(): AccessTokenModel { return refreshAccessTokenRefreshDataSource.refreshAccessToken().toAccessTokenModel() } diff --git a/domain/src/main/java/com/tht/tht/domain/chat/model/ChatDetailInformationModel.kt b/domain/src/main/java/com/tht/tht/domain/chat/model/ChatDetailInformationModel.kt new file mode 100644 index 00000000..0a8532ce --- /dev/null +++ b/domain/src/main/java/com/tht/tht/domain/chat/model/ChatDetailInformationModel.kt @@ -0,0 +1,9 @@ +package com.tht.tht.domain.chat.model + +data class ChatDetailInformationModel( + val chatRoomIdx: Long, + val talkSubject: String, + val talkIssue: String, + val startDate: String, + val isChatAble: Boolean +) diff --git a/domain/src/main/java/com/tht/tht/domain/chat/model/ChatHistoryModel.kt b/domain/src/main/java/com/tht/tht/domain/chat/model/ChatHistoryModel.kt new file mode 100644 index 00000000..7af69c5a --- /dev/null +++ b/domain/src/main/java/com/tht/tht/domain/chat/model/ChatHistoryModel.kt @@ -0,0 +1,10 @@ +package com.tht.tht.domain.chat.model + +data class ChatHistoryModel( + val chatIdx: String, + val sender: String, + val senderUuid: String, + val msg: String, + val imgUrl: String, + val dateTime: String, +) diff --git a/domain/src/main/java/com/tht/tht/domain/chat/repository/ChatRepository.kt b/domain/src/main/java/com/tht/tht/domain/chat/repository/ChatRepository.kt index 9c39e074..61eb6908 100644 --- a/domain/src/main/java/com/tht/tht/domain/chat/repository/ChatRepository.kt +++ b/domain/src/main/java/com/tht/tht/domain/chat/repository/ChatRepository.kt @@ -1,8 +1,14 @@ package com.tht.tht.domain.chat.repository +import com.tht.tht.domain.chat.model.ChatDetailInformationModel +import com.tht.tht.domain.chat.model.ChatHistoryModel import com.tht.tht.domain.chat.model.ChatListModel interface ChatRepository { suspend fun getChatList(): List + + suspend fun getChatDetailInformation(roomIdx: Long): ChatDetailInformationModel + + suspend fun getChatHistory(roomIdx: Long, chatIdx: String?, size: String): List } diff --git a/domain/src/main/java/com/tht/tht/domain/chat/usecase/GetChatDetailInformationUseCase.kt b/domain/src/main/java/com/tht/tht/domain/chat/usecase/GetChatDetailInformationUseCase.kt new file mode 100644 index 00000000..174d9239 --- /dev/null +++ b/domain/src/main/java/com/tht/tht/domain/chat/usecase/GetChatDetailInformationUseCase.kt @@ -0,0 +1,12 @@ +package com.tht.tht.domain.chat.usecase + +import com.tht.tht.domain.chat.repository.ChatRepository + +class GetChatDetailInformationUseCase ( + private val repository: ChatRepository, +) { + + suspend operator fun invoke(roomIdx: Long) = kotlin.runCatching { + repository.getChatDetailInformation(roomIdx) + } +} diff --git a/domain/src/main/java/com/tht/tht/domain/chat/usecase/GetChatHistoryUseCase.kt b/domain/src/main/java/com/tht/tht/domain/chat/usecase/GetChatHistoryUseCase.kt new file mode 100644 index 00000000..c3e9540d --- /dev/null +++ b/domain/src/main/java/com/tht/tht/domain/chat/usecase/GetChatHistoryUseCase.kt @@ -0,0 +1,12 @@ +package com.tht.tht.domain.chat.usecase + +import com.tht.tht.domain.chat.repository.ChatRepository + +class GetChatHistoryUseCase( + private val repository: ChatRepository, +) { + + suspend operator fun invoke(roomIdx: Long, chatIdx: String?, size: String) = kotlin.runCatching { + repository.getChatHistory(roomIdx, chatIdx, size) + } +} diff --git a/domain/src/main/java/com/tht/tht/domain/login/usecase/LoginUseCase.kt b/domain/src/main/java/com/tht/tht/domain/login/usecase/LoginUseCase.kt index 2d6e56b3..f04d6fa6 100644 --- a/domain/src/main/java/com/tht/tht/domain/login/usecase/LoginUseCase.kt +++ b/domain/src/main/java/com/tht/tht/domain/login/usecase/LoginUseCase.kt @@ -21,7 +21,8 @@ class LoginUseCase( tokenRepository.updateThtToken( tokenInfo.accessToken, tokenInfo.accessTokenExpiresIn, - phone + phone, + tokenInfo.userUuid, ) } } diff --git a/domain/src/main/java/com/tht/tht/domain/signup/model/SignupResponseModel.kt b/domain/src/main/java/com/tht/tht/domain/signup/model/SignupResponseModel.kt index aa4eb5a2..0f05ce54 100644 --- a/domain/src/main/java/com/tht/tht/domain/signup/model/SignupResponseModel.kt +++ b/domain/src/main/java/com/tht/tht/domain/signup/model/SignupResponseModel.kt @@ -2,5 +2,6 @@ package com.tht.tht.domain.signup.model data class SignupResponseModel( val accessToken: String, - val accessTokenExpiresIn: Long + val accessTokenExpiresIn: Long, + val userUuid: String ) diff --git a/domain/src/main/java/com/tht/tht/domain/signup/model/SignupUserModel.kt b/domain/src/main/java/com/tht/tht/domain/signup/model/SignupUserModel.kt index dcb087b4..aedaebc1 100644 --- a/domain/src/main/java/com/tht/tht/domain/signup/model/SignupUserModel.kt +++ b/domain/src/main/java/com/tht/tht/domain/signup/model/SignupUserModel.kt @@ -29,7 +29,7 @@ data class SignupUserModel( SOMETIMES, FREQUENTLY; companion object { - fun from(smoke: String): Smoke? { + fun from(smoke: String?): Smoke? { return when (smoke) { NONE.name -> NONE SOMETIMES.name -> SOMETIMES @@ -44,7 +44,7 @@ data class SignupUserModel( SOMETIMES, FREQUENTLY; companion object { - fun from(drink: String): Drink? { + fun from(drink: String?): Drink? { return when (drink) { NONE.name -> NONE SOMETIMES.name -> SOMETIMES @@ -62,7 +62,7 @@ data class SignupUserModel( WON_BUDDHISM, OTHER; companion object { - fun from(religion: String): Religion? { + fun from(religion: String?): Religion? { return when (religion) { NONE.name -> NONE CHRISTIAN.name -> CHRISTIAN diff --git a/domain/src/main/java/com/tht/tht/domain/signup/usecase/RequestSignupUseCase.kt b/domain/src/main/java/com/tht/tht/domain/signup/usecase/RequestSignupUseCase.kt index 1f32157b..9b068ee1 100644 --- a/domain/src/main/java/com/tht/tht/domain/signup/usecase/RequestSignupUseCase.kt +++ b/domain/src/main/java/com/tht/tht/domain/signup/usecase/RequestSignupUseCase.kt @@ -28,7 +28,9 @@ class RequestSignupUseCase( user.birthday.isBlank() -> throw SignupException.SignupUserInfoInvalidateException("birthday") - user.interestKeys.size < SignupConstant.INTEREST_REQUIRE_SIZE -> throw SignupException.SignupUserInfoInvalidateException("interest") + user.interestKeys.size < SignupConstant.INTEREST_REQUIRE_SIZE -> throw SignupException.SignupUserInfoInvalidateException( + "interest" + ) user.lat < 0 -> throw SignupException.SignupUserInfoInvalidateException("lat") @@ -40,11 +42,15 @@ class RequestSignupUseCase( user.preferredGender.isBlank() -> throw SignupException.SignupUserInfoInvalidateException("preferred gender") - user.profileImgUrl.size < SignupConstant.PROFILE_IMAGE_REQUIRE_SIZE -> throw SignupException.SignupUserInfoInvalidateException("profile image") + user.profileImgUrl.size < SignupConstant.PROFILE_IMAGE_REQUIRE_SIZE -> throw SignupException.SignupUserInfoInvalidateException( + "profile image" + ) user.introduce.isBlank() -> throw SignupException.SignupUserInfoInvalidateException("introduce") - user.idealTypeKeys.size < SignupConstant.IDEAL_TYPE_REQUIRE_SIZE -> throw SignupException.SignupUserInfoInvalidateException("ideal") + user.idealTypeKeys.size < SignupConstant.IDEAL_TYPE_REQUIRE_SIZE -> throw SignupException.SignupUserInfoInvalidateException( + "ideal" + ) user.height < 0 -> throw SignupException.SignupUserInfoInvalidateException("height") @@ -58,7 +64,7 @@ class RequestSignupUseCase( signupRepository.requestSignup( user.copy(fcmToken = fcmToken) ).let { - tokenRepository.updateThtToken(it.accessToken, it.accessTokenExpiresIn, phone) + tokenRepository.updateThtToken(it.accessToken, it.accessTokenExpiresIn, phone, it.userUuid) it.accessToken.isNotBlank() } } diff --git a/domain/src/main/java/com/tht/tht/domain/token/model/AccessTokenModel.kt b/domain/src/main/java/com/tht/tht/domain/token/model/AccessTokenModel.kt index 06b20e65..a476e4f9 100644 --- a/domain/src/main/java/com/tht/tht/domain/token/model/AccessTokenModel.kt +++ b/domain/src/main/java/com/tht/tht/domain/token/model/AccessTokenModel.kt @@ -2,5 +2,6 @@ package com.tht.tht.domain.token.model data class AccessTokenModel( val accessToken: String?, - val expiredTime: Long + val expiredTime: Long, + val userUuid: String?, ) diff --git a/domain/src/main/java/com/tht/tht/domain/token/model/FcmTokenLoginResponseModel.kt b/domain/src/main/java/com/tht/tht/domain/token/model/FcmTokenLoginResponseModel.kt index daf7ee6b..80825fc9 100644 --- a/domain/src/main/java/com/tht/tht/domain/token/model/FcmTokenLoginResponseModel.kt +++ b/domain/src/main/java/com/tht/tht/domain/token/model/FcmTokenLoginResponseModel.kt @@ -2,5 +2,6 @@ package com.tht.tht.domain.token.model data class FcmTokenLoginResponseModel( val accessToken: String, - val accessTokenExpiresIn: Long + val accessTokenExpiresIn: Long, + val userUuid: String, ) diff --git a/domain/src/main/java/com/tht/tht/domain/token/repository/TokenRepository.kt b/domain/src/main/java/com/tht/tht/domain/token/repository/TokenRepository.kt index fd21d42f..4046fc8a 100644 --- a/domain/src/main/java/com/tht/tht/domain/token/repository/TokenRepository.kt +++ b/domain/src/main/java/com/tht/tht/domain/token/repository/TokenRepository.kt @@ -8,12 +8,14 @@ interface TokenRepository { suspend fun updateFcmToken(token: String) - suspend fun updateThtToken(token: String, accessTokenExpiresIn: Long, phone: String) + suspend fun updateThtToken(token: String, accessTokenExpiresIn: Long, phone: String, userUuid: String?) suspend fun fetchThtToken(): AccessTokenModel suspend fun fetchPhone(): String? + suspend fun fetchUserUuid(): String? + suspend fun refreshAccessToken(): AccessTokenModel suspend fun clearSavedToken() diff --git a/domain/src/main/java/com/tht/tht/domain/token/token/FetchThtUserUuidUseCase.kt b/domain/src/main/java/com/tht/tht/domain/token/token/FetchThtUserUuidUseCase.kt new file mode 100644 index 00000000..0ff3cc4b --- /dev/null +++ b/domain/src/main/java/com/tht/tht/domain/token/token/FetchThtUserUuidUseCase.kt @@ -0,0 +1,13 @@ +package com.tht.tht.domain.token.token + +import com.tht.tht.domain.token.repository.TokenRepository + +class FetchThtUserUuidUseCase( + private val tokenRepository: TokenRepository +) { + suspend operator fun invoke(): Result { + return kotlin.runCatching { + tokenRepository.fetchUserUuid() + } + } +} diff --git a/domain/src/main/java/com/tht/tht/domain/token/token/RefreshFcmTokenUseCase.kt b/domain/src/main/java/com/tht/tht/domain/token/token/RefreshFcmTokenUseCase.kt index bc7f6e05..3436f46f 100644 --- a/domain/src/main/java/com/tht/tht/domain/token/token/RefreshFcmTokenUseCase.kt +++ b/domain/src/main/java/com/tht/tht/domain/token/token/RefreshFcmTokenUseCase.kt @@ -21,7 +21,8 @@ class RefreshFcmTokenUseCase( tokenRepository.updateThtToken( it.accessToken, it.accessTokenExpiresIn, - phone + phone, + it.userUuid, ) it } diff --git a/domain/src/main/java/com/tht/tht/domain/token/token/RefreshThtAccessTokenUseCase.kt b/domain/src/main/java/com/tht/tht/domain/token/token/RefreshThtAccessTokenUseCase.kt index 5021ed8e..d09c15a4 100644 --- a/domain/src/main/java/com/tht/tht/domain/token/token/RefreshThtAccessTokenUseCase.kt +++ b/domain/src/main/java/com/tht/tht/domain/token/token/RefreshThtAccessTokenUseCase.kt @@ -16,7 +16,8 @@ class RefreshThtAccessTokenUseCase( tokenRepository.updateThtToken( tokenInfo.accessToken, tokenInfo.expiredTime, - phone + phone, + tokenInfo.userUuid, ) } } diff --git a/feature/chat/build.gradle.kts b/feature/chat/build.gradle.kts index 31db22a2..322a9e74 100644 --- a/feature/chat/build.gradle.kts +++ b/feature/chat/build.gradle.kts @@ -4,8 +4,10 @@ plugins { id("com.android.library") id("org.jetbrains.kotlin.android") id("kotlin-kapt") - id("org.jlleitschuh.gradle.ktlint") id("dagger.hilt.android.plugin") + id("org.jlleitschuh.gradle.ktlint") + id("io.gitlab.arturbosch.detekt") + kotlin("plugin.serialization") version "1.8.0" } android { @@ -50,6 +52,7 @@ dependencies { implementation(project(":core:ui")) implementation(project(":core:compose-ui")) implementation(project(":domain")) + implementation(project(":feature:setting")) implementation(libs.androidx.core) implementation(libs.androidx.appcompat) @@ -73,4 +76,21 @@ dependencies { kaptTest(libs.hilt.android.compiler) kapt(libs.hilt.compiler) implementation(libs.jetpack.compose.hilt.navigation) + +// implementation("com.beust:klaxon:5.6") +// implementation("com.squareup.okhttp:okhttp-ws:2.7.5") +// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") +// testImplementation("com.squareup.okhttp:mockwebserver:2.7.5") +// testImplementation("org.jetbrains.kotlin:kotlin-test-junit:1.8.22") +// implementation("com.google.code.gson:gson:2.10.1") + + implementation(libs.moshi.kotlin) + implementation(libs.moshi.converter) + implementation(libs.krossbow.stomp.core) + implementation(libs.krossbow.websocket.okhttp) + implementation(libs.krossbow.stomp.moshi) + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + +// implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0") + } diff --git a/feature/chat/src/main/java/tht/feature/chat/chat/fragment/ChatFragment.kt b/feature/chat/src/main/java/tht/feature/chat/chat/fragment/ChatFragment.kt index ff73f2de..d8675b29 100644 --- a/feature/chat/src/main/java/tht/feature/chat/chat/fragment/ChatFragment.kt +++ b/feature/chat/src/main/java/tht/feature/chat/chat/fragment/ChatFragment.kt @@ -24,7 +24,9 @@ class ChatFragment : Fragment() { return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - ChatNavigation() + ChatNavigation( + context = requireActivity() + ) } } } diff --git a/feature/chat/src/main/java/tht/feature/chat/chat/screen/ChatDetailScreen.kt b/feature/chat/src/main/java/tht/feature/chat/chat/screen/ChatDetailScreen.kt index 4eaa2c8f..e6e4e089 100644 --- a/feature/chat/src/main/java/tht/feature/chat/chat/screen/ChatDetailScreen.kt +++ b/feature/chat/src/main/java/tht/feature/chat/chat/screen/ChatDetailScreen.kt @@ -1,14 +1,20 @@ package tht.feature.chat.chat.screen +import android.content.Context +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import com.example.compose_ui.common.viewmodel.collectAsState import tht.feature.chat.chat.state.ChatDetailState @@ -17,40 +23,68 @@ import tht.feature.chat.component.detail.ChatDetailList import tht.feature.chat.component.detail.ChatDetailTopAppBar import tht.feature.chat.component.detail.ChatEditTextContainer +@OptIn(ExperimentalFoundationApi::class) @Composable internal fun ChatDetailScreen( - viewModel: ChatDetailViewModel = hiltViewModel() + viewModel: ChatDetailViewModel = hiltViewModel(), + onBack: () -> Unit, + roomIdx: Long, + partnerName: String, + context: Context, ) { - LaunchedEffect(key1 = Unit) { - viewModel.getChatList() + LaunchedEffect(Unit) { + viewModel.getChatDetailInformation(roomIdx) + viewModel.getUserUuid() } val state = viewModel.collectAsState().value val currentText = viewModel.currentText.collectAsState().value - Box(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.fillMaxSize()) { + + DisposableEffect(key1 = Unit) { + viewModel.initStomp(roomIdx) + viewModel.hideBottomNavigation(context) + onDispose { + viewModel.showBottomNavigation(context) + viewModel.cancelStomp() + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.systemBars) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .imePadding() + ) { ChatDetailTopAppBar( - title = "마음", - onClickBack = { }, + title = partnerName, + onClickBack = onBack, onClickReport = {}, onClickLogout = {} ) Box(modifier = Modifier.weight(1f)) { when (state) { - is ChatDetailState.ChatList -> ChatDetailList(state.chatList) + is ChatDetailState.ChatList -> { + ChatDetailList( + userUuid = state.userUuid, + chatDetailInformation = state.chatDetailInformation, + chatList = state.chatList, + onLoadMore = { + viewModel.getChatHistory(roomIdx) + } + ) + } } } } ChatEditTextContainer( modifier = Modifier.align(Alignment.BottomCenter), text = currentText, - onChangedText = viewModel::updateCurrentText + onChangedText = viewModel::updateCurrentText, + onClickSend = { viewModel.onClickSent(roomIdx) } ) } } - -@Composable -@Preview(showBackground = true) -fun ChatDetailScreenPreview() { - ChatDetailScreen() -} diff --git a/feature/chat/src/main/java/tht/feature/chat/chat/screen/ChatEmptyScreen.kt b/feature/chat/src/main/java/tht/feature/chat/chat/screen/ChatEmptyScreen.kt index 653e8d7b..ed6a7eca 100644 --- a/feature/chat/src/main/java/tht/feature/chat/chat/screen/ChatEmptyScreen.kt +++ b/feature/chat/src/main/java/tht/feature/chat/chat/screen/ChatEmptyScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.compose_ui.component.button.ThtButton import com.example.compose_ui.component.text.headline.ThtHeadline4 @@ -73,3 +74,11 @@ internal fun ChatEmptyScreen( Spacer(modifier = Modifier.height(56.dp)) } } + +@Preview +@Composable +private fun EmptyChatListPreview() { + ChatEmptyScreen( + onClickChangeTitle = {}, + ) +} diff --git a/feature/chat/src/main/java/tht/feature/chat/chat/screen/ChatListScreen.kt b/feature/chat/src/main/java/tht/feature/chat/chat/screen/ChatListScreen.kt index 926a743c..a041e413 100644 --- a/feature/chat/src/main/java/tht/feature/chat/chat/screen/ChatListScreen.kt +++ b/feature/chat/src/main/java/tht/feature/chat/chat/screen/ChatListScreen.kt @@ -4,21 +4,65 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import tht.feature.chat.component.LazyColumnChatItem +import androidx.compose.ui.tooling.preview.Preview +import kotlinx.collections.immutable.ImmutableList +import okhttp3.internal.toImmutableList import tht.feature.chat.chat.state.ChatState +import tht.feature.chat.component.LazyColumnChatItem +import tht.feature.chat.model.ChatListUiModel @Composable internal fun ChatListScreen( - navigateChatDetail: () -> Unit = { }, - items: ChatState.ChatList + navigateChatDetail: (Long, String) -> Unit = { _, _ -> }, + items: ChatState.ChatList, ) { Column(modifier = Modifier.fillMaxSize()) { LazyColumnChatItem( items = items.chatList, isLoading = items.isLoading, - onClickItem = { - navigateChatDetail() - } + onClickItem = navigateChatDetail, ) } } + + +@Preview +@Composable +private fun ChatListScreenPreview(modifier: Modifier = Modifier) { + ChatListScreen( + navigateChatDetail = { _, _ -> }, + items = ChatState.ChatList( + isLoading = false, + chatList = listOf( + ChatListUiModel( + chatRoomIdx = 0L, + partnerName = "헬로우우", + partnerProfileUrl = "", + currentMessage = "매칭된 무디와 먼저 대화를 시작해보세요.", + messageTime = "08:24 PM" + ), + ChatListUiModel( + chatRoomIdx = 1L, + partnerName = "폴링처음이에요", + partnerProfileUrl = "", + currentMessage = "매칭된 무디와 먼저 대화를 시작해보세요.", + messageTime = "08:25 PM" + ), + ChatListUiModel( + chatRoomIdx = 2L, + partnerName = "미니미니미", + partnerProfileUrl = "", + currentMessage = "매칭된 무디와 먼저 대화를 시작해보세요.", + messageTime = "02:23 PM" + ), + ChatListUiModel( + chatRoomIdx = 0L, + partnerName = "헬로우우", + partnerProfileUrl = "", + currentMessage = "매칭된 무디와 먼저 대화를 시작해보세요.", + messageTime = "08:24 PM" + ), + ).toImmutableList() as ImmutableList + ) + ) +} diff --git a/feature/chat/src/main/java/tht/feature/chat/chat/screen/ChatScreen.kt b/feature/chat/src/main/java/tht/feature/chat/chat/screen/ChatScreen.kt index a854b2ee..9ba5b575 100644 --- a/feature/chat/src/main/java/tht/feature/chat/chat/screen/ChatScreen.kt +++ b/feature/chat/src/main/java/tht/feature/chat/chat/screen/ChatScreen.kt @@ -1,55 +1,132 @@ package tht.feature.chat.chat.screen +import android.content.Context import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.Icon +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +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.graphicsLayer import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import com.example.compose_ui.R +import androidx.lifecycle.Lifecycle import com.example.compose_ui.common.viewmodel.collectAsState -import tht.feature.chat.component.ChatTopAppBar -import tht.feature.chat.chat.viewmodel.ChatViewModel +import com.example.compose_ui.component.spacer.Spacer +import com.example.compose_ui.component.text.p.ThtP1 import tht.feature.chat.chat.state.ChatState +import tht.feature.chat.chat.viewmodel.ChatViewModel +import tht.feature.chat.component.ChatTopAppBar @Composable internal fun ChatScreen( viewModel: ChatViewModel = hiltViewModel(), - navigateChatDetail: () -> Unit = { } + context: Context, + navigateChatDetail: (Long, String) -> Unit = { _, _ -> } ) { - LaunchedEffect(key1 = Unit) { - viewModel.getFakeChatList() + OnLifecycleEvent { _, event -> + if (event == Lifecycle.Event.ON_START) { + viewModel.showBottomNavigation(context) + } } - val state = viewModel.collectAsState().value - Column(modifier = Modifier.fillMaxWidth()) { - ChatTopAppBar( - title = "채팅", - rightIcons = { - Icon( - tint = Color.White, - painter = painterResource(id = R.drawable.ic_non_alert), - contentDescription = null - ) - } - ) + ChatScreen( + state = state, + navigateChatDetail = navigateChatDetail, + ) +} + +@Composable +internal fun ChatScreen( + state: ChatState, + navigateChatDetail: (Long, String) -> Unit = { _, _ -> } +) { + Box { + Column( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.systemBars) + ) { + ChatTopAppBar( + title = "채팅", + rightIcons = { + Image( + painter = painterResource(id = tht.feature.chat.R.drawable.ic_bling), + contentDescription = null + ) + } + ) - Crossfade( - modifier = Modifier.fillMaxSize(), - targetState = state, - animationSpec = tween(400), - label = "" - ) { state -> - when (state) { - is ChatState.Empty -> ChatEmptyScreen(onClickChangeTitle = {}) - is ChatState.ChatList -> ChatListScreen(items = state, navigateChatDetail = navigateChatDetail) + Crossfade( + modifier = Modifier.fillMaxSize(), + targetState = state, + animationSpec = tween(400), + label = "" + ) { state -> + when (state) { + is ChatState.Empty -> ChatEmptyScreen(onClickChangeTitle = {}) + is ChatState.ChatList -> ChatListScreen(items = state, navigateChatDetail = navigateChatDetail) + } } } + NewTopicTip( + modifier = Modifier + .graphicsLayer { + translationY = 32f + } + .align(Alignment.TopCenter) + ) + } +} + +@Composable +@Preview +private fun BoxScope.NewTopicTip( + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .statusBarsPadding() + .clip(RoundedCornerShape(30.dp)) + .background(Color(0xFF222222)) + .padding(horizontal = 17.dp, vertical = 14.dp) + ) { + ThtP1( + text = "새로운 주제어가 오픈되었어요!", + fontWeight = FontWeight.W400, + color = Color.White + ) + Spacer(9.dp) + ThtP1( + text = "메인화면으로 이동", + fontWeight = FontWeight.SemiBold, + color = Color(0xFFF9CC2E), + ) } } + +@Preview +@Composable +private fun ChatScreenPreview() { + ChatScreen( + state = ChatState.Empty + ) +} diff --git a/feature/chat/src/main/java/tht/feature/chat/chat/screen/Extensions.kt b/feature/chat/src/main/java/tht/feature/chat/chat/screen/Extensions.kt index 46566d68..fd4435c0 100644 --- a/feature/chat/src/main/java/tht/feature/chat/chat/screen/Extensions.kt +++ b/feature/chat/src/main/java/tht/feature/chat/chat/screen/Extensions.kt @@ -1,7 +1,5 @@ package tht.feature.chat.chat.screen -import android.os.Build -import androidx.annotation.RequiresApi import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.rememberUpdatedState @@ -11,8 +9,9 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import java.time.Instant import java.time.LocalDateTime -import java.time.ZoneOffset +import java.time.ZoneId import java.time.format.DateTimeFormatter +import java.util.Locale @Composable fun OnLifecycleEvent( @@ -37,10 +36,15 @@ fun OnLifecycleEvent( } } -@RequiresApi(Build.VERSION_CODES.O) -fun format(milliseconds: Long): String { - return LocalDateTime.ofInstant( - Instant.ofEpochMilli(milliseconds), - ZoneOffset.systemDefault() - ).format(DateTimeFormatter.ISO_DATE) +fun String.formatToAmPm(): String { + val instant = try { + Instant.parse(this) + } catch (e: Exception) { + LocalDateTime.parse(this, DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS")) + .atZone(ZoneId.systemDefault()).toInstant() + } + + val formatter = DateTimeFormatter.ofPattern("hh:mm a", Locale.ENGLISH) + .withZone(ZoneId.systemDefault()) + return formatter.format(instant) } diff --git a/feature/chat/src/main/java/tht/feature/chat/chat/state/ChatDetailState.kt b/feature/chat/src/main/java/tht/feature/chat/chat/state/ChatDetailState.kt index b2386357..15c66217 100644 --- a/feature/chat/src/main/java/tht/feature/chat/chat/state/ChatDetailState.kt +++ b/feature/chat/src/main/java/tht/feature/chat/chat/state/ChatDetailState.kt @@ -1,12 +1,16 @@ package tht.feature.chat.chat.state -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import tht.feature.chat.model.ChatListUiModel +import tht.feature.chat.model.ChatDetailInformationUiModel +import tht.feature.chat.model.ChatHistoryUiModel +import tht.feature.setting.uimodel.MyPageUserInfoUiModel sealed class ChatDetailState { data class ChatList( val isLoading: Boolean, - val chatList: ImmutableList = persistentListOf() + val chatDetailInformation: ChatDetailInformationUiModel? = null, + val chatList: List = emptyList(), + val chatIdx: String? = null, + val userUuid: String? = null, + val userInformation: MyPageUserInfoUiModel? = null ) : ChatDetailState() } diff --git a/feature/chat/src/main/java/tht/feature/chat/chat/viewmodel/ChatDetailViewModel.kt b/feature/chat/src/main/java/tht/feature/chat/chat/viewmodel/ChatDetailViewModel.kt index 778c6878..10029c31 100644 --- a/feature/chat/src/main/java/tht/feature/chat/chat/viewmodel/ChatDetailViewModel.kt +++ b/feature/chat/src/main/java/tht/feature/chat/chat/viewmodel/ChatDetailViewModel.kt @@ -1,57 +1,265 @@ package tht.feature.chat.chat.viewmodel +import android.content.Context +import android.util.Log import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.example.compose_ui.common.viewmodel.Container import com.example.compose_ui.common.viewmodel.Store import com.example.compose_ui.common.viewmodel.intent import com.example.compose_ui.common.viewmodel.store +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import com.tht.tht.domain.chat.usecase.GetChatDetailInformationUseCase +import com.tht.tht.domain.chat.usecase.GetChatHistoryUseCase +import com.tht.tht.domain.setting.usecase.FetchMyPageUserInfoUseCase +import com.tht.tht.domain.token.token.FetchThtAccessTokenUseCase +import com.tht.tht.domain.token.token.FetchThtUserUuidUseCase import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.collections.immutable.persistentListOf +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.hildan.krossbow.stomp.StompClient +import org.hildan.krossbow.stomp.StompSession +import org.hildan.krossbow.stomp.conversions.convertAndSend +import org.hildan.krossbow.stomp.conversions.moshi.withMoshi +import org.hildan.krossbow.stomp.frame.StompFrame +import org.hildan.krossbow.stomp.headers.StompSendHeaders +import org.hildan.krossbow.stomp.headers.StompSubscribeHeaders +import org.hildan.krossbow.websocket.okhttp.OkHttpWebSocketClient +import tht.core.navigation.BottomNavigationProvider +import tht.feature.chat.chat.screen.formatToAmPm import tht.feature.chat.chat.state.ChatDetailSideEffect import tht.feature.chat.chat.state.ChatDetailState -import tht.feature.chat.model.ChatListUiModel +import tht.feature.chat.mapper.toModel +import tht.feature.chat.model.ChatHistoryUiModel +import tht.feature.setting.uimodel.mapper.toUiModel import javax.inject.Inject @HiltViewModel -internal class ChatDetailViewModel @Inject constructor() : - ViewModel(), Container { +internal class ChatDetailViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val getChatDetailInformationUseCase: GetChatDetailInformationUseCase, + private val getChatHistoryUseCase: GetChatHistoryUseCase, + private val fetchThtUserUuidUseCase: FetchThtUserUuidUseCase, + private val fetchThtAccessTokenUseCase: FetchThtAccessTokenUseCase, + private val fetchMyPageUserInfoUseCase: FetchMyPageUserInfoUseCase, + private val bottomNavigationProvider: BottomNavigationProvider, +) : ViewModel(), Container { override val store: Store = store( initialState = ChatDetailState.ChatList( isLoading = true, - chatList = persistentListOf() + chatDetailInformation = null, + chatList = emptyList() ) ) + /** + * connect request url : ws://3.34.157.62/websocket-endpoint + * subscribe url : /sub/chat/{room-number} + * publish url : /pub/chat/{room-number} + * stomp REQUEST body + * { + * "sender" : "user name - 1", + * "senderUuid" : "user-uuid", + * "imgUrl" : "user-profile-url", + * "msg" : "i am very hansome guyguy ! ! ! !!" //대화 메세지 + * } + * stomp RESPONSE body + * { + * "chatIdx": "65698d494ad0c35a716aa8a1", + * "sender": "user name - 1", + * "senderUuid": "user-uuid", + * "msg": "i am very hansome guyguy ! ! ! !!", + * "imgUrl": "user-profile-url", + * "dateTime": "2023-12-01T16:37:45.39331" + * } + */ + private lateinit var stompSession: StompSession + private val moshi: Moshi = Moshi.Builder() + .addLast(KotlinJsonAdapterFactory()) + .build() + private lateinit var newChatMessage: Flow private var _currentText: MutableStateFlow = MutableStateFlow("") val currentText = _currentText.asStateFlow() - fun getChatList() { - intent { - reduce { - ChatDetailState.ChatList( - isLoading = false, - chatList = persistentListOf( - ChatListUiModel( - chatRoomIdx = 1L, - partnerName = "하하", - partnerProfileUrl = "", - currentMessage = "", - messageTime = "" - ) - ) + + fun initStomp(roomIdx: Long) { + viewModelScope.launch { + fetchThtAccessTokenUseCase.invoke().getOrNull()?.let { token -> + connectStomp(token, roomIdx) + } + } + } + + fun hideBottomNavigation(context: Context) { + bottomNavigationProvider.hide(context) + } + + fun showBottomNavigation(context: Context) { + bottomNavigationProvider.show(context) + } + + fun connectStomp(token: String, roomIdx: Long) { + viewModelScope.launch { + val okHttpClient = OkHttpClient.Builder() + .addInterceptor( + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } ) + .build() + + val client = StompClient( + OkHttpWebSocketClient(okHttpClient) + ) + + stompSession = + client.connect(STOMP_ENDPOINT, customStompConnectHeaders = mapOf(HEADER_AUTHORIZATION to token)) + .withMoshi(moshi) + + newChatMessage = stompSession.subscribe( + StompSubscribeHeaders( + destination = "${SUBSCRIBE_URL}${roomIdx}", + customHeaders = mapOf(HEADER_AUTHORIZATION to token) + ) + ) + + newChatMessage.collectLatest { + val chatMessage = moshi.adapter(ChatMessage::class.java).fromJson(it.bodyAsText) + chatMessage?.let { message -> + intent { + reduce { state -> + (state as ChatDetailState.ChatList).copy( + chatList = state.chatList + ChatHistoryUiModel( + message.chatIdx, + message.sender, + message.senderUuid, + message.msg, + message.imgUrl, + message.dateTime.formatToAmPm() + ) + ) + } + } + } + } + } + } + + fun getChatDetailInformation(roomIdx: Long) { + viewModelScope.launch { + val chatDetailInformation = getChatDetailInformationUseCase(roomIdx).getOrNull() + intent { + reduce { state -> + (state as ChatDetailState.ChatList).copy( + isLoading = false, + chatDetailInformation = chatDetailInformation?.toModel(), + ) + } } } } + fun getChatHistory(roomIdx: Long, chatIdx: String? = null, size: String = "100") { + if ((store.state.value as? ChatDetailState.ChatList)?.chatIdx == "-1") return + viewModelScope.launch { + val history = getChatHistoryUseCase( + roomIdx = roomIdx, + chatIdx = (store.state.value as? ChatDetailState.ChatList)?.chatIdx ?: chatIdx, + size = size + ).getOrNull() ?: emptyList() + intent { + reduce { state -> + (state as ChatDetailState.ChatList).copy( + isLoading = false, + chatList = state.chatList.toMutableList().apply { + addAll(0, history.map { it.toModel() }.reversed()) + }, + chatIdx = if (history.isEmpty()) "-1" + else history.map { it.toModel() }.reversed().firstOrNull()?.chatIdx + ) + } + } + } + } + + fun getUserUuid() { + viewModelScope.launch { + val userUuid = fetchThtUserUuidUseCase().getOrNull() + fetchMyPageUserInfoUseCase() + .onSuccess { userInformation -> + intent { + reduce { state -> + (state as ChatDetailState.ChatList).copy( + userUuid = userUuid, + userInformation = userInformation.toUiModel() + ) + } + } + } + } + } + fun updateCurrentText(text: String) { _currentText.update { text } } - fun onClickSent(text: String) {} + fun onClickSent(roomIdx: Long) { + if (_currentText.value.isEmpty() || _currentText.value.isBlank()) return + viewModelScope.launch { + (store.state.value as? ChatDetailState.ChatList)?.let { state -> + state.userInformation?.let { user -> + fetchThtAccessTokenUseCase.invoke().getOrNull()?.let { token -> + stompSession.withMoshi(moshi).convertAndSend( + StompSendHeaders( + destination = "${SEND_URL}${roomIdx}", + customHeaders = mapOf(HEADER_AUTHORIZATION to token) + ), + ChatMessage( + sender = user.username, + senderUuid = user.userUuid, + msg = currentText.value, + imgUrl = user.userProfilePhotos.firstOrNull()?.url ?: "", + ) + ) + } + _currentText.value = "" + } + } + } + } + + fun cancelStomp() { + try { + viewModelScope.launch { + stompSession.disconnect() + } + } catch (e: Exception) { + Log.d("test", "cancelStomp: ${e.message}") - fun onClickGallery() {} + } + } + + companion object { + const val HEADER_AUTHORIZATION = "Authorization" + const val SEND_URL = "/pub/chat/" + const val SUBSCRIBE_URL = "/sub/chat/" + const val STOMP_ENDPOINT = "ws://3.34.157.62/websocket-endpoint" + } } + +data class ChatMessage( + val chatIdx: String = "", + val sender: String, + val senderUuid: String, + val msg: String, + val imgUrl: String, + val dateTime: String = "", +) diff --git a/feature/chat/src/main/java/tht/feature/chat/chat/viewmodel/ChatViewModel.kt b/feature/chat/src/main/java/tht/feature/chat/chat/viewmodel/ChatViewModel.kt index 7e36b9e5..6b6f5af6 100644 --- a/feature/chat/src/main/java/tht/feature/chat/chat/viewmodel/ChatViewModel.kt +++ b/feature/chat/src/main/java/tht/feature/chat/chat/viewmodel/ChatViewModel.kt @@ -1,17 +1,18 @@ package tht.feature.chat.chat.viewmodel +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.compose_ui.common.viewmodel.Container import com.example.compose_ui.common.viewmodel.Store import com.example.compose_ui.common.viewmodel.intent import com.example.compose_ui.common.viewmodel.store -import com.tht.tht.domain.chat.model.ChatListModel import com.tht.tht.domain.chat.usecase.GetChatListUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch +import tht.core.navigation.BottomNavigationProvider import tht.feature.chat.chat.state.ChatSideEffect import tht.feature.chat.chat.state.ChatState import tht.feature.chat.mapper.toModel @@ -19,12 +20,21 @@ import javax.inject.Inject @HiltViewModel internal class ChatViewModel @Inject constructor( - private val getChatListUseCase: GetChatListUseCase + private val getChatListUseCase: GetChatListUseCase, + private val bottomNavigationProvider: BottomNavigationProvider, ) : ViewModel(), Container { override val store: Store = store(initialState = ChatState.ChatList(isLoading = true, chatList = persistentListOf())) - fun getChatList() { + init { + getChatList() + } + + fun showBottomNavigation(context: Context) { + bottomNavigationProvider.show(context) + } + + private fun getChatList() { viewModelScope.launch { val chatList = getChatListUseCase().getOrNull() ?: listOf() intent { @@ -41,46 +51,4 @@ internal class ChatViewModel @Inject constructor( } } } - - fun getFakeChatList() { - viewModelScope.launch { - intent { - reduce { - ChatState.ChatList( - isLoading = false, - chatList = listOf( - ChatListModel( - chatRoomIdx = 1L, - partnerName = "최웅재", - partnerProfileUrl = "", - currentMessage = "안녕", - messageTime = "2020.08.08" - ), - ChatListModel( - chatRoomIdx = 2L, - partnerName = "최웅재", - partnerProfileUrl = "", - currentMessage = "안녕", - messageTime = "2020.08.08" - ), - ChatListModel( - chatRoomIdx = 3L, - partnerName = "최웅재", - partnerProfileUrl = "", - currentMessage = "안녕", - messageTime = "2020.08.08" - ), - ChatListModel( - chatRoomIdx = 4L, - partnerName = "최웅재", - partnerProfileUrl = "", - currentMessage = "안녕", - messageTime = "2020.08.08" - ) - ).map { it.toModel() }.toImmutableList() - ) - } - } - } - } } diff --git a/feature/chat/src/main/java/tht/feature/chat/component/ChatAlert.kt b/feature/chat/src/main/java/tht/feature/chat/component/ChatAlert.kt index 03e60395..809a3e9b 100644 --- a/feature/chat/src/main/java/tht/feature/chat/component/ChatAlert.kt +++ b/feature/chat/src/main/java/tht/feature/chat/component/ChatAlert.kt @@ -22,10 +22,10 @@ internal fun ChatAlert( modifier = Modifier .size(18.dp) .clip(RoundedCornerShape(size = 50.dp)) - .background(Color(0xFFEF4444)), + .background(Color(0xFFF9CC2E)), contentAlignment = Alignment.Center ) { - ThtCaption1(text = "$number", fontWeight = FontWeight.Normal, color = Color(0xFFF9FAFA)) + ThtCaption1(text = "$number", fontWeight = FontWeight.Normal, color = Color.Black) } } diff --git a/feature/chat/src/main/java/tht/feature/chat/component/ChatItem.kt b/feature/chat/src/main/java/tht/feature/chat/component/ChatItem.kt index c343297e..45bc05a0 100644 --- a/feature/chat/src/main/java/tht/feature/chat/component/ChatItem.kt +++ b/feature/chat/src/main/java/tht/feature/chat/component/ChatItem.kt @@ -1,8 +1,10 @@ package tht.feature.chat.component +import androidx.compose.foundation.background 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.runtime.Composable import androidx.compose.ui.Alignment @@ -26,12 +28,14 @@ import tht.feature.chat.model.ChatListUiModel internal fun ChatItem( item: ChatListUiModel, isLoading: Boolean, - onClickItem: () -> Unit + onClickItem: (Long, String) -> Unit ) { Row( modifier = Modifier - .noRippleClickable { onClickItem() } + .noRippleClickable { onClickItem(item.chatRoomIdx, item.partnerName) } .fillMaxWidth() + .background(Color.Black) + .height(76.dp) .padding(horizontal = 15.dp, vertical = (16.5).dp), verticalAlignment = Alignment.CenterVertically ) { @@ -78,10 +82,10 @@ internal fun ChatItemPreivew() { chatRoomIdx = 1L, partnerProfileUrl = "", partnerName = "스티치", - messageTime = "", + messageTime = "08:24 PM", currentMessage = "안녕" ), isLoading = false, - onClickItem = {} + onClickItem = { _, _ -> } ) } diff --git a/feature/chat/src/main/java/tht/feature/chat/component/LazyColumnChatItem.kt b/feature/chat/src/main/java/tht/feature/chat/component/LazyColumnChatItem.kt index cd91325f..952b18e0 100644 --- a/feature/chat/src/main/java/tht/feature/chat/component/LazyColumnChatItem.kt +++ b/feature/chat/src/main/java/tht/feature/chat/component/LazyColumnChatItem.kt @@ -8,24 +8,26 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import tht.feature.chat.component.draggableItem.DraggableItem import tht.feature.chat.model.ChatListUiModel @Composable internal fun LazyColumnChatItem( items: ImmutableList, isLoading: Boolean, - onClickItem: () -> Unit + onClickItem: (Long, String) -> Unit, ) { LazyColumn( modifier = Modifier.fillMaxSize(), state = rememberLazyListState() ) { items(items) { item -> - ChatItem( - item = item, + DraggableItem( + chatItem = item, isLoading = isLoading, - onClickItem = onClickItem + onClickItem = onClickItem, + onClickDelete = {}, ) } } @@ -35,8 +37,16 @@ internal fun LazyColumnChatItem( @Preview(showBackground = true, backgroundColor = 0xFF000000) internal fun LazyColumnChatItemPreview() { LazyColumnChatItem( - items = persistentListOf(), + items = listOf( + ChatListUiModel( + chatRoomIdx = 1L, + partnerProfileUrl = "", + partnerName = "스티치", + messageTime = "08:24 PM", + currentMessage = "오늘이지" + ) + ).toImmutableList(), isLoading = false, - onClickItem = {} + onClickItem = { _, _ -> } ) } diff --git a/feature/chat/src/main/java/tht/feature/chat/component/detail/ChatBubbleTitle.kt b/feature/chat/src/main/java/tht/feature/chat/component/detail/ChatBubbleTitle.kt new file mode 100644 index 00000000..4b951b00 --- /dev/null +++ b/feature/chat/src/main/java/tht/feature/chat/component/detail/ChatBubbleTitle.kt @@ -0,0 +1,90 @@ +package tht.feature.chat.component.detail + +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.compose_ui.component.spacer.Spacer +import tht.feature.chat.component.detail.bubble.ArrowAlignment +import tht.feature.chat.component.detail.bubble.ArrowShape +import tht.feature.chat.component.detail.bubble.BubbleLayout +import tht.feature.chat.component.detail.bubble.BubbleShadow +import tht.feature.chat.component.detail.bubble.rememberBubbleState +import tht.feature.chat.model.ChatDetailInformationUiModel + +@Composable +fun ChatBubbleTitle(chatDetailInformation: ChatDetailInformationUiModel?) { + if (chatDetailInformation == null) return + + val bubbleStateBottom = rememberBubbleState( + alignment = ArrowAlignment.BottomCenter, + arrowShape = ArrowShape.FullTriangle, + cornerRadius = 24.dp + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + BubbleLayout( + modifier = Modifier + .fillMaxWidth(), + bubbleState = bubbleStateBottom, + backgroundColor = Color(0xFF252525), + shadow = BubbleShadow( + elevation = 0.dp + ) + ) { + Row( + modifier = Modifier + .padding(vertical = 8.dp, horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .border(width = 1.dp, color = Color(0xFFF9CC2E), shape = RoundedCornerShape(24.dp)) + ) { + Text( + modifier = Modifier + .padding(vertical = 4.dp, horizontal = 10.dp), + text = chatDetailInformation.talkSubject, + color = Color(0xFFF9CC2E) + ) + } + Spacer(space = 8.dp) + Text( + modifier = Modifier, + text = chatDetailInformation.talkIssue, + color = Color.White + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun ChatBubbleTitlePreview() { + Column(modifier = Modifier.background(Color.Black)) { + ChatBubbleTitle( + chatDetailInformation = ChatDetailInformationUiModel( + chatRoomIdx = 0L, + talkSubject = "행복", + talkIssue = "오늘 행복한 하루 보내셨나요?", + startDate = "2024년 7월 11일 목요일", + isChatAble = true, + ) + ) + } +} + diff --git a/feature/chat/src/main/java/tht/feature/chat/component/detail/ChatDetailList.kt b/feature/chat/src/main/java/tht/feature/chat/component/detail/ChatDetailList.kt index 11b102cc..0942ff8f 100644 --- a/feature/chat/src/main/java/tht/feature/chat/component/detail/ChatDetailList.kt +++ b/feature/chat/src/main/java/tht/feature/chat/component/detail/ChatDetailList.kt @@ -1,7 +1,7 @@ package tht.feature.chat.component.detail +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -13,48 +13,96 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow 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.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import com.example.compose_ui.component.image.ThtImage import com.example.compose_ui.component.spacer.Spacer import com.example.compose_ui.component.text.caption.ThtCaption2 -import com.example.compose_ui.component.text.p.ThtP1 import com.example.compose_ui.component.text.p.ThtP2 -import kotlinx.collections.immutable.ImmutableList -import tht.feature.chat.model.ChatListUiModel +import tht.feature.chat.model.ChatDetailInformationUiModel +import tht.feature.chat.model.ChatHistoryUiModel +@OptIn(ExperimentalFoundationApi::class) @Composable -fun ChatDetailList(items: ImmutableList) { +fun ChatDetailList( + userUuid: String?, + chatDetailInformation: ChatDetailInformationUiModel?, + chatList: List, + onLoadMore: () -> Unit +) { + val listState = rememberLazyListState() + var previousPosition: Int? by remember { + mutableStateOf(null) + } + var isScrolling by remember { + mutableStateOf(false) + } + + listState.OnTopReached(buffer = 10) { + previousPosition = chatList.size + onLoadMore() + } + + LaunchedEffect(chatList, !isScrolling) { + if (chatList.isNotEmpty()) { + listState.scrollToItem(chatList.lastIndex) + } + } + LazyColumn( modifier = Modifier .fillMaxSize() - .padding(start = 16.dp, end = 16.dp, bottom = 69.dp) + .padding(start = 16.dp, end = 16.dp, bottom = 69.dp), + state = listState, ) { - item { + stickyHeader { Spacer(modifier = Modifier.height(16.dp)) - ChatRandomTitle(title = "마음이 답답할 때 무엇을 하나요?") + ChatBubbleTitle(chatDetailInformation = chatDetailInformation) Spacer(modifier = Modifier.height(8.dp)) } - itemsIndexed(items) { index, item -> - if (index % 2 == 0) { - Sender(text = item.currentMessage, updateTime = "3:13 PM") + item { + chatDetailInformation?.let { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(space = 8.dp) + ThtP2(text = it.startDate, fontWeight = FontWeight.W400, color = Color.White) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + var lastDate: String? = null + itemsIndexed(chatList) { index, item -> + val currentDate = item.dateTime.split("T")[0] // 날짜 부분만 가져오기 (yyyy-MM-dd) + val isSameUser = + if (index != 0 && chatList[index - 1].senderUuid != userUuid) true else if (index == 0) null else false + val shouldShowTime = + (index == chatList.lastIndex) || (chatList.getOrNull(index + 1)?.dateTime != item.dateTime) + if (item.senderUuid == userUuid) { + MyChat(item, shouldShowTime) } else { - Receiver( - text = item.currentMessage, - updateTime = "3:12 PM", - isShowProfile = true, - userName = "Stitch" - ) + OtherChat(item, isSameUser = isSameUser, isShowProfile = true, shouldShowTime = shouldShowTime) } Spacer(modifier = Modifier.height(6.dp)) } @@ -62,141 +110,131 @@ fun ChatDetailList(items: ImmutableList) { } @Composable -fun ChatRandomTitle(title: String) { - ThtP1( - modifier = Modifier - .fillMaxWidth() - .clip(shape = RoundedCornerShape(6.dp)) - .border(color = Color(0xFFF9CC2E), width = 1.dp, shape = RoundedCornerShape(6.dp)) - .background(Color(0xFF222222)) - .padding(vertical = 12.dp), - text = title, - fontWeight = FontWeight.Normal, - color = Color.White, - textAlign = TextAlign.Center - ) -} - -@Composable -fun Sender(text: String, updateTime: String) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.End - ) { - ThtCaption2( - text = updateTime, - fontWeight = FontWeight.Normal, - color = Color(0xFFF9FAFA), - textAlign = TextAlign.End - ) - Spacer(space = 8.dp) - ThtP2( - modifier = Modifier - .widthIn(min = 0.dp, max = 226.dp) - .clip(RoundedCornerShape(20.dp)) - .background(Color(0xFFF9CC2E)) - .padding(horizontal = 10.dp, vertical = (6.5).dp), - text = text, - fontWeight = FontWeight.Normal, - color = Color.Black, - textAlign = TextAlign.Start - ) +fun LazyListState.OnTopReached( + buffer: Int = 0, + onLoadMore: () -> Unit, +) { + require(buffer >= 0) { "buffer가 0보다 작습니다 - $buffer" } + val shouldLoadMore = remember { + derivedStateOf { + val firstVisibleItem = + layoutInfo.visibleItemsInfo.firstOrNull() ?: return@derivedStateOf false + firstVisibleItem.index == 0 + } + } + LaunchedEffect(shouldLoadMore) { + snapshotFlow { shouldLoadMore.value }.collect { + if (it) onLoadMore() + } } } + @Composable -fun Receiver( +fun OtherChat( + chat: ChatHistoryUiModel, isShowProfile: Boolean, - userName: String, - text: String, - updateTime: String + isSameUser: Boolean?, + shouldShowTime: Boolean, ) { + val screenWidthDp = with(LocalDensity.current) { + LocalContext.current.resources.displayMetrics.widthPixels.toDp() + } + val maxWidthDp = screenWidthDp * 0.6f Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.Start ) { - if (isShowProfile) { - ThtImage( - modifier = Modifier.clip(shape = RoundedCornerShape(6.dp)), - src = "https://search.pstatic.net/common/?src=http%3A%2F%2Fblogfiles.naver.net%2FMjAyMTEyMjJfMjYz%2FMDAxNjQwMTA3ODUyNzgy.2vrUEWwtR7K3P-TtNzfIsdCoM73Af9YPfpDLwq_iwMUg.D5PI3qGu_Q1tGN1HaZvFJX0dWqocJEk0AsnQ5zz1RGsg.JPEG.eeducator%2Fpexels-cottonbro-3663069.jpg&type=sc960_832", // ktlint-disable max-line-length - size = DpSize(34.dp, 34.dp) - ) - Spacer(space = 10.dp) + if (isSameUser == false || isSameUser == null) { + if (isShowProfile) { + ThtImage( + modifier = Modifier.clip(shape = RoundedCornerShape(6.dp)), + src = chat.imgUrl, + size = DpSize(34.dp, 34.dp) + ) + Spacer(space = 10.dp) + } } + if (isSameUser == true && isShowProfile) Spacer(space = 44.dp) Column { - ThtP2( - modifier = Modifier, - text = userName, - fontWeight = FontWeight.Normal, - color = Color(0xFF8D8D8D), - textAlign = TextAlign.Start - ) - Spacer(space = 8.dp) + if (isSameUser == false || isSameUser == null) { + ThtP2( + modifier = Modifier, + text = chat.sender, + fontWeight = FontWeight.Normal, + color = Color(0xFF8D8D8D), + textAlign = TextAlign.Start + ) + Spacer(space = 8.dp) + } + Row( verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.End ) { ThtP2( modifier = Modifier - .widthIn(min = 0.dp, max = 226.dp) - .clip(RoundedCornerShape(20.dp)) - .background(Color(0xFF222222)) - .padding(horizontal = 10.dp, vertical = (6.5).dp), - text = text, + .background(Color(0xFF222222), shape = RoundedCornerShape(20.dp)) + .padding(horizontal = 10.dp, vertical = (6.5).dp) + .widthIn(max = maxWidthDp) + .wrapContentWidth(), + text = chat.msg, fontWeight = FontWeight.Normal, color = Color(0xFFF9FAFA), textAlign = TextAlign.Start ) Spacer(space = 8.dp) - ThtCaption2( - modifier = Modifier - .weight(1f) - .wrapContentWidth(align = Alignment.Start), - text = updateTime, - fontWeight = FontWeight.Normal, - color = Color(0xFFF9FAFA), - textAlign = TextAlign.Start - ) + if (shouldShowTime) { + ThtCaption2( + modifier = Modifier + .weight(1f) + .wrapContentWidth(align = Alignment.Start), + text = chat.dateTime, + fontWeight = FontWeight.Normal, + color = Color(0xFFF9FAFA), + textAlign = TextAlign.Start + ) + } } } } } -@Preview(showBackground = true) -@Composable -fun ReceiverPreview() { - Receiver( - text = "긴 텍스트 세줄 이상 문장은 이렇게씁니다아아아아아아아아아아아아아아아아아아아아아긴 텍스트 세줄 이상 문장은 이렇게씁니다아", - updateTime = "3:12 PM", - isShowProfile = false, - userName = "stitch" - ) -} - -@Preview(showBackground = true) -@Composable -fun ReceiverPreview2() { - Receiver( - text = "긴 텍스트", - updateTime = "3:12 PM", - isShowProfile = false, - userName = "stitch" - ) -} - -@Preview(showBackground = true) @Composable -fun SenderPreview() { - Sender(text = "안녕하세요!", updateTime = "3:12 PM") -} - -@Preview(showBackground = true) -@Composable -fun SenderPreview2() { - Sender( - text = "긴 텍스트 세줄 이상 문장은 이렇게씁니다아아아아아아아아아아아아아아아아아아아아아긴 텍스트 세줄 이상 문장은 이렇게씁니다아", - updateTime = "3:12 PM" - ) +fun MyChat( + chat: ChatHistoryUiModel, + shouldShowTime: Boolean, +) { + val screenWidthDp = with(LocalDensity.current) { + LocalContext.current.resources.displayMetrics.widthPixels.toDp() + } + val maxWidthDp = screenWidthDp * 0.6f + Row( + modifier = Modifier + .fillMaxWidth() + .padding(6.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.Bottom + ) { + if (shouldShowTime) { + ThtCaption2( + text = chat.dateTime, + fontWeight = FontWeight.Normal, + color = Color(0xFFF9FAFA), + textAlign = TextAlign.End + ) + Spacer(space = 8.dp) + } + ThtP2( + modifier = Modifier + .background(Color(0xFFF9CC2E), RoundedCornerShape(20.dp)) + .padding(horizontal = 10.dp, vertical = (6.5).dp) + .widthIn(max = maxWidthDp), + text = chat.msg, + fontWeight = FontWeight.Normal, + color = Color.Black, + textAlign = TextAlign.Start + ) + } } diff --git a/feature/chat/src/main/java/tht/feature/chat/component/detail/ChatDetailTopBar.kt b/feature/chat/src/main/java/tht/feature/chat/component/detail/ChatDetailTopBar.kt index b937dbec..2fab7c74 100644 --- a/feature/chat/src/main/java/tht/feature/chat/component/detail/ChatDetailTopBar.kt +++ b/feature/chat/src/main/java/tht/feature/chat/component/detail/ChatDetailTopBar.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.compose_ui.component.spacer.Spacer import com.example.compose_ui.component.text.caption.ThtCaption1 +import com.example.compose_ui.component.text.headline.ThtHeadline4 import com.example.compose_ui.component.text.p.ThtP2 import com.example.compose_ui.extensions.noRippleClickable import tht.feature.chat.R @@ -29,55 +30,53 @@ internal fun ChatDetailTopAppBar( title: String, onClickBack: () -> Unit, onClickReport: () -> Unit, - onClickLogout: () -> Unit + onClickLogout: () -> Unit, ) { - Box( + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 9.dp) + .padding( + horizontal = 20.dp, + vertical = (15.5).dp + ), + verticalAlignment = Alignment.CenterVertically ) { - Image( - modifier = Modifier - .align(Alignment.CenterStart) - .noRippleClickable { onClickBack() }, - painter = painterResource(id = R.drawable.ic_back), - contentDescription = "뒤로가기 버튼" - ) - Column( - modifier = Modifier - .border(1.dp, Color(0xFF2D2D2D), RoundedCornerShape(24.dp)) - .clip(RoundedCornerShape(24.dp)) - .align(Alignment.Center) - .background(Color(0xFF252525)) - .padding(top = 4.dp, bottom = 2.dp, start = 33.dp, end = 33.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - ThtCaption1(text = "우리가 통한 주제", fontWeight = FontWeight.Normal, color = Color.White) - ThtP2(text = title, fontWeight = FontWeight.SemiBold, color = Color(0xFFF9CC2E)) - } Row( - modifier = Modifier - .align(Alignment.CenterEnd) + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically ) { Image( modifier = Modifier - .noRippleClickable { onClickReport() }, - painter = painterResource(id = R.drawable.ic_report), - contentDescription = "신고하기 버튼" + .noRippleClickable { onClickBack() }, + painter = painterResource(id = R.drawable.ic_back), + contentDescription = "뒤로가기 버튼" ) - Spacer(space = 20.dp) - Image( - modifier = Modifier - .noRippleClickable { onClickLogout() }, - painter = painterResource(id = R.drawable.ic_logout), - contentDescription = "로그아웃 버튼" + Spacer(12.dp) + ThtHeadline4( + text = title, + fontWeight = FontWeight.SemiBold, + color = Color(0xFFF9FAFA) ) } + Spacer(space = 20.dp) + Image( + modifier = Modifier + .noRippleClickable { onClickReport() }, + painter = painterResource(id = R.drawable.ic_report), + contentDescription = "신고하기 버튼" + ) + Spacer(space = 20.dp) + Image( + modifier = Modifier + .noRippleClickable { onClickLogout() }, + painter = painterResource(id = R.drawable.ic_logout), + contentDescription = "로그아웃 버튼" + ) } } @Composable @Preview(showBackground = true, backgroundColor = 0xFF000000) -internal fun ChatTopAppBarPreview() { - ChatDetailTopAppBar(title = "채팅", onClickBack = {}, onClickLogout = {}, onClickReport = {}) +internal fun ChatDetailTopAppBarPreview() { + ChatDetailTopAppBar(title = "닉네임의라믄이름와요으아러야아르아어랴여래으랴열", onClickBack = {}, onClickLogout = {}, onClickReport = {}) } diff --git a/feature/chat/src/main/java/tht/feature/chat/component/detail/ChatEditText.kt b/feature/chat/src/main/java/tht/feature/chat/component/detail/ChatEditText.kt index 6b65c6fb..f67659b2 100644 --- a/feature/chat/src/main/java/tht/feature/chat/component/detail/ChatEditText.kt +++ b/feature/chat/src/main/java/tht/feature/chat/component/detail/ChatEditText.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField @@ -17,6 +18,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview @@ -29,10 +31,12 @@ import tht.feature.chat.R fun ChatEditTextContainer( modifier: Modifier, text: String, - onChangedText: (String) -> Unit + onChangedText: (String) -> Unit, + onClickSend: () -> Unit, ) { Row( modifier = Modifier + .imePadding() .fillMaxWidth() .height(59.dp) .background(Color(0xFF161616)) @@ -49,13 +53,6 @@ fun ChatEditTextContainer( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { - Image( - modifier = Modifier - .noRippleClickable { }, - painter = painterResource(id = R.drawable.ic_attachment), - contentDescription = "파일 첨부 버튼" - ) - Spacer(space = 10.dp) ChatEditText( modifier = Modifier.weight(1f), text = text, @@ -64,9 +61,13 @@ fun ChatEditTextContainer( Spacer(space = 10.dp) Image( modifier = Modifier - .noRippleClickable { }, + .noRippleClickable { onClickSend() }, painter = painterResource(id = R.drawable.ic_sent), - contentDescription = "보내기 버튼" + contentDescription = "보내기 버튼", + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint( + if (text.isEmpty()) Color(0xFF8D8D8D) + else Color(0xFFF9CC2E) + ) ) } } @@ -86,7 +87,8 @@ fun ChatEditText( .then(modifier), value = text, onValueChange = onChangedText, - textStyle = TextStyle(color = Color.White) + textStyle = TextStyle(color = Color.White), + cursorBrush = SolidColor(Color(0xFFF9CC2E)) // 커서 색상 설정 (예: 녹색) ) } @@ -96,6 +98,7 @@ fun ChatEditTextContainer() { ChatEditTextContainer( modifier = Modifier, text = "테스트", - onChangedText = {} + onChangedText = {}, + onClickSend = {}, ) } diff --git a/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/ArrowPath.kt b/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/ArrowPath.kt new file mode 100644 index 00000000..000f75f2 --- /dev/null +++ b/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/ArrowPath.kt @@ -0,0 +1,348 @@ +package tht.feature.chat.component.detail.bubble + +import androidx.compose.ui.graphics.Path + +internal fun Path.addHorizontalArrowToPath( + alignment: ArrowAlignment, + arrowShape: ArrowShape, + arrowLeft: Float, + arrowRight: Float, + arrowTop: Float, + arrowBottom: Float, + arrowHeight: Float +) { + when (alignment) { + + ArrowAlignment.LeftTop -> { + + moveTo(arrowRight, arrowTop) + when (arrowShape) { + + ArrowShape.HalfTriangle -> { + // Draw horizontal line to left + lineTo(arrowLeft, arrowTop) + lineTo(arrowRight, arrowBottom) + } + + ArrowShape.FullTriangle -> { + lineTo(arrowLeft, arrowBottom - arrowHeight / 2) + lineTo(arrowRight, arrowBottom) + } + + ArrowShape.Curved -> { + + } + } + } + + ArrowAlignment.LeftCenter -> { + moveTo(arrowRight, arrowBottom) + when (arrowShape) { + + ArrowShape.HalfTriangle -> { + // Draw horizontal line to left + lineTo(arrowLeft, arrowTop) + lineTo(arrowRight, arrowTop) + } + + ArrowShape.FullTriangle -> { + lineTo(arrowLeft, arrowBottom - arrowHeight / 2) + lineTo(arrowRight, arrowTop) + } + + ArrowShape.Curved -> { + + } + } + } + + ArrowAlignment.LeftBottom -> { + moveTo(arrowRight, arrowBottom) + when (arrowShape) { + + ArrowShape.HalfTriangle -> { + // Draw horizontal line to left + lineTo(arrowLeft, arrowBottom) + lineTo(arrowRight, arrowTop) + } + + ArrowShape.FullTriangle -> { + lineTo(arrowLeft, arrowBottom - arrowHeight / 2) + lineTo(arrowRight, arrowTop) + } + + ArrowShape.Curved -> { + + } + } + } + + ArrowAlignment.RightTop -> { + moveTo(arrowLeft, arrowTop) + when (arrowShape) { + + ArrowShape.HalfTriangle -> { + lineTo(arrowRight, arrowTop) + lineTo(arrowLeft, arrowBottom) + } + + ArrowShape.FullTriangle -> { + lineTo(arrowRight, arrowBottom - arrowHeight / 2) + lineTo(arrowLeft, arrowBottom) + } + + ArrowShape.Curved -> { + + } + } + } + + ArrowAlignment.RightCenter -> { + moveTo(arrowLeft, arrowTop) + when (arrowShape) { + + ArrowShape.HalfTriangle -> { + lineTo(arrowRight, arrowTop) + lineTo(arrowLeft, arrowBottom) + } + + ArrowShape.FullTriangle -> { + lineTo(arrowRight, arrowBottom - arrowHeight / 2) + lineTo(arrowLeft, arrowBottom) + } + + ArrowShape.Curved -> { + + } + } + } + + ArrowAlignment.RightBottom -> { + moveTo(arrowLeft, arrowTop) + when (arrowShape) { + + ArrowShape.HalfTriangle -> { + lineTo(arrowRight, arrowBottom) + lineTo(arrowLeft, arrowBottom) + } + + ArrowShape.FullTriangle -> { + lineTo(arrowRight, arrowBottom - arrowHeight / 2) + lineTo(arrowLeft, arrowBottom) + } + + ArrowShape.Curved -> { + + } + } + } + + else -> Unit + } + + close() +} + +/** + * Calculate top position of the arrow on either left or right side + */ +internal fun calculateArrowTopPosition( + state: BubbleState, + arrowHeight: Float, + contentHeight: Float, + density: Float, +): Float { + + val arrowOffsetY = state.arrowOffsetY.value * density + + var arrowTop = when { + state.isHorizontalTopAligned() -> { + arrowOffsetY + } + + state.isHorizontalBottomAligned() -> { + contentHeight + arrowOffsetY - arrowHeight + } + + else -> { + (contentHeight - arrowHeight) / 2f + arrowOffsetY + } + } + + if (arrowTop < 0) arrowTop = 0f + + if (arrowTop + arrowHeight > contentHeight) arrowTop = contentHeight - arrowHeight + + return arrowTop +} + +internal fun Path.addVerticalArrowToPath( + alignment: ArrowAlignment, + arrowShape: ArrowShape, + arrowLeft: Float, + arrowRight: Float, + arrowBottom: Float, + arrowTop: Float, + arrowWidth: Float +) { + + when (alignment) { + ArrowAlignment.BottomLeft -> { + moveTo(arrowRight, arrowTop) + + when (arrowShape) { + + ArrowShape.HalfTriangle -> { + lineTo(arrowLeft, arrowBottom) + lineTo(arrowLeft, arrowTop) + } + + ArrowShape.FullTriangle -> { + lineTo(arrowRight - arrowWidth / 2, arrowBottom) + lineTo(arrowLeft, arrowTop) + } + + ArrowShape.Curved -> { + + } + } + } + + ArrowAlignment.BottomCenter -> { + moveTo(arrowRight, arrowTop) + + when (arrowShape) { + + ArrowShape.HalfTriangle -> { + lineTo(arrowLeft, arrowBottom) + lineTo(arrowLeft, arrowTop) + } + + ArrowShape.FullTriangle -> { + lineTo(arrowRight - arrowWidth / 2, arrowBottom) + lineTo(arrowLeft, arrowTop) + } + + ArrowShape.Curved -> { + + } + } + } + + ArrowAlignment.BottomRight -> { + moveTo(arrowRight, arrowTop) + + when (arrowShape) { + + ArrowShape.HalfTriangle -> { + lineTo(arrowRight, arrowBottom) + lineTo(arrowLeft, arrowTop) + } + + ArrowShape.FullTriangle -> { + lineTo(arrowRight - arrowWidth / 2, arrowBottom) + lineTo(arrowLeft, arrowTop) + } + + ArrowShape.Curved -> { + + } + } + } + + ArrowAlignment.TopLeft -> { + moveTo(arrowLeft, arrowBottom) + + when (arrowShape) { + ArrowShape.HalfTriangle -> { + lineTo(arrowLeft, arrowTop) + lineTo(arrowRight, arrowBottom) + } + + ArrowShape.FullTriangle -> { + lineTo(arrowLeft + arrowWidth / 2, arrowTop) + lineTo(arrowRight, arrowBottom) + } + + ArrowShape.Curved -> { + + } + } + } + + ArrowAlignment.TopCenter -> { + moveTo(arrowLeft, arrowBottom) + + when (arrowShape) { + ArrowShape.HalfTriangle -> { + lineTo(arrowLeft, arrowTop) + lineTo(arrowRight, arrowBottom) + } + + ArrowShape.FullTriangle -> { + lineTo(arrowLeft + arrowWidth / 2, arrowTop) + lineTo(arrowRight, arrowBottom) + } + + ArrowShape.Curved -> { + + } + } + } + + ArrowAlignment.TopRight -> { + moveTo(arrowRight, arrowBottom) + + when (arrowShape) { + ArrowShape.HalfTriangle -> { + lineTo(arrowRight, arrowTop) + lineTo(arrowLeft, arrowBottom) + } + + ArrowShape.FullTriangle -> { + lineTo(arrowLeft + arrowWidth / 2, arrowTop) + lineTo(arrowLeft, arrowBottom) + } + + ArrowShape.Curved -> { + + } + } + + } + + else -> Unit + } + + close() +} + +internal fun calculateArrowLeftPosition( + state: BubbleState, + arrowWidth: Float, + contentWidth: Float, + density: Float +): Float { + + val arrowOffsetX: Float = state.arrowOffsetX.value * density + + var arrowLeft = when { + state.isVerticalLeftAligned() -> { + arrowOffsetX + } + + state.isVerticalRightAligned() -> { + contentWidth + arrowOffsetX - arrowWidth + } + + else -> { + (contentWidth - arrowWidth) / 2f + arrowOffsetX + } + } + + if (arrowLeft < 0) arrowLeft = 0f + + if (arrowLeft + arrowWidth > contentWidth) arrowLeft = contentWidth - arrowWidth + + return arrowLeft +} diff --git a/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/ArrowProperties.kt b/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/ArrowProperties.kt new file mode 100644 index 00000000..e2fb339a --- /dev/null +++ b/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/ArrowProperties.kt @@ -0,0 +1,34 @@ +package tht.feature.chat.component.detail.bubble + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +class BubbleCornerRadius( + val topLeft: Dp = 0.dp, + val topRight: Dp = 0.dp, + val bottomLeft: Dp = 0.dp, + val bottomRight: Dp = 0.dp, +) + + +enum class ArrowShape { + HalfTriangle, + FullTriangle, + Curved +} + +enum class ArrowAlignment { + None, + LeftTop, + LeftCenter, + LeftBottom, + RightTop, + RightCenter, + RightBottom, + BottomLeft, + BottomCenter, + BottomRight, + TopLeft, + TopCenter, + TopRight +} diff --git a/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/BubbleLayous.kt b/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/BubbleLayous.kt new file mode 100644 index 00000000..655f45b2 --- /dev/null +++ b/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/BubbleLayous.kt @@ -0,0 +1,28 @@ +package tht.feature.chat.component.detail.bubble + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +@Composable +fun BubbleLayout( + modifier: Modifier = Modifier, + bubbleState: BubbleState, + backgroundColor: Color = Color.White, + shadow: BubbleShadow? = null, + borderStroke: BorderStroke? = null, + content: @Composable () -> Unit +) { + Column( + modifier.bubble( + bubbleState = bubbleState, + color = backgroundColor, + shadow = shadow, + borderStroke = borderStroke + ) + ) { + content() + } +} diff --git a/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/BubbleModifier.kt b/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/BubbleModifier.kt new file mode 100644 index 00000000..5f6c6272 --- /dev/null +++ b/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/BubbleModifier.kt @@ -0,0 +1,157 @@ +package tht.feature.chat.component.detail.bubble + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.* +import kotlin.math.roundToInt + +fun Modifier.bubble( + bubbleState: BubbleState, + color: Color = Color.Transparent, + shadow: BubbleShadow? = null, + borderStroke: BorderStroke? = null +) = composed( + // pass inspector information for debug + inspectorInfo = debugInspectorInfo { + // name should match the name of the modifier + name = "drawBubble" + // add name and value of each argument + properties["bubbleState"] = bubbleState + properties["color"] = color + properties["shadow"] = shadow + properties["borderStroke"] = borderStroke + }, + + factory = { + + val density = LocalDensity.current + val shape = remember( + key1 = bubbleState + ) { + createBubbleShape(bubbleState, density.density) + } + + Modifier + .then( + if (shadow != null) { + Modifier.shadow( + elevation = shadow.elevation, + ambientColor = shadow.ambientColor, + spotColor = shadow.spotColor, + shape = shape + ) + } else { + Modifier + } + ) + .then( + if (borderStroke != null) { + Modifier.border(border = borderStroke, shape = shape) + } else { + Modifier + } + ) + .clip(shape) + .background(color, shape) + .layout { measurable, constraints -> + measureBubbleResult( + bubbleState, measurable, constraints + ) + } + } +) + + +/** + * Measure layout to create a bubble with rounded rectangle with arrow is [bubbleState] + * has parameter to draw arrow. + */ +internal fun MeasureScope.measureBubbleResult( + bubbleState: BubbleState, + measurable: Measurable, + constraints: Constraints, +): MeasureResult { + + val arrowWidth = (bubbleState.arrowWidth.value * density).roundToInt() + val arrowHeight = (bubbleState.arrowHeight.value * density).roundToInt() + + // Check arrow position + val isHorizontalLeftAligned = bubbleState.isHorizontalLeftAligned() + val isVerticalTopAligned = bubbleState.isVerticalTopAligned() + val isHorizontallyPositioned = bubbleState.isArrowHorizontallyPositioned() + val isVerticallyPositioned = bubbleState.isArrowVerticallyPositioned() + + // Offset to limit max width when arrow is horizontally placed + // if we don't remove arrowWidth bubble will overflow from it's parent as much as arrow + // width is. So we measure our placeable as content + arrow width + val offsetX: Int = if (isHorizontallyPositioned) { + arrowWidth + } else 0 + + // Offset to limit max height when arrow is vertically placed + + val offsetY: Int = if (isVerticallyPositioned) { + arrowHeight + } else 0 + + val placeable = measurable.measure(constraints.offset(-offsetX, -offsetY)) + + val desiredWidth = constraints.constrainWidth(placeable.width + offsetX) + val desiredHeight: Int = constraints.constrainHeight(placeable.height + offsetY) + + val alignment = bubbleState.alignment + val arrowShape = bubbleState.arrowShape + + val arrowMaxWidth = arrowWidth.coerceAtMost(desiredWidth).toFloat() + val arrowMaxHeight = arrowHeight.coerceAtMost(desiredHeight).toFloat() + + bubbleState.arrowRect = getArrowRect( + bubbleState, + arrowMaxWidth, + arrowMaxHeight, + density, + desiredWidth.toFloat(), + desiredHeight.toFloat() + ) + + val arrowRect = bubbleState.arrowRect + val arrowLeft = arrowRect.left + val arrowRight = arrowRect.right + val arrowTop = arrowRect.top + val arrowBottom = arrowRect.bottom + + bubbleState.arrowTip = getArrowTip( + arrowAlignment = alignment, + arrowShape = arrowShape, + arrowLeft = arrowLeft, + arrowRight = arrowRight, + arrowTop = arrowTop, + arrowBottom = arrowBottom, + arrowWidth = arrowMaxWidth, + arrowHeight = arrowMaxHeight + ) + + // Position of content(Text or Column/Row/Box for instance) in Bubble + // These positions effect placeable area for our content + // if xPos is greater than 0 it's required to translate background path(bubble) to match total + // area since left of xPos is not usable(reserved for arrowWidth) otherwise + val xPos = if (isHorizontalLeftAligned) arrowWidth else 0 + val yPos = if (isVerticalTopAligned) arrowHeight else 0 + + return layout(desiredWidth, desiredHeight) { + placeable.placeRelative(xPos, yPos) + } +} diff --git a/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/BubblePath.kt b/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/BubblePath.kt new file mode 100644 index 00000000..878790ab --- /dev/null +++ b/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/BubblePath.kt @@ -0,0 +1,99 @@ +package tht.feature.chat.component.detail.bubble + +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.graphics.Path +import kotlin.math.min + +internal fun Path.addRoundedBubbleRect( + state: BubbleState, + contentRect: BubbleRect, + density: Float +) { + + val alignment = state.alignment + + val cornerRadius: BubbleCornerRadius = state.cornerRadius + + val width = contentRect.width + val height = contentRect.height + val left = contentRect.left + val right = contentRect.right + val top = contentRect.top + val bottom = contentRect.bottom + + val maxRadius = width.coerceAtMost(height) / 2f + + val drawArrow = state.drawArrow + + var topLeftCornerRadius = cornerRadius.topLeft.value * density + .coerceAtMost(maxRadius) + var topRightCornerRadius = cornerRadius.topRight.value * density + .coerceAtMost(maxRadius) + var bottomLeftCornerRadius = cornerRadius.bottomLeft.value * density + .coerceAtMost(maxRadius) + var bottomRightCornerRadius = cornerRadius.bottomRight.value * density + .coerceAtMost(maxRadius) + + val arrowTop = state.arrowTop + val arrowBottom = state.arrowBottom + val arrowLeft = state.arrowLeft + val arrowRight = state.arrowRight + + if (drawArrow) { + when (alignment) { + // Arrow on left side of the bubble + ArrowAlignment.LeftTop, ArrowAlignment.LeftCenter, ArrowAlignment.LeftBottom -> { + topLeftCornerRadius = min(arrowTop, topLeftCornerRadius) + bottomLeftCornerRadius = + min(bottomLeftCornerRadius, (height - arrowBottom)) + } + + // Arrow on right side of the bubble + ArrowAlignment.RightTop, ArrowAlignment.RightCenter, ArrowAlignment.RightBottom -> { + topRightCornerRadius = min(arrowTop, topRightCornerRadius) + bottomRightCornerRadius = + min(bottomRightCornerRadius, (height - arrowBottom)) + } + + // Arrow at the bottom of bubble + ArrowAlignment.BottomLeft, ArrowAlignment.BottomCenter, ArrowAlignment.BottomRight -> { + + bottomLeftCornerRadius = min(arrowLeft, bottomLeftCornerRadius) + bottomRightCornerRadius = + min(bottomRightCornerRadius, (width - arrowRight)) + } + + // Arrow at the top of bubble + ArrowAlignment.TopLeft, ArrowAlignment.TopCenter, ArrowAlignment.TopRight -> { + topLeftCornerRadius = min(arrowLeft, topLeftCornerRadius) + topRightCornerRadius = min(topRightCornerRadius, (width - arrowRight)) + } + + else -> Unit + } + } + + addRoundRect( + RoundRect( + rect = Rect(left, top, right, bottom), + topLeft = CornerRadius( + topLeftCornerRadius, + topLeftCornerRadius + ), + topRight = CornerRadius( + topRightCornerRadius, + topRightCornerRadius + ), + bottomRight = CornerRadius( + bottomRightCornerRadius, + bottomRightCornerRadius + ), + bottomLeft = CornerRadius( + bottomLeftCornerRadius, + bottomLeftCornerRadius + ) + ) + ) +} diff --git a/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/BubbleRect.kt b/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/BubbleRect.kt new file mode 100644 index 00000000..3da2d235 --- /dev/null +++ b/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/BubbleRect.kt @@ -0,0 +1,33 @@ +package tht.feature.chat.component.detail.bubble + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable + +@Immutable +data class BubbleRect( + val left: Float = 0f, + val top: Float = 0f, + val right: Float = 0f, + val bottom: Float = 0f +) { + + val height: Float + get() { + return bottom - top + } + + val width: Float + get() { + return right - left + } + + override fun toString(): String { + return "left: $left, top: $top, right: $right, bottom: $bottom, " + + "width: $width, height: $height" + } + + companion object { + @Stable + val Zero = BubbleRect(0f, 0f, 0f, 0f) + } +} diff --git a/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/BubbleShadow.kt b/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/BubbleShadow.kt new file mode 100644 index 00000000..ee4f68d8 --- /dev/null +++ b/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/BubbleShadow.kt @@ -0,0 +1,12 @@ +package tht.feature.chat.component.detail.bubble + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.* +import androidx.compose.ui.unit.Dp + +@Immutable +data class BubbleShadow( + val elevation: Dp, + val ambientColor: Color = DefaultShadowColor, + val spotColor: Color = DefaultShadowColor, +) diff --git a/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/BubbleShape.kt b/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/BubbleShape.kt new file mode 100644 index 00000000..3d8f8afb --- /dev/null +++ b/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/BubbleShape.kt @@ -0,0 +1,175 @@ +package tht.feature.chat.component.detail.bubble + +import androidx.compose.foundation.shape.GenericShape +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathOperation +import androidx.compose.ui.unit.LayoutDirection + +fun createBubbleShape( + state: BubbleState, + density: Float +): GenericShape { + + return GenericShape { size: Size, layoutDirection: LayoutDirection -> + + val isHorizontalArrow = state.isArrowHorizontallyPositioned() + + val arrowShape: ArrowShape = state.arrowShape + val alignment: ArrowAlignment = state.alignment + + val contentWidth: Float = size.width + val contentHeight: Float = size.height + + val arrowWidth: Float = (state.arrowWidth.value * density).coerceAtMost(contentWidth) + val arrowHeight: Float = (state.arrowHeight.value * density).coerceAtMost(contentHeight) + + state.arrowRect = getArrowRect( + state, + arrowWidth, + arrowHeight, + density, + contentWidth, + contentHeight + ) + + val arrowRect = state.arrowRect + val arrowLeft = arrowRect.left + val arrowRight = arrowRect.right + val arrowTop = arrowRect.top + val arrowBottom = arrowRect.bottom + + state.arrowTip = getArrowTip( + arrowAlignment = alignment, + arrowShape = arrowShape, + arrowLeft = arrowLeft, + arrowRight = arrowRight, + arrowTop = arrowTop, + arrowBottom = arrowBottom, + arrowWidth = arrowWidth, + arrowHeight = arrowHeight + ) + + val path = Path().apply { + if (state.drawArrow) { + if (isHorizontalArrow) { + addHorizontalArrowToPath( + alignment = alignment, + arrowShape = arrowShape, + arrowLeft = arrowLeft, + arrowRight = arrowRight, + arrowTop = arrowTop, + arrowBottom = arrowBottom, + arrowHeight = arrowHeight + ) + } else { + addVerticalArrowToPath( + alignment = alignment, + arrowShape = arrowShape, + arrowLeft = arrowLeft, + arrowRight = arrowRight, + arrowBottom = arrowBottom, + arrowTop = arrowTop, + arrowWidth = arrowWidth + ) + } + } + } + + val contentRect: BubbleRect = getContentRect( + bubbleState = state, + width = size.width.toInt(), + height = size.height.toInt(), + density = density + ) + + addRoundedBubbleRect(state, contentRect, density) + this.op(this, path, PathOperation.Union) + } +} + +fun getArrowTip( + arrowAlignment: ArrowAlignment, + arrowShape: ArrowShape, + arrowLeft: Float, + arrowTop: Float, + arrowRight: Float, + arrowBottom: Float, + arrowWidth: Float, + arrowHeight: Float +): Offset { + + return when (arrowAlignment) { + ArrowAlignment.LeftTop, + ArrowAlignment.LeftCenter -> { + if (arrowShape == ArrowShape.FullTriangle) { + Offset(arrowLeft, arrowTop + arrowHeight / 2) + } else { + Offset(arrowLeft, arrowTop) + } + } + + ArrowAlignment.LeftBottom -> { + if (arrowShape == ArrowShape.FullTriangle) { + Offset(arrowLeft, arrowTop + arrowHeight / 2) + } else { + Offset(arrowLeft, arrowBottom) + } + } + + ArrowAlignment.RightTop, + ArrowAlignment.RightCenter -> { + if (arrowShape == ArrowShape.FullTriangle) { + Offset(arrowRight, arrowTop + arrowHeight / 2) + } else { + Offset(arrowRight, arrowTop) + } + } + + ArrowAlignment.RightBottom -> { + if (arrowShape == ArrowShape.FullTriangle) { + Offset(arrowRight, arrowTop + arrowHeight / 2) + } else { + Offset(arrowRight, arrowBottom) + } + } + + ArrowAlignment.BottomLeft, + ArrowAlignment.BottomCenter -> { + if (arrowShape == ArrowShape.FullTriangle) { + Offset(arrowLeft + arrowWidth / 2, arrowBottom) + } else { + Offset(arrowLeft, arrowBottom) + } + } + + ArrowAlignment.BottomRight -> { + if (arrowShape == ArrowShape.FullTriangle) { + Offset(arrowLeft + arrowWidth / 2, arrowBottom) + } else { + Offset(arrowRight, arrowBottom) + } + } + + ArrowAlignment.TopLeft, + ArrowAlignment.TopCenter -> { + if (arrowShape == ArrowShape.FullTriangle) { + Offset(arrowLeft + arrowWidth / 2, arrowTop) + } else { + Offset(arrowLeft, arrowTop) + } + } + + ArrowAlignment.TopRight -> { + if (arrowShape == ArrowShape.FullTriangle) { + Offset(arrowLeft + arrowWidth / 2, arrowTop) + } else { + Offset(arrowRight, arrowTop) + } + } + + else -> Offset.Zero + } + +} diff --git a/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/BubbleState.kt b/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/BubbleState.kt new file mode 100644 index 00000000..6eb5763c --- /dev/null +++ b/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/BubbleState.kt @@ -0,0 +1,282 @@ +package tht.feature.chat.component.detail.bubble + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * + * [BubbleState] class contains information about chat or speech **Bubble**. + * + * @param cornerRadius Constructs a Radius for each side of bubble rectangle + * @param alignment Arrow alignment determines in which side of the bubble this arrow should be drawn. + * When [ArrowAlignment.None] is selected no arrow is drawn. + * @param arrowShape Shape of the arrow, It can be right or isosceles triangle or curved shape + * @param arrowOffsetX Vertical offset for arrow that is positioned on top or at the bottom of the bubble. + * Positive values move arrow right while negative values move left. Arrow position + * is limited between left of content and content right minus arrow width. + * @param arrowOffsetY Vertical offset for arrow that is positioned on left or right side of the bubble. + * Positive values move arrow bottom while negative values move up. Arrow position + * is limited between top of content and content bottom minus arrow height. + * @param arrowWidth width of the arrow + * @param arrowHeight height of the arrow + * @param drawArrow whether we should draw arrow or only have rectangle shape bubble + */ +@Composable +fun rememberBubbleState( + cornerRadius: Dp = 8.dp, + alignment: ArrowAlignment = ArrowAlignment.None, + arrowShape: ArrowShape = ArrowShape.HalfTriangle, + arrowOffsetX: Dp = 0.dp, + arrowOffsetY: Dp = 0.dp, + arrowWidth: Dp = 14.dp, + arrowHeight: Dp = 14.dp, + drawArrow: Boolean = true +): BubbleState { + + return remember( + keys = arrayOf( + cornerRadius, + alignment, + arrowShape, + arrowOffsetX, + arrowOffsetY, + arrowWidth, + arrowHeight, + drawArrow + ) + ) { + BubbleState( + cornerRadius = BubbleCornerRadius( + topLeft = cornerRadius, + topRight = cornerRadius, + bottomLeft = cornerRadius, + bottomRight = cornerRadius, + ), + alignment = alignment, + arrowShape = arrowShape, + arrowOffsetX = arrowOffsetX, + arrowOffsetY = arrowOffsetY, + arrowWidth = arrowWidth, + arrowHeight = arrowHeight, + drawArrow = drawArrow + ) + } +} + +/** + * + * [BubbleState] class contains information about chat or speech **Bubble**. + * + * @param cornerRadius Constructs a Radius for each side of bubble rectangle + * @param alignment Arrow alignment determines in which side of the bubble this arrow should be drawn. + * When [ArrowAlignment.None] is selected no arrow is drawn. + * @param arrowShape Shape of the arrow, It can be right or isosceles triangle or curved shape + * @param arrowOffsetX Vertical offset for arrow that is positioned on top or at the bottom of the bubble. + * Positive values move arrow right while negative values move left. Arrow position + * is limited between left of content and content right minus arrow width. + * @param arrowOffsetY Vertical offset for arrow that is positioned on left or right side of the bubble. + * Positive values move arrow bottom while negative values move up. Arrow position + * is limited between top of content and content bottom minus arrow height. + * @param arrowWidth width of the arrow + * @param arrowHeight height of the arrow + * @param drawArrow whether we should draw arrow or only have rectangle shape bubble + */ +@Composable +fun rememberBubbleState( + cornerRadius: BubbleCornerRadius = BubbleCornerRadius( + topLeft = 8.dp, + topRight = 8.dp, + bottomLeft = 8.dp, + bottomRight = 8.dp + ), + alignment: ArrowAlignment = ArrowAlignment.None, + arrowShape: ArrowShape = ArrowShape.HalfTriangle, + arrowOffsetX: Dp = 0.dp, + arrowOffsetY: Dp = 0.dp, + arrowWidth: Dp = 14.dp, + arrowHeight: Dp = 14.dp, + drawArrow: Boolean = true +): BubbleState { + + return remember( + keys = arrayOf( + cornerRadius, + alignment, + arrowShape, + arrowOffsetX, + arrowOffsetY, + arrowWidth, + arrowHeight, + drawArrow + ) + ) { + BubbleState( + cornerRadius = cornerRadius, + alignment = alignment, + arrowShape = arrowShape, + arrowOffsetX = arrowOffsetX, + arrowOffsetY = arrowOffsetY, + arrowWidth = arrowWidth, + arrowHeight = arrowHeight, + drawArrow = drawArrow + ) + } +} + +/** + * + * [BubbleState] class contains information about chat or speech **Bubble**. + * + * @param cornerRadius Constructs a Radius for each side of bubble rectangle + * @param alignment Arrow alignment determines in which side of the bubble this arrow should be drawn. + * When [ArrowAlignment.None] is selected no arrow is drawn. + * @param arrowShape Shape of the arrow, It can be right or isosceles triangle or curved shape + * @param arrowOffsetX Vertical offset for arrow that is positioned on top or at the bottom of the bubble. + * Positive values move arrow right while negative values move left. Arrow position + * is limited between left of content and content right minus arrow width. + * @param arrowOffsetY Vertical offset for arrow that is positioned on left or right side of the bubble. + * Positive values move arrow bottom while negative values move up. Arrow position + * is limited between top of content and content bottom minus arrow height. + * @param arrowWidth width of the arrow + * @param arrowHeight height of the arrow + * @param drawArrow whether we should draw arrow or only have rectangle shape bubble + */ +@Stable +open class BubbleState( + val cornerRadius: BubbleCornerRadius = BubbleCornerRadius( + topLeft = 8.dp, + topRight = 8.dp, + bottomLeft = 8.dp, + bottomRight = 8.dp, + ), + val alignment: ArrowAlignment = ArrowAlignment.None, + val arrowShape: ArrowShape = ArrowShape.HalfTriangle, + val arrowOffsetX: Dp = 0.dp, + val arrowOffsetY: Dp = 0.dp, + val arrowWidth: Dp = 14.dp, + val arrowHeight: Dp = 14.dp, + val drawArrow: Boolean = true +) { + + var arrowRect:BubbleRect = BubbleRect.Zero + + /** + * Top position of arrow. This is read-only for implementation. It's calculated when arrow + * positions are calculated or adjusted based on width/height of bubble, + * offsetX/y, arrow width/height. + */ + val arrowTop: Float + get() = arrowRect.top + + /** + * Bottom position of arrow. This is read-only for implementation. It's calculated when arrow + * positions are calculated or adjusted based on width/height of bubble, + * offsetX/y, arrow width/height. + */ + + val arrowBottom: Float + get() = arrowRect.bottom + + + /** + * Right position of arrow. This is read-only for implementation. It's calculated when arrow + * positions are calculated or adjusted based on width/height of bubble, + * offsetX/y, arrow width/height. + */ + val arrowLeft: Float + get() = arrowRect.left + + /** + * Right position of arrow. This is read-only for implementation. It's calculated when arrow + * positions are calculated or adjusted based on width/height of bubble, + * offsetX/y, arrow width/height. + */ + val arrowRight: Float + get() = arrowRect.right + + + var arrowTip by mutableStateOf( + Offset.Unspecified + ) + + /** + * Arrow is on left side of the bubble + */ + fun isHorizontalLeftAligned(): Boolean = + (alignment == ArrowAlignment.LeftTop + || alignment == ArrowAlignment.LeftBottom + || alignment == ArrowAlignment.LeftCenter) + + + /** + * Arrow is on right side of the bubble + */ + fun isHorizontalRightAligned(): Boolean = + (alignment == ArrowAlignment.RightTop + || alignment == ArrowAlignment.RightBottom + || alignment == ArrowAlignment.RightCenter) + + + /** + * Arrow is on top left or right side of the bubble + */ + fun isHorizontalTopAligned(): Boolean = + (alignment == ArrowAlignment.LeftTop || alignment == ArrowAlignment.RightTop) + + + /** + * Arrow is on top left or right side of the bubble + */ + fun isHorizontalBottomAligned(): Boolean = + (alignment == ArrowAlignment.LeftBottom || alignment == ArrowAlignment.RightBottom) + + /** + * Check if arrow is horizontally positioned either on left or right side + */ + fun isArrowHorizontallyPositioned(): Boolean = + isHorizontalLeftAligned() + || isHorizontalRightAligned() + + + /** + * Arrow is at the bottom of the bubble + */ + fun isVerticalBottomAligned(): Boolean = + alignment == ArrowAlignment.BottomLeft || + alignment == ArrowAlignment.BottomRight || + alignment == ArrowAlignment.BottomCenter + + /** + * Arrow is at the yop of the bubble + */ + fun isVerticalTopAligned(): Boolean = + alignment == ArrowAlignment.TopLeft || + alignment == ArrowAlignment.TopRight || + alignment == ArrowAlignment.TopCenter + + /** + * Arrow is on left side of the bubble + */ + fun isVerticalLeftAligned(): Boolean = + (alignment == ArrowAlignment.BottomLeft) || (alignment == ArrowAlignment.TopLeft) + + + /** + * Arrow is on right side of the bubble + */ + fun isVerticalRightAligned(): Boolean = + (alignment == ArrowAlignment.BottomRight) || (alignment == ArrowAlignment.TopRight) + + + /** + * Check if arrow is vertically positioned either on top or at the bottom of bubble + */ + fun isArrowVerticallyPositioned(): Boolean = isVerticalBottomAligned() || isVerticalTopAligned() +} diff --git a/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/Util.kt b/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/Util.kt new file mode 100644 index 00000000..20a2652e --- /dev/null +++ b/feature/chat/src/main/java/tht/feature/chat/component/detail/bubble/Util.kt @@ -0,0 +1,214 @@ +package tht.feature.chat.component.detail.bubble + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Path + +fun getArrowRect( + state: BubbleState, + arrowWidth: Float, + arrowHeight: Float, + density: Float, + contentWidth: Float, + contentHeight: Float +): BubbleRect { + val isHorizontalArrow = state.isArrowHorizontallyPositioned() + + return if (isHorizontalArrow) { + // This is offset from top/bottom or center for arrows on left or right. + // Maximum offset + arrow height cannot be bigger + // than bottom of bubble or smaller than top of bubble. + val arrowTop: Float = calculateArrowTopPosition( + state, + arrowHeight, + contentHeight, + density + ) + + val arrowBottom = arrowTop + arrowHeight + val arrowLeft = if (state.isHorizontalLeftAligned()) 0f + else contentWidth - arrowWidth + val arrowRight = arrowLeft + arrowWidth + + BubbleRect( + left = arrowLeft, + top = arrowTop, + right = arrowRight, + bottom = arrowBottom + ) + } else { + val arrowLeft = calculateArrowLeftPosition( + state, + arrowWidth, + contentWidth, + density + ) + + val arrowRight = arrowLeft + arrowWidth + val arrowTop = if (state.isVerticalBottomAligned()) { + contentHeight - arrowHeight + } else { + 0f + } + val arrowBottom = arrowTop + arrowHeight + + BubbleRect( + left = arrowLeft, + top = arrowTop, + right = arrowRight, + bottom = arrowBottom + ) + } +} + +/** + * Retrieve rectangle for measuring for space to be used content other than arrow itself. + * + * @param bubbleState state that contains bubble properties + + * @param width is the total width reserved for content and arrow if available in horizontal position + * @param height is the total height reserved for content and arrow if available in vertical position + */ +internal fun getContentRect( + bubbleState: BubbleState, + width: Int, + height: Int, + density: Float +): BubbleRect { + + val isHorizontalRightAligned = bubbleState.isHorizontalRightAligned() + val isHorizontalLeftAligned = bubbleState.isHorizontalLeftAligned() + val isVerticalBottomAligned = bubbleState.isVerticalBottomAligned() + val isVerticalTopAligned = bubbleState.isVerticalTopAligned() + + val arrowWidth = bubbleState.arrowWidth.value * density + val arrowHeight = bubbleState.arrowHeight.value * density + + return when { + isHorizontalLeftAligned -> { + BubbleRect( + left = arrowWidth, + top = 0f, + right = width.toFloat(), + bottom = height.toFloat() + ) + + } + + isHorizontalRightAligned -> { + BubbleRect( + left = 0f, + top = 0f, + right = width.toFloat() - arrowWidth, + bottom = height.toFloat() + ) + + } + + isVerticalBottomAligned -> { + BubbleRect( + left = 0f, + top = 0f, + right = width.toFloat(), + bottom = height.toFloat() - arrowHeight + ) + } + + isVerticalTopAligned -> { + BubbleRect( + left = 0f, + top = arrowHeight, + right = width.toFloat(), + bottom = height.toFloat() + ) + } + + else -> { + BubbleRect( + left = 0f, + top = 0f, + right = width.toFloat(), + bottom = height.toFloat() + ) + } + } +} + +internal fun Path.addRoundedRect( + radiusTopLeft: Float, + radiusTopRight: Float, + radiusBottomRight: Float, + radiusBottomLeft: Float, + topLeft: Offset, + size: Size +) { + val topLeftRadius = radiusTopLeft * 2 + val topRightRadius = radiusTopRight * 2 + val bottomRightRadius = radiusBottomRight * 2 + val bottomLeftRadius = radiusBottomLeft * 2 + + val width = size.width + val height = size.height + + // Top left arc + arcTo( + rect = Rect( + left = topLeft.x, + top = topLeft.y, + right = topLeft.x + topLeftRadius, + bottom = topLeft.y + topLeftRadius + ), + startAngleDegrees = 180.0f, + sweepAngleDegrees = 90.0f, + forceMoveTo = false + ) + + lineTo(x = topLeft.x + width - topRightRadius, y = topLeft.y) + + // Top right arc + arcTo( + rect = Rect( + left = topLeft.x + width - topRightRadius, + top = topLeft.y, + right = topLeft.x + width, + bottom = topLeft.y + topRightRadius + ), + startAngleDegrees = -90.0f, + sweepAngleDegrees = 90.0f, + forceMoveTo = false + ) + + lineTo(x = topLeft.x + width, y = topLeft.y + height - bottomRightRadius) + + // Bottom right arc + arcTo( + rect = Rect( + left = topLeft.x + width - bottomRightRadius, + top = topLeft.y + height - bottomRightRadius, + right = topLeft.x + width, + bottom = topLeft.y + height + ), + startAngleDegrees = 0f, + sweepAngleDegrees = 90.0f, + forceMoveTo = false + ) + + lineTo(x = topLeft.x + bottomLeftRadius, y = topLeft.y + height) + + // Bottom left arc + arcTo( + rect = Rect( + left = topLeft.x, + top = topLeft.y + height - bottomLeftRadius, + right = topLeft.x + bottomLeftRadius, + bottom = topLeft.y + height + ), + startAngleDegrees = 90.0f, + sweepAngleDegrees = 90.0f, + forceMoveTo = false + ) + + lineTo(x = topLeft.x, y = topLeft.y + topLeftRadius) + close() +} diff --git a/feature/chat/src/main/java/tht/feature/chat/component/draggableItem/DragAnchors.kt b/feature/chat/src/main/java/tht/feature/chat/component/draggableItem/DragAnchors.kt new file mode 100644 index 00000000..0f5ab9d8 --- /dev/null +++ b/feature/chat/src/main/java/tht/feature/chat/component/draggableItem/DragAnchors.kt @@ -0,0 +1,116 @@ +package tht.feature.chat.component.draggableItem + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.exponentialDecay +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +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 +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.example.compose_ui.component.text.p.ThtP2 +import tht.feature.chat.component.ChatItem +import tht.feature.chat.model.ChatListUiModel +import kotlin.math.roundToInt + +enum class DragAnchors { + Start, + Center, + End, +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun DraggableItem( + chatItem: ChatListUiModel, + isLoading: Boolean, + onClickItem: (Long, String) -> Unit, + onClickDelete: () -> Unit, + startAction: @Composable (BoxScope.() -> Unit)? = {}, + endAction: @Composable (BoxScope.() -> Unit)? = {} +) { + val density = LocalDensity.current + val defaultActionSize = 80.dp + val endActionSizePx = with(density) { (defaultActionSize).toPx() } + val state = remember { + AnchoredDraggableState( + initialValue = DragAnchors.Center, + anchors = DraggableAnchors { + DragAnchors.Center at 0f + DragAnchors.End at endActionSizePx + }, + positionalThreshold = { distance: Float -> distance * 0.5f }, + velocityThreshold = { with(density) { 80.dp.toPx() } }, + snapAnimationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing), + decayAnimationSpec = exponentialDecay(), + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RectangleShape) + ) { + + endAction?.let { + Box( + modifier = Modifier + .width(80.dp) + .height(76.dp) + .align(Alignment.CenterEnd) + .clickable { onClickDelete() } + .background(Color(0xFFEF4444)) + ) { + ThtP2( + modifier = Modifier.align(Alignment.Center), + text = "나가기", + fontWeight = FontWeight.SemiBold, + color = Color.White + ) + } + } + startAction?.let { startAction() } + + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterStart) + .offset { + IntOffset( + x = -state + .requireOffset() + .roundToInt(), + y = 0, + ) + } + .anchoredDraggable(state, true, Orientation.Horizontal), + content = { + ChatItem( + item = chatItem, + isLoading = isLoading, + onClickItem = onClickItem, + ) + } + ) + } +} diff --git a/feature/chat/src/main/java/tht/feature/chat/mapper/ChatDetailInformationMapper.kt b/feature/chat/src/main/java/tht/feature/chat/mapper/ChatDetailInformationMapper.kt new file mode 100644 index 00000000..61f7919f --- /dev/null +++ b/feature/chat/src/main/java/tht/feature/chat/mapper/ChatDetailInformationMapper.kt @@ -0,0 +1,12 @@ +package tht.feature.chat.mapper + +import com.tht.tht.domain.chat.model.ChatDetailInformationModel +import tht.feature.chat.model.ChatDetailInformationUiModel + +fun ChatDetailInformationModel.toModel() = ChatDetailInformationUiModel( + chatRoomIdx = chatRoomIdx, + talkSubject = talkSubject, + talkIssue = talkIssue, + startDate = startDate, + isChatAble = isChatAble, +) diff --git a/feature/chat/src/main/java/tht/feature/chat/mapper/ChatHistoryMapper.kt b/feature/chat/src/main/java/tht/feature/chat/mapper/ChatHistoryMapper.kt new file mode 100644 index 00000000..b84fc33e --- /dev/null +++ b/feature/chat/src/main/java/tht/feature/chat/mapper/ChatHistoryMapper.kt @@ -0,0 +1,48 @@ +package tht.feature.chat.mapper + +import com.tht.tht.domain.chat.model.ChatHistoryModel +import tht.feature.chat.model.ChatHistoryUiModel +import java.time.LocalDate +import java.time.temporal.ChronoUnit + +fun ChatHistoryModel.toModel() = ChatHistoryUiModel( + chatIdx = chatIdx, + sender = sender, + senderUuid = senderUuid, + msg = msg, + imgUrl = imgUrl, + dateTime = dateTime.parseTimeString(ampm = true), +) + +fun String.parseTimeString(ampm: Boolean = false): String { + // 날짜와 시간 부분 분리 + val dateTimeParts = this.split("T") + val dateString = dateTimeParts[0] + val timePartWithMs = dateTimeParts[1] + + // 시간 부분 분리 + val timeParts = timePartWithMs.split(":") + val hours = timeParts[0].toInt() + val minutes = timeParts[1].toInt() + val seconds = timeParts[2].split(".")[0].toInt() + + // 12시간제 변환 + val isPM = hours >= 12 + val hour12 = if (hours > 12) hours - 12 else if (hours == 0) 12 else hours + val amPm = if (isPM) "PM" else "AM" + + // 날짜 처리 + val currentDate = LocalDate.now() + val inputDate = LocalDate.parse(dateString) + val daysBetween = ChronoUnit.DAYS.between(inputDate, currentDate) + return if (ampm) { + "%02d:%02d %s".format(hour12, minutes, amPm) + } else { + // 결과 문자열 생성 + when (daysBetween) { + 0L -> "%02d:%02d %s".format(hour12, minutes, amPm) + 1L -> "어제" + else -> "%04d.%02d.%02d".format(inputDate.year, inputDate.monthValue, inputDate.dayOfMonth) + } + } +} diff --git a/feature/chat/src/main/java/tht/feature/chat/mapper/ChatListModelMapper.kt b/feature/chat/src/main/java/tht/feature/chat/mapper/ChatListModelMapper.kt index 61eac5a5..054ff619 100644 --- a/feature/chat/src/main/java/tht/feature/chat/mapper/ChatListModelMapper.kt +++ b/feature/chat/src/main/java/tht/feature/chat/mapper/ChatListModelMapper.kt @@ -8,5 +8,5 @@ fun ChatListModel.toModel() = ChatListUiModel( partnerName = partnerName, partnerProfileUrl = partnerProfileUrl, currentMessage = currentMessage, - messageTime = messageTime + messageTime = messageTime.parseTimeString() ) diff --git a/feature/chat/src/main/java/tht/feature/chat/model/ChatDetailInformationUiModel.kt b/feature/chat/src/main/java/tht/feature/chat/model/ChatDetailInformationUiModel.kt new file mode 100644 index 00000000..7c17aff1 --- /dev/null +++ b/feature/chat/src/main/java/tht/feature/chat/model/ChatDetailInformationUiModel.kt @@ -0,0 +1,9 @@ +package tht.feature.chat.model + +data class ChatDetailInformationUiModel( + val chatRoomIdx: Long, + val talkSubject: String, + val talkIssue: String, + val startDate: String, + val isChatAble: Boolean +) diff --git a/feature/chat/src/main/java/tht/feature/chat/model/ChatHistoryUiModel.kt b/feature/chat/src/main/java/tht/feature/chat/model/ChatHistoryUiModel.kt new file mode 100644 index 00000000..5777ac90 --- /dev/null +++ b/feature/chat/src/main/java/tht/feature/chat/model/ChatHistoryUiModel.kt @@ -0,0 +1,10 @@ +package tht.feature.chat.model + +data class ChatHistoryUiModel( + val chatIdx: String, + val sender: String, + val senderUuid: String, + val msg: String, + val imgUrl: String, + val dateTime: String, +) diff --git a/feature/chat/src/main/java/tht/feature/chat/navigation/ChatNavigation.kt b/feature/chat/src/main/java/tht/feature/chat/navigation/ChatNavigation.kt index fb687c67..db8fdefe 100644 --- a/feature/chat/src/main/java/tht/feature/chat/navigation/ChatNavigation.kt +++ b/feature/chat/src/main/java/tht/feature/chat/navigation/ChatNavigation.kt @@ -1,39 +1,61 @@ package tht.feature.chat.navigation +import android.content.Context import androidx.compose.runtime.Composable import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument import tht.feature.chat.chat.screen.ChatDetailScreen import tht.feature.chat.chat.screen.ChatScreen @Composable -fun ChatNavigation() { +fun ChatNavigation( + context: Context, +) { val navController = rememberNavController() NavHost( navController = navController, startDestination = Chat.route ) { - addChatNavGraph(navController) + addChatNavGraph(navController, context) } } private fun NavGraphBuilder.addChatNavGraph( - navController: NavHostController + navController: NavHostController, + context: Context, ) { composable( route = Chat.route ) { ChatScreen( - navigateChatDetail = { navController.navigate(ChatDetail.route) } + context = context, + navigateChatDetail = { roomIdx, partnerName -> + navController.navigate("${ChatDetail.route}/${roomIdx}/${partnerName}") + } ) } composable( - route = ChatDetail.route - ) { - ChatDetailScreen() + route = "${ChatDetail.route}/{roomIdx}/{partnerName}", + arguments = listOf( + navArgument("roomIdx") { + type = NavType.LongType + } + ) + ) { entry -> + val roomIdx = entry.arguments?.getLong("roomIdx") + val partnerName = entry.arguments?.getString("partnerName") + if (roomIdx == null || partnerName == null) return@composable + ChatDetailScreen( + roomIdx = roomIdx, + partnerName = partnerName, + onBack = { navController.navigateUp() }, + context = context, + ) } } diff --git a/feature/chat/src/main/res/drawable/ic_bling.xml b/feature/chat/src/main/res/drawable/ic_bling.xml new file mode 100644 index 00000000..a0464c3c --- /dev/null +++ b/feature/chat/src/main/res/drawable/ic_bling.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/chat/src/test/java/tht/feature/chat/ExampleUnitTest.kt b/feature/chat/src/test/java/tht/feature/chat/ExampleUnitTest.kt deleted file mode 100644 index 251be807..00000000 --- a/feature/chat/src/test/java/tht/feature/chat/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package tht.feature.chat - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/feature/chat/src/test/java/tht/feature/chat/SubscriptionTest.kt b/feature/chat/src/test/java/tht/feature/chat/SubscriptionTest.kt new file mode 100644 index 00000000..76ef0c5f --- /dev/null +++ b/feature/chat/src/test/java/tht/feature/chat/SubscriptionTest.kt @@ -0,0 +1,107 @@ +package tht.feature.chat + +import com.squareup.okhttp.RequestBody +import com.squareup.okhttp.Response +import com.squareup.okhttp.ResponseBody +import com.squareup.okhttp.mockwebserver.MockResponse +import com.squareup.okhttp.mockwebserver.MockWebServer +import com.squareup.okhttp.ws.WebSocket +import com.squareup.okhttp.ws.WebSocketListener +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Dispatchers.Unconfined +import kotlinx.coroutines.ObsoleteCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import okio.Buffer +import okio.IOException +import org.junit.Assert.assertEquals +import org.junit.Test +import tht.feature.chat.actioncable.Consumer +import tht.feature.chat.actioncable.Subscription +import java.net.URI + +private const val TIMEOUT = 10000L + +class SubscriptionTest { + + @Test + fun identifier() { + val consumer = Consumer(URI("ws://3.34.157.62/websocket-endpoint")) + val channel = tht.feature.chat.actioncable.Channel("/sub/chat/1") + val subscription = Subscription(consumer, channel) + + assertEquals("{\"channel\":\"/sub/chat/1\"}", subscription.identifier) + } + + + @OptIn(ObsoleteCoroutinesApi::class) + @Test(timeout = TIMEOUT) + fun onConnected() = runBlocking { + val events = Channel() + + val mockWebServer = MockWebServer() + val mockResponse = MockResponse().withWebSocketUpgrade(object : DefaultWebSocketListener() { + private var currentWebSocket: WebSocket? = null + + override fun onOpen(webSocket: WebSocket?, response: Response?) { + currentWebSocket = webSocket + // send welcome message + launch(Unconfined) { + currentWebSocket?.sendMessage(RequestBody.create(WebSocket.TEXT, "{\"type\":\"welcome\"}")) + } + } + + override fun onMessage(message: ResponseBody?) { + message?.also { + val text = it.source()?.readUtf8()!! + if (text.contains("subscribe")) { + // accept subscribe command + launch(Unconfined) { + currentWebSocket?.sendMessage( + RequestBody.create( + WebSocket.TEXT, + "{\"identifier\":\"{\\\"channel\\\":\\\"CommentsChannel\\\"}\",\"type\":\"confirm_subscription\"}" + ) + ) + } + } + }?.close() + } + }) + mockWebServer.enqueue(mockResponse) + mockWebServer.start() + val channel = tht.feature.chat.actioncable.Channel("CommentsChannel") + val consumer = Consumer(URI(mockWebServer.url("/").uri().toString())) + val subscription = consumer.subscriptions.create(channel) + + subscription.onConnected = { + launch(Unconfined) { + events.send("onConnected") + } + } + + consumer.connect() + + assertEquals("onConnected", events.receive()) + + mockWebServer.shutdown() + } + + private open class DefaultWebSocketListener : WebSocketListener { + override fun onOpen(webSocket: WebSocket?, response: Response?) { + } + + override fun onFailure(e: IOException?, response: Response?) { + } + + override fun onMessage(message: ResponseBody?) { + } + + override fun onPong(payload: Buffer?) { + } + + override fun onClose(code: Int, reason: String?) { + } + } +} diff --git a/gradle/libraries.version.toml b/gradle/libraries.version.toml index 4dfb1db4..d4bd1dcd 100644 --- a/gradle/libraries.version.toml +++ b/gradle/libraries.version.toml @@ -76,6 +76,11 @@ renderscript-intrinsics-replacement-toolkit = "b6363490c3" serialization = "1.8.10" +# Krossbow +krossbow = "7.0.0" +moshi-converter = "2.9.0" +moshi-kotlin = "1.14.0" + [plugins] #android application = { id = "com.android.application", version.ref = "android.gradle" } @@ -145,6 +150,8 @@ okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } mock-webserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "mockwebserver" } okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +moshi-converter = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "moshi-converter" } +moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi-kotlin" } #firebase gms-google-services = { module = "com.google.gms:google-services", version.ref = "gms-google-services" } @@ -181,3 +188,9 @@ lottie-compose = { module = "com.airbnb.android:lottie-compose", version.ref = " #compose-blur renderscript-intrinsics-replacement-toolkit = { module = "com.github.android:renderscript-intrinsics-replacement-toolkit", version.ref = "renderscript-intrinsics-replacement-toolkit" } + +#Krossbow +krossbow-stomp-core = { group = "org.hildan.krossbow", name = "krossbow-stomp-core", version.ref = "krossbow" } +krossbow-websocket-okhttp = { group = "org.hildan.krossbow", name = "krossbow-websocket-okhttp", version.ref = "krossbow" } +krossbow-stomp-moshi = { group = "org.hildan.krossbow", name = "krossbow-stomp-moshi", version.ref = "krossbow" } +