diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 074f2dfa..f3c1785f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,7 @@ import com.teamoffroad.app.setNamespace plugins { id("offroad.android.application") + alias(libs.plugins.google.services) } android { @@ -55,4 +56,7 @@ dependencies { implementation(project(":feature:mypage")) implementation(project(":feature:characterchat")) implementation(libs.kakao.user) + implementation(libs.coil) + implementation(libs.bundles.firebase) + implementation(platform(libs.firebase.bom)) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b70a38e3..93af6fcc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ + - + + + + + @@ -55,5 +62,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/teamoffroad/offroad.app/OffRoadMessagingService.kt b/app/src/main/java/com/teamoffroad/offroad.app/OffRoadMessagingService.kt new file mode 100644 index 00000000..b509e89c --- /dev/null +++ b/app/src/main/java/com/teamoffroad/offroad.app/OffRoadMessagingService.kt @@ -0,0 +1,199 @@ +package com.teamoffroad.offroad.app + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.drawable.BitmapDrawable +import android.os.Build +import androidx.core.app.NotificationCompat +import coil.Coil +import coil.request.ImageRequest +import com.google.firebase.messaging.Constants.MessageNotificationKeys +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.teamoffroad.core.common.domain.model.FcmNotificationKey.CHANNEL_ID +import com.teamoffroad.core.common.domain.model.FcmNotificationKey.KEY_BODY +import com.teamoffroad.core.common.domain.model.FcmNotificationKey.KEY_ID +import com.teamoffroad.core.common.domain.model.FcmNotificationKey.KEY_IMAGE +import com.teamoffroad.core.common.domain.model.FcmNotificationKey.KEY_TITLE +import com.teamoffroad.core.common.domain.model.FcmNotificationKey.KEY_TYPE +import com.teamoffroad.core.common.domain.model.FcmNotificationKey.NOTICE +import com.teamoffroad.core.common.domain.model.FcmNotificationKey.TYPE_CHARACTER_CHAT +import com.teamoffroad.core.common.domain.repository.DeviceTokenRepository +import com.teamoffroad.core.common.util.ActivityLifecycleHandler +import com.teamoffroad.feature.main.MainActivity +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class OffRoadMessagingService : FirebaseMessagingService() { + @Inject + lateinit var dataStore: DeviceTokenRepository + + override fun onNewToken(token: String) { + super.onNewToken(token) + + CoroutineScope(Dispatchers.IO).launch { dataStore.updateDeviceTokenEnabled(token) } + } + + //FCM 메세지를 받을 때 호출됨 + override fun onMessageReceived(remoteMessage: RemoteMessage) { + super.onMessageReceived(remoteMessage) + if (remoteMessage.data.isNotEmpty()) { + if (ActivityLifecycleHandler.isAppInForeground) { + if (remoteMessage.data[KEY_TYPE] != TYPE_CHARACTER_CHAT) + sendNotification(remoteMessage, true) + else { + // 앱이 포그라운드에 있고, 알림타임이 캐릭터채팅인 경우 + // 정현이 봐야할곳은 여기!! 요쪽 따라가십쇼 + sendCharacterChatNotificationInForeground(remoteMessage) + } + } else { + sendNotification(remoteMessage, false) + } + } + } + + override fun handleIntent(intent: Intent?) { + val new = intent?.apply { + val temp = extras?.apply { + remove(MessageNotificationKeys.ENABLE_NOTIFICATION) + remove(keyWithOldPrefix()) + } + replaceExtras(temp) + } + super.handleIntent(new) + } + + private fun keyWithOldPrefix(): String { + if (!MessageNotificationKeys.ENABLE_NOTIFICATION.startsWith(MessageNotificationKeys.NOTIFICATION_PREFIX)) { + return MessageNotificationKeys.ENABLE_NOTIFICATION + } + + return MessageNotificationKeys.ENABLE_NOTIFICATION.replace( + MessageNotificationKeys.NOTIFICATION_PREFIX, + MessageNotificationKeys.NOTIFICATION_PREFIX_OLD + ) + } + + private fun generateUniqueIdentifier(): Int { + return (System.currentTimeMillis() / 7).toInt() + } + + private fun createPendingIntent(intent: Intent, uniqueIdentifier: Int): PendingIntent { + return PendingIntent.getActivity( + this, + uniqueIdentifier, + intent, + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + ) + } + + private fun createNotificationIntent( + remoteMessage: RemoteMessage, + isForeGround: Boolean + ): Intent { + return Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK + putExtra(KEY_TYPE, remoteMessage.data[KEY_TYPE]) + if (remoteMessage.data[KEY_TYPE] != TYPE_CHARACTER_CHAT) { + putExtra(KEY_ID, remoteMessage.data[KEY_ID]) + + } + } + } + + private fun createNotificationBuilder( + remoteMessage: RemoteMessage, + pendingIntent: PendingIntent, + onLargeIconReady: (NotificationCompat.Builder) -> Unit + ): NotificationCompat.Builder { + + val notificationBuilder = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(remoteMessage.data[KEY_TITLE]) + .setContentText(remoteMessage.data[KEY_BODY]) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .setPriority(NotificationCompat.PRIORITY_MAX) + + val imageUrl = remoteMessage.data[KEY_IMAGE] + imageUrl?.let { + val request = ImageRequest.Builder(this) + .data(it) + .target { drawable -> + val bitmap = (drawable as BitmapDrawable).bitmap + notificationBuilder.setLargeIcon(bitmap) + onLargeIconReady(notificationBuilder) + } + .build() + + Coil.imageLoader(this).enqueue(request) + } ?: run { + onLargeIconReady(notificationBuilder) + } + return notificationBuilder + } + + private fun sendNotification(remoteMessage: RemoteMessage, isForeGround: Boolean) { + if (!isForeGround) { + val uniqueIdentifier = generateUniqueIdentifier() + val intent = createNotificationIntent(remoteMessage, isForeGround) + val pendingIntent = createPendingIntent(intent, uniqueIdentifier) + createNotificationBuilder(remoteMessage, pendingIntent) { notificationBuilder -> + showNotification(notificationBuilder, uniqueIdentifier) + } + } else { + val uniqueIdentifier = generateUniqueIdentifier() + val broadCastIntent = + Intent("com.teamoffroad.offroad.app.ANNOUNCEMENT_FOREGROUND").apply { + putExtra(KEY_TITLE, remoteMessage.data[KEY_TITLE]) + putExtra(KEY_ID, remoteMessage.data[KEY_ID]) + } + val pendingIntent = PendingIntent.getBroadcast( + this, + 0, + broadCastIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE // FLAG_IMMUTABLE 추가 + ) + createNotificationBuilder(remoteMessage, pendingIntent) { notificationBuilder -> + showNotification(notificationBuilder, uniqueIdentifier) + } + } + } + + //브로드캐스트리시버에 필요한 데이터(캐릭터이름, 대화내용, 알림타입) 저장하고 브로드캐스트 발신 + //feature main의 CharacterChatBroadcastReceiver로 가면 됩니다. + private fun sendCharacterChatNotificationInForeground( + remoteMessage: RemoteMessage, + ) { + val broadCastIntent = + Intent("com.teamoffroad.offroad.app.CHARACTER_CHAT_FOREGROUND").apply { + putExtra(KEY_TITLE, remoteMessage.data[KEY_TITLE]) + putExtra(KEY_BODY, remoteMessage.data[KEY_BODY]) + putExtra(KEY_TYPE, remoteMessage.data[KEY_TYPE]) + } + sendBroadcast(broadCastIntent) + } + + private fun showNotification( + notificationBuilder: NotificationCompat.Builder, + uniqueIdentifier: Int + ) { + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = + NotificationChannel(CHANNEL_ID, NOTICE, NotificationManager.IMPORTANCE_HIGH) + notificationManager.createNotificationChannel(channel) + } + + notificationManager.notify(uniqueIdentifier, notificationBuilder.build()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/teamoffroad/offroad.app/OffroadApplication.kt b/app/src/main/java/com/teamoffroad/offroad.app/OffroadApplication.kt index 2f1cea24..913debef 100644 --- a/app/src/main/java/com/teamoffroad/offroad.app/OffroadApplication.kt +++ b/app/src/main/java/com/teamoffroad/offroad.app/OffroadApplication.kt @@ -2,6 +2,7 @@ package com.teamoffroad.offroad.app import android.app.Application import com.kakao.sdk.common.KakaoSdk +import com.teamoffroad.core.common.util.ActivityLifecycleHandler import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp @@ -9,6 +10,7 @@ class OffroadApplication : Application(){ override fun onCreate() { super.onCreate() setKakaoSdk() + registerActivityLifecycleCallbacks(ActivityLifecycleHandler()) } private fun setKakaoSdk() { diff --git a/build.gradle.kts b/build.gradle.kts index db622bad..3ef32e69 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,4 +6,5 @@ plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.daggers.hilt) apply false alias(libs.plugins.ksp) apply false + alias(libs.plugins.google.services) apply false } diff --git a/core/common/src/main/java/com/teamoffroad/core/common/data/datasource/AutoSignInPreferencesDataSource.kt b/core/common/src/main/java/com/teamoffroad/core/common/data/datasource/AutoSignInPreferencesDataSource.kt index d76280d6..a324a567 100644 --- a/core/common/src/main/java/com/teamoffroad/core/common/data/datasource/AutoSignInPreferencesDataSource.kt +++ b/core/common/src/main/java/com/teamoffroad/core/common/data/datasource/AutoSignInPreferencesDataSource.kt @@ -4,6 +4,5 @@ import kotlinx.coroutines.flow.Flow interface AutoSignInPreferencesDataSource { val autoLogin: Flow - suspend fun setAutoLogin(autoLogin: Boolean) } diff --git a/core/common/src/main/java/com/teamoffroad/core/common/data/datasource/DefaultDeviceTokenPreferencesDataSource.kt b/core/common/src/main/java/com/teamoffroad/core/common/data/datasource/DefaultDeviceTokenPreferencesDataSource.kt new file mode 100644 index 00000000..ec7137ba --- /dev/null +++ b/core/common/src/main/java/com/teamoffroad/core/common/data/datasource/DefaultDeviceTokenPreferencesDataSource.kt @@ -0,0 +1,28 @@ +package com.teamoffroad.core.common.data.datasource + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class DefaultDeviceTokenPreferencesDataSource @Inject constructor( + private val dataStore: DataStore, +) : DeviceTokenPreferencesDataSource { + + object PreferencesKey { + val DEVICE_TOKEN_KEY = stringPreferencesKey("DEVICE_TOKEN_KEY") + } + + override val deviceToken: Flow = dataStore.data.map { preferences -> + preferences[PreferencesKey.DEVICE_TOKEN_KEY].orEmpty() + } + + override suspend fun setDeviceToken(deviceToken: String) { + dataStore.edit { preferences -> + preferences[PreferencesKey.DEVICE_TOKEN_KEY] = deviceToken + } + } +} \ No newline at end of file diff --git a/core/common/src/main/java/com/teamoffroad/core/common/data/datasource/DeviceTokenPreferencesDataSource.kt b/core/common/src/main/java/com/teamoffroad/core/common/data/datasource/DeviceTokenPreferencesDataSource.kt new file mode 100644 index 00000000..0125b0ee --- /dev/null +++ b/core/common/src/main/java/com/teamoffroad/core/common/data/datasource/DeviceTokenPreferencesDataSource.kt @@ -0,0 +1,8 @@ +package com.teamoffroad.core.common.data.datasource + +import kotlinx.coroutines.flow.Flow + +interface DeviceTokenPreferencesDataSource { + val deviceToken: Flow + suspend fun setDeviceToken(deviceToken: String) +} \ No newline at end of file diff --git a/core/common/src/main/java/com/teamoffroad/core/common/data/di/DataModule.kt b/core/common/src/main/java/com/teamoffroad/core/common/data/di/DataModule.kt index 39cbde71..5d7f7b9f 100644 --- a/core/common/src/main/java/com/teamoffroad/core/common/data/di/DataModule.kt +++ b/core/common/src/main/java/com/teamoffroad/core/common/data/di/DataModule.kt @@ -2,7 +2,9 @@ package com.teamoffroad.core.common.data.di import com.teamoffroad.core.common.data.datasource.AutoSignInPreferencesDataSource import com.teamoffroad.core.common.data.datasource.DefaultAutoSignInPreferencesDataSource +import com.teamoffroad.core.common.data.datasource.DefaultDeviceTokenPreferencesDataSource import com.teamoffroad.core.common.data.datasource.DefaultTokenPreferencesDataSource +import com.teamoffroad.core.common.data.datasource.DeviceTokenPreferencesDataSource import com.teamoffroad.core.common.data.datasource.TokenPreferencesDataSource import dagger.Binds import dagger.Module @@ -22,4 +24,9 @@ internal abstract class DataModule { abstract fun bindsAutoSignInLocalDataSource( dataSource: DefaultAutoSignInPreferencesDataSource, ): AutoSignInPreferencesDataSource + + @Binds + abstract fun bindsDeviceTokenLocalDataSource( + dataSource: DefaultDeviceTokenPreferencesDataSource, + ): DeviceTokenPreferencesDataSource } diff --git a/core/common/src/main/java/com/teamoffroad/core/common/data/di/DataStoreModule.kt b/core/common/src/main/java/com/teamoffroad/core/common/data/di/DataStoreModule.kt index d66b748b..897c0e1d 100644 --- a/core/common/src/main/java/com/teamoffroad/core/common/data/di/DataStoreModule.kt +++ b/core/common/src/main/java/com/teamoffroad/core/common/data/di/DataStoreModule.kt @@ -44,6 +44,13 @@ object DataStoreModule { return context.createDataStore(AUTH_PREFERENCES) } + @Provides + @Singleton + fun provideDeviceTokenDataStore(@ApplicationContext context: Context): DataStore { + return context.createDataStore(DEVICE_TOKEN_PREFERENCES) + } + private const val TOKEN_PREFERENCES = "com.teamoffroad.token_preferences" private const val AUTH_PREFERENCES = "com.teamoffroad.auth_preferences" + private const val DEVICE_TOKEN_PREFERENCES = "com.teamoffroad.device_token_preferences" } diff --git a/core/common/src/main/java/com/teamoffroad/core/common/data/di/RepositoryModule.kt b/core/common/src/main/java/com/teamoffroad/core/common/data/di/RepositoryModule.kt index 92c49565..ba5a091c 100644 --- a/core/common/src/main/java/com/teamoffroad/core/common/data/di/RepositoryModule.kt +++ b/core/common/src/main/java/com/teamoffroad/core/common/data/di/RepositoryModule.kt @@ -1,8 +1,10 @@ package com.teamoffroad.core.common.data.di import com.teamoffroad.core.common.data.repository.AutoSignInRepositoryImpl +import com.teamoffroad.core.common.data.repository.DeviceTokenRepositoryImpl import com.teamoffroad.core.common.data.repository.TokenRepositoryImpl import com.teamoffroad.core.common.domain.repository.AutoSignInRepository +import com.teamoffroad.core.common.domain.repository.DeviceTokenRepository import com.teamoffroad.core.common.domain.repository.TokenRepository import dagger.Binds import dagger.Module @@ -24,4 +26,10 @@ abstract class RepositoryModule { abstract fun bindAutoSignInRepository( authRepositoryImpl: AutoSignInRepositoryImpl, ): AutoSignInRepository + + @Binds + @Singleton + abstract fun bindDeviceTokenRepository( + deviceTokenRepositoryImpl: DeviceTokenRepositoryImpl + ): DeviceTokenRepository } diff --git a/core/common/src/main/java/com/teamoffroad/core/common/data/local/AuthAuthenticator.kt b/core/common/src/main/java/com/teamoffroad/core/common/data/local/AuthAuthenticator.kt index 9042c196..ec9cfbf5 100644 --- a/core/common/src/main/java/com/teamoffroad/core/common/data/local/AuthAuthenticator.kt +++ b/core/common/src/main/java/com/teamoffroad/core/common/data/local/AuthAuthenticator.kt @@ -1,7 +1,6 @@ package com.teamoffroad.core.common.data.local import android.content.Context -import android.util.Log import com.jakewharton.processphoenix.ProcessPhoenix import com.teamoffroad.core.common.data.datasource.TokenPreferencesDataSource import com.teamoffroad.core.common.data.remote.service.TokenService @@ -26,33 +25,26 @@ class AuthAuthenticator @Inject constructor( override fun authenticate(route: Route?, response: Response): Request? { val tokenResponse = runCatching { - runBlocking { - refreshTokenUseCase.refreshAccessToken("Bearer ${tokenPreferencesDataSource.refreshToken.first()}") + runBlocking { + refreshTokenUseCase.refreshAccessToken("Bearer ${tokenPreferencesDataSource.refreshToken.first()}") + } + }.onSuccess { + runBlocking { + tokenPreferencesDataSource.apply { + setAccessToken(it.data?.accessToken ?: return@runBlocking) + setRefreshToken(it.data.refreshToken ?: return@runBlocking) } - }.onSuccess { - - Log.d("asdasd", "재발급성공") - runBlocking { - tokenPreferencesDataSource.apply { - if (it != null) { - setAccessToken(it.data?.accessToken ?: return@runBlocking) - setRefreshToken(it.data?.refreshToken ?: return@runBlocking) - } - } - } - }.onFailure { - - Log.d("asdasd", "재발급실패") - runBlocking { - setAutoSignInUseCase.invoke(false) - } - ProcessPhoenix.triggerRebirth(context, intentProvider.getIntent()) - }.getOrThrow() + } + }.onFailure { + runBlocking { + setAutoSignInUseCase.invoke(false) + } + ProcessPhoenix.triggerRebirth(context, intentProvider.getIntent()) + }.getOrThrow() return response.request.newBuilder() - .header(AUTHORIZATION, "Bearer ${tokenResponse?.data?.accessToken}") + .header(AUTHORIZATION, "Bearer ${tokenResponse.data?.accessToken}") .build() - Log.d("asdasd", response.message) } companion object { diff --git a/core/common/src/main/java/com/teamoffroad/core/common/data/repository/AutoSignInRepositoryImpl.kt b/core/common/src/main/java/com/teamoffroad/core/common/data/repository/AutoSignInRepositoryImpl.kt index 61f5fe7c..9232e87d 100644 --- a/core/common/src/main/java/com/teamoffroad/core/common/data/repository/AutoSignInRepositoryImpl.kt +++ b/core/common/src/main/java/com/teamoffroad/core/common/data/repository/AutoSignInRepositoryImpl.kt @@ -14,6 +14,4 @@ class AutoSignInRepositoryImpl @Inject constructor( override suspend fun updateAutoSignInEnabled(enabled: Boolean) { autoSignInPreferencesDataSource.setAutoLogin(enabled) } - - } \ No newline at end of file diff --git a/core/common/src/main/java/com/teamoffroad/core/common/data/repository/DeviceTokenRepositoryImpl.kt b/core/common/src/main/java/com/teamoffroad/core/common/data/repository/DeviceTokenRepositoryImpl.kt new file mode 100644 index 00000000..61b6c7f4 --- /dev/null +++ b/core/common/src/main/java/com/teamoffroad/core/common/data/repository/DeviceTokenRepositoryImpl.kt @@ -0,0 +1,17 @@ +package com.teamoffroad.core.common.data.repository + +import com.teamoffroad.core.common.data.datasource.DeviceTokenPreferencesDataSource +import com.teamoffroad.core.common.domain.repository.DeviceTokenRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class DeviceTokenRepositoryImpl @Inject constructor( + private val deviceTokenPreferencesDataSource: DeviceTokenPreferencesDataSource, +) : DeviceTokenRepository { + + override val deviceToken: Flow = deviceTokenPreferencesDataSource.deviceToken + + override suspend fun updateDeviceTokenEnabled(deviceToken: String) { + deviceTokenPreferencesDataSource.setDeviceToken(deviceToken) + } +} \ No newline at end of file diff --git a/core/common/src/main/java/com/teamoffroad/core/common/domain/model/FcmNotificationKey.kt b/core/common/src/main/java/com/teamoffroad/core/common/domain/model/FcmNotificationKey.kt new file mode 100644 index 00000000..19031878 --- /dev/null +++ b/core/common/src/main/java/com/teamoffroad/core/common/domain/model/FcmNotificationKey.kt @@ -0,0 +1,13 @@ +package com.teamoffroad.core.common.domain.model + +object FcmNotificationKey { + const val CHANNEL_ID = "channelId" + const val NOTICE = "Notice" + const val KEY_TITLE = "title" + const val KEY_BODY = "body" + const val KEY_TYPE = "type" + const val KEY_IMAGE = "image" + const val KEY_ID = "additionalProp1" + const val TYPE_CHARACTER_CHAT = "CHARACTER_CHAT" + const val TYPE_ANNOUNCEMENT = "ANNOUNCEMENT_REDIRECT" +} \ No newline at end of file diff --git a/core/common/src/main/java/com/teamoffroad/core/common/domain/model/NotificationEvent.kt b/core/common/src/main/java/com/teamoffroad/core/common/domain/model/NotificationEvent.kt new file mode 100644 index 00000000..69b592a0 --- /dev/null +++ b/core/common/src/main/java/com/teamoffroad/core/common/domain/model/NotificationEvent.kt @@ -0,0 +1,7 @@ +package com.teamoffroad.core.common.domain.model + +data class NotificationEvent( + val characterName: String?, + val characterContent: String?, + val type: String?, +) diff --git a/core/common/src/main/java/com/teamoffroad/core/common/domain/repository/DeviceTokenRepository.kt b/core/common/src/main/java/com/teamoffroad/core/common/domain/repository/DeviceTokenRepository.kt new file mode 100644 index 00000000..f52e8959 --- /dev/null +++ b/core/common/src/main/java/com/teamoffroad/core/common/domain/repository/DeviceTokenRepository.kt @@ -0,0 +1,8 @@ +package com.teamoffroad.core.common.domain.repository + +import kotlinx.coroutines.flow.Flow + +interface DeviceTokenRepository { + val deviceToken: Flow + suspend fun updateDeviceTokenEnabled(deviceToken: String) +} \ No newline at end of file diff --git a/core/common/src/main/java/com/teamoffroad/core/common/util/ActivityLifecycleHandler.kt b/core/common/src/main/java/com/teamoffroad/core/common/util/ActivityLifecycleHandler.kt new file mode 100644 index 00000000..69d07070 --- /dev/null +++ b/core/common/src/main/java/com/teamoffroad/core/common/util/ActivityLifecycleHandler.kt @@ -0,0 +1,34 @@ +package com.teamoffroad.core.common.util + +import android.app.Activity +import android.app.Application +import android.os.Bundle + +class ActivityLifecycleHandler : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(p0: Activity, p1: Bundle?) { + } + + override fun onActivityStarted(p0: Activity) { + } + + override fun onActivityResumed(p0: Activity) { + isAppInForeground = true + } + + override fun onActivityPaused(p0: Activity) { + isAppInForeground = false + } + + override fun onActivityStopped(p0: Activity) { + } + + override fun onActivitySaveInstanceState(p0: Activity, p1: Bundle) { + } + + override fun onActivityDestroyed(p0: Activity) { + } + + companion object { + var isAppInForeground = false + } +} \ No newline at end of file diff --git a/core/navigation/src/main/java/com/teamoffroad/core/navigation/RouteModel.kt b/core/navigation/src/main/java/com/teamoffroad/core/navigation/RouteModel.kt index 137d0fdd..9b22e7f2 100644 --- a/core/navigation/src/main/java/com/teamoffroad/core/navigation/RouteModel.kt +++ b/core/navigation/src/main/java/com/teamoffroad/core/navigation/RouteModel.kt @@ -3,9 +3,6 @@ package com.teamoffroad.core.navigation import kotlinx.serialization.Serializable sealed interface Route { - @Serializable - data object Splash : Route - @Serializable data object Auth : Route } @@ -76,7 +73,9 @@ sealed interface MyPageRoute : Route { data object Setting : MyPageRoute @Serializable - data object Announcement : MyPageRoute + data class Announcement( + val announcementId: String?, + ) : MyPageRoute @Serializable data class AnnouncementDetail( diff --git a/feature/auth/src/main/java/com/teamoffroad/feature/auth/presentation/AuthScreen.kt b/feature/auth/src/main/java/com/teamoffroad/feature/auth/presentation/AuthScreen.kt index 83eec220..7ff6c750 100644 --- a/feature/auth/src/main/java/com/teamoffroad/feature/auth/presentation/AuthScreen.kt +++ b/feature/auth/src/main/java/com/teamoffroad/feature/auth/presentation/AuthScreen.kt @@ -63,8 +63,12 @@ internal fun AuthScreen( EntryPointAccessors.fromActivity(context) val oAuthInteractor = entryPoint.getOAuthInteractor() + LaunchedEffect(Unit) { + viewModel.checkAutoSignIn() + } LaunchedEffect(isAuthUiState) { when { + isAuthUiState.isAutoSignIn -> navigateToHome() isAuthUiState.signInSuccess && !isAuthUiState.alreadyExist -> navigateToAgreeTermsAndConditions() isAuthUiState.signInSuccess && isAuthUiState.alreadyExist -> navigateToHome() isAuthUiState.kakaoSignIn -> { diff --git a/feature/auth/src/main/java/com/teamoffroad/feature/auth/presentation/AuthViewModel.kt b/feature/auth/src/main/java/com/teamoffroad/feature/auth/presentation/AuthViewModel.kt index 13f63d50..15c413a1 100644 --- a/feature/auth/src/main/java/com/teamoffroad/feature/auth/presentation/AuthViewModel.kt +++ b/feature/auth/src/main/java/com/teamoffroad/feature/auth/presentation/AuthViewModel.kt @@ -6,6 +6,7 @@ import com.google.android.gms.auth.api.signin.GoogleSignInAccount import com.google.android.gms.auth.api.signin.GoogleSignInClient import com.google.android.gms.common.api.ApiException import com.google.android.gms.tasks.Task +import com.teamoffroad.core.common.domain.usecase.GetAutoSignInUseCase import com.teamoffroad.core.common.domain.usecase.SaveAccessTokenUseCase import com.teamoffroad.core.common.domain.usecase.SaveRefreshTokenUseCase import com.teamoffroad.feature.auth.domain.model.SocialSignInPlatform @@ -24,6 +25,7 @@ class AuthViewModel @Inject constructor( private val authUseCase: AuthUseCase, private val saveAccessTokenUseCase: SaveAccessTokenUseCase, private val saveRefreshTokenUseCase: SaveRefreshTokenUseCase, + private val getAutoSignInUseCase: GetAutoSignInUseCase, ) : ViewModel() { private val _authUiState: MutableStateFlow = MutableStateFlow(AuthUiState(empty = true)) @@ -77,4 +79,14 @@ class AuthViewModel @Inject constructor( } } } + + fun checkAutoSignIn() { + viewModelScope.launch { + getAutoSignInUseCase().collect { isAutoSignIn -> + _authUiState.value = _authUiState.value.copy( + isAutoSignIn = isAutoSignIn + ) + } + } + } } diff --git a/feature/auth/src/main/java/com/teamoffroad/feature/auth/presentation/SetGenderScreen.kt b/feature/auth/src/main/java/com/teamoffroad/feature/auth/presentation/SetGenderScreen.kt index 2ea82851..58fc341a 100644 --- a/feature/auth/src/main/java/com/teamoffroad/feature/auth/presentation/SetGenderScreen.kt +++ b/feature/auth/src/main/java/com/teamoffroad/feature/auth/presentation/SetGenderScreen.kt @@ -141,20 +141,32 @@ fun SetGenderButton( GenderHintButton( modifier = Modifier .padding(bottom = 12.dp) - .clickableWithoutRipple(interactionSource = interactionSource) { viewModel.updateCheckedGender("MALE") }, + .clickableWithoutRipple(interactionSource = interactionSource) { + viewModel.updateCheckedGender( + "MALE" + ) + }, value = stringResource(R.string.auth_set_gender_male), isActive = male ) GenderHintButton( modifier = Modifier .padding(bottom = 12.dp) - .clickableWithoutRipple(interactionSource =interactionSource) { viewModel.updateCheckedGender("FEMALE") }, + .clickableWithoutRipple(interactionSource = interactionSource) { + viewModel.updateCheckedGender( + "FEMALE" + ) + }, value = stringResource(R.string.auth_set_gender_female), isActive = female ) GenderHintButton( modifier = Modifier - .clickableWithoutRipple(interactionSource =interactionSource) { viewModel.updateCheckedGender("OTHER") }, + .clickableWithoutRipple(interactionSource = interactionSource) { + viewModel.updateCheckedGender( + "OTHER" + ) + }, value = stringResource(R.string.auth_set_gender_other), isActive = other ) diff --git a/feature/auth/src/main/java/com/teamoffroad/feature/auth/presentation/component/AgreeTermsAndConditionsDialog.kt b/feature/auth/src/main/java/com/teamoffroad/feature/auth/presentation/component/AgreeTermsAndConditionsDialog.kt index a8c1c1fd..52e8a665 100644 --- a/feature/auth/src/main/java/com/teamoffroad/feature/auth/presentation/component/AgreeTermsAndConditionsDialog.kt +++ b/feature/auth/src/main/java/com/teamoffroad/feature/auth/presentation/component/AgreeTermsAndConditionsDialog.kt @@ -44,7 +44,7 @@ fun AgreeTermsAndConditionsDialog( ) { Dialog( onDismissRequest = { onClickCancel() }, - properties = DialogProperties(dismissOnClickOutside = true, dismissOnBackPress = true) + properties = DialogProperties(dismissOnClickOutside = false, dismissOnBackPress = true) ) { Box( modifier = modifier diff --git a/feature/auth/src/main/java/com/teamoffroad/feature/auth/presentation/model/AuthUiState.kt b/feature/auth/src/main/java/com/teamoffroad/feature/auth/presentation/model/AuthUiState.kt index 73af2c56..2fc198f9 100644 --- a/feature/auth/src/main/java/com/teamoffroad/feature/auth/presentation/model/AuthUiState.kt +++ b/feature/auth/src/main/java/com/teamoffroad/feature/auth/presentation/model/AuthUiState.kt @@ -6,5 +6,6 @@ data class AuthUiState( val signInSuccess: Boolean = false, val alreadyExist: Boolean = false, val kakaoSignIn: Boolean = false, + val isAutoSignIn: Boolean = false, ) diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index 4ddd5103..3c86824e 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -11,11 +11,12 @@ android { dependencies { implementation(project(":feature:auth")) + implementation(project(":feature:characterchat")) implementation(libs.retrofit.kotlinx.serialization) implementation(libs.androidx.appcompat) implementation(libs.google.accompanist.permissions) implementation(libs.gson) implementation(libs.lottie.compose) implementation(libs.coil.svg) - //implementation(libs.androidsvg.aar) + implementation(libs.eventbus) } diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/data/di/NetworkModule.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/data/di/NetworkModule.kt index 39ca121e..3b7ddd77 100644 --- a/feature/home/src/main/java/com/teamoffroad/feature/home/data/di/NetworkModule.kt +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/data/di/NetworkModule.kt @@ -2,6 +2,7 @@ package com.teamoffroad.feature.home.data.di import com.teamoffroad.core.common.data.di.qualifier.Auth import com.teamoffroad.feature.home.data.remote.service.DummyUserService +import com.teamoffroad.feature.home.data.remote.service.FcmTokenService import com.teamoffroad.feature.home.data.remote.service.UserService import dagger.Module import dagger.Provides @@ -25,4 +26,10 @@ object NetworkModule { fun provideEmblemService(@Auth retrofit: Retrofit): UserService { return retrofit.create(UserService::class.java) } + + @Provides + @Singleton + fun provideFcmTokenService(@Auth retrofit: Retrofit): FcmTokenService { + return retrofit.create(FcmTokenService::class.java) + } } diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/data/di/RepositoryModule.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/data/di/RepositoryModule.kt index 745fe7c8..fc8b1121 100644 --- a/feature/home/src/main/java/com/teamoffroad/feature/home/data/di/RepositoryModule.kt +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/data/di/RepositoryModule.kt @@ -1,8 +1,10 @@ package com.teamoffroad.feature.home.data.di import com.teamoffroad.feature.home.data.repository.DummyDummyUserRepositoryImpl +import com.teamoffroad.feature.home.data.repository.FcmTokenRepositoryImpl import com.teamoffroad.feature.home.data.repository.UserRepositoryImpl import com.teamoffroad.feature.home.domain.repository.DummyUserRepository +import com.teamoffroad.feature.home.domain.repository.FcmTokenRepository import com.teamoffroad.feature.home.domain.repository.UserRepository import dagger.Binds import dagger.Module @@ -25,4 +27,10 @@ abstract class RepositoryModule { abstract fun bindUserRepository( userRepositoryImpl: UserRepositoryImpl ): UserRepository + + @Binds + @Singleton + abstract fun bindFcmRepository( + fcmTokenRepositoryImpl: FcmTokenRepositoryImpl + ): FcmTokenRepository } diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/data/di/UseCaseModule.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/data/di/UseCaseModule.kt index 23264d6e..2b410087 100644 --- a/feature/home/src/main/java/com/teamoffroad/feature/home/data/di/UseCaseModule.kt +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/data/di/UseCaseModule.kt @@ -1,8 +1,10 @@ package com.teamoffroad.feature.home.data.di import com.teamoffroad.feature.home.domain.repository.DummyUserRepository +import com.teamoffroad.feature.home.domain.repository.FcmTokenRepository import com.teamoffroad.feature.home.domain.repository.UserRepository import com.teamoffroad.feature.home.domain.usecase.GetDummyUserListUseCase +import com.teamoffroad.feature.home.domain.usecase.PostFcmTokenUseCase import com.teamoffroad.feature.home.domain.usecase.UserUseCase import dagger.Module import dagger.Provides @@ -29,4 +31,12 @@ class UseCaseModule { ): UserUseCase { return UserUseCase(userRepository) } + + @Provides + @Singleton + fun providePostFcmTokenUseCase( + fcmTokenRepository: FcmTokenRepository, + ): PostFcmTokenUseCase { + return PostFcmTokenUseCase(fcmTokenRepository) + } } diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/data/remote/request/FcmTokenRequestDto.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/data/remote/request/FcmTokenRequestDto.kt new file mode 100644 index 00000000..7c7bfbcb --- /dev/null +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/data/remote/request/FcmTokenRequestDto.kt @@ -0,0 +1,10 @@ +package com.teamoffroad.feature.home.data.remote.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FcmTokenRequestDto( + @SerialName("token") + val token: String, +) diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/data/remote/request/gitkeep b/feature/home/src/main/java/com/teamoffroad/feature/home/data/remote/request/gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/data/remote/service/FcmTokenService.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/data/remote/service/FcmTokenService.kt new file mode 100644 index 00000000..82159d8a --- /dev/null +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/data/remote/service/FcmTokenService.kt @@ -0,0 +1,14 @@ +package com.teamoffroad.feature.home.data.remote.service + +import com.teamoffroad.core.common.data.remote.response.BaseResponse +import com.teamoffroad.feature.home.data.remote.request.FcmTokenRequestDto +import retrofit2.http.Body +import retrofit2.http.POST + +interface FcmTokenService { + + @POST("fcm/token") + suspend fun postFcmToken( + @Body request: FcmTokenRequestDto, + ): BaseResponse +} \ No newline at end of file diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/data/repository/FcmTokenRepositoryImpl.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/data/repository/FcmTokenRepositoryImpl.kt new file mode 100644 index 00000000..b4efc98d --- /dev/null +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/data/repository/FcmTokenRepositoryImpl.kt @@ -0,0 +1,14 @@ +package com.teamoffroad.feature.home.data.repository + +import com.teamoffroad.feature.home.data.remote.request.FcmTokenRequestDto +import com.teamoffroad.feature.home.data.remote.service.FcmTokenService +import com.teamoffroad.feature.home.domain.repository.FcmTokenRepository +import javax.inject.Inject + +class FcmTokenRepositoryImpl @Inject constructor( + private val fcmTokenService: FcmTokenService +) : FcmTokenRepository { + override suspend fun postFcmToken(fcmToken: String) { + fcmTokenService.postFcmToken(FcmTokenRequestDto(fcmToken)) + } +} \ No newline at end of file diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/domain/repository/FcmTokenRepository.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/domain/repository/FcmTokenRepository.kt new file mode 100644 index 00000000..7298ea70 --- /dev/null +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/domain/repository/FcmTokenRepository.kt @@ -0,0 +1,5 @@ +package com.teamoffroad.feature.home.domain.repository + +interface FcmTokenRepository { + suspend fun postFcmToken(fcmToken: String) +} \ No newline at end of file diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/domain/usecase/PostFcmTokenUseCase.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/domain/usecase/PostFcmTokenUseCase.kt new file mode 100644 index 00000000..661c8378 --- /dev/null +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/domain/usecase/PostFcmTokenUseCase.kt @@ -0,0 +1,11 @@ +package com.teamoffroad.feature.home.domain.usecase + +import com.teamoffroad.feature.home.domain.repository.FcmTokenRepository + +class PostFcmTokenUseCase( + private val fcmTokenRepository: FcmTokenRepository +) { + suspend operator fun invoke(fcmToken: String) { + return fcmTokenRepository.postFcmToken(fcmToken) + } +} \ No newline at end of file diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/navigation/HomeNavigation.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/navigation/HomeNavigation.kt index 4aaedea0..a3233928 100644 --- a/feature/home/src/main/java/com/teamoffroad/feature/home/navigation/HomeNavigation.kt +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/navigation/HomeNavigation.kt @@ -21,6 +21,7 @@ fun NavController.navigateToHome( @RequiresApi(Build.VERSION_CODES.TIRAMISU) fun NavGraphBuilder.homeNavGraph( navigateToBack: () -> Unit, + navigateToCharacterChatScreen: (Int, String) -> Unit, navigateToGainedCharacter: () -> Unit, ) { composable { backStackEntry -> @@ -29,6 +30,7 @@ fun NavGraphBuilder.homeNavGraph( HomeScreen( category = category, completeQuests = completeQuests, + navigateToCharacterChatScreen = navigateToCharacterChatScreen, navigateToGainedCharacter = navigateToGainedCharacter, ) } diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/HomeScreen.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/HomeScreen.kt index 2dffa015..b08a6175 100644 --- a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/HomeScreen.kt +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/HomeScreen.kt @@ -4,50 +4,81 @@ import android.content.Context import android.os.Build import android.widget.Toast import androidx.annotation.RequiresApi +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Surface +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import com.teamoffroad.core.designsystem.component.StaticAnimationWrapper +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieComposition import com.teamoffroad.core.designsystem.component.actionBarPadding +import com.teamoffroad.core.designsystem.component.clickableWithoutRipple +import com.teamoffroad.core.designsystem.theme.BtnInactive import com.teamoffroad.core.designsystem.theme.HomeGradi1 import com.teamoffroad.core.designsystem.theme.HomeGradi2 import com.teamoffroad.core.designsystem.theme.HomeGradi3 import com.teamoffroad.core.designsystem.theme.HomeGradi4 import com.teamoffroad.core.designsystem.theme.HomeGradi5 import com.teamoffroad.core.designsystem.theme.HomeGradi6 +import com.teamoffroad.core.designsystem.theme.Main2 +import com.teamoffroad.core.designsystem.theme.Main3 import com.teamoffroad.core.designsystem.theme.OffroadTheme +import com.teamoffroad.core.designsystem.theme.Sub +import com.teamoffroad.core.designsystem.theme.Sub4 +import com.teamoffroad.core.designsystem.theme.Sub55 +import com.teamoffroad.core.designsystem.theme.White import com.teamoffroad.feature.home.domain.model.UserQuests import com.teamoffroad.feature.home.presentation.component.CompleteQuestDialog import com.teamoffroad.feature.home.presentation.component.HomeIcons import com.teamoffroad.feature.home.presentation.component.UiState +import com.teamoffroad.feature.home.presentation.component.character.CharacterChat +import com.teamoffroad.feature.home.presentation.component.character.CharacterChatAnimation import com.teamoffroad.feature.home.presentation.component.character.CharacterItem import com.teamoffroad.feature.home.presentation.component.quest.progressbar.CloseCompleteRequest import com.teamoffroad.feature.home.presentation.component.quest.progressbar.RecentQuest import com.teamoffroad.feature.home.presentation.component.user.NicknameText +import com.teamoffroad.feature.home.presentation.component.user.UserChat import com.teamoffroad.feature.home.presentation.model.HomeProgressBarModel import com.teamoffroad.offroad.feature.home.R +import kotlinx.coroutines.launch val homeGradientBackground = Brush.verticalGradient( colors = listOf(HomeGradi1, HomeGradi2, HomeGradi3, HomeGradi4, HomeGradi5, HomeGradi6) @@ -59,52 +90,91 @@ fun HomeScreen( category: String?, completeQuests: List = emptyList(), navigateToGainedCharacter: () -> Unit = {}, + navigateToCharacterChatScreen: (Int, String) -> Unit ) { val context = LocalContext.current val viewModel: HomeViewModel = hiltViewModel() val isCompleteQuestDialogShown = remember { mutableStateOf(false) } - + val isUserChatting = remember { mutableStateOf(false) } + val userChattingText = viewModel.chattingText.collectAsStateWithLifecycle() + val userSendMessage = remember { mutableStateOf("") } + val characterChat = viewModel.getCharacterChat.collectAsStateWithLifecycle() + val isCharacterChatting = viewModel.isCharacterChatting.collectAsStateWithLifecycle() + val isCharacterChattingLoading = + viewModel.isCharacterChattingLoading.collectAsStateWithLifecycle() + val userSendChat = remember { mutableStateOf(false) } + val characterName = viewModel.characterName.collectAsStateWithLifecycle() LaunchedEffect(Unit) { viewModel.updateAutoSignIn() + viewModel.updateFcmToken() viewModel.updateCategory(if (category.isNullOrEmpty()) "NONE" else category) viewModel.getUsersAdventuresInformation(viewModel.category.value) viewModel.getUserQuests() if (completeQuests.isNotEmpty()) isCompleteQuestDialogShown.value = true } - StaticAnimationWrapper { - Surface( - modifier = Modifier - .background(homeGradientBackground) - .padding(bottom = 140.dp) - .navigationBarsPadding(), - color = Color.Transparent - ) { - StaticAnimationWrapper { - Column(modifier = Modifier.fillMaxWidth()) { - UsersAdventuresInformation( - context = context, - modifier = Modifier - .weight(1f) - .actionBarPadding(), - viewModel = viewModel, - navigateToGainedCharacter = navigateToGainedCharacter, - ) - Spacer(modifier = Modifier.padding(top = 12.dp)) - UsersQuestInformation(context, viewModel) - } + Box( + modifier = Modifier + .background(homeGradientBackground) + .fillMaxSize() + .padding(bottom = 180.dp) + ) { + Column(modifier = Modifier.fillMaxSize()) { + UsersAdventuresInformation( + isChatting = isUserChatting, + context = context, + characterName = characterName.value, + modifier = Modifier + .weight(1f) + .actionBarPadding(), + viewModel = viewModel, + navigateToGainedCharacter = navigateToGainedCharacter, + navigateToCharacterChatScreen = navigateToCharacterChatScreen + ) + Spacer(modifier = Modifier.padding(top = 12.dp)) + UsersQuestInformation(context, viewModel) + + } + + if (isUserChatting.value) { + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(bottom = 196.dp) + ) { + UserChat( + isChatting = isUserChatting, + chattingText = userChattingText, + sendMessage = userSendMessage, + userSendChat = userSendChat, + updateCharacterChatting = viewModel::updateCharacterChatting, + updateChattingText = viewModel::updateChattingText, + sendChat = viewModel::sendChat + ) } } + + + if (isCharacterChatting.value) { + CharacterChatAnimation( + isCharacterChatting = isCharacterChatting, + isChatting = isUserChatting, + isCharacterChattingLoading = isCharacterChattingLoading, + answerCharacterChat = userSendChat, + characterName = characterName.value, + characterContent = characterChat.value.characterContent, + navigateToCharacterChatScreen = navigateToCharacterChatScreen + ) + } } if (isCompleteQuestDialogShown.value) { CompleteQuestDialog( isCompleteQuestDialogShown = isCompleteQuestDialogShown, completeQuests = completeQuests, - onClickCancel = { - isCompleteQuestDialogShown.value = false - }, + onClickCancel = { isCompleteQuestDialogShown.value = false }, ) } } @@ -112,10 +182,13 @@ fun HomeScreen( @RequiresApi(Build.VERSION_CODES.TIRAMISU) @Composable private fun UsersAdventuresInformation( + isChatting: MutableState, context: Context, + characterName: String, modifier: Modifier = Modifier, viewModel: HomeViewModel, navigateToGainedCharacter: () -> Unit, + navigateToCharacterChatScreen: (Int, String) -> Unit ) { val adventuresInformationState = viewModel.getUsersAdventuresInformationState.collectAsState(initial = UiState.Loading).value @@ -141,9 +214,12 @@ private fun UsersAdventuresInformation( contentAlignment = Alignment.TopEnd ) { HomeIcons( + isChatting = isChatting, context = context, imageUrl = imageUrl, + characterName = characterName, navigateToGainedCharacter = navigateToGainedCharacter, + navigateToCharacterChatScreen = navigateToCharacterChatScreen ) } @@ -224,7 +300,8 @@ fun HomeScreenPreview() { OffroadTheme { HomeScreen( //padding = PaddingValues(), - category = "NONE" + category = "NONE", + navigateToCharacterChatScreen = { _, _ -> } ) } } diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/HomeUserChatTextField.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/HomeUserChatTextField.kt new file mode 100644 index 00000000..89885837 --- /dev/null +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/HomeUserChatTextField.kt @@ -0,0 +1,218 @@ +package com.teamoffroad.feature.home.presentation + +import android.graphics.Rect +import android.view.ViewTreeObserver +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieComposition +import com.teamoffroad.core.designsystem.component.clickableWithoutRipple +import com.teamoffroad.core.designsystem.theme.BtnInactive +import com.teamoffroad.core.designsystem.theme.Main2 +import com.teamoffroad.core.designsystem.theme.OffroadTheme +import com.teamoffroad.core.designsystem.theme.Transparent +import com.teamoffroad.core.designsystem.theme.White +import com.teamoffroad.offroad.feature.home.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeUserChatTextField( + modifier: Modifier = Modifier, + text: String = "", + sentMessage: String, + isChatting: MutableState, + keyboard: Boolean, + isCharacterChatting: (Boolean) -> Unit, + onValueChange: (String) -> Unit = {}, + onFocusChange: (Boolean) -> Unit = {}, + onSendClick: () -> Unit = {}, +) { + val scrollState = rememberScrollState() + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + val contextView = LocalView.current + var showLottieLoading by remember { mutableStateOf(false) } + + var keyboardVisible by remember { mutableStateOf(keyboard) } + + LaunchedEffect(isChatting) { + if (isChatting.value) { + focusRequester.requestFocus() + } + } + + LaunchedEffect(keyboardVisible) { + if (!keyboardVisible) { + focusManager.clearFocus() + isChatting.value = false + isCharacterChatting(false) + } + } + + DisposableEffect(contextView) { + val rect = Rect() + val listener = ViewTreeObserver.OnGlobalLayoutListener { + contextView.getWindowVisibleDisplayFrame(rect) + val screenHeight = contextView.rootView.height + val keypadHeight = screenHeight - rect.bottom + keyboardVisible = keypadHeight > screenHeight * 0.15 + } + contextView.viewTreeObserver.addOnGlobalLayoutListener(listener) + onDispose { + contextView.viewTreeObserver.removeOnGlobalLayoutListener(listener) + } + } + + AnimatedVisibility( + visible = isChatting.value, + ) { + Box( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .background( + color = White, + shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) + ) + .padding(horizontal = 22.dp, vertical = 4.dp), + ) { + val textFieldHeight = remember { mutableIntStateOf(0) } + + Column{ + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp, bottom = 4.dp) + ) { + Text( + text = stringResource(id = R.string.home_chat_me), + color = Main2, + style = OffroadTheme.typography.textBold, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 6.dp) + ) + Box{ + if (text.isNotBlank()) { + Box( + modifier = Modifier + .size(width = 54.dp, height = 27.dp) + ) { + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(com.teamoffroad.offroad.core.designsystem.R.raw.loading_linear)) + val animationState = animateLottieCompositionAsState(composition, iterations = LottieConstants.IterateForever) + + if (animationState.isAtEnd && animationState.isPlaying) { + LaunchedEffect(Unit) { } + } + + LottieAnimation(composition, animationState.progress) + } + } else { + Text( + text = sentMessage, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .onGloballyPositioned { layoutCoordinates -> + textFieldHeight.intValue = layoutCoordinates.size.height + }, + style = OffroadTheme.typography.textRegular, + maxLines = 2, + ) + } + } + } + + Box { + Box( + modifier = Modifier + .fillMaxWidth() + .height(with(LocalDensity.current) { textFieldHeight.intValue.toDp() }) + .align(Alignment.Center) + .padding(vertical = 10.dp) + .padding(end = 44.dp) + .background( + color = BtnInactive, + shape = RoundedCornerShape(10.dp), + ), + ) + TextField( + value = text, + onValueChange = { onValueChange(it) }, + textStyle = OffroadTheme.typography.textRegular, + modifier = Modifier + .verticalScroll(scrollState) + .padding(end = 44.dp) + .padding(horizontal = 2.dp) + .fillMaxWidth() + .focusRequester(focusRequester) + .onGloballyPositioned { layoutCoordinates -> + textFieldHeight.intValue = layoutCoordinates.size.height + } + .onFocusChanged { focusState -> + onFocusChange(focusState.isFocused) + }, + maxLines = 2, + colors = TextFieldDefaults.textFieldColors( + containerColor = Transparent, + focusedIndicatorColor = Transparent, + unfocusedIndicatorColor = Transparent, + focusedTextColor = Main2, + ), + shape = RoundedCornerShape(12.dp), + ) + Image( + painter = painterResource(id = R.drawable.ic_character_chat_send), + contentDescription = "send", + modifier = Modifier + .padding(end = 2.dp) + .size(36.dp) + .align(Alignment.CenterEnd) + .clickableWithoutRipple { if (text.isNotBlank()) onSendClick() }, + ) + } + } + + } + } +} diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/HomeViewModel.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/HomeViewModel.kt index e38fb4c8..541118b2 100644 --- a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/HomeViewModel.kt +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/HomeViewModel.kt @@ -1,25 +1,46 @@ package com.teamoffroad.feature.home.presentation +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.teamoffroad.characterchat.domain.model.Chat +import com.teamoffroad.characterchat.domain.repository.CharacterChatRepository +import com.teamoffroad.characterchat.presentation.model.ChatModel +import com.teamoffroad.characterchat.presentation.model.ChatType +import com.teamoffroad.characterchat.presentation.model.TimeType +import com.teamoffroad.core.common.domain.model.NotificationEvent +import com.teamoffroad.core.common.domain.repository.DeviceTokenRepository import com.teamoffroad.core.common.domain.usecase.SetAutoSignInUseCase import com.teamoffroad.feature.home.domain.model.Emblem import com.teamoffroad.feature.home.domain.model.UserQuests import com.teamoffroad.feature.home.domain.model.UsersAdventuresInformation import com.teamoffroad.feature.home.domain.repository.UserRepository +import com.teamoffroad.feature.home.domain.usecase.PostFcmTokenUseCase import com.teamoffroad.feature.home.presentation.component.UiState import com.teamoffroad.feature.home.presentation.component.getErrorMessage +import com.teamoffroad.feature.home.presentation.model.CharacterChatModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import java.time.LocalDateTime +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( private val userRepository: UserRepository, + private val characterChatRepository: CharacterChatRepository, private val setAutoSignInUseCase: SetAutoSignInUseCase, + private val deviceTokenRepository: DeviceTokenRepository, + private val fcmTokenUseCase: PostFcmTokenUseCase, ) : ViewModel() { + private val _getCharacterChat = MutableStateFlow(CharacterChatModel("", "")) + val getCharacterChat = _getCharacterChat.asStateFlow() private val _getUsersAdventuresInformationState = MutableStateFlow>( @@ -48,18 +69,69 @@ class HomeViewModel @Inject constructor( private val _getUserQuestsState = MutableStateFlow>(UiState.Loading) val getUserQuestsState = _getUserQuestsState.asStateFlow() + private val _sendChatState = MutableStateFlow>(UiState.Loading) + val sendChatState = _sendChatState.asStateFlow() + private val _circleProgressBar = MutableStateFlow(0f) val circleProgressBar = _circleProgressBar.asStateFlow() private val _linearProgressBar = MutableStateFlow(0f) val linearProgressBar = _linearProgressBar.asStateFlow() + private val _isCharacterChatting: MutableStateFlow = MutableStateFlow(false) + val isCharacterChatting: StateFlow = _isCharacterChatting.asStateFlow() + + private val _isCharacterChattingLoading = MutableStateFlow(false) + val isCharacterChattingLoading = _isCharacterChattingLoading.asStateFlow() + + private val _chattingText: MutableStateFlow = MutableStateFlow("") + val chattingText: StateFlow = _chattingText.asStateFlow() + + private val _characterName = MutableStateFlow("") + val characterName = _characterName.asStateFlow() + + var asd = MutableStateFlow("") + init { + //아까 CharacterChatBroadcastReceiver에서 게시한 브로드캐스트리시버를 여기서 받습니다. + EventBus.getDefault().register(this) + } + + //뷰모델이 삭제될때 이벤트버스도 해제시켜줍니다. + override fun onCleared() { + super.onCleared() + EventBus.getDefault().unregister(this) + } + + //브로드캐스트리시버가 작동할때마다 동작하는 함수(fcm발송 > 앱이 포그라운드에 있고, 타입이 캐릭터채팅이라면 작동) + //그런데 홈화면이 아니고 다른화면에서 이 함수가 호출되면 ui가 활성되있지 않기 때문에 ui작업을 할 수 없습니다.(함수 실행될때 로그는 찍힘) + //그래서 데이터스토어 같은 로컬저장소에 데이터와 캐릭터 채팅확인 여부를 저장해두었다가 + //홈화면에 들어와서 채팅확인 여부가 x라면 알림을 보여주고, 알림을 봤다면 다시 채팅확인 여부가 o로 만드는식으로 하면 될거같습니다. + //그래서 포스트맨으로 fcm쏴보면서 요함수에서 하면 될 것 같습니다. + @Subscribe(threadMode = ThreadMode.MAIN) + fun onNotificationEvent(event: NotificationEvent) { + Log.d("characterChat data", event.toString()) + + // 1. 홈 화면에서 캐릭터한테 메시지가 왔을 때 + val characterName = event.characterName + val characterContent = event.characterContent + if(characterName != null && characterContent != null) { + _getCharacterChat.value = CharacterChatModel(_characterName.value, characterContent) + updateCharacterChatting(true) + } + + } + + fun updateCharacterChatting(state: Boolean) { + _isCharacterChatting.value = state + } + fun getUsersAdventuresInformation(category: String) { viewModelScope.launch { runCatching { userRepository.getUsersAdventuresInformation(category) }.onSuccess { state -> _getUsersAdventuresInformationState.emit(UiState.Success(state)) + _characterName.value = state.characterName updateSelectedEmblem(state.emblemName) updateCharacterImage(state.baseImageUrl) updateMotionImageUrl(state.motionImageUrl) @@ -134,10 +206,56 @@ class HomeViewModel @Inject constructor( } } - fun updateAutoSignIn(){ + fun updateAutoSignIn() { viewModelScope.launch { setAutoSignInUseCase.invoke(true) } } + fun updateChattingText(text: String) { + _chattingText.value = text + } + + fun sendChat() { + val chattingText = chattingText.value + _isCharacterChattingLoading.value = true + + viewModelScope.launch { + runCatching { + val now = LocalDateTime.now() + val userChat = ChatModel( + chatType = ChatType.USER, + text = chattingText, + date = now.toLocalDate(), + time = Triple(TimeType.toTimeType(now.hour), now.hour, now.minute), + ) + characterChatRepository.saveChat(1, chattingText) + }.onSuccess { chat -> + // 보낸 채팅 내용 홈에 보여주어야 함 + _sendChatState.emit(UiState.Success(chat)) + _isCharacterChattingLoading.value = false + + val characterContent = chat.content + if (characterContent != null) { + _getCharacterChat.value = CharacterChatModel(_characterName.value, characterContent) + updateCharacterChatting(true) + } + + }.onFailure { t -> + val errorMessage = getErrorMessage(t) + _sendChatState.emit(UiState.Failure(errorMessage)) + } + } + } + + fun updateFcmToken() { + viewModelScope.launch { + val deviceToken = deviceTokenRepository.deviceToken.first() + if (deviceToken.isBlank()) return@launch + runCatching { + fcmTokenUseCase.invoke(deviceToken) + }.onSuccess { } + .onFailure {} + } + } } \ No newline at end of file diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/HomeIcons.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/HomeIcons.kt index c7a42a9b..916cb5ea 100644 --- a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/HomeIcons.kt +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/HomeIcons.kt @@ -8,21 +8,29 @@ import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi +import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import com.teamoffroad.core.designsystem.component.clickableWithoutRipple +import com.teamoffroad.core.designsystem.theme.ErrorNew import com.teamoffroad.feature.home.presentation.component.upload.uploadImage import com.teamoffroad.offroad.feature.home.R import kotlinx.coroutines.Dispatchers @@ -32,9 +40,12 @@ import kotlinx.coroutines.withContext @RequiresApi(Build.VERSION_CODES.TIRAMISU) @Composable fun HomeIcons( + isChatting: MutableState, context: Context, imageUrl: String, + characterName: String, navigateToGainedCharacter: () -> Unit, + navigateToCharacterChatScreen: (Int, String) -> Unit ) { val scope = rememberCoroutineScope() @@ -59,22 +70,38 @@ fun HomeIcons( } } - Box( - contentAlignment = Alignment.TopEnd, - modifier = Modifier - .aspectRatio(48f / 144f) - .padding(top = 80.dp, end = 20.dp) - ) { - Column { - val characterChatInteractionSource = remember { MutableInteractionSource() } - Image( - painter = painterResource(id = R.drawable.ic_home_chat), - contentDescription = "chat", - modifier = Modifier - .clickableWithoutRipple(interactionSource = characterChatInteractionSource) { - + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 80.dp, end = 20.dp) + .width(48.dp) + ) { + Box { + Image( + painter = painterResource(id = R.drawable.ic_home_chat), + contentDescription = "chat", + modifier = Modifier + .clickableWithoutRipple { + navigateToCharacterChatScreen(-1, characterName) + } + ) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.TopEnd + ) { + Canvas( + modifier = Modifier + .padding(top = 6.dp, end = 6.dp) + .size(8.dp) + ) { + drawCircle( + color = ErrorNew, + style = Fill + ) } - ) + } + } val uploadInteractionSource = remember { MutableInteractionSource() } Image( @@ -107,8 +134,8 @@ fun HomeIcons( } } -private suspend fun showToast(context: Context, message: String) { +suspend fun showToast(context: Context, message: String) { withContext(Dispatchers.Main) { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } -} +} \ No newline at end of file diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/character/CharacterChat.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/character/CharacterChat.kt new file mode 100644 index 00000000..19e83981 --- /dev/null +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/character/CharacterChat.kt @@ -0,0 +1,177 @@ +package com.teamoffroad.feature.home.presentation.component.character + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.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.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieComposition +import com.teamoffroad.core.designsystem.component.clickableWithoutRipple +import com.teamoffroad.core.designsystem.theme.BtnInactive +import com.teamoffroad.core.designsystem.theme.Main2 +import com.teamoffroad.core.designsystem.theme.Main3 +import com.teamoffroad.core.designsystem.theme.OffroadTheme +import com.teamoffroad.core.designsystem.theme.Sub4 +import com.teamoffroad.offroad.feature.home.R + +@Composable +fun CharacterChat( + isChatting: MutableState, + isCharacterChattingLoading: State, + answerCharacterChat: MutableState, + characterName: String, + characterContent: String, + characterTextColor: Color = Sub4, + characterTextStyle: TextStyle = OffroadTheme.typography.textBold, + messageTextColor: Color = Main2, + messageTextStyle: TextStyle = OffroadTheme.typography.textRegular, + backgroundColor: Color = Main3, + borderColor: Color = BtnInactive, + navigateToCharacterChatScreen: (Int, String) -> Unit +) { + val checkCharacterChattingLines = remember { mutableStateOf(false) } + val isExpanded = remember { mutableStateOf(false) } + + val rotationAngle by animateFloatAsState( + targetValue = if (isExpanded.value) 180f else 0f, + animationSpec = tween(durationMillis = 300), label = "" + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = backgroundColor, + shape = RoundedCornerShape(12.dp) + ) + .border( + width = 1.dp, + shape = RoundedCornerShape(12.dp), + color = borderColor + ) + .padding(vertical = 14.dp, horizontal = 18.dp) + .clickableWithoutRipple { + navigateToCharacterChatScreen(-1, characterName) + } + ) { + Column { + Row { + Text( + text = "$characterName : ", + modifier = Modifier, + color = characterTextColor, + style = characterTextStyle + ) + + if (isCharacterChattingLoading.value) { + Box( + modifier = Modifier + .size(width = 54.dp, height = 27.dp) + ) { + val composition by rememberLottieComposition( + LottieCompositionSpec.RawRes( + com.teamoffroad.offroad.core.designsystem.R.raw.loading_linear + ) + ) + val animationState = animateLottieCompositionAsState( + composition, + iterations = LottieConstants.IterateForever + ) + + if (animationState.isAtEnd && animationState.isPlaying) { + LaunchedEffect(Unit) { } + } + + LottieAnimation(composition, animationState.progress) + } + } else { + Text( + text = characterContent, + modifier = Modifier.weight(1f), + color = messageTextColor, + style = messageTextStyle, + onTextLayout = { textLayoutResult -> + checkCharacterChattingLines.value = textLayoutResult.lineCount >= 3 + }, + maxLines = if (isExpanded.value) Int.MAX_VALUE else 2, + overflow = TextOverflow.Ellipsis, + ) + + Image( + painter = painterResource(id = R.drawable.ic_home_accordion), + contentDescription = "accordion down", + modifier = Modifier + .graphicsLayer(rotationX = rotationAngle) + .clickableWithoutRipple { + isExpanded.value = !isExpanded.value + } + ) + } + + } + + if (!answerCharacterChat.value) { + AnswerCharacterChat(isChatting = isChatting) + } + } + + } +} + +@Composable +fun AnswerCharacterChat( + isChatting: MutableState, + backgroundColor: Color = Main2, + textColor: Color = Main3, + textStyle: TextStyle = OffroadTheme.typography.textContents +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp) + ) { + Box(contentAlignment = Alignment.CenterEnd, modifier = Modifier.fillMaxWidth()) { + Text( + text = "답장하기", + modifier = Modifier + .background( + color = backgroundColor, + shape = RoundedCornerShape(8.dp) + ) + .padding(horizontal = 14.dp, vertical = 6.dp) + .clickableWithoutRipple { + isChatting.value = true + }, + color = textColor, + style = textStyle + ) + } + } +} \ No newline at end of file diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/character/CharacterChatAnimation.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/character/CharacterChatAnimation.kt new file mode 100644 index 00000000..f335a71a --- /dev/null +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/character/CharacterChatAnimation.kt @@ -0,0 +1,56 @@ +package com.teamoffroad.feature.home.presentation.component.character + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +@Composable +fun CharacterChatAnimation( + isCharacterChatting: State, + isChatting: MutableState, + isCharacterChattingLoading: State, + answerCharacterChat: MutableState, + characterName: String, + characterContent: String, + navigateToCharacterChatScreen: (Int, String) -> Unit +) { + val offsetY = remember { Animatable(-10.dp.value) } + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(isCharacterChatting) { + coroutineScope.launch { + offsetY.animateTo( + targetValue = 0.dp.value, + animationSpec = tween(durationMillis = 500) + ) + } + } + + Box( + contentAlignment = Alignment.TopCenter, + modifier = Modifier + .offset(y = offsetY.value.dp) + .padding(start = 24.dp, top = 70.dp, end = 24.dp) + ) { + CharacterChat( + isChatting = isChatting, + isCharacterChattingLoading = isCharacterChattingLoading, + answerCharacterChat = answerCharacterChat, + characterName = characterName, + characterContent = characterContent, + navigateToCharacterChatScreen = navigateToCharacterChatScreen + ) + } +} \ No newline at end of file diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/character/CharacterItem.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/character/CharacterItem.kt index 3c776500..184271f0 100644 --- a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/character/CharacterItem.kt +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/character/CharacterItem.kt @@ -73,23 +73,22 @@ class CharacterItem { .fillMaxHeight() .align(Alignment.BottomCenter) ) { - Image( - painter = painterResource(id = R.drawable.img_home_character), - contentDescription = "character", - modifier = Modifier.fillMaxSize() - ) - -// AsyncImage( -// model = ImageRequest.Builder(context) -// .data(baseCharacterImage) -// .decoderFactory(SvgDecoder.Factory()) -// .build(), -// contentDescription = "explorer", -// modifier = Modifier -// .fillMaxSize() -// .align(Alignment.BottomCenter), -// // TODO: placeholder, error일 때 +// Image( +// painter = painterResource(id = R.drawable.img_home_character), +// contentDescription = "character", +// modifier = Modifier.fillMaxSize() // ) + + AsyncImage( + model = ImageRequest.Builder(context) + .data(baseCharacterImage) + .decoderFactory(SvgDecoder.Factory()) + .build(), + contentDescription = "explorer", + modifier = Modifier + .fillMaxSize() + .align(Alignment.BottomCenter), + ) } } else { val composition by rememberLottieComposition( diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/user/UserChat.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/user/UserChat.kt new file mode 100644 index 00000000..50a257a3 --- /dev/null +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/component/user/UserChat.kt @@ -0,0 +1,97 @@ +package com.teamoffroad.feature.home.presentation.component.user + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.teamoffroad.core.designsystem.component.clickableWithoutRipple +import com.teamoffroad.core.designsystem.theme.OffroadTheme +import com.teamoffroad.core.designsystem.theme.Sub +import com.teamoffroad.core.designsystem.theme.Sub55 +import com.teamoffroad.core.designsystem.theme.White +import com.teamoffroad.feature.home.presentation.HomeUserChatTextField +import com.teamoffroad.offroad.feature.home.R + +@Composable +fun UserChat( + isChatting: MutableState, + chattingText: State, + sendMessage: MutableState, + userSendChat: MutableState, + updateCharacterChatting: (Boolean) -> Unit, + updateChattingText: (String) -> Unit, + sendChat: () -> Unit, +) { + Column { + Box( + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(end = 20.dp), + contentAlignment = Alignment.CenterEnd + ) { + FinishChatting(isChatting) + } + } + + HomeUserChatTextField( + text = chattingText.value, + sentMessage = sendMessage.value, + isChatting = isChatting, + keyboard = true, + isCharacterChatting = updateCharacterChatting, + onValueChange = { text -> + updateChattingText(text) + }, + onSendClick = { + userSendChat.value = true // 사용자가 채팅 보냄 + sendMessage.value = chattingText.value // 보낼 메시지 + sendChat() // 서버에 보내기 + updateChattingText("") // 초기화 + } + ) + } +} + +@Composable +fun FinishChatting( + isChatting: MutableState, + backgroundColor: Color = Sub55, + borderColor: Color = Sub +) { + Text( + style = OffroadTheme.typography.subtitle2Semibold, + text = stringResource(id = R.string.home_chat_finish), + modifier = Modifier + .padding(bottom = 8.dp) + .background( + color = backgroundColor, + shape = RoundedCornerShape(20.dp) + ) + .border( + width = 1.dp, + shape = RoundedCornerShape(20.dp), + color = borderColor + ) + .padding(horizontal = 16.dp) + .padding(vertical = 8.dp) + .clickableWithoutRipple { + isChatting.value = false + }, + color = White + ) +} \ No newline at end of file diff --git a/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/model/CharacterChatModel.kt b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/model/CharacterChatModel.kt new file mode 100644 index 00000000..8fda6f2d --- /dev/null +++ b/feature/home/src/main/java/com/teamoffroad/feature/home/presentation/model/CharacterChatModel.kt @@ -0,0 +1,6 @@ +package com.teamoffroad.feature.home.presentation.model + +data class CharacterChatModel( + val characterName: String, + val characterContent: String, +) \ No newline at end of file diff --git a/feature/home/src/main/res/drawable/ic_character_chat_send.xml b/feature/home/src/main/res/drawable/ic_character_chat_send.xml new file mode 100644 index 00000000..d3108306 --- /dev/null +++ b/feature/home/src/main/res/drawable/ic_character_chat_send.xml @@ -0,0 +1,12 @@ + + + + diff --git a/feature/home/src/main/res/drawable/ic_home_accordion.xml b/feature/home/src/main/res/drawable/ic_home_accordion.xml new file mode 100644 index 00000000..c218271b --- /dev/null +++ b/feature/home/src/main/res/drawable/ic_home_accordion.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/feature/home/src/main/res/values/strings.xml b/feature/home/src/main/res/values/strings.xml index 7bf2846c..ee791a42 100644 --- a/feature/home/src/main/res/values/strings.xml +++ b/feature/home/src/main/res/values/strings.xml @@ -12,6 +12,8 @@ 퀘스트 \'%s\' 외 %d개를\n클리어했어요! 마이페이지에서\n보상을 확인해보세요. 확인 - + 나 :   + 채팅 종료 권한이 허용되었습니다. diff --git a/feature/main/build.gradle.kts b/feature/main/build.gradle.kts index 019a27a3..a70c2e55 100644 --- a/feature/main/build.gradle.kts +++ b/feature/main/build.gradle.kts @@ -13,6 +13,7 @@ android { } dependencies { + implementation(project(":core:common")) implementation(project(":feature:auth")) implementation(project(":feature:home")) implementation(project(":feature:explore")) @@ -25,5 +26,7 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.kotlinx.immutable) + implementation(libs.eventbus) + implementation(libs.androidx.constraintlayout.compose) implementation(libs.accompanist.insets) } diff --git a/feature/main/src/main/java/com/teamoffroad/feature/main/CharacterChatBroadcastReceiver.kt b/feature/main/src/main/java/com/teamoffroad/feature/main/CharacterChatBroadcastReceiver.kt new file mode 100644 index 00000000..c8eac734 --- /dev/null +++ b/feature/main/src/main/java/com/teamoffroad/feature/main/CharacterChatBroadcastReceiver.kt @@ -0,0 +1,74 @@ +package com.teamoffroad.feature.main + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Context.RECEIVER_EXPORTED +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.util.Log +import com.teamoffroad.core.common.domain.model.FcmNotificationKey.KEY_BODY +import com.teamoffroad.core.common.domain.model.FcmNotificationKey.KEY_ID +import com.teamoffroad.core.common.domain.model.FcmNotificationKey.KEY_TITLE +import com.teamoffroad.core.common.domain.model.FcmNotificationKey.KEY_TYPE +import com.teamoffroad.core.common.domain.model.NotificationEvent +import org.greenrobot.eventbus.EventBus + +class CharacterChatBroadcastReceiver( + private val navigateToAnnouncement: (id: String) -> Unit, + private val navigateToHome: (characterName: String, characterChatting: String) -> Unit, +) : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + + when (intent.action) { + ACTION_ANNOUNCEMENT_FOREGROUND -> { + val announcementID = intent.getStringExtra(KEY_ID) + if (announcementID != null) { + Log.d("asdasd", announcementID) + navigateToAnnouncement(announcementID) + } + } + + ACTION_CHARACTER_CHAT_FOREGROUND -> { + val characterName = intent.getStringExtra(KEY_TITLE) + val characterChatting = intent.getStringExtra(KEY_BODY) + if (characterChatting != null && characterName != null) { + Log.d("asdasd", characterName) + navigateToHome(characterName, characterChatting) + } + } + } + + //아까 브로드캐스트한 값들을 이벤트버스에 담아서 게시합니다. 홈화면에서 실시간으로 받을 수 있게하기 위해서! + //다음 메인액티비티로 가면 됩니다. + val notificationTitle = intent.getStringExtra(KEY_TITLE) + val notificationBody = intent.getStringExtra(KEY_BODY) + val notificationType = intent.getStringExtra(KEY_TYPE) + + EventBus.getDefault() + .post(NotificationEvent(notificationTitle, notificationBody, notificationType)) + } + + companion object { + const val ACTION_CHARACTER_CHAT_FOREGROUND = + "com.teamoffroad.offroad.app.CHARACTER_CHAT_FOREGROUND" + const val ACTION_ANNOUNCEMENT_FOREGROUND = + "com.teamoffroad.offroad.app.ANNOUNCEMENT_FOREGROUND" + + fun register(context: Context, receiver: CharacterChatBroadcastReceiver) { + val intentFilter = IntentFilter().apply { + addAction(ACTION_CHARACTER_CHAT_FOREGROUND) + addAction(ACTION_ANNOUNCEMENT_FOREGROUND) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(receiver, intentFilter, RECEIVER_EXPORTED) + } else { + context.registerReceiver(receiver, intentFilter) + } + } + + fun unregister(context: Context, receiver: CharacterChatBroadcastReceiver) { + context.unregisterReceiver(receiver) + } + } +} \ No newline at end of file diff --git a/feature/main/src/main/java/com/teamoffroad/feature/main/MainActivity.kt b/feature/main/src/main/java/com/teamoffroad/feature/main/MainActivity.kt index d0e92c88..db53a4fa 100644 --- a/feature/main/src/main/java/com/teamoffroad/feature/main/MainActivity.kt +++ b/feature/main/src/main/java/com/teamoffroad/feature/main/MainActivity.kt @@ -6,46 +6,74 @@ import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.annotation.RequiresApi -import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import com.teamoffroad.core.common.domain.model.FcmNotificationKey.KEY_ID +import com.teamoffroad.core.common.domain.model.FcmNotificationKey.KEY_TYPE import com.teamoffroad.core.designsystem.theme.OffroadTheme import com.teamoffroad.feature.main.component.MainTransparentActionBar import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay @AndroidEntryPoint @RequiresApi(Build.VERSION_CODES.TIRAMISU) class MainActivity : ComponentActivity() { + private val notificationTypeState = mutableStateOf(null) + private val notificationIdState = mutableStateOf(null) + private lateinit var characterBroadcastReceiver: CharacterChatBroadcastReceiver + private val viewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + notificationTypeState.value = intent.getStringExtra(KEY_TYPE) + notificationIdState.value = intent.getStringExtra(KEY_ID) + + //액티비티 생명주기에 따라 브로드캐스터리시버 만들어주기 + //밑에 onDestroy에서 브로드캐스트리시버 해제도 해줍니다. + //이제 홈 뷰모델로 가면됩니다. + characterBroadcastReceiver = CharacterChatBroadcastReceiver( + navigateToAnnouncement = viewModel::navigateToAnnouncement, + navigateToHome = viewModel::navigateToHome, + ) + CharacterChatBroadcastReceiver.register(this, characterBroadcastReceiver) setContent { val navigator: MainNavigator = rememberMainNavigator() + val showSplash = remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + delay(1550) + showSplash.value = false + } MainTransparentActionBar(window) OffroadTheme { - MainScreen( - navigator = navigator, - modifier = Modifier - ) + when (showSplash.value) { + true -> SplashScreen() + false -> MainScreen( + navigator = navigator, + modifier = Modifier, + notificationType = notificationTypeState.value, + notificationId = notificationIdState.value, + viewModel = viewModel + ) + } } } } + override fun onDestroy() { + super.onDestroy() + CharacterChatBroadcastReceiver.unregister(this, characterBroadcastReceiver) + } + companion object { @JvmStatic fun newInstance(context: Context) = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } } -} - -@Preview(showBackground = true) -@Composable -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -fun GreetingPreview() { - OffroadTheme { - MainScreen() - } -} - +} \ No newline at end of file diff --git a/feature/main/src/main/java/com/teamoffroad/feature/main/MainNavigator.kt b/feature/main/src/main/java/com/teamoffroad/feature/main/MainNavigator.kt index 17efa3ad..1933c7cc 100644 --- a/feature/main/src/main/java/com/teamoffroad/feature/main/MainNavigator.kt +++ b/feature/main/src/main/java/com/teamoffroad/feature/main/MainNavigator.kt @@ -22,9 +22,9 @@ import com.teamoffroad.feature.explore.navigation.navigateToExplore import com.teamoffroad.feature.explore.navigation.navigateToPlace import com.teamoffroad.feature.explore.navigation.navigateToQuest import com.teamoffroad.feature.home.navigation.navigateToHome -import com.teamoffroad.feature.main.splash.navigation.navigateToAuth import com.teamoffroad.feature.mypage.navigation.navigateToAnnouncement import com.teamoffroad.feature.mypage.navigation.navigateToAnnouncementDetail +import com.teamoffroad.feature.mypage.navigation.navigateToAuth import com.teamoffroad.feature.mypage.navigation.navigateToAvailableCouponDetail import com.teamoffroad.feature.mypage.navigation.navigateToCharacterDetail import com.teamoffroad.feature.mypage.navigation.navigateToGainedCharacter @@ -40,7 +40,7 @@ internal class MainNavigator( @Composable get() = navController .currentBackStackEntryAsState().value?.destination - val startDestination = Route.Splash + val startDestination = Route.Auth val currentTab: MainNavTab? @Composable get() = MainNavTab.find { tab -> @@ -176,8 +176,8 @@ internal class MainNavigator( navController.navigateToSetting() } - fun navigateToAnnouncement() { - navController.navigateToAnnouncement() + fun navigateToAnnouncement(announcementId: String?) { + navController.navigateToAnnouncement(announcementId) } fun navigateToAnnouncementDetail( diff --git a/feature/main/src/main/java/com/teamoffroad/feature/main/MainScreen.kt b/feature/main/src/main/java/com/teamoffroad/feature/main/MainScreen.kt index 8867ace2..9e0265d8 100644 --- a/feature/main/src/main/java/com/teamoffroad/feature/main/MainScreen.kt +++ b/feature/main/src/main/java/com/teamoffroad/feature/main/MainScreen.kt @@ -4,7 +4,11 @@ import android.os.Build import androidx.annotation.RequiresApi import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import com.teamoffroad.core.common.domain.model.FcmNotificationKey.TYPE_ANNOUNCEMENT import com.teamoffroad.core.common.util.OnBackButtonListener import com.teamoffroad.feature.main.component.MainBottomBar import com.teamoffroad.feature.main.component.MainNavHost @@ -15,7 +19,32 @@ import kotlinx.collections.immutable.toPersistentList internal fun MainScreen( modifier: Modifier = Modifier, navigator: MainNavigator = rememberMainNavigator(), + notificationType: String?, + notificationId: String?, + viewModel: MainViewModel, ) { + val isMainUiState by viewModel.mainUiState.collectAsState() + LaunchedEffect(isMainUiState) { + if (!isMainUiState.characterName.isNullOrBlank() && !isMainUiState.characterChatting.isNullOrBlank()) { + // + } else if (!isMainUiState.announcementId.isNullOrBlank()) { + navigator.navigateToAnnouncement(isMainUiState.announcementId) + } + viewModel.initState() + } + LaunchedEffect(notificationType, notificationId) { + if (!notificationType.isNullOrBlank()) { + if (notificationType == TYPE_ANNOUNCEMENT) + notificationId?.let { + navigator.navigateToMyPage() + navigator.navigateToSetting() + navigator.navigateToAnnouncement(it) + } + else { + navigator.navigateToHome() + } + } + } MainScreenContent( navigator = navigator, modifier = modifier, diff --git a/feature/main/src/main/java/com/teamoffroad/feature/main/MainUiState.kt b/feature/main/src/main/java/com/teamoffroad/feature/main/MainUiState.kt new file mode 100644 index 00000000..921092ab --- /dev/null +++ b/feature/main/src/main/java/com/teamoffroad/feature/main/MainUiState.kt @@ -0,0 +1,7 @@ +package com.teamoffroad.feature.main + +data class MainUiState( + val announcementId: String? = null, + val characterName: String? = null, + val characterChatting: String? = null, +) diff --git a/feature/main/src/main/java/com/teamoffroad/feature/main/MainViewModel.kt b/feature/main/src/main/java/com/teamoffroad/feature/main/MainViewModel.kt new file mode 100644 index 00000000..09fb73b0 --- /dev/null +++ b/feature/main/src/main/java/com/teamoffroad/feature/main/MainViewModel.kt @@ -0,0 +1,34 @@ +package com.teamoffroad.feature.main + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +@HiltViewModel +class MainViewModel @Inject constructor() : ViewModel() { + private val _mainUiState = MutableStateFlow(MainUiState()) + val mainUiState = _mainUiState.asStateFlow() + + fun navigateToAnnouncement(announcementId: String) { + _mainUiState.value = _mainUiState.value.copy( + announcementId = announcementId + ) + } + + fun navigateToHome(characterName: String, characterChatting: String) { + _mainUiState.value = _mainUiState.value.copy( + characterName = characterName, + characterChatting = characterChatting, + ) + } + + fun initState() { + _mainUiState.value = _mainUiState.value.copy( + announcementId = null, + characterName = null, + characterChatting = null, + ) + } +} \ No newline at end of file diff --git a/feature/main/src/main/java/com/teamoffroad/feature/main/splash/SplashScreen.kt b/feature/main/src/main/java/com/teamoffroad/feature/main/SplashScreen.kt similarity index 79% rename from feature/main/src/main/java/com/teamoffroad/feature/main/splash/SplashScreen.kt rename to feature/main/src/main/java/com/teamoffroad/feature/main/SplashScreen.kt index 961ae735..9c1d344b 100644 --- a/feature/main/src/main/java/com/teamoffroad/feature/main/splash/SplashScreen.kt +++ b/feature/main/src/main/java/com/teamoffroad/feature/main/SplashScreen.kt @@ -1,4 +1,4 @@ -package com.teamoffroad.feature.main.splash +package com.teamoffroad.feature.main import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterTransition @@ -23,9 +23,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.flowWithLifecycle import com.teamoffroad.core.designsystem.component.ChangeBottomBarColor import com.teamoffroad.core.designsystem.theme.Main2 import kotlinx.coroutines.delay @@ -33,25 +30,7 @@ import kotlinx.coroutines.launch @Composable fun SplashScreen( - navigateToAuth: () -> Unit, - navigateToHome: () -> Unit, - viewModel: SplashViewModel = hiltViewModel(), ) { - val lifecycleOwner = LocalLifecycleOwner.current - - LaunchedEffect(Unit) { - viewModel.showSplash() - } - LaunchedEffect(viewModel.splashUiState, lifecycleOwner) { - viewModel.splashUiState.flowWithLifecycle(lifecycle = lifecycleOwner.lifecycle) - .collect { splashUiState -> - when (splashUiState) { - is SplashUiState.NavigateHome -> navigateToHome() - is SplashUiState.NavigateLogin -> navigateToAuth() - } - } - } - ChangeBottomBarColor(Main2) var backgroundVisibility by remember { mutableStateOf(true) } val scale = remember { Animatable(1f) } diff --git a/feature/main/src/main/java/com/teamoffroad/feature/main/component/MainNavHost.kt b/feature/main/src/main/java/com/teamoffroad/feature/main/component/MainNavHost.kt index 51abfd46..15966659 100644 --- a/feature/main/src/main/java/com/teamoffroad/feature/main/component/MainNavHost.kt +++ b/feature/main/src/main/java/com/teamoffroad/feature/main/component/MainNavHost.kt @@ -17,7 +17,6 @@ import com.teamoffroad.feature.auth.navigation.authNavGraph import com.teamoffroad.feature.explore.navigation.exploreNavGraph import com.teamoffroad.feature.home.navigation.homeNavGraph import com.teamoffroad.feature.main.MainNavigator -import com.teamoffroad.feature.main.splash.navigation.splashNavGraph import com.teamoffroad.feature.mypage.navigation.myPageNavGraph @RequiresApi(Build.VERSION_CODES.TIRAMISU) @@ -40,12 +39,11 @@ internal fun MainNavHost( exitTransition = { ExitTransition.None }, popExitTransition = { ExitTransition.None }, ) { - splashNavGraph( - navigateToAuth = { navigator.navigateToAuth() }, - navigateToHome = { navigator.navigateToHome() } - ) homeNavGraph( navigateToBack = navigator::popBackStackIfNotMainTabRoute, + navigateToCharacterChatScreen = { id, characterName -> + navigator.navigateToCharacterChat(id, characterName) + }, navigateToGainedCharacter = { navigator.navigateToMyPage().also { navigator.navigateToGainedCharacter() @@ -82,7 +80,9 @@ internal fun MainNavHost( }, navigateToGainedEmblems = navigator::navigateToGainedEmblems, navigateToSetting = navigator::navigateToSetting, - navigateToAnnouncement = navigator::navigateToAnnouncement, + navigateToAnnouncement = { announcementId -> + navigator.navigateToAnnouncement(announcementId) + }, navigateToAnnouncementDetail = navigator::navigateToAnnouncementDetail, navigateToSignIn = navigator::navigateToAuth, navigateToCharacterDetail = navigator::navigateToCharacterDetail, diff --git a/feature/main/src/main/java/com/teamoffroad/feature/main/splash/SplashUiState.kt b/feature/main/src/main/java/com/teamoffroad/feature/main/splash/SplashUiState.kt deleted file mode 100644 index a300ec35..00000000 --- a/feature/main/src/main/java/com/teamoffroad/feature/main/splash/SplashUiState.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.teamoffroad.feature.main.splash - -sealed class SplashUiState { - data object NavigateHome: SplashUiState() - data object NavigateLogin: SplashUiState() -} diff --git a/feature/main/src/main/java/com/teamoffroad/feature/main/splash/SplashViewModel.kt b/feature/main/src/main/java/com/teamoffroad/feature/main/splash/SplashViewModel.kt deleted file mode 100644 index 6c02d952..00000000 --- a/feature/main/src/main/java/com/teamoffroad/feature/main/splash/SplashViewModel.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.teamoffroad.feature.main.splash - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.teamoffroad.core.common.domain.usecase.GetAutoSignInUseCase -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class SplashViewModel @Inject constructor( - private val getAutoSignInUseCase: GetAutoSignInUseCase, -) : ViewModel() { - private val _splashUiState = MutableSharedFlow() - val splashUiState: SharedFlow get() = _splashUiState.asSharedFlow() - - fun showSplash() { - viewModelScope.launch { - delay(1550L) - checkAutoSignIn() - } - } - - private fun checkAutoSignIn() { - viewModelScope.launch { - val isAuthSignIn = getAutoSignInUseCase() - if (isAuthSignIn.first()) { - _splashUiState.emit(SplashUiState.NavigateHome) - } else { - _splashUiState.emit(SplashUiState.NavigateLogin) - } - } - } -} \ No newline at end of file diff --git a/feature/main/src/main/java/com/teamoffroad/feature/main/splash/navigation/SplashNavigation.kt b/feature/main/src/main/java/com/teamoffroad/feature/main/splash/navigation/SplashNavigation.kt deleted file mode 100644 index fff773be..00000000 --- a/feature/main/src/main/java/com/teamoffroad/feature/main/splash/navigation/SplashNavigation.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.teamoffroad.feature.main.splash.navigation - -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import com.teamoffroad.core.navigation.Route -import com.teamoffroad.feature.main.splash.SplashScreen - -fun NavController.navigateToAuth() { - navigate(Route.Auth) -} - -fun NavGraphBuilder.splashNavGraph( - navigateToAuth: () -> Unit, - navigateToHome: () -> Unit, -) { - composable { - SplashScreen( - navigateToAuth, - navigateToHome, - ) - } -} \ No newline at end of file diff --git a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/navigation/MyPageNavigation.kt b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/navigation/MyPageNavigation.kt index 743282e8..14f60070 100644 --- a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/navigation/MyPageNavigation.kt +++ b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/navigation/MyPageNavigation.kt @@ -48,8 +48,10 @@ fun NavController.navigateToSetting() { navigate(MyPageRoute.Setting) } -fun NavController.navigateToAnnouncement() { - navigate(MyPageRoute.Announcement) +fun NavController.navigateToAnnouncement(announcementId: String?) { + navigate( + MyPageRoute.Announcement(announcementId) + ) } fun NavController.navigateToAnnouncementDetail( @@ -74,7 +76,7 @@ fun NavController.navigateToAnnouncementDetail( ) } -fun NavController.navigateToSignIn() { +fun NavController.navigateToAuth() { navigate(Route.Auth) } @@ -88,7 +90,7 @@ fun NavGraphBuilder.myPageNavGraph( navigateToAvailableCouponDetail: (Int, String, String, String, Int) -> Unit, navigateToGainedEmblems: () -> Unit, navigateToSetting: () -> Unit, - navigateToAnnouncement: () -> Unit, + navigateToAnnouncement: (String?) -> Unit, navigateToAnnouncementDetail: (String, String, Boolean, String, Boolean, List, List) -> Unit, navigateToSignIn: () -> Unit, navigateToCharacterDetail: (Int, Boolean) -> Unit, @@ -128,14 +130,16 @@ fun NavGraphBuilder.myPageNavGraph( composable { SettingScreen( - navigateToAnnouncement = navigateToAnnouncement, + navigateToAnnouncement = { navigateToAnnouncement(null) }, navigateToSignIn = navigateToSignIn, navigateToBack = navigateToBack ) } - composable { + composable { backStackEntry -> + val announcementId = backStackEntry.toRoute().announcementId AnnouncementScreen( + announcementId, navigateToAnnouncementDetail, navigateToBack ) diff --git a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/AnnouncementScreen.kt b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/AnnouncementScreen.kt index 380e639b..6c337916 100644 --- a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/AnnouncementScreen.kt +++ b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/AnnouncementScreen.kt @@ -1,5 +1,6 @@ package com.teamoffroad.feature.mypage.presentation +import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -34,14 +35,38 @@ import com.teamoffroad.offroad.feature.mypage.R @Composable internal fun AnnouncementScreen( + announcementId: String?, navigateToAnnouncementDetail: (String, String, Boolean, String, Boolean, List, List) -> Unit, navigateToBack: () -> Unit, viewModel: AnnouncementViewModel = hiltViewModel() ) { val isAnnouncementState by viewModel.announcementUiState.collectAsState() + + //TODO. id 어떻게 초기화? + Log.d("asdsad", announcementId.toString()) + LaunchedEffect(Unit) { viewModel.updateAnnouncement() } + LaunchedEffect(isAnnouncementState) { + if (announcementId != null) { + isAnnouncementState.announcementList.forEach { + if (it.title.trim().equals(announcementId.trim(), ignoreCase = true)) { + Log.d("asdsad", "Navigating to detail for $announcementId") + navigateToAnnouncementDetail( + it.title, + it.content, + it.isImportant, + it.updateAt, + it.hasExternalLinks, + it.externalLinks, + it.externalLinksTitles, + ) + } + } + } + } + Column( modifier = Modifier .navigationPadding() diff --git a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/GainedEmblemsScreen.kt b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/GainedEmblemsScreen.kt index b572729d..8c4e4844 100644 --- a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/GainedEmblemsScreen.kt +++ b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/GainedEmblemsScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.rememberLottieComposition @@ -36,6 +37,7 @@ internal fun GainedEmblemsScreen( viewModel: GainedEmblemsViewModel = hiltViewModel(), ) { val isEmblemState by viewModel.emblemsUiState.collectAsState() + val isLoadMoreEmblemsUiState by viewModel.loadMoreEmblemsUiState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { viewModel.getEmblems() @@ -62,7 +64,11 @@ internal fun GainedEmblemsScreen( ) when (isEmblemState.gainedEmblemsValidateResult) { GainedEmblemsResult.Success -> { - GainedEmblemsItems(isEmblemState = isEmblemState) + GainedEmblemsItems( + isEmblemState = isEmblemState, + onLoadMore = { viewModel.loadMoreEmblems() }, + isLoading = isLoadMoreEmblemsUiState + ) } GainedEmblemsResult.Error -> { diff --git a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/GainedEmblemsViewModel.kt b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/GainedEmblemsViewModel.kt index 976e59bc..6a80d9c3 100644 --- a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/GainedEmblemsViewModel.kt +++ b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/GainedEmblemsViewModel.kt @@ -23,12 +23,19 @@ class GainedEmblemsViewModel @Inject constructor( MutableStateFlow(GainedEmblemsUiState()) val emblemsUiState: StateFlow = _emblemsUiState.asStateFlow() + private val _loadMoreEmblemsUiState: MutableStateFlow = + MutableStateFlow(false) + val loadMoreEmblemsUiState: StateFlow = _loadMoreEmblemsUiState.asStateFlow() + + private var currentCount = 18 + fun getEmblems() { viewModelScope.launch { runCatching { getUserEmblemListUseCase() }.onSuccess { result -> - val emblems = result.getOrNull()?.toImmutableList() ?: persistentListOf() + val emblems = + result.getOrNull()?.take(currentCount)?.toImmutableList() ?: persistentListOf() _emblemsUiState.value = _emblemsUiState.value.copy( emblemList = emblems, gainedEmblemsValidateResult = GainedEmblemsResult.Success, @@ -39,4 +46,25 @@ class GainedEmblemsViewModel @Inject constructor( } } } + + fun loadMoreEmblems() { + viewModelScope.launch { + if (_loadMoreEmblemsUiState.value) return@launch + _loadMoreEmblemsUiState.value = true + runCatching { + getUserEmblemListUseCase() + }.onSuccess { result -> + val allEmblems = result.getOrNull() ?: persistentListOf() + val newItems = allEmblems.drop(currentCount).take(10).toImmutableList() + + _emblemsUiState.value = _emblemsUiState.value.copy( + emblemList = (_emblemsUiState.value.emblemList + newItems).toImmutableList() + ) + currentCount += 10 + }.onFailure { + }.also { + _loadMoreEmblemsUiState.value = false + } + } + } } diff --git a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/component/GainedEmblemsItems.kt b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/component/GainedEmblemsItems.kt index 034bbcc4..77d586d1 100644 --- a/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/component/GainedEmblemsItems.kt +++ b/feature/mypage/src/main/java/com/teamoffroad/feature/mypage/presentation/component/GainedEmblemsItems.kt @@ -2,26 +2,55 @@ package com.teamoffroad.feature.mypage.presentation.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.rememberLottieComposition import com.teamoffroad.core.designsystem.theme.ListBg import com.teamoffroad.feature.mypage.presentation.model.GainedEmblemsUiState +import kotlinx.coroutines.flow.collectLatest @Composable fun GainedEmblemsItems( modifier: Modifier = Modifier, isEmblemState: GainedEmblemsUiState, + onLoadMore: () -> Unit, + isLoading: Boolean, ) { + val listState = rememberLazyListState() + + LaunchedEffect(listState) { + snapshotFlow { + val isAtEnd = + listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == listState.layoutInfo.totalItemsCount - 1 + isAtEnd + }.collectLatest { isAtEnd -> + if (isAtEnd) { + onLoadMore() + } + } + } + LazyColumn( modifier = modifier .fillMaxSize() .background(ListBg), + state = listState, verticalArrangement = Arrangement.spacedBy(16.dp) ) { item { @@ -35,8 +64,31 @@ fun GainedEmblemsItems( isLock = it.isLock ) } + if (isLoading) { + item { + OnMoreEmblemsLoading() + } + } item { - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(100.dp)) } } +} + +@Composable +fun OnMoreEmblemsLoading() { + Column( + modifier = Modifier + .background(ListBg) + .fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(com.teamoffroad.offroad.core.designsystem.R.raw.loading_circle)) + LottieAnimation( + modifier = Modifier + .size(50.dp), + composition = composition, + ) + } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ac476aed..0241b4c2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -74,6 +74,10 @@ naverMapSdk = "3.18.0" googlePlayService = "21.1.0" googleAccompanistPermissions = "0.32.0" accompanistInsets = "0.31.5-beta" +googleServices = "4.4.2" + +# Firebase +firebase_bom = "33.5.1" # Kakao kakao = "2.20.6" @@ -81,6 +85,10 @@ kakao = "2.20.6" # Process phoenix processPhoenix = "3.0.0" +# Event Bus +eventbus = "3.2.0" + + [libraries] # Core Libraries androidsvg-aar = { module = "com.caverock:androidsvg-aar", version.ref = "androidsvgAar" } @@ -171,12 +179,23 @@ google-accompanist-permissions = { group = "com.google.accompanist", name = "acc google-play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "googlePlayService" } accompanist-insets = { module = "com.google.accompanist:accompanist-insets", version.ref = "accompanistInsets" } +# Firebase +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebase_bom" } +firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" } +firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx" } +firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx" } +firebase-remoteConfig = { group = "com.google.firebase", name = "firebase-config-ktx" } +firebase-database = { group = "com.google.firebase", name = "firebase-database-ktx" } + # Kakao kakao-user = { module = "com.kakao.sdk:v2-user", version.ref = "kakao" } # Process Phoenix process-phoenix = { group = "com.jakewharton", name = "process-phoenix", version.ref = "processPhoenix" } +# Event Bus +eventbus = { group = "org.greenrobot", name = "eventbus", version.ref = "eventbus" } + [plugins] # Android Application Plugin android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } @@ -198,6 +217,9 @@ android-library = { id = "com.android.library", version.ref = "androidGradlePlug # Kotlin Symbol Processing Plugin ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +# Google +google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } + [bundles] # Offroad Map Libraries offroad-map = [ @@ -207,3 +229,11 @@ offroad-map = [ "google-accompanist-permissions", ] + +firebase = [ + "firebase-analytics", + "firebase-database", + "firebase-messaging", + "firebase-remoteConfig" +] +