diff --git a/.github/workflows/backend-dev-ci-cd.yml b/.github/workflows/backend-dev-ci-cd.yml
index 255ca93dd..1f79658b3 100644
--- a/.github/workflows/backend-dev-ci-cd.yml
+++ b/.github/workflows/backend-dev-ci-cd.yml
@@ -44,6 +44,10 @@ jobs:
- name: Set Application yml for dev
run: |
echo "${{ secrets.APPLICATION_PROPERTIES_DEV }}" > src/main/resources/application.properties
+ mkdir -p src/main/resources/fcm
+ echo '${{ secrets.FCM_SECRET_KEY }}' > src/main/resources/fcm/chongdaemarket-fcm-key.json
+ mkdir -p src/test/resources/fcm
+ echo '${{ secrets.FCM_SECRET_KEY }}' > src/test/resources/fcm/chongdaemarket-fcm-key.json
working-directory: ./backend
- name: Build with Gradle Wrapper
@@ -57,16 +61,24 @@ jobs:
docker tag ${{ secrets.BE_DOCKER_IMAGE_NAME_DEV }} ${{ secrets.BE_DOCKERHUB_USERNAME }}/${{ secrets.BE_DOCKER_IMAGE_NAME_DEV }}:${GITHUB_SHA::7}
docker push ${{ secrets.BE_DOCKERHUB_USERNAME }}/${{ secrets.BE_DOCKER_IMAGE_NAME_DEV }}:${GITHUB_SHA::7}
- deploy:
+ deploy-new-container:
needs: build-and-test
runs-on: [ self-hosted, dev ]
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Deploy new container
+ run: |
+ bash launch_next_container.sh ${GITHUB_SHA::7} dev ${{ secrets.BE_DOCKERHUB_USERNAME }} ${{ secrets.BE_DOCKER_IMAGE_NAME_DEV }}
+ working-directory: backend/deploy
+
+ switch-new-container:
+ needs: deploy-new-container
+ runs-on: [ self-hosted, dev ]
steps:
- - name: Pull Image And Restart Container
+ - name: Switch from old to new container
run: |
- docker login -u ${{ secrets.BE_DOCKERHUB_USERNAME }} -p ${{ secrets.BE_DOCKERHUB_PASSWORD }}
- docker stop ${{ secrets.BE_DOCKER_CONTAINER_NAME }} | true
- docker rm ${{ secrets.BE_DOCKER_CONTAINER_NAME }} | true
- docker image prune -a -f
- docker pull ${{ secrets.BE_DOCKERHUB_USERNAME }}/${{ secrets.BE_DOCKER_IMAGE_NAME_DEV }}:${GITHUB_SHA::7}
- docker run --name ${{ secrets.BE_DOCKER_CONTAINER_NAME }} -d -p 80:8080 -v /logs:/logs -e SPRING_PROFILES_ACTIVE=dev ${{ secrets.BE_DOCKERHUB_USERNAME }}/${{ secrets.BE_DOCKER_IMAGE_NAME_DEV }}:${GITHUB_SHA::7}
+ bash switch_blue_green_container.sh
+ working-directory: backend/deploy
diff --git a/.github/workflows/backend-prod-ci-cd.yml b/.github/workflows/backend-prod-ci-cd.yml
index 8c605ad52..10f6faacb 100644
--- a/.github/workflows/backend-prod-ci-cd.yml
+++ b/.github/workflows/backend-prod-ci-cd.yml
@@ -44,6 +44,10 @@ jobs:
- name: Set Application yml for prod
run: |
echo "${{ secrets.APPLICATION_PROPERTIES_PROD }}" > src/main/resources/application.properties
+ mkdir -p src/main/resources/fcm
+ echo '${{ secrets.FCM_SECRET_KEY }}' > src/main/resources/fcm/chongdaemarket-fcm-key.json
+ mkdir -p src/test/resources/fcm
+ echo '${{ secrets.FCM_SECRET_KEY }}' > src/test/resources/fcm/chongdaemarket-fcm-key.json
working-directory: ./backend
- name: Build with Gradle Wrapper
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index b7b8cc5c9..1e147bdb1 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -29,11 +29,12 @@ android {
applicationId = "com.zzang.chongdae"
minSdk = 26
targetSdk = 34
- versionCode = 6
- versionName = "1.1.4"
+ versionCode = 7
+ versionName = "1.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- testInstrumentationRunnerArguments["runnerBuilder"] = "de.mannodermaus.junit5.AndroidJUnit5Builder"
+ testInstrumentationRunnerArguments["runnerBuilder"] =
+ "de.mannodermaus.junit5.AndroidJUnit5Builder"
vectorDrawables {
useSupportLibrary = true
}
@@ -154,6 +155,7 @@ dependencies {
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.analytics)
implementation(libs.firebase.crashlytics)
+ implementation(libs.firebase.message)
// 카카오 로그인
implementation(libs.kakao.sdk)
@@ -168,6 +170,9 @@ dependencies {
// Hilt
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
+
+ // Skeleton-UI
+ implementation(libs.shimmer)
}
kapt {
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index d411e8557..3f4cc640f 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -23,6 +23,14 @@
android:usesCleartextTraffic="true"
tools:targetApi="33">
+
+
+
+
+
+
diff --git a/android/app/src/main/java/com/zzang/chongdae/auth/api/AuthApiService.kt b/android/app/src/main/java/com/zzang/chongdae/auth/api/AuthApiService.kt
index f34018e13..b70678bd9 100644
--- a/android/app/src/main/java/com/zzang/chongdae/auth/api/AuthApiService.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/auth/api/AuthApiService.kt
@@ -1,6 +1,6 @@
package com.zzang.chongdae.auth.api
-import com.zzang.chongdae.auth.dto.request.AccessTokenRequest
+import com.zzang.chongdae.auth.dto.request.TokensRequest
import com.zzang.chongdae.auth.dto.response.MemberResponse
import retrofit2.Response
import retrofit2.http.Body
@@ -9,7 +9,7 @@ import retrofit2.http.POST
interface AuthApiService {
@POST("/auth/login/kakao")
suspend fun postLogin(
- @Body accessToken: AccessTokenRequest,
+ @Body tokensRequest: TokensRequest,
): Response
@POST("/auth/refresh")
diff --git a/android/app/src/main/java/com/zzang/chongdae/auth/dto/request/AccessTokenRequest.kt b/android/app/src/main/java/com/zzang/chongdae/auth/dto/request/TokensRequest.kt
similarity index 72%
rename from android/app/src/main/java/com/zzang/chongdae/auth/dto/request/AccessTokenRequest.kt
rename to android/app/src/main/java/com/zzang/chongdae/auth/dto/request/TokensRequest.kt
index bc8c5e5a4..39289ef70 100644
--- a/android/app/src/main/java/com/zzang/chongdae/auth/dto/request/AccessTokenRequest.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/auth/dto/request/TokensRequest.kt
@@ -4,6 +4,7 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
-data class AccessTokenRequest(
+data class TokensRequest(
@SerialName("accessToken") val accessToken: String,
+ @SerialName("fcmToken") val fcmToken: String,
)
diff --git a/android/app/src/main/java/com/zzang/chongdae/auth/repository/AuthRepository.kt b/android/app/src/main/java/com/zzang/chongdae/auth/repository/AuthRepository.kt
index 697b03357..f0011b172 100644
--- a/android/app/src/main/java/com/zzang/chongdae/auth/repository/AuthRepository.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/auth/repository/AuthRepository.kt
@@ -5,7 +5,10 @@ import com.zzang.chongdae.common.handler.DataError
import com.zzang.chongdae.common.handler.Result
interface AuthRepository {
- suspend fun saveLogin(accessToken: String): Result
+ suspend fun saveLogin(
+ accessToken: String,
+ fcmToken: String,
+ ): Result
suspend fun saveRefresh(): Result
}
diff --git a/android/app/src/main/java/com/zzang/chongdae/auth/repository/AuthRepositoryImpl.kt b/android/app/src/main/java/com/zzang/chongdae/auth/repository/AuthRepositoryImpl.kt
index 9977c7a8a..5b47307aa 100644
--- a/android/app/src/main/java/com/zzang/chongdae/auth/repository/AuthRepositoryImpl.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/auth/repository/AuthRepositoryImpl.kt
@@ -1,6 +1,6 @@
package com.zzang.chongdae.auth.repository
-import com.zzang.chongdae.auth.dto.request.AccessTokenRequest
+import com.zzang.chongdae.auth.dto.request.TokensRequest
import com.zzang.chongdae.auth.mapper.toDomain
import com.zzang.chongdae.auth.model.Member
import com.zzang.chongdae.auth.source.AuthRemoteDataSource
@@ -14,9 +14,12 @@ class AuthRepositoryImpl
constructor(
@AuthDataSourceQualifier private val authRemoteDataSource: AuthRemoteDataSource,
) : AuthRepository {
- override suspend fun saveLogin(accessToken: String): Result {
+ override suspend fun saveLogin(
+ accessToken: String,
+ fcmToken: String,
+ ): Result {
return authRemoteDataSource.saveLogin(
- accessTokenRequest = AccessTokenRequest(accessToken),
+ tokensRequest = TokensRequest(accessToken, fcmToken),
).map { it.toDomain() }
}
diff --git a/android/app/src/main/java/com/zzang/chongdae/auth/source/AuthRemoteDataSource.kt b/android/app/src/main/java/com/zzang/chongdae/auth/source/AuthRemoteDataSource.kt
index 8bdcc7403..278c71ab5 100644
--- a/android/app/src/main/java/com/zzang/chongdae/auth/source/AuthRemoteDataSource.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/auth/source/AuthRemoteDataSource.kt
@@ -1,12 +1,12 @@
package com.zzang.chongdae.auth.source
-import com.zzang.chongdae.auth.dto.request.AccessTokenRequest
+import com.zzang.chongdae.auth.dto.request.TokensRequest
import com.zzang.chongdae.auth.dto.response.MemberResponse
import com.zzang.chongdae.common.handler.DataError
import com.zzang.chongdae.common.handler.Result
interface AuthRemoteDataSource {
- suspend fun saveLogin(accessTokenRequest: AccessTokenRequest): Result
+ suspend fun saveLogin(tokensRequest: TokensRequest): Result
suspend fun saveRefresh(): Result
}
diff --git a/android/app/src/main/java/com/zzang/chongdae/auth/source/AuthRemoteDataSourceImpl.kt b/android/app/src/main/java/com/zzang/chongdae/auth/source/AuthRemoteDataSourceImpl.kt
index fd97158dd..b6172991a 100644
--- a/android/app/src/main/java/com/zzang/chongdae/auth/source/AuthRemoteDataSourceImpl.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/auth/source/AuthRemoteDataSourceImpl.kt
@@ -1,7 +1,7 @@
package com.zzang.chongdae.auth.source
import com.zzang.chongdae.auth.api.AuthApiService
-import com.zzang.chongdae.auth.dto.request.AccessTokenRequest
+import com.zzang.chongdae.auth.dto.request.TokensRequest
import com.zzang.chongdae.auth.dto.response.MemberResponse
import com.zzang.chongdae.common.handler.DataError
import com.zzang.chongdae.common.handler.Result
@@ -14,8 +14,8 @@ class AuthRemoteDataSourceImpl
constructor(
@AuthApiServiceQualifier private val service: AuthApiService,
) : AuthRemoteDataSource {
- override suspend fun saveLogin(accessTokenRequest: AccessTokenRequest): Result {
- return safeApiCall { service.postLogin(accessTokenRequest) }
+ override suspend fun saveLogin(tokensRequest: TokensRequest): Result {
+ return safeApiCall { service.postLogin(tokensRequest) }
}
override suspend fun saveRefresh(): Result {
diff --git a/android/app/src/main/java/com/zzang/chongdae/common/datastore/UserPreferencesDataStore.kt b/android/app/src/main/java/com/zzang/chongdae/common/datastore/UserPreferencesDataStore.kt
index 0d257498f..8111c7c4e 100644
--- a/android/app/src/main/java/com/zzang/chongdae/common/datastore/UserPreferencesDataStore.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/common/datastore/UserPreferencesDataStore.kt
@@ -2,7 +2,9 @@ package com.zzang.chongdae.common.datastore
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.zzang.chongdae.di.annotations.DataStoreQualifier
@@ -35,6 +37,21 @@ class UserPreferencesDataStore
preferences[REFRESH_TOKEN_KEY]
}
+ val fcmTokenFlow: Flow =
+ dataStore.data.map { preferences ->
+ preferences[FCM_TOKEN_KEY]
+ }
+
+ val notificationActivateFlow: Flow =
+ dataStore.data.map { preferences ->
+ preferences[NOTIFICATION_ACTIVATE_KEY] ?: DEFAULT_NOTIFICATION_ACTIVATE
+ }
+
+ val notificationImportanceFlow: Flow =
+ dataStore.data.map { preferences ->
+ preferences[NOTIFICATION_IMPORTANCE_KEY] ?: DEFAULT_NOTIFICATION_IMPORTANCE
+ }
+
suspend fun saveMember(
memberId: Long,
nickName: String,
@@ -45,7 +62,7 @@ class UserPreferencesDataStore
}
}
- suspend fun saveTokens(
+ suspend fun saveAccountTokens(
accessToken: String,
refreshToken: String,
) {
@@ -55,6 +72,24 @@ class UserPreferencesDataStore
}
}
+ suspend fun saveFcmToken(fcmToken: String) {
+ dataStore.edit { preferences ->
+ preferences[FCM_TOKEN_KEY] = fcmToken
+ }
+ }
+
+ suspend fun setNotificationActivate(activate: Boolean) {
+ dataStore.edit { preferences ->
+ preferences[NOTIFICATION_ACTIVATE_KEY] = activate
+ }
+ }
+
+ suspend fun setNotificationImportance(importance: Int) {
+ dataStore.edit { preferences ->
+ preferences[NOTIFICATION_IMPORTANCE_KEY] = importance
+ }
+ }
+
suspend fun removeAllData() {
dataStore.edit { preferences ->
preferences.clear()
@@ -62,9 +97,14 @@ class UserPreferencesDataStore
}
companion object {
- val MEMBER_ID_KEY = longPreferencesKey("member_id_key")
- val NICKNAME_KEY = stringPreferencesKey("nickname_key")
- val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token_key")
- val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token_key")
+ private val MEMBER_ID_KEY = longPreferencesKey("member_id_key")
+ private val NICKNAME_KEY = stringPreferencesKey("nickname_key")
+ private val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token_key")
+ private val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token_key")
+ private val FCM_TOKEN_KEY = stringPreferencesKey("fcm_token_key")
+ private val NOTIFICATION_ACTIVATE_KEY = booleanPreferencesKey("notification_activate_key")
+ private val NOTIFICATION_IMPORTANCE_KEY = intPreferencesKey("notification_importance_key")
+ private const val DEFAULT_NOTIFICATION_ACTIVATE = true
+ private const val DEFAULT_NOTIFICATION_IMPORTANCE = 4
}
}
diff --git a/android/app/src/main/java/com/zzang/chongdae/common/firebase/fcm/ChongdaeFirebaseMessagingService.kt b/android/app/src/main/java/com/zzang/chongdae/common/firebase/fcm/ChongdaeFirebaseMessagingService.kt
new file mode 100644
index 000000000..a935b4d22
--- /dev/null
+++ b/android/app/src/main/java/com/zzang/chongdae/common/firebase/fcm/ChongdaeFirebaseMessagingService.kt
@@ -0,0 +1,144 @@
+package com.zzang.chongdae.common.firebase.fcm
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Intent
+import androidx.core.app.NotificationCompat
+import com.google.firebase.messaging.FirebaseMessagingService
+import com.google.firebase.messaging.RemoteMessage
+import com.zzang.chongdae.R
+import com.zzang.chongdae.common.datastore.UserPreferencesDataStore
+import com.zzang.chongdae.presentation.view.commentdetail.CommentDetailActivity
+import com.zzang.chongdae.presentation.view.commentdetail.CommentDetailActivity.Companion.EXTRA_OFFERING_ID_KEY
+import com.zzang.chongdae.presentation.view.main.MainActivity
+import com.zzang.chongdae.presentation.view.main.MainActivity.Companion.NOTIFICATION_FLAG_KEY
+import com.zzang.chongdae.presentation.view.main.MainActivity.Companion.NOTIFICATION_OFFERING_ID_KEY
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class ChongdaeFirebaseMessagingService : FirebaseMessagingService() {
+ @Inject
+ lateinit var dataStore: UserPreferencesDataStore
+
+ private lateinit var notificationImportance: NotificationImportance
+
+ override fun onMessageReceived(remoteMessage: RemoteMessage) {
+ super.onMessageReceived(remoteMessage)
+ CoroutineScope(Dispatchers.IO).launch {
+ if (isLoggedOut()) return@launch
+ if (isNotificationInactivate()) return@launch
+ setNotificationImportance()
+ notifyFromRemoteMessage(remoteMessage)
+ }
+ }
+
+ private suspend fun isLoggedOut(): Boolean {
+ return dataStore.accessTokenFlow.first() == null
+ }
+
+ private suspend fun isNotificationInactivate(): Boolean {
+ return !dataStore.notificationActivateFlow.first()
+ }
+
+ private suspend fun setNotificationImportance() {
+ when (dataStore.notificationImportanceFlow.first()) {
+ NotificationManager.IMPORTANCE_DEFAULT -> notificationImportance = NotificationImportance.Default
+ NotificationManager.IMPORTANCE_HIGH -> notificationImportance = NotificationImportance.High
+ }
+ }
+
+ private fun notifyFromRemoteMessage(remoteMessage: RemoteMessage) {
+ if (remoteMessage.data.isNotEmpty()) {
+ val title = remoteMessage.data[TITLE_KEY]
+ val messageBody = remoteMessage.data[BODY_KEY]
+ val notificationType = remoteMessage.data[NOTIFICATION_TYPE_KEY]
+ val offeringId = remoteMessage.data[OFFERING_ID_KEY]
+ displayNotification(title, messageBody, notificationType, offeringId)
+ }
+ }
+
+ private fun displayNotification(
+ title: String?,
+ body: String?,
+ notificationType: String?,
+ offeringId: String?,
+ ) {
+ val pendingIntent = generatePendingIntent(notificationType, offeringId)
+ val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+ createNotificationChannel(notificationManager)
+ val uniqueNotificationId = System.currentTimeMillis().toInt()
+ val notificationBuilder = buildNotification(title, body, pendingIntent)
+ notificationManager.notify(uniqueNotificationId, notificationBuilder.build())
+ }
+
+ private fun generatePendingIntent(
+ type: String?,
+ offeringId: String?,
+ ): PendingIntent? {
+ val notificationType = NotificationType.of(type)
+ val parsedOfferingId = offeringId?.toLong() ?: error("알림 데이터에 offeringId가 없음")
+ val intent = intentOf(notificationType, parsedOfferingId)
+ val uniqueRequestCode = System.currentTimeMillis().toInt()
+ return PendingIntent.getActivity(
+ this,
+ uniqueRequestCode,
+ intent,
+ PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+
+ private fun intentOf(
+ notificationType: NotificationType,
+ parsedOfferingId: Long,
+ ): Intent {
+ val intent: Intent
+ when (notificationType) {
+ NotificationType.COMMENT_DETAIL -> {
+ intent = Intent(this, CommentDetailActivity::class.java)
+ intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
+ intent.putExtra(EXTRA_OFFERING_ID_KEY, parsedOfferingId)
+ }
+
+ NotificationType.OFFERING_DETAIL -> {
+ intent = Intent(this, MainActivity::class.java)
+ intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
+ intent.putExtra(NOTIFICATION_OFFERING_ID_KEY, parsedOfferingId)
+ intent.putExtra(NOTIFICATION_FLAG_KEY, true)
+ }
+ }
+ return intent
+ }
+
+ private fun createNotificationChannel(notificationManager: NotificationManager) {
+ notificationImportance.apply {
+ val channel = NotificationChannel(channelId, channelName, importance)
+ notificationManager.createNotificationChannel(channel)
+ }
+ }
+
+ private fun buildNotification(
+ title: String?,
+ messageBody: String?,
+ pendingIntent: PendingIntent?,
+ ): NotificationCompat.Builder {
+ return NotificationCompat.Builder(this, notificationImportance.channelId)
+ .setSmallIcon(R.mipmap.ic_launcher)
+ .setContentTitle(title)
+ .setContentText(messageBody)
+ .setAutoCancel(true)
+ .setContentIntent(pendingIntent)
+ }
+
+ companion object {
+ private const val TITLE_KEY = "title"
+ private const val BODY_KEY = "body"
+ private const val NOTIFICATION_TYPE_KEY = "type"
+ private const val OFFERING_ID_KEY = "offering_id"
+ }
+}
diff --git a/android/app/src/main/java/com/zzang/chongdae/common/firebase/fcm/NotificationImportance.kt b/android/app/src/main/java/com/zzang/chongdae/common/firebase/fcm/NotificationImportance.kt
new file mode 100644
index 000000000..f1a43a6a8
--- /dev/null
+++ b/android/app/src/main/java/com/zzang/chongdae/common/firebase/fcm/NotificationImportance.kt
@@ -0,0 +1,28 @@
+package com.zzang.chongdae.common.firebase.fcm
+
+import android.app.NotificationManager
+
+sealed interface NotificationImportance {
+ val importance: Int
+ val channelId: String
+ val channelName: String
+
+ data object Default : NotificationImportance {
+ override val importance: Int = NotificationManager.IMPORTANCE_DEFAULT
+ override val channelId: String = CHANNEL_ID_IMPORTANCE_DEFAULT
+ override val channelName: String = CHANNEL_NAME_IMPORTANCE_DEFAULT
+ }
+
+ data object High : NotificationImportance {
+ override val importance: Int = NotificationManager.IMPORTANCE_HIGH
+ override val channelId: String = CHANNEL_ID_IMPORTANCE_HIGH
+ override val channelName: String = CHANNEL_NAME_IMPORTANCE_HIGH
+ }
+
+ companion object {
+ private const val CHANNEL_ID_IMPORTANCE_DEFAULT = "channel_importance_default"
+ private const val CHANNEL_NAME_IMPORTANCE_DEFAULT = "Default Channel"
+ private const val CHANNEL_ID_IMPORTANCE_HIGH = "channel_importance_high"
+ private const val CHANNEL_NAME_IMPORTANCE_HIGH = "High Channel"
+ }
+}
diff --git a/android/app/src/main/java/com/zzang/chongdae/common/firebase/fcm/NotificationType.kt b/android/app/src/main/java/com/zzang/chongdae/common/firebase/fcm/NotificationType.kt
new file mode 100644
index 000000000..aa7ce51a7
--- /dev/null
+++ b/android/app/src/main/java/com/zzang/chongdae/common/firebase/fcm/NotificationType.kt
@@ -0,0 +1,20 @@
+package com.zzang.chongdae.common.firebase.fcm
+
+enum class NotificationType {
+ COMMENT_DETAIL,
+ OFFERING_DETAIL,
+ ;
+
+ companion object {
+ private const val COMMENT_DETAIL_KEY = "comment_detail"
+ private const val OFFERING_DETAIL_KEY = "offering_detail"
+
+ fun of(key: String?): NotificationType {
+ return when (key) {
+ COMMENT_DETAIL_KEY -> COMMENT_DETAIL
+ OFFERING_DETAIL_KEY -> OFFERING_DETAIL
+ else -> error("알림 타입이 유효하지 않음")
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/util/TokensCookieJar.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/util/TokensCookieJar.kt
index 4c94fb81d..bc7446249 100644
--- a/android/app/src/main/java/com/zzang/chongdae/data/remote/util/TokensCookieJar.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/util/TokensCookieJar.kt
@@ -36,7 +36,7 @@ class TokensCookieJar(private val userPreferencesDataStore: UserPreferencesDataS
val accessToken = cookies.first { it.name == ACCESS_TOKEN_NAME }.value
val refreshToken = cookies.first { it.name == REFRESH_TOKEN_NAME }.value
CoroutineScope(Dispatchers.IO).launch {
- userPreferencesDataStore.saveTokens(accessToken, refreshToken)
+ userPreferencesDataStore.saveAccountTokens(accessToken, refreshToken)
}
}
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/util/LocalDateTimeToString.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/util/LocalDateTimeToString.kt
new file mode 100644
index 000000000..15304c46d
--- /dev/null
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/util/LocalDateTimeToString.kt
@@ -0,0 +1,14 @@
+package com.zzang.chongdae.presentation.util
+
+import java.time.LocalDate
+import java.time.LocalTime
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+
+fun LocalDate.toFormattedDate(): String {
+ return this.format(DateTimeFormatter.ofPattern("yyyy년 M월 d일"))
+}
+
+fun LocalTime.toFormattedTime(): String {
+ return this.format(DateTimeFormatter.ofPattern(("a h:mm"), Locale.KOREAN))
+}
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailActivity.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailActivity.kt
index 8d5056e87..08b0eefe0 100644
--- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailActivity.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailActivity.kt
@@ -110,7 +110,7 @@ class CommentDetailActivity : AppCompatActivity(), OnUpdateStatusClickListener {
private fun observeComments() {
viewModel.comments.observe(this) { comments ->
- commentAdapter.submitComments(comments)
+ commentAdapter.submitList(comments)
binding.rvComments.doOnPreDraw {
binding.rvComments.scrollToPosition(comments.size - 1)
}
@@ -145,12 +145,7 @@ class CommentDetailActivity : AppCompatActivity(), OnUpdateStatusClickListener {
private fun showError(message: String) {
toast?.cancel()
- toast =
- Toast.makeText(
- this,
- message,
- Toast.LENGTH_SHORT,
- )
+ toast = Toast.makeText(this, message, Toast.LENGTH_SHORT)
toast?.show()
}
@@ -248,7 +243,7 @@ class CommentDetailActivity : AppCompatActivity(), OnUpdateStatusClickListener {
companion object {
private const val EXTRA_DEFAULT_VALUE = 1L
- private const val EXTRA_OFFERING_ID_KEY = "offering_id_key"
+ const val EXTRA_OFFERING_ID_KEY = "offering_id_key"
fun startActivity(
context: Context,
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailViewModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailViewModel.kt
index e81fbfba5..2df558ab9 100644
--- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailViewModel.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailViewModel.kt
@@ -19,6 +19,9 @@ import com.zzang.chongdae.domain.repository.OfferingRepository
import com.zzang.chongdae.domain.repository.ParticipantRepository
import com.zzang.chongdae.presentation.util.Event
import com.zzang.chongdae.presentation.view.commentdetail.event.CommentDetailEvent
+import com.zzang.chongdae.presentation.view.commentdetail.model.comment.CommentUiModel
+import com.zzang.chongdae.presentation.view.commentdetail.model.comment.CommentUiModel.Companion.toUiModel
+import com.zzang.chongdae.presentation.view.commentdetail.model.comment.CommentUiModel.Companion.toUiModelListWithSeparators
import com.zzang.chongdae.presentation.view.commentdetail.model.information.CommentOfferingInfoUiModel
import com.zzang.chongdae.presentation.view.commentdetail.model.information.CommentOfferingInfoUiModel.Companion.toUiModel
import com.zzang.chongdae.presentation.view.commentdetail.model.meeting.MeetingsUiModel
@@ -53,8 +56,8 @@ class CommentDetailViewModel
val commentContent = MutableLiveData("")
- private val _comments: MutableLiveData> = MutableLiveData()
- val comments: LiveData> get() = _comments
+ private val _comments: MutableLiveData> = MutableLiveData()
+ val comments: LiveData> get() = _comments
private var cachedComments: List = emptyList()
private val _commentOfferingInfo = MutableLiveData()
@@ -72,6 +75,9 @@ class CommentDetailViewModel
private val _event = MutableLiveData>()
val event: LiveData> get() = _event
+ private val _exitLoading: MutableLiveData = MutableLiveData(false)
+ val exitLoading: LiveData get() = _exitLoading
+
init {
startPolling()
updateCommentInfo()
@@ -144,15 +150,11 @@ class CommentDetailViewModel
is Result.Success -> {
val newComments = result.data
if (cachedComments != newComments) {
- _comments.value = newComments
+ _comments.value = newComments.toUiModelListWithSeparators()
cachedComments = newComments
}
}
-
- is Result.Error ->
- handleNetworkError(result.error) {
- loadComments()
- }
+ is Result.Error -> handleNetworkError(result.error) { loadComments() }
}
}
}
@@ -208,6 +210,7 @@ class CommentDetailViewModel
}
private fun exitOffering() {
+ _exitLoading.value = true
viewModelScope.launch {
when (val result = participantRepository.deleteParticipations(offeringId)) {
is Result.Success -> {
@@ -231,11 +234,14 @@ class CommentDetailViewModel
}
}
+ DataError.Network.CONNECTION_ERROR -> {}
+
else -> {
return@launch
}
}
}
+ _exitLoading.value = false
}
}
@@ -248,6 +254,7 @@ class CommentDetailViewModel
}
override fun onClickConfirm() {
+ _event.value = Event(CommentDetailEvent.AlertCancelled)
exitOffering()
}
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/CommentAdapter.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/CommentAdapter.kt
index 9357a609e..9df225fea 100644
--- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/CommentAdapter.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/CommentAdapter.kt
@@ -8,48 +8,37 @@ import androidx.recyclerview.widget.RecyclerView
import com.zzang.chongdae.databinding.ItemDateSeparatorBinding
import com.zzang.chongdae.databinding.ItemMyCommentBinding
import com.zzang.chongdae.databinding.ItemOtherCommentBinding
-import com.zzang.chongdae.domain.model.Comment
-
-class CommentAdapter : ListAdapter(DIFF_CALLBACK) {
- fun submitComments(comments: List) {
- val newItems = mutableListOf()
-
- for (i in comments.indices) {
- val currentComment = comments[i]
- val previousComment = if (i > 0) comments[i - 1] else null
-
- if (previousComment == null || isDifferentDates(currentComment, previousComment)) {
- newItems.add(CommentViewType.DateSeparator(currentComment))
- }
-
- newItems.add(CommentViewType.fromComment(currentComment))
- }
-
- submitList(newItems)
- }
-
- private fun isDifferentDates(
- currentComment: Comment,
- previousComment: Comment,
- ) = currentComment.commentCreatedAt.date != previousComment.commentCreatedAt.date
+import com.zzang.chongdae.presentation.view.commentdetail.model.comment.CommentUiModel
+class CommentAdapter : ListAdapter(DIFF_CALLBACK) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
): RecyclerView.ViewHolder {
return when (viewType) {
VIEW_TYPE_MY_COMMENT -> {
- val binding = ItemMyCommentBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ val binding =
+ ItemMyCommentBinding.inflate(LayoutInflater.from(parent.context), parent, false)
MyCommentViewHolder(binding)
}
VIEW_TYPE_OTHER_COMMENT -> {
- val binding = ItemOtherCommentBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ val binding =
+ ItemOtherCommentBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ )
OtherCommentViewHolder(binding)
}
VIEW_TYPE_DATE_SEPARATOR -> {
- val binding = ItemDateSeparatorBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ val binding =
+ ItemDateSeparatorBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ )
DateSeparatorViewHolder(binding)
}
@@ -61,18 +50,18 @@ class CommentAdapter : ListAdapter(DIF
holder: RecyclerView.ViewHolder,
position: Int,
) {
- when (val item = getItem(position)) {
- is CommentViewType.MyComment -> (holder as MyCommentViewHolder).bind(item.comment)
- is CommentViewType.OtherComment -> (holder as OtherCommentViewHolder).bind(item.comment)
- is CommentViewType.DateSeparator -> (holder as DateSeparatorViewHolder).bind(item.comment)
+ when (holder) {
+ is MyCommentViewHolder -> holder.bind(getItem(position))
+ is OtherCommentViewHolder -> holder.bind(getItem(position))
+ is DateSeparatorViewHolder -> holder.bind(getItem(position))
}
}
override fun getItemViewType(position: Int): Int {
- return when (getItem(position)) {
- is CommentViewType.MyComment -> VIEW_TYPE_MY_COMMENT
- is CommentViewType.OtherComment -> VIEW_TYPE_OTHER_COMMENT
- is CommentViewType.DateSeparator -> VIEW_TYPE_DATE_SEPARATOR
+ return when (getItem(position).commentViewType) {
+ CommentViewType.MyComment -> VIEW_TYPE_MY_COMMENT
+ CommentViewType.OtherComment -> VIEW_TYPE_OTHER_COMMENT
+ CommentViewType.DateSeparator -> VIEW_TYPE_DATE_SEPARATOR
}
}
@@ -82,28 +71,18 @@ class CommentAdapter : ListAdapter(DIF
private const val VIEW_TYPE_DATE_SEPARATOR = 3
private val DIFF_CALLBACK =
- object : DiffUtil.ItemCallback() {
+ object : DiffUtil.ItemCallback() {
override fun areItemsTheSame(
- oldItem: CommentViewType,
- newItem: CommentViewType,
+ oldItem: CommentUiModel,
+ newItem: CommentUiModel,
): Boolean {
- return when {
- oldItem is CommentViewType.MyComment && newItem is CommentViewType.MyComment ->
- oldItem.comment == newItem.comment
-
- oldItem is CommentViewType.OtherComment && newItem is CommentViewType.OtherComment ->
- oldItem.comment == newItem.comment
-
- oldItem is CommentViewType.DateSeparator && newItem is CommentViewType.DateSeparator ->
- oldItem.comment == newItem.comment
-
- else -> false
- }
+ return oldItem.commentViewType == newItem.commentViewType &&
+ oldItem.date == newItem.date && oldItem.time == newItem.time
}
override fun areContentsTheSame(
- oldItem: CommentViewType,
- newItem: CommentViewType,
+ oldItem: CommentUiModel,
+ newItem: CommentUiModel,
): Boolean {
return oldItem == newItem
}
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/CommentViewType.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/CommentViewType.kt
index 8b8f30ec8..7a6ee7cd8 100644
--- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/CommentViewType.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/CommentViewType.kt
@@ -1,21 +1,9 @@
package com.zzang.chongdae.presentation.view.commentdetail.adapter.comment
-import com.zzang.chongdae.domain.model.Comment
-
sealed class CommentViewType {
- data class MyComment(val comment: Comment) : CommentViewType()
-
- data class OtherComment(val comment: Comment) : CommentViewType()
+ data object MyComment : CommentViewType()
- data class DateSeparator(val comment: Comment) : CommentViewType()
+ data object OtherComment : CommentViewType()
- companion object {
- fun fromComment(comment: Comment): CommentViewType {
- return if (comment.isMine) {
- MyComment(comment)
- } else {
- OtherComment(comment)
- }
- }
- }
+ data object DateSeparator : CommentViewType()
}
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/DateSeparatorViewHolder.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/DateSeparatorViewHolder.kt
index 332edbbdd..eea8f62a0 100644
--- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/DateSeparatorViewHolder.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/DateSeparatorViewHolder.kt
@@ -2,12 +2,12 @@ package com.zzang.chongdae.presentation.view.commentdetail.adapter.comment
import androidx.recyclerview.widget.RecyclerView
import com.zzang.chongdae.databinding.ItemDateSeparatorBinding
-import com.zzang.chongdae.domain.model.Comment
+import com.zzang.chongdae.presentation.view.commentdetail.model.comment.CommentUiModel
class DateSeparatorViewHolder(
private val binding: ItemDateSeparatorBinding,
) : RecyclerView.ViewHolder(binding.root) {
- fun bind(comment: Comment) {
+ fun bind(comment: CommentUiModel) {
binding.comment = comment
}
}
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/MyCommentViewHolder.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/MyCommentViewHolder.kt
index e6b48a8e6..0655c02b2 100644
--- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/MyCommentViewHolder.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/MyCommentViewHolder.kt
@@ -2,12 +2,12 @@ package com.zzang.chongdae.presentation.view.commentdetail.adapter.comment
import androidx.recyclerview.widget.RecyclerView
import com.zzang.chongdae.databinding.ItemMyCommentBinding
-import com.zzang.chongdae.domain.model.Comment
+import com.zzang.chongdae.presentation.view.commentdetail.model.comment.CommentUiModel
class MyCommentViewHolder(
private val binding: ItemMyCommentBinding,
) : RecyclerView.ViewHolder(binding.root) {
- fun bind(comment: Comment) {
+ fun bind(comment: CommentUiModel) {
binding.comment = comment
}
}
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/OtherCommentViewHolder.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/OtherCommentViewHolder.kt
index f14fd231d..56400dad7 100644
--- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/OtherCommentViewHolder.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/OtherCommentViewHolder.kt
@@ -2,12 +2,12 @@ package com.zzang.chongdae.presentation.view.commentdetail.adapter.comment
import androidx.recyclerview.widget.RecyclerView
import com.zzang.chongdae.databinding.ItemOtherCommentBinding
-import com.zzang.chongdae.domain.model.Comment
+import com.zzang.chongdae.presentation.view.commentdetail.model.comment.CommentUiModel
class OtherCommentViewHolder(
private val binding: ItemOtherCommentBinding,
) : RecyclerView.ViewHolder(binding.root) {
- fun bind(comment: Comment) {
+ fun bind(comment: CommentUiModel) {
binding.comment = comment
}
}
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/model/comment/CommentUiModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/model/comment/CommentUiModel.kt
new file mode 100644
index 000000000..ac11a01cc
--- /dev/null
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/model/comment/CommentUiModel.kt
@@ -0,0 +1,59 @@
+package com.zzang.chongdae.presentation.view.commentdetail.model.comment
+
+import com.zzang.chongdae.domain.model.Comment
+import com.zzang.chongdae.presentation.util.toFormattedDate
+import com.zzang.chongdae.presentation.util.toFormattedTime
+import com.zzang.chongdae.presentation.view.commentdetail.adapter.comment.CommentViewType
+
+data class CommentUiModel(
+ val content: String,
+ val date: String,
+ val time: String,
+ val isMine: Boolean,
+ val isProposer: Boolean,
+ val nickname: String,
+ val commentViewType: CommentViewType,
+) {
+ companion object {
+ fun Comment.toUiModel(): CommentUiModel {
+ val viewType = if (this.isMine) CommentViewType.MyComment else CommentViewType.OtherComment
+ return CommentUiModel(
+ content = this.content,
+ date = this.commentCreatedAt.date.toFormattedDate(),
+ time = this.commentCreatedAt.time.toFormattedTime(),
+ isMine = this.isMine,
+ isProposer = this.isProposer,
+ nickname = this.nickname,
+ commentViewType = viewType,
+ )
+ }
+
+ fun createDateSeparator(date: String): CommentUiModel {
+ return CommentUiModel(
+ content = "",
+ date = date,
+ time = "",
+ isMine = false,
+ isProposer = false,
+ nickname = "",
+ commentViewType = CommentViewType.DateSeparator,
+ )
+ }
+
+ fun List.toUiModelListWithSeparators(): List {
+ val uiModels = mutableListOf()
+ var currentDate: String? = null
+
+ for (comment in this) {
+ val commentDate = comment.commentCreatedAt.date.toFormattedDate()
+ if (commentDate != currentDate) {
+ uiModels.add(CommentUiModel.createDateSeparator(commentDate))
+ currentDate = commentDate
+ }
+ uiModels.add(comment.toUiModel())
+ }
+
+ return uiModels
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/HomeFragment.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/HomeFragment.kt
index ae52cdfd0..a118618c0 100644
--- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/HomeFragment.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/HomeFragment.kt
@@ -27,9 +27,9 @@ import com.zzang.chongdae.common.firebase.FirebaseAnalyticsManager
import com.zzang.chongdae.databinding.FragmentHomeBinding
import com.zzang.chongdae.domain.model.FilterName
import com.zzang.chongdae.presentation.util.setDebouncedOnClickListener
-import com.zzang.chongdae.presentation.view.MainActivity
import com.zzang.chongdae.presentation.view.home.adapter.OfferingAdapter
import com.zzang.chongdae.presentation.view.login.LoginActivity
+import com.zzang.chongdae.presentation.view.main.MainActivity
import com.zzang.chongdae.presentation.view.offeringdetail.OfferingDetailFragment
import com.zzang.chongdae.presentation.view.write.OfferingWriteOptionalFragment
import dagger.hilt.android.AndroidEntryPoint
@@ -118,11 +118,13 @@ class HomeFragment : Fragment(), OnOfferingClickListener {
private fun initFragmentResultListener() {
setFragmentResultListener(OfferingDetailFragment.OFFERING_DETAIL_BUNDLE_KEY) { _, bundle ->
- viewModel.fetchUpdatedOffering(bundle.getLong(OfferingDetailFragment.UPDATED_OFFERING_ID_KEY))
- }
+ if (bundle.containsKey(OfferingDetailFragment.UPDATED_OFFERING_ID_KEY)) {
+ viewModel.fetchUpdatedOffering(bundle.getLong(OfferingDetailFragment.UPDATED_OFFERING_ID_KEY))
+ }
- setFragmentResultListener(OfferingDetailFragment.OFFERING_DETAIL_BUNDLE_KEY) { _, bundle ->
- viewModel.refreshOfferings(bundle.getBoolean(OfferingDetailFragment.DELETED_OFFERING_ID_KEY))
+ if (bundle.containsKey(OfferingDetailFragment.DELETED_OFFERING_ID_KEY)) {
+ viewModel.refreshOfferings(bundle.getBoolean(OfferingDetailFragment.DELETED_OFFERING_ID_KEY))
+ }
}
setFragmentResultListener(OfferingWriteOptionalFragment.OFFERING_WRITE_BUNDLE_KEY) { _, bundle ->
@@ -221,7 +223,8 @@ class HomeFragment : Fragment(), OnOfferingClickListener {
viewModel.updatedOffering.observe(viewLifecycleOwner) {
offeringAdapter.addUpdatedItem(it.toList())
}
- viewModel.updatedOffering.getValue()?.toList()?.let { offeringAdapter.addUpdatedItem(it) }
+ viewModel.updatedOffering.getValue()?.toList()
+ ?.let { offeringAdapter.addUpdatedItem(it) }
viewModel.refreshTokenExpiredEvent.observe(viewLifecycleOwner) {
LoginActivity.startActivity(requireContext())
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/OfferingViewModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/OfferingViewModel.kt
index d8ec6d13d..22b38e76a 100644
--- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/OfferingViewModel.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/OfferingViewModel.kt
@@ -42,6 +42,7 @@ class OfferingViewModel
val offerings: LiveData> get() = _offerings
val search: MutableLiveData = MutableLiveData(null)
+ val isSearchKeywordExist = search.map { (it != null) && (it != "") }
private val _filters: MutableLiveData> = MutableLiveData()
val filters: LiveData> get() = _filters
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/login/LoginActivity.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/login/LoginActivity.kt
index a4e33ec8f..de2fcbf58 100644
--- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/login/LoginActivity.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/login/LoginActivity.kt
@@ -7,13 +7,14 @@ import android.util.Log
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import com.google.firebase.analytics.FirebaseAnalytics
+import com.google.firebase.messaging.FirebaseMessaging
import com.kakao.sdk.auth.model.OAuthToken
import com.kakao.sdk.common.model.ClientError
import com.kakao.sdk.common.model.ClientErrorCause
import com.kakao.sdk.user.UserApiClient
import com.zzang.chongdae.common.firebase.FirebaseAnalyticsManager
import com.zzang.chongdae.databinding.ActivityLoginBinding
-import com.zzang.chongdae.presentation.view.MainActivity
+import com.zzang.chongdae.presentation.view.main.MainActivity
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
@@ -114,11 +115,24 @@ class LoginActivity : AppCompatActivity(), OnAuthClickListener {
if (error != null) {
Log.d("error", "사용자 정보 요청 실패 $error")
} else if (user != null) {
- viewModel.postLogin(accessToken)
+ loadFcmToken { fcmToken ->
+ viewModel.postLogin(accessToken, fcmToken)
+ }
}
}
}
+ private fun loadFcmToken(onFcmTokenReceived: (String) -> Unit) {
+ FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
+ if (!task.isSuccessful) {
+ Log.e("error", "Fetching FCM registration token failed", task.exception)
+ return@addOnCompleteListener
+ }
+ val fcmToken = task.result
+ onFcmTokenReceived(fcmToken)
+ }
+ }
+
private fun navigateToNextActivity() {
MainActivity.startActivity(this)
finish()
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/login/LoginViewModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/login/LoginViewModel.kt
index 00815f981..22ab89501 100644
--- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/login/LoginViewModel.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/login/LoginViewModel.kt
@@ -40,11 +40,15 @@ class LoginViewModel
}
}
- fun postLogin(accessToken: String) {
+ fun postLogin(
+ accessToken: String,
+ fcmToken: String,
+ ) {
viewModelScope.launch {
- when (val result = authRepository.saveLogin(accessToken = accessToken)) {
+ when (val result = authRepository.saveLogin(accessToken = accessToken, fcmToken = fcmToken)) {
is Result.Success -> {
userPreferencesDataStore.saveMember(result.data.memberId, result.data.nickName)
+ userPreferencesDataStore.saveFcmToken(fcmToken)
_loginSuccessEvent.setValue(Unit)
}
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/MainActivity.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/main/MainActivity.kt
similarity index 59%
rename from android/app/src/main/java/com/zzang/chongdae/presentation/view/MainActivity.kt
rename to android/app/src/main/java/com/zzang/chongdae/presentation/view/main/MainActivity.kt
index 04eba11eb..994c594a0 100644
--- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/MainActivity.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/main/MainActivity.kt
@@ -1,14 +1,20 @@
-package com.zzang.chongdae.presentation.view
+package com.zzang.chongdae.presentation.view.main
+import android.Manifest
import android.content.Context
import android.content.Intent
+import android.content.pm.PackageManager
import android.net.Uri
+import android.os.Build
import android.os.Bundle
import android.view.MotionEvent
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
+import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
@@ -16,6 +22,7 @@ import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import com.zzang.chongdae.R
import com.zzang.chongdae.databinding.ActivityMainBinding
+import com.zzang.chongdae.presentation.view.login.LoginActivity
import com.zzang.chongdae.presentation.view.offeringdetail.OfferingDetailFragment
import dagger.hilt.android.AndroidEntryPoint
@@ -23,15 +30,45 @@ import dagger.hilt.android.AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private var _binding: ActivityMainBinding? = null
private val binding get() = _binding!!
+
+ private val viewModel by viewModels()
+
private lateinit var navHostFragment: NavHostFragment
private lateinit var navController: NavController
+ private val offeringIdFromNotification by lazy {
+ intent.getLongExtra(NOTIFICATION_OFFERING_ID_KEY, OFFERING_ID_ERROR)
+ }
+ private val isNotificationTriggered by lazy {
+ intent.getBooleanExtra(NOTIFICATION_FLAG_KEY, DEFAULT_NOTIFICATION_FLAG)
+ }
+
+ private var toast: Toast? = null
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ requestNotificationPermission()
initBinding()
initNavController()
setupBottomNavigation()
handleDeepLink(intent)
+ handleNotificationTrigger()
+ setupObserve()
+ }
+
+ private fun requestNotificationPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (ContextCompat.checkSelfPermission(
+ this, Manifest.permission.POST_NOTIFICATIONS,
+ ) != PackageManager.PERMISSION_GRANTED
+ ) {
+ ActivityCompat.requestPermissions(
+ this,
+ arrayOf(Manifest.permission.POST_NOTIFICATIONS),
+ PENDING_INTENT_REQUEST_CODE,
+ )
+ }
+ }
}
private fun initBinding() {
@@ -79,10 +116,10 @@ class MainActivity : AppCompatActivity() {
if (offeringId != null) {
openOfferingDetailFragment(offeringId)
} else {
- Toast.makeText(this, "공모 ID가 올바르지 않습니다.", Toast.LENGTH_SHORT).show()
+ showToast(getString(R.string.main_invalid_offering_id))
}
} else {
- Toast.makeText(this, "Deeplink가 올바르지 않습니다.", Toast.LENGTH_SHORT).show()
+ showToast(getString(R.string.main_invalid_deeplink))
}
}
}
@@ -94,6 +131,25 @@ class MainActivity : AppCompatActivity() {
navController.navigate(R.id.action_home_fragment_to_offering_detail_fragment, bundle)
}
+ private fun handleNotificationTrigger() {
+ if (isNotificationTriggered) {
+ openOfferingDetailFragment(offeringIdFromNotification)
+ }
+ }
+
+ private fun setupObserve() {
+ viewModel.fcmTokenEmptyEvent.observe(this) {
+ LoginActivity.startActivity(this)
+ showToast(getString(R.string.main_require_re_login))
+ }
+ }
+
+ private fun showToast(message: String) {
+ toast?.cancel()
+ toast = Toast.makeText(this, message, Toast.LENGTH_SHORT)
+ toast?.show()
+ }
+
override fun onDestroy() {
super.onDestroy()
_binding = null
@@ -102,6 +158,11 @@ class MainActivity : AppCompatActivity() {
companion object {
private const val SCHEME = "chongdaeapp"
private const val HOST = "offerings"
+ const val NOTIFICATION_OFFERING_ID_KEY = "notification_offering_id_key"
+ const val NOTIFICATION_FLAG_KEY = "notification_flag_key"
+ private const val OFFERING_ID_ERROR = -1L
+ private const val DEFAULT_NOTIFICATION_FLAG = false
+ private const val PENDING_INTENT_REQUEST_CODE = 1001
fun startActivity(context: Context) =
Intent(context, MainActivity::class.java).run {
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/main/MainViewModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/main/MainViewModel.kt
new file mode 100644
index 000000000..5ae8e133a
--- /dev/null
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/main/MainViewModel.kt
@@ -0,0 +1,33 @@
+package com.zzang.chongdae.presentation.view.main
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.zzang.chongdae.common.datastore.UserPreferencesDataStore
+import com.zzang.chongdae.presentation.util.MutableSingleLiveData
+import com.zzang.chongdae.presentation.util.SingleLiveData
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class MainViewModel
+ @Inject
+ constructor(
+ private val userPreferencesDataStore: UserPreferencesDataStore,
+ ) : ViewModel() {
+ private val _fcmTokenEmptyEvent: MutableSingleLiveData = MutableSingleLiveData()
+ val fcmTokenEmptyEvent: SingleLiveData get() = _fcmTokenEmptyEvent
+
+ init {
+ makeFcmTokenEmptyEvent()
+ }
+
+ private fun makeFcmTokenEmptyEvent() {
+ viewModelScope.launch {
+ if (userPreferencesDataStore.fcmTokenFlow.first() != null) return@launch
+ userPreferencesDataStore.removeAllData()
+ _fcmTokenEmptyEvent.setValue(Unit)
+ }
+ }
+ }
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/mypage/MyPageViewModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/mypage/MyPageViewModel.kt
index a00cb7664..06ab176d0 100644
--- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/mypage/MyPageViewModel.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/mypage/MyPageViewModel.kt
@@ -1,6 +1,8 @@
package com.zzang.chongdae.presentation.view.mypage
+import android.app.NotificationManager
import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
@@ -9,6 +11,7 @@ import com.zzang.chongdae.presentation.util.MutableSingleLiveData
import com.zzang.chongdae.presentation.util.SingleLiveData
import com.zzang.chongdae.presentation.view.common.OnAlertClickListener
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -39,6 +42,30 @@ class MyPageViewModel
"https://silent-apparatus-578.notion.site/f1f5cd1609d4469dba3ab7d0f95c183c?pvs=4"
private val withdrawalUrl = "https://forms.gle/z5MUzVTUoyunfqEu8"
+ val isNotificationActivate = MutableLiveData(true)
+ val isNotificationImportanceHigh = MutableLiveData(true)
+
+ init {
+ initNotificationSwitches()
+ }
+
+ private fun initNotificationSwitches() {
+ viewModelScope.launch {
+ val notificationActivate = userPreferencesDataStore.notificationActivateFlow.first()
+ when (notificationActivate) {
+ true -> isNotificationActivate.value = true
+ false -> isNotificationActivate.value = false
+ }
+
+ val notificationImportance = userPreferencesDataStore.notificationImportanceFlow.first()
+ when (notificationImportance) {
+ NotificationManager.IMPORTANCE_HIGH -> isNotificationImportanceHigh.value = true
+
+ NotificationManager.IMPORTANCE_DEFAULT -> isNotificationImportanceHigh.value = false
+ }
+ }
+ }
+
fun onClickTermsOfUse() {
_openUrlInBrowserEvent.setValue(termsOfUseUrl)
}
@@ -65,4 +92,24 @@ class MyPageViewModel
override fun onClickCancel() {
_alertCancelEvent.setValue(Unit)
}
+
+ fun onNotificationActivateSwitchChanged(isChecked: Boolean) {
+ isNotificationActivate.value = isChecked
+ viewModelScope.launch {
+ when (isChecked) {
+ true -> userPreferencesDataStore.setNotificationActivate(true)
+ false -> userPreferencesDataStore.setNotificationActivate(false)
+ }
+ }
+ }
+
+ fun onNotificationImportanceSwitchChanged(isChecked: Boolean) {
+ isNotificationImportanceHigh.value = isChecked
+ viewModelScope.launch {
+ when (isChecked) {
+ true -> userPreferencesDataStore.setNotificationImportance(NotificationManager.IMPORTANCE_HIGH)
+ false -> userPreferencesDataStore.setNotificationImportance(NotificationManager.IMPORTANCE_DEFAULT)
+ }
+ }
+ }
}
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OfferingDetailFragment.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OfferingDetailFragment.kt
index 30d0cbaaf..136cc69d0 100644
--- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OfferingDetailFragment.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OfferingDetailFragment.kt
@@ -20,10 +20,10 @@ import com.zzang.chongdae.common.firebase.FirebaseAnalyticsManager
import com.zzang.chongdae.databinding.DialogAlertBinding
import com.zzang.chongdae.databinding.DialogDeleteOfferingBinding
import com.zzang.chongdae.databinding.FragmentOfferingDetailBinding
-import com.zzang.chongdae.presentation.view.MainActivity
import com.zzang.chongdae.presentation.view.commentdetail.CommentDetailActivity
import com.zzang.chongdae.presentation.view.home.HomeFragment
import com.zzang.chongdae.presentation.view.login.LoginActivity
+import com.zzang.chongdae.presentation.view.main.MainActivity
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@@ -116,7 +116,8 @@ class OfferingDetailFragment : Fragment(), OnOfferingDeleteAlertClickListener {
viewModel.showAlertEvent.observe(viewLifecycleOwner) {
val alertBinding = DialogAlertBinding.inflate(layoutInflater, null, false)
- alertBinding.tvDialogMessage.text = getString(R.string.offering_detail_participate_alert)
+ alertBinding.tvDialogMessage.text =
+ getString(R.string.offering_detail_participate_alert)
alertBinding.listener = viewModel
dialog.setContentView(alertBinding.root)
@@ -126,6 +127,10 @@ class OfferingDetailFragment : Fragment(), OnOfferingDeleteAlertClickListener {
viewModel.alertCancelEvent.observe(viewLifecycleOwner) {
dialog.dismiss()
}
+
+ viewModel.isOfferingDetailLoading.observe(viewLifecycleOwner) {
+ startShimmer(it)
+ }
}
override fun onClickConfirm() {
@@ -207,6 +212,14 @@ class OfferingDetailFragment : Fragment(), OnOfferingDeleteAlertClickListener {
toast?.show()
}
+ private fun startShimmer(isLoading: Boolean) {
+ if (isLoading) {
+ binding.sflOfferingDetail.startShimmer()
+ return
+ }
+ binding.sflOfferingDetail.stopShimmer()
+ }
+
companion object {
const val OFFERING_DETAIL_BUNDLE_KEY = "offering_detail_bundle_key"
const val UPDATED_OFFERING_ID_KEY = "updated_offering_id"
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OfferingDetailViewModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OfferingDetailViewModel.kt
index 21455ec1e..ba2775565 100644
--- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OfferingDetailViewModel.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OfferingDetailViewModel.kt
@@ -95,19 +95,30 @@ class OfferingDetailViewModel
private val _alertCancelEvent = MutableSingleLiveData()
val alertCancelEvent: SingleLiveData get() = _alertCancelEvent
+ private val _isOfferingDetailLoading: MutableLiveData = MutableLiveData(false)
+ val isOfferingDetailLoading: LiveData get() = _isOfferingDetailLoading
+
+ private val _isParticipationLoading: MutableLiveData = MutableLiveData(false)
+ val isParticipationLoading: LiveData get() = _isParticipationLoading
+
init {
loadOffering()
}
fun loadOffering() {
viewModelScope.launch {
+ _isOfferingDetailLoading.value = true
when (val result = offeringDetailRepository.fetchOfferingDetail(offeringId)) {
is Result.Error ->
when (result.error) {
DataError.Network.UNAUTHORIZED -> {
when (authRepository.saveRefresh()) {
- is Result.Success -> loadOffering()
+ is Result.Success -> {
+ loadOffering()
+ }
+
is Result.Error -> {
+ _isOfferingDetailLoading.value = false
userPreferencesDataStore.removeAllData()
_refreshTokenExpiredEvent.setValue(Unit)
return@launch
@@ -125,6 +136,7 @@ class OfferingDetailViewModel
}
is Result.Success -> {
+ _isOfferingDetailLoading.value = false
_offeringDetail.value = result.data
_currentCount.value = result.data.currentCount.value
_offeringCondition.value = result.data.condition
@@ -139,6 +151,7 @@ class OfferingDetailViewModel
override fun participate() {
viewModelScope.launch {
+ _isParticipationLoading.value = true
when (val result = offeringDetailRepository.saveParticipation(offeringId)) {
is Result.Error ->
when (result.error) {
@@ -163,6 +176,7 @@ class OfferingDetailViewModel
}
is Result.Success -> {
+ _isParticipationLoading.value = false
_isParticipated.value = true
_commentDetailEvent.setValue(offeringDetail.value?.title ?: DEFAULT_TITLE)
_updatedOfferingId.value = offeringId
@@ -251,6 +265,7 @@ class OfferingDetailViewModel
}
override fun onClickConfirm() {
+ _alertCancelEvent.setValue(Unit)
participate()
}
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringmodify/OfferingModifyEssentialFragment.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringmodify/OfferingModifyEssentialFragment.kt
index 7ea58f1ac..0b8b9c7a3 100644
--- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringmodify/OfferingModifyEssentialFragment.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringmodify/OfferingModifyEssentialFragment.kt
@@ -18,9 +18,9 @@ import com.zzang.chongdae.common.firebase.FirebaseAnalyticsManager
import com.zzang.chongdae.databinding.DialogDatePickerBinding
import com.zzang.chongdae.databinding.FragmentOfferingModifyEssentialBinding
import com.zzang.chongdae.presentation.util.setDebouncedOnClickListener
-import com.zzang.chongdae.presentation.view.MainActivity
import com.zzang.chongdae.presentation.view.address.AddressFinderDialog
import com.zzang.chongdae.presentation.view.home.HomeFragment
+import com.zzang.chongdae.presentation.view.main.MainActivity
import com.zzang.chongdae.presentation.view.write.OnDateTimeButtonsClickListener
import dagger.hilt.android.AndroidEntryPoint
import java.util.Calendar
@@ -138,14 +138,14 @@ class OfferingModifyEssentialFragment : Fragment(), OnDateTimeButtonsClickListen
}
}
- override fun onDateTimeSubmitButtonClick() {
+ override fun onConfirmButtonClick() {
viewModel.updateMeetingDate(
dateTimePickerBinding.tvDate.text.toString(),
)
dialog.dismiss()
}
- override fun onDateTimeCancelButtonClick() {
+ override fun onCancelButtonClick() {
dialog.dismiss()
}
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteEssentialFragment.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteEssentialFragment.kt
index 803fa9603..dd082ae0d 100644
--- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteEssentialFragment.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteEssentialFragment.kt
@@ -18,8 +18,8 @@ import com.zzang.chongdae.common.firebase.FirebaseAnalyticsManager
import com.zzang.chongdae.databinding.DialogDatePickerBinding
import com.zzang.chongdae.databinding.FragmentOfferingWriteEssentialBinding
import com.zzang.chongdae.presentation.util.setDebouncedOnClickListener
-import com.zzang.chongdae.presentation.view.MainActivity
import com.zzang.chongdae.presentation.view.address.AddressFinderDialog
+import com.zzang.chongdae.presentation.view.main.MainActivity
import dagger.hilt.android.AndroidEntryPoint
import java.util.Calendar
@@ -123,14 +123,14 @@ class OfferingWriteEssentialFragment : Fragment(), OnDateTimeButtonsClickListene
}
}
- override fun onDateTimeSubmitButtonClick() {
+ override fun onConfirmButtonClick() {
viewModel.updateMeetingDate(
dateTimePickerBinding.tvDate.text.toString(),
)
dialog.dismiss()
}
- override fun onDateTimeCancelButtonClick() {
+ override fun onCancelButtonClick() {
dialog.dismiss()
}
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteViewModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteViewModel.kt
index c8b337b7b..43173f7ff 100644
--- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteViewModel.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteViewModel.kt
@@ -94,7 +94,10 @@ class OfferingWriteViewModel
private val _writeUIState = MutableLiveData(WriteUIState.Initial)
val writeUIState: LiveData get() = _writeUIState
- val isLoading: LiveData = _writeUIState.map { it is WriteUIState.Loading }
+ val isImageUpLoading: LiveData = _writeUIState.map { it is WriteUIState.Loading }
+
+ private val _isSubmitLoading: MutableLiveData = MutableLiveData(false)
+ val isSubmitLoading: LiveData get() = _isSubmitLoading
init {
_essentialSubmitButtonEnabled.apply {
@@ -267,6 +270,7 @@ class OfferingWriteViewModel
}
fun postOffering() {
+ _isSubmitLoading.value = true
val title = title.value ?: return
val totalCount = totalCount.value ?: return
val totalPrice = totalPrice.value ?: return
@@ -308,7 +312,10 @@ class OfferingWriteViewModel
),
)
) {
- is Result.Success -> makeSubmitOfferingEvent()
+ is Result.Success -> {
+ makeSubmitOfferingEvent()
+ _isSubmitLoading.value = false
+ }
is Result.Error -> {
Log.e("error", "postOffering: ${result.error}")
@@ -325,6 +332,7 @@ class OfferingWriteViewModel
WriteUIState.Error(R.string.write_error_writing, "${result.error}")
}
}
+ _isSubmitLoading.value = false
}
}
}
diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OnDateTimeButtonsClickListener.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OnDateTimeButtonsClickListener.kt
index 477df6f5f..71a5bc13b 100644
--- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OnDateTimeButtonsClickListener.kt
+++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OnDateTimeButtonsClickListener.kt
@@ -1,7 +1,7 @@
package com.zzang.chongdae.presentation.view.write
interface OnDateTimeButtonsClickListener {
- fun onDateTimeSubmitButtonClick()
+ fun onConfirmButtonClick()
- fun onDateTimeCancelButtonClick()
+ fun onCancelButtonClick()
}
diff --git a/android/app/src/main/res/drawable/bg_white_radius_16dp.xml b/android/app/src/main/res/drawable/bg_white_radius_16dp.xml
index cf3ab0aae..9250e71ef 100644
--- a/android/app/src/main/res/drawable/bg_white_radius_16dp.xml
+++ b/android/app/src/main/res/drawable/bg_white_radius_16dp.xml
@@ -1,7 +1,7 @@
-
+
diff --git a/android/app/src/main/res/drawable/ic_detail_clock.xml b/android/app/src/main/res/drawable/ic_detail_clock.xml
index e5297910a..24d8d12bb 100644
--- a/android/app/src/main/res/drawable/ic_detail_clock.xml
+++ b/android/app/src/main/res/drawable/ic_detail_clock.xml
@@ -5,16 +5,16 @@
android:viewportWidth="18"
android:viewportHeight="18">
+ android:strokeColor="@color/default_icon_color" />
+ android:strokeColor="@color/default_icon_color"
+ android:strokeLineCap="round"
+ android:strokeLineJoin="round" />
diff --git a/android/app/src/main/res/drawable/ic_detail_location.xml b/android/app/src/main/res/drawable/ic_detail_location.xml
index 8dbc86a40..2d75de699 100644
--- a/android/app/src/main/res/drawable/ic_detail_location.xml
+++ b/android/app/src/main/res/drawable/ic_detail_location.xml
@@ -5,6 +5,6 @@
android:viewportWidth="18"
android:viewportHeight="18">
+ android:fillColor="@color/default_icon_color"
+ android:pathData="M7,0.875C4.585,0.875 2.625,2.639 2.625,4.813C2.625,8.313 7,13.125 7,13.125C7,13.125 11.375,8.313 11.375,4.813C11.375,2.639 9.415,0.875 7,0.875ZM7,7C6.654,7 6.316,6.897 6.028,6.705C5.74,6.513 5.516,6.239 5.383,5.92C5.251,5.6 5.216,5.248 5.284,4.909C5.351,4.569 5.518,4.257 5.763,4.013C6.007,3.768 6.319,3.601 6.659,3.534C6.998,3.466 7.35,3.501 7.67,3.633C7.989,3.766 8.263,3.99 8.455,4.278C8.647,4.566 8.75,4.904 8.75,5.25C8.749,5.714 8.565,6.159 8.237,6.487C7.909,6.815 7.464,6.999 7,7Z" />
diff --git a/android/app/src/main/res/drawable/ic_detail_modify.xml b/android/app/src/main/res/drawable/ic_detail_modify.xml
index 4d92a367a..bf57dc4c2 100644
--- a/android/app/src/main/res/drawable/ic_detail_modify.xml
+++ b/android/app/src/main/res/drawable/ic_detail_modify.xml
@@ -1,20 +1,17 @@
+ android:width="19dp"
+ android:height="19dp"
+ android:viewportWidth="19"
+ android:viewportHeight="19">
-
diff --git a/android/app/src/main/res/drawable/ic_detail_remove.xml b/android/app/src/main/res/drawable/ic_detail_remove.xml
index 9af26eca6..93780d1b4 100644
--- a/android/app/src/main/res/drawable/ic_detail_remove.xml
+++ b/android/app/src/main/res/drawable/ic_detail_remove.xml
@@ -1,12 +1,9 @@
+ android:width="16dp"
+ android:height="18dp"
+ android:viewportWidth="16"
+ android:viewportHeight="18">
-
diff --git a/android/app/src/main/res/drawable/ic_my_page_logout.xml b/android/app/src/main/res/drawable/ic_my_page_logout.xml
index 0a91782b8..712f83bb2 100644
--- a/android/app/src/main/res/drawable/ic_my_page_logout.xml
+++ b/android/app/src/main/res/drawable/ic_my_page_logout.xml
@@ -5,5 +5,5 @@
android:viewportHeight="10">
+ android:fillColor="@color/default_icon_color"/>
diff --git a/android/app/src/main/res/drawable/ic_my_page_privacy.xml b/android/app/src/main/res/drawable/ic_my_page_privacy.xml
index 8a1c69528..fae2ab562 100644
--- a/android/app/src/main/res/drawable/ic_my_page_privacy.xml
+++ b/android/app/src/main/res/drawable/ic_my_page_privacy.xml
@@ -5,5 +5,5 @@
android:viewportHeight="12">
+ android:fillColor="@color/default_icon_color"/>
diff --git a/android/app/src/main/res/drawable/ic_my_page_terms_of_use.xml b/android/app/src/main/res/drawable/ic_my_page_terms_of_use.xml
index 07834b7f8..e5c6f6fd1 100644
--- a/android/app/src/main/res/drawable/ic_my_page_terms_of_use.xml
+++ b/android/app/src/main/res/drawable/ic_my_page_terms_of_use.xml
@@ -5,5 +5,5 @@
android:viewportHeight="8">
+ android:fillColor="@color/default_icon_color"/>
diff --git a/android/app/src/main/res/drawable/ic_my_page_withdrawal.xml b/android/app/src/main/res/drawable/ic_my_page_withdrawal.xml
index 48c1c485b..17e895a2f 100644
--- a/android/app/src/main/res/drawable/ic_my_page_withdrawal.xml
+++ b/android/app/src/main/res/drawable/ic_my_page_withdrawal.xml
@@ -3,7 +3,7 @@
android:height="13dp"
android:viewportWidth="13"
android:viewportHeight="13">
-
+
diff --git a/android/app/src/main/res/layout-land/activity_comment_detail.xml b/android/app/src/main/res/layout-land/activity_comment_detail.xml
new file mode 100644
index 000000000..f13e4d93c
--- /dev/null
+++ b/android/app/src/main/res/layout-land/activity_comment_detail.xml
@@ -0,0 +1,410 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/activity_comment_detail.xml b/android/app/src/main/res/layout/activity_comment_detail.xml
index f39ec8188..82af20922 100644
--- a/android/app/src/main/res/layout/activity_comment_detail.xml
+++ b/android/app/src/main/res/layout/activity_comment_detail.xml
@@ -38,9 +38,9 @@
android:layout_marginStart="@dimen/margin_10"
android:layout_marginTop="@dimen/margin_20"
android:contentDescription="@string/comment_detail"
- app:debouncedOnClick="@{() -> vm.onBackClick()}"
android:padding="@dimen/margin_10"
android:src="@drawable/btn_left_vector"
+ app:debouncedOnClick="@{() -> vm.onBackClick()}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
@@ -74,7 +74,7 @@
app:layout_constraintTop_toTopOf="@id/tv_title_text" />
+ app:layout_constraintStart_toStartOf="@+id/view_white_round"
+ app:layout_constraintTop_toBottomOf="@id/view_white_round" />
@@ -128,8 +128,8 @@
android:layout_width="match_parent"
android:layout_height="@dimen/size_36"
android:background="@color/gray_100"
- app:debouncedOnClick="@{() -> vm.toggleCollapsibleView()}"
android:translationZ="1dp"
+ app:debouncedOnClick="@{() -> vm.toggleCollapsibleView()}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_update_status"
@@ -271,6 +271,7 @@
android:paddingEnd="@dimen/margin_30"
android:paddingBottom="@dimen/size_14"
android:text="@={vm.commentContent}"
+ android:textColor="@color/black"
android:textColorHint="@color/gray_500"
android:textSize="@dimen/size_15"
app:layout_constraintBottom_toBottomOf="parent"
@@ -281,9 +282,9 @@
android:id="@+id/iv_send_comment"
android:layout_width="@dimen/size_50"
android:layout_height="@dimen/size_44"
- app:debouncedOnClick="@{() -> vm.postComment()}"
android:padding="@dimen/margin_10"
android:src="@drawable/btn_comment_detail_send"
+ app:debouncedOnClick="@{() -> vm.postComment()}"
app:layout_constraintBottom_toBottomOf="@id/et_comment"
app:layout_constraintEnd_toEndOf="@id/et_comment"
app:layout_constraintTop_toTopOf="@id/et_comment" />
@@ -309,8 +310,8 @@
android:layout_width="@dimen/icon_size_24"
android:layout_height="@dimen/icon_size_24"
android:layout_marginEnd="@dimen/margin_20"
- app:debouncedOnClick="@{() -> vm.onClickReport()}"
android:src="@drawable/ic_detail_report"
+ app:debouncedOnClick="@{() -> vm.onClickReport()}"
app:layout_constraintBottom_toBottomOf="@id/tv_offering_members"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/tv_offering_members" />
@@ -391,12 +392,25 @@
android:layout_width="@dimen/icon_size_24"
android:layout_height="@dimen/icon_size_24"
android:layout_marginStart="@dimen/margin_20"
- app:debouncedOnClick="@{() -> vm.onExitClick()}"
android:src="@drawable/btn_exit"
+ app:debouncedOnClick="@{() -> vm.onExitClick()}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/horizon_line" />
+
+
+
+
diff --git a/android/app/src/main/res/layout/activity_login.xml b/android/app/src/main/res/layout/activity_login.xml
index eb23270e4..a2a8ac00c 100644
--- a/android/app/src/main/res/layout/activity_login.xml
+++ b/android/app/src/main/res/layout/activity_login.xml
@@ -18,8 +18,7 @@
+ android:layout_height="match_parent">
+ android:layout_marginEnd="@dimen/margin_30">
+ app:layout_constraintTop_toTopOf="parent"
+ tools:text="다이얼로그 메세지" />
-->
-
+ app:layout_constraintVertical_bias="1.0"
+ app:layout_constraintWidth_percent="0.5" />
-
+ app:layout_constraintStart_toEndOf="@id/btn_cancel"
+ app:layout_constraintTop_toBottomOf="@+id/picker_date"
+ app:layout_constraintVertical_bias="1"
+ app:layout_constraintWidth_percent="0.5" />
\ No newline at end of file
diff --git a/android/app/src/main/res/layout/dialog_date_time_picker.xml b/android/app/src/main/res/layout/dialog_date_time_picker.xml
index f25a6dc23..b3abe5a46 100644
--- a/android/app/src/main/res/layout/dialog_date_time_picker.xml
+++ b/android/app/src/main/res/layout/dialog_date_time_picker.xml
@@ -101,7 +101,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/btn_rounded"
- app:debouncedOnClick="@{() -> onClickListener.onDateTimeSubmitButtonClick()}"
+ app:debouncedOnClick="@{() -> onClickListener.onConfirmButtonClick()}"
android:text="확인"
android:textColor="@color/white"
android:textSize="16dp"
@@ -116,7 +116,7 @@
android:layout_marginTop="16dp"
android:layout_marginBottom="10dp"
android:background="@drawable/btn_rounded_gray"
- app:debouncedOnClick="@{() -> onClickListener.onDateTimeCancelButtonClick()}"
+ app:debouncedOnClick="@{() -> onClickListener.onCancelButtonClick()}"
android:text="취소"
android:textColor="@color/black_700"
android:textSize="16dp"
diff --git a/android/app/src/main/res/layout/dialog_update_status.xml b/android/app/src/main/res/layout/dialog_update_status.xml
index c94fb6707..62c47268e 100644
--- a/android/app/src/main/res/layout/dialog_update_status.xml
+++ b/android/app/src/main/res/layout/dialog_update_status.xml
@@ -18,8 +18,7 @@
android:layout_width="match_parent"
android:layout_height="@dimen/size_200"
android:layout_marginStart="@dimen/margin_30"
- android:layout_marginEnd="@dimen/margin_30"
- android:background="@color/white">
+ android:layout_marginEnd="@dimen/margin_30">
+ app:layout_constraintTop_toTopOf="parent"
+ tools:text="텍스트" />
@@ -43,11 +44,13 @@
android:id="@+id/iv_clear_url"
android:layout_width="@dimen/icon_size_20"
android:layout_height="@dimen/icon_size_20"
+ android:layout_marginTop="@dimen/size_6"
android:layout_marginEnd="@dimen/size_3"
android:layout_marginBottom="5dp"
android:padding="@dimen/size_3"
android:src="@drawable/btn_offering_write_clear"
app:debouncedOnClick="@{() -> vm.refreshOfferings(true)}"
+ app:isVisible="@{vm.isSearchKeywordExist}"
app:layout_constraintBottom_toBottomOf="@id/et_search"
app:layout_constraintEnd_toStartOf="@id/iv_search_button"
app:layout_constraintTop_toTopOf="@id/iv_search_button" />
@@ -62,8 +65,8 @@
android:paddingEnd="10dp"
android:paddingBottom="10dp"
android:src="@drawable/ic_main_search"
- app:debouncedOnClick="@{() -> vm.onClickSearchButton()}"
app:debounceTime="@{800L}"
+ app:debouncedOnClick="@{() -> vm.onClickSearchButton()}"
app:layout_constraintBottom_toBottomOf="@id/et_search"
app:layout_constraintEnd_toEndOf="@id/et_search" />
@@ -121,6 +124,7 @@
android:id="@+id/pb_loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
+ android:indeterminateTint="@color/main_color"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
diff --git a/android/app/src/main/res/layout/fragment_my_page.xml b/android/app/src/main/res/layout/fragment_my_page.xml
index a910e3158..801a061a3 100644
--- a/android/app/src/main/res/layout/fragment_my_page.xml
+++ b/android/app/src/main/res/layout/fragment_my_page.xml
@@ -90,6 +90,41 @@
app:layout_constraintStart_toEndOf="@id/tv_nickname"
app:layout_constraintTop_toTopOf="@id/tv_nickname" />
+
+
+
+
+
+
+ app:layout_constraintTop_toTopOf="@id/tv_terms_of_use" />
+ app:layout_constraintTop_toBottomOf="@id/switch_notification_popup" />
+ app:layout_constraintTop_toTopOf="@id/iv_privacy" />
+
diff --git a/android/app/src/main/res/layout/fragment_offering_detail.xml b/android/app/src/main/res/layout/fragment_offering_detail.xml
index 82d6d8c29..f253e2337 100644
--- a/android/app/src/main/res/layout/fragment_offering_detail.xml
+++ b/android/app/src/main/res/layout/fragment_offering_detail.xml
@@ -14,7 +14,7 @@
@@ -50,10 +52,10 @@
android:layout_height="wrap_content"
android:layout_marginTop="35dp"
android:fontFamily="@font/suit_medium"
- app:debouncedOnClick="@{() -> vm.onClickReport()}"
android:text="@string/all_report_text"
android:textColor="@color/gray_font"
android:textSize="15sp"
+ app:debouncedOnClick="@{() -> vm.onClickReport()}"
app:isVisible="@{!vm.isRepresentative}"
app:layout_constraintEnd_toStartOf="@id/iv_report"
app:layout_constraintTop_toTopOf="parent" />
@@ -63,8 +65,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/size_10"
- app:debouncedOnClick="@{() -> vm.onClickReport()}"
android:src="@drawable/ic_detail_report"
+ app:debouncedOnClick="@{() -> vm.onClickReport()}"
app:layout_constraintBottom_toBottomOf="@id/tv_report_comment"
app:layout_constraintEnd_toEndOf="parent" />
@@ -77,8 +79,10 @@
android:background="@drawable/bg_white_radius_16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent">
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintVertical_bias="1.0">
@@ -145,8 +149,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
- app:debouncedOnClick="@{() -> vm.onClickDeleteButton()}"
android:src="@drawable/ic_detail_remove"
+ app:debouncedOnClick="@{() -> vm.onClickDeleteButton()}"
app:isVisible="@{vm.isRepresentative}"
app:layout_constraintStart_toEndOf="@id/view_divide_line"
app:layout_constraintTop_toTopOf="@id/view_divide_line" />
@@ -165,10 +169,10 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/suit_bold"
- app:debouncedOnClick="@{() -> vm.onClickProductRedirectText(vm.offeringDetail.productUrl)}"
android:text="@string/offering_detail_product_url_text"
android:textColor="@color/main_color"
android:textSize="16sp"
+ app:debouncedOnClick="@{() -> vm.onClickProductRedirectText(vm.offeringDetail.productUrl)}"
app:isVisible="@{vm.offeringDetail.productUrl != null}"
app:layout_constraintBottom_toBottomOf="@id/tv_product_link_comment"
app:layout_constraintStart_toEndOf="@id/tv_product_link_comment"
@@ -330,6 +334,7 @@
android:layout_width="0dp"
android:layout_marginTop="18dp"
android:layout_marginEnd="18dp"
+ android:paddingBottom="@dimen/size_50"
android:text="@{vm.offeringDetail.description}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/tv_title"
@@ -354,11 +359,11 @@
android:background="@drawable/btn_participation_enabled"
android:enabled="@{vm.isParticipationAvailable}"
android:fontFamily="@font/suit_medium"
- app:debouncedOnClick="@{() -> vm.onParticipateClick()}"
android:textColor="@color/white"
app:condition="@{vm.offeringCondition}"
app:currentCount="@{vm.currentCount}"
- app:isVisible="@{!vm.isParticipated}"
+ app:debouncedOnClick="@{() -> vm.onParticipateClick()}"
+ app:isVisible="@{!vm.isParticipated && !vm.isOfferingDetailLoading}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
@@ -375,14 +380,37 @@
android:layout_marginBottom="20dp"
android:background="@drawable/btn_participation_opened"
android:fontFamily="@font/suit_medium"
- app:debouncedOnClick="@{() -> vm.onClickMoveCommentDetail()}"
android:text="@string/offering_detail_move_comment_detail"
android:textColor="@color/white"
- app:isVisible="@{vm.isParticipated}"
+ app:debouncedOnClick="@{() -> vm.onClickMoveCommentDetail()}"
+ app:isVisible="@{vm.isParticipated && !vm.isOfferingDetailLoading}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/sv_layout" />
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/fragment_offering_detail_shimmer.xml b/android/app/src/main/res/layout/fragment_offering_detail_shimmer.xml
new file mode 100644
index 000000000..714815577
--- /dev/null
+++ b/android/app/src/main/res/layout/fragment_offering_detail_shimmer.xml
@@ -0,0 +1,197 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/fragment_offering_modify_essential.xml b/android/app/src/main/res/layout/fragment_offering_modify_essential.xml
index 4efeb52eb..bb8123089 100644
--- a/android/app/src/main/res/layout/fragment_offering_modify_essential.xml
+++ b/android/app/src/main/res/layout/fragment_offering_modify_essential.xml
@@ -84,10 +84,11 @@
android:background="@drawable/btn_offering_write_minus_total_personnel"
android:fontFamily="@font/suit_bold"
android:gravity="center"
- app:debouncedOnClick="@{() -> vm.decreaseTotalCount()}"
android:text="@string/all_minus"
android:textColor="@color/white"
android:textSize="@dimen/size_20"
+ app:debounceTime="@{0L}"
+ app:debouncedOnClick="@{() -> vm.decreaseTotalCount()}"
app:layout_constraintStart_toStartOf="@id/tv_title"
app:layout_constraintTop_toBottomOf="@id/tv_total_personnel" />
@@ -118,10 +119,11 @@
android:background="@drawable/btn_offering_write_minus_total_personnel"
android:fontFamily="@font/suit_bold"
android:gravity="center"
- app:debouncedOnClick="@{() -> vm.increaseTotalCount()}"
android:text="@string/all_plus"
android:textColor="@color/white"
android:textSize="@dimen/size_20"
+ app:debounceTime="@{0L}"
+ app:debouncedOnClick="@{() -> vm.increaseTotalCount()}"
app:layout_constraintStart_toEndOf="@id/et_total_personnel"
app:layout_constraintTop_toTopOf="@id/btn_minus_total_personnel" />
@@ -246,8 +248,8 @@
android:fontFamily="@font/suit_medium"
android:gravity="center_vertical"
android:hint="@string/write_deadline_description"
- app:debouncedOnClick="@{() -> vm.makeMeetingDateChoiceEvent()}"
android:text="@{vm.meetingDate}"
+ app:debouncedOnClick="@{() -> vm.makeMeetingDateChoiceEvent()}"
app:layout_constraintStart_toStartOf="@id/tv_title"
app:layout_constraintTop_toBottomOf="@id/tv_meeting_date" />
@@ -282,10 +284,10 @@
android:background="@drawable/btn_background_selector"
android:enabled="@{vm.essentialSubmitButtonEnabled}"
android:fontFamily="@font/suit_medium"
- app:debouncedOnClick="@{() -> vm.makeNavigateToOptionalEvent()}"
android:text="@{vm.essentialSubmitButtonEnabled ? @string/modify_submit : @string/write_start_offering_button_disabled}"
android:textColor="@color/white"
android:textSize="@dimen/size_16"
+ app:debouncedOnClick="@{() -> vm.makeNavigateToOptionalEvent()}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
diff --git a/android/app/src/main/res/layout/fragment_offering_modify_optional.xml b/android/app/src/main/res/layout/fragment_offering_modify_optional.xml
index 64fecb3fb..63eb84fb6 100644
--- a/android/app/src/main/res/layout/fragment_offering_modify_optional.xml
+++ b/android/app/src/main/res/layout/fragment_offering_modify_optional.xml
@@ -109,6 +109,7 @@
android:id="@+id/progressBar"
android:layout_width="@dimen/size_20"
android:layout_height="@dimen/size_20"
+ android:indeterminateTint="@color/main_color"
app:isVisible="@{vm.isLoading}"
app:layout_constraintBottom_toBottomOf="@+id/iv_upload_photo"
app:layout_constraintEnd_toEndOf="@+id/iv_upload_photo"
@@ -271,8 +272,8 @@
android:text="@string/modify_submit"
android:textColor="@color/white"
android:textSize="@dimen/size_16"
+ app:debounceTime="@{1200L}"
app:debouncedOnClick="@{() -> vm.postOfferingModify()}"
- app:debounceTime="@{800L}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
@@ -282,4 +283,4 @@
-
\ No newline at end of file
+
diff --git a/android/app/src/main/res/layout/fragment_offering_write_essential.xml b/android/app/src/main/res/layout/fragment_offering_write_essential.xml
index a2b745084..0d5e59603 100644
--- a/android/app/src/main/res/layout/fragment_offering_write_essential.xml
+++ b/android/app/src/main/res/layout/fragment_offering_write_essential.xml
@@ -84,10 +84,11 @@
android:background="@drawable/btn_offering_write_minus_total_personnel"
android:fontFamily="@font/suit_bold"
android:gravity="center"
- app:debouncedOnClick="@{() -> vm.decreaseTotalCount()}"
android:text="@string/all_minus"
android:textColor="@color/white"
android:textSize="@dimen/size_20"
+ app:debounceTime="@{0L}"
+ app:debouncedOnClick="@{() -> vm.decreaseTotalCount()}"
app:layout_constraintStart_toStartOf="@id/tv_title"
app:layout_constraintTop_toBottomOf="@id/tv_total_personnel" />
@@ -118,10 +119,11 @@
android:background="@drawable/btn_offering_write_minus_total_personnel"
android:fontFamily="@font/suit_bold"
android:gravity="center"
- app:debouncedOnClick="@{() -> vm.increaseTotalCount()}"
android:text="@string/all_plus"
android:textColor="@color/white"
android:textSize="@dimen/size_20"
+ app:debounceTime="@{0L}"
+ app:debouncedOnClick="@{() -> vm.increaseTotalCount()}"
app:layout_constraintStart_toEndOf="@id/et_total_personnel"
app:layout_constraintTop_toTopOf="@id/btn_minus_total_personnel" />
@@ -246,8 +248,8 @@
android:fontFamily="@font/suit_medium"
android:gravity="center_vertical"
android:hint="@string/write_deadline_description"
- app:debouncedOnClick="@{() -> vm.makeMeetingDateChoiceEvent()}"
android:text="@{vm.meetingDate}"
+ app:debouncedOnClick="@{() -> vm.makeMeetingDateChoiceEvent()}"
app:layout_constraintStart_toStartOf="@id/tv_title"
app:layout_constraintTop_toBottomOf="@id/tv_meeting_date" />
@@ -282,10 +284,10 @@
android:background="@drawable/btn_background_selector"
android:enabled="@{vm.essentialSubmitButtonEnabled}"
android:fontFamily="@font/suit_medium"
- app:debouncedOnClick="@{() -> vm.makeNavigateToOptionalEvent()}"
android:text="@{vm.essentialSubmitButtonEnabled ? @string/write_start_offering : @string/write_start_offering_button_disabled}"
android:textColor="@color/white"
android:textSize="@dimen/size_16"
+ app:debouncedOnClick="@{() -> vm.makeNavigateToOptionalEvent()}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
diff --git a/android/app/src/main/res/layout/fragment_offering_write_optional.xml b/android/app/src/main/res/layout/fragment_offering_write_optional.xml
index d3ad5f5f3..098a9bac8 100644
--- a/android/app/src/main/res/layout/fragment_offering_write_optional.xml
+++ b/android/app/src/main/res/layout/fragment_offering_write_optional.xml
@@ -14,6 +14,17 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
+
+
@@ -76,10 +87,10 @@
android:layout_height="@dimen/size_44"
android:background="@drawable/btn_background_selector"
android:enabled="@{vm.extractButtonEnabled}"
- app:debouncedOnClick="@{() -> vm.postProductImageOg()}"
- app:debounceTime="@{800L}"
android:text="@string/write_extract_image"
android:textColor="@color/white"
+ app:debounceTime="@{800L}"
+ app:debouncedOnClick="@{() -> vm.postProductImageOg()}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/et_purchase_link" />
@@ -97,10 +108,10 @@
android:layout_marginTop="@dimen/size_9"
android:background="@drawable/bg_radius_10dp"
android:clipToOutline="true"
- app:debouncedOnClick="@{() -> vm.onUploadPhotoClick()}"
- app:debounceTime="@{800L}"
android:scaleType="centerCrop"
android:src="@drawable/btn_upload_photo"
+ app:debounceTime="@{800L}"
+ app:debouncedOnClick="@{() -> vm.onUploadPhotoClick()}"
app:importProductImageUrl="@{vm.thumbnailUrl}"
app:layout_constraintStart_toStartOf="@id/tv_information_message_optional"
app:layout_constraintTop_toBottomOf="@id/tv_photo" />
@@ -109,7 +120,7 @@
android:id="@+id/progressBar"
android:layout_width="@dimen/size_20"
android:layout_height="@dimen/size_20"
- app:isVisible="@{vm.isLoading}"
+ app:isVisible="@{vm.isImageUpLoading}"
app:layout_constraintBottom_toBottomOf="@+id/iv_upload_photo"
app:layout_constraintEnd_toEndOf="@+id/iv_upload_photo"
app:layout_constraintStart_toStartOf="@+id/iv_upload_photo"
@@ -119,8 +130,8 @@
android:id="@+id/iv_delete_image"
android:layout_width="@dimen/icon_size_30"
android:layout_height="@dimen/icon_size_30"
- app:debouncedOnClick="@{() -> vm.clearProductImage()}"
android:src="@drawable/btn_offering_write_delete_image"
+ app:debouncedOnClick="@{() -> vm.clearProductImage()}"
app:isVisible="@{vm.deleteImageVisible}"
app:layout_constraintBottom_toTopOf="@+id/iv_upload_photo"
app:layout_constraintEnd_toEndOf="@+id/iv_upload_photo"
@@ -213,11 +224,13 @@
android:id="@+id/et_contents"
style="@style/Theme.AppCompat.TextView.Write.EditText"
android:layout_height="278dp"
+ android:layout_marginTop="20dp"
android:gravity="top"
android:hint="@string/write_deadline_contents_description"
android:maxLength="1000"
android:padding="12dp"
android:text="@={vm.description}"
+ app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@id/tv_information_message_optional"
app:layout_constraintTop_toBottomOf="@+id/tv_contents" />
@@ -229,8 +242,8 @@
android:text="@{@string/write_current_description_count(vm.descriptionLength)}"
android:textColor="@color/main_color"
android:textSize="@dimen/font_size_12"
- app:layout_constraintTop_toTopOf="@id/tv_total_description_count"
app:layout_constraintEnd_toStartOf="@id/tv_total_description_count"
+ app:layout_constraintTop_toTopOf="@id/tv_total_description_count"
tools:text="10자" />
+
diff --git a/android/app/src/main/res/layout/item_comment_room_participant.xml b/android/app/src/main/res/layout/item_comment_room_participant.xml
index c04b33ab8..fc9bd8b33 100644
--- a/android/app/src/main/res/layout/item_comment_room_participant.xml
+++ b/android/app/src/main/res/layout/item_comment_room_participant.xml
@@ -1,5 +1,7 @@
-
+
@@ -13,59 +15,68 @@
-
+ android:textSize="@dimen/size_15"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintEnd_toStartOf="@id/tv_last_comment_time"
+ app:layout_constraintStart_toEndOf="@+id/iv_profile"
+ app:layout_constraintTop_toTopOf="@+id/iv_profile"
+ tools:text="엄청나게 긴 제목이 들어가는 것을 표현하는 중 입니다" />
+
+
-
-
+ tools:text="물품에 대해 설명 드릴게요. 물품에 대해 설명 드릴게요. 물품에 대해 설명 드릴게요. " />
-
\ No newline at end of file
+
diff --git a/android/app/src/main/res/layout/item_comment_room_proposer.xml b/android/app/src/main/res/layout/item_comment_room_proposer.xml
index d5ca30c9e..76b19f0b7 100644
--- a/android/app/src/main/res/layout/item_comment_room_proposer.xml
+++ b/android/app/src/main/res/layout/item_comment_room_proposer.xml
@@ -1,5 +1,7 @@
-
+
@@ -12,59 +14,69 @@
type="com.zzang.chongdae.presentation.view.comment.adapter.OnCommentRoomClickListener" />
-
+ android:textSize="@dimen/size_15"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintEnd_toStartOf="@id/tv_last_comment_time"
+ app:layout_constraintStart_toEndOf="@+id/iv_profile"
+ app:layout_constraintTop_toTopOf="@+id/iv_profile"
+ tools:text="엄청나게 긴 제목이 들어가는 것을 표현하는 중 입니다" />
+
+
-
-
+ tools:text="물품에 대해 설명 드릴게요. 물품에 대해 설명 드릴게요. 물품에 대해 설명 드릴게요. " />
-
\ No newline at end of file
+
diff --git a/android/app/src/main/res/layout/item_date_separator.xml b/android/app/src/main/res/layout/item_date_separator.xml
index 3ee39ea44..8f24d4225 100644
--- a/android/app/src/main/res/layout/item_date_separator.xml
+++ b/android/app/src/main/res/layout/item_date_separator.xml
@@ -7,7 +7,7 @@
+ type="com.zzang.chongdae.presentation.view.commentdetail.model.comment.CommentUiModel" />
+ type="com.zzang.chongdae.presentation.view.commentdetail.model.comment.CommentUiModel" />
diff --git a/android/app/src/main/res/layout/item_other_comment.xml b/android/app/src/main/res/layout/item_other_comment.xml
index fce240006..6f3be7f3a 100644
--- a/android/app/src/main/res/layout/item_other_comment.xml
+++ b/android/app/src/main/res/layout/item_other_comment.xml
@@ -7,7 +7,7 @@
+ type="com.zzang.chongdae.presentation.view.commentdetail.model.comment.CommentUiModel" />
diff --git a/android/app/src/main/res/layout/item_switch.xml b/android/app/src/main/res/layout/item_switch.xml
new file mode 100644
index 000000000..bc091e4c2
--- /dev/null
+++ b/android/app/src/main/res/layout/item_switch.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml
new file mode 100644
index 000000000..5a0efaf51
--- /dev/null
+++ b/android/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,12 @@
+
+
+ #FFFFFFFF
+ #FFD9D9D9
+ #FFFFFFFF
+ #FF303030
+ #FFFFFFFF
+ #FFFFFFFF
+ #FFFFFFFF
+ #FFFFFFFF
+ #FFFFFFFF
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
index 8bff57566..33bf67e4e 100644
--- a/android/app/src/main/res/values/colors.xml
+++ b/android/app/src/main/res/values/colors.xml
@@ -31,4 +31,6 @@
#FF606060
#FF4978D6
#FFFEE500
+ #FFFFFFFF
+ #FF000000
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 7d4f49d4e..587efcb28 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -31,6 +31,11 @@
카카오톡으로 \n간편하게 공동구매 시작하기
카카오 로그인
+
+ 재로그인이 필요합니다. 다시 로그인해주세요!
+ 공모 ID가 올바르지 않습니다.
+ Deeplink가 올바르지 않습니다.
+
홈
채팅방
@@ -57,6 +62,8 @@
개인정보처리 방침
로그아웃
회원 탈퇴
+ 푸시 알림 받기
+ 푸시 알림 수신 시 팝업 띄우기
마이페이지
내가 올린 공구
로그아웃 하시겠습니까?
diff --git a/android/app/src/main/res/values/style.xml b/android/app/src/main/res/values/style.xml
index d8ffd962b..26721cfae 100644
--- a/android/app/src/main/res/values/style.xml
+++ b/android/app/src/main/res/values/style.xml
@@ -167,4 +167,8 @@
- 16dp
+
+
diff --git a/android/app/src/test/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailViewModelTest.kt b/android/app/src/test/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailViewModelTest.kt
index c5b6090b1..1b3917db4 100644
--- a/android/app/src/test/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailViewModelTest.kt
+++ b/android/app/src/test/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailViewModelTest.kt
@@ -70,7 +70,7 @@ class CommentDetailViewModelTest {
// when
// then
val result = viewModel.comments.getOrAwaitValue()
- assertThat(result).isEqualTo(TestFixture.comments)
+ assertThat(result).isEqualTo(TestFixture.commentsUiModels)
}
@DisplayName("댓글을 작성하면 댓글 목록에 추가된다")
@@ -78,7 +78,6 @@ class CommentDetailViewModelTest {
fun addComment() {
// given
val before = viewModel.comments.getOrAwaitValue()
- assertThat(before.size).isEqualTo(1)
// when
viewModel.commentContent.value = "new comment"
@@ -87,7 +86,7 @@ class CommentDetailViewModelTest {
// then
val result = viewModel.comments.getOrAwaitValue()
- assertThat(result.size).isEqualTo(2)
+ assertThat(result.size).isEqualTo(before.size + 1)
}
@DisplayName("약속 장소(도로명주소)를 불러온다")
diff --git a/android/app/src/test/java/com/zzang/chongdae/repository/FakeAuthRepository.kt b/android/app/src/test/java/com/zzang/chongdae/repository/FakeAuthRepository.kt
index e9a570f57..a7856da73 100644
--- a/android/app/src/test/java/com/zzang/chongdae/repository/FakeAuthRepository.kt
+++ b/android/app/src/test/java/com/zzang/chongdae/repository/FakeAuthRepository.kt
@@ -6,7 +6,10 @@ import com.zzang.chongdae.common.handler.DataError
import com.zzang.chongdae.common.handler.Result
class FakeAuthRepository : AuthRepository {
- override suspend fun saveLogin(accessToken: String): Result {
+ override suspend fun saveLogin(
+ accessToken: String,
+ fcmToken: String,
+ ): Result {
TODO("Not yet implemented")
}
diff --git a/android/app/src/test/java/com/zzang/chongdae/repository/FakeCommentDetailRepository.kt b/android/app/src/test/java/com/zzang/chongdae/repository/FakeCommentDetailRepository.kt
index b177a9a22..c63560308 100644
--- a/android/app/src/test/java/com/zzang/chongdae/repository/FakeCommentDetailRepository.kt
+++ b/android/app/src/test/java/com/zzang/chongdae/repository/FakeCommentDetailRepository.kt
@@ -11,6 +11,11 @@ class FakeCommentDetailRepository : CommentDetailRepository {
private val comments: MutableList = TestFixture.comments
private var commentOfferingInfo: CommentOfferingInfo = TestFixture.commentOfferingInfo
+ init {
+ comments.clear()
+ comments.add(TestFixture.comment)
+ }
+
override suspend fun saveComment(
offeringId: Long,
comment: String,
diff --git a/android/app/src/test/java/com/zzang/chongdae/util/TestFixture.kt b/android/app/src/test/java/com/zzang/chongdae/util/TestFixture.kt
index 8755f6c83..f3cda7fa4 100644
--- a/android/app/src/test/java/com/zzang/chongdae/util/TestFixture.kt
+++ b/android/app/src/test/java/com/zzang/chongdae/util/TestFixture.kt
@@ -20,6 +20,9 @@ import com.zzang.chongdae.domain.model.participant.Participant
import com.zzang.chongdae.domain.model.participant.ParticipantCount
import com.zzang.chongdae.domain.model.participant.Participants
import com.zzang.chongdae.domain.model.participant.Proposer
+import com.zzang.chongdae.presentation.view.commentdetail.adapter.comment.CommentViewType
+import com.zzang.chongdae.presentation.view.commentdetail.model.comment.CommentUiModel
+import com.zzang.chongdae.presentation.view.commentdetail.model.comment.CommentUiModel.Companion.toUiModelListWithSeparators
import okhttp3.MultipartBody
import java.time.LocalDateTime
@@ -50,6 +53,22 @@ object TestFixture {
comment,
)
+ val commentUiModel: CommentUiModel =
+ CommentUiModel(
+ content = "content1",
+ date = "2021-10-10",
+ time = "10:10:10",
+ isMine = true,
+ isProposer = true,
+ nickname = "nickname",
+ commentViewType = CommentViewType.MyComment,
+ )
+
+ val commentsUiModels: List =
+ mutableListOf(
+ comment,
+ ).toUiModelListWithSeparators()
+
val commentOfferingInfo: CommentOfferingInfo =
CommentOfferingInfo(
status = "status",
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
index ad1be0e0e..50170cf4d 100644
--- a/android/build.gradle.kts
+++ b/android/build.gradle.kts
@@ -5,7 +5,8 @@ buildscript {
}
dependencies {
val navigationVersion = "2.7.7"
- classpath("androidx.navigation:navigation-safe-args-gradle-plugin:$navigationVersion")
+ classpath(libs.androidx.navigation.safe.args.gradle.plugin)
+ classpath(libs.google.services)
}
}
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index a0e70018f..5f215c8fe 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -1,5 +1,6 @@
[versions]
agp = "8.3.1"
+googleServices = "4.4.2"
hiltAndroid = "2.48.1"
kotlin = "1.9.0"
coreKtx = "1.10.1"
@@ -24,6 +25,7 @@ paging = "3.3.0"
swiperefreshlayout = "1.1.0"
datastore = "1.0.0"
junit-jupiter = "5.10.2"
+shimmer = "0.5.0"
#assert-core = "3.25.3"
#core-testing = "2.1.0"
#mannodermaus = "1.3.0"
@@ -39,6 +41,8 @@ androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecyc
# AndroidX, UI 관련
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+androidx-navigation-safe-args-gradle-plugin = { module = "androidx.navigation:navigation-safe-args-gradle-plugin", version.ref = "navigation" }
+google-services = { module = "com.google.gms:google-services", version.ref = "googleServices" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroid" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hiltAndroid" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
@@ -85,6 +89,7 @@ glide-compiler = { group = "com.github.bumptech.glide", name = "compiler", versi
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" }
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" }
+firebase-message = { group = "com.google.firebase", name = "firebase-messaging-ktx" }
# mockk
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
@@ -98,6 +103,9 @@ kakao-sdk = { group = "com.kakao.sdk", name = "v2-all", version.ref = "kakaoSdk"
# Kotlin Serialization
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
+# Skeleton-UI
+shimmer = { group = "com.facebook.shimmer", name = "shimmer", version.ref = "shimmer" }
+
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
diff --git a/backend/.gitignore b/backend/.gitignore
index 484aa34ee..9731aa956 100644
--- a/backend/.gitignore
+++ b/backend/.gitignore
@@ -44,3 +44,6 @@ openapi3.yaml
### Intellij IDEA ###
.idea
+
+### FCM ###
+/src/main/resources/fcm
diff --git a/backend/build.gradle b/backend/build.gradle
index b5bb6b302..69b412390 100644
--- a/backend/build.gradle
+++ b/backend/build.gradle
@@ -35,6 +35,7 @@ dependencies {
implementation 'org.jsoup:jsoup:1.18.1'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'javax.xml.bind:jaxb-api:2.3.1'
+ implementation 'com.google.firebase:firebase-admin:9.3.0'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
diff --git a/backend/deploy/README.md b/backend/deploy/README.md
new file mode 100644
index 000000000..45f23b627
--- /dev/null
+++ b/backend/deploy/README.md
@@ -0,0 +1,33 @@
+# blue-green 스크립트
+
+무중단 배포를 하기 위한 blue green 스크립트 입니다.
+
+## 초기 설정
+
+blue green을 처음 구축할 때 사용됩니다.
+
+스크립트 실행 시 nginx 컨테이너와 총대마켓 컨테이너가 각각 80, 8080 포트로 구동되고, nginx는 총대마켓 컨테이너로 proxy_pass가 설정되도록 구축이됩니다.
+외부의 요청을 받기 위해 nginx는 외부 80포트가 오픈됩니다.
+
+### 스크립트 실행 방법
+
+``` bash
+./setup.sh <최근 배포 성공한 commit 해시> <적용 프로파일> <컨테이너 계정 명> <컨테이너 이미지 명>
+```
+
+## 이후 전략
+
+CI에서 배포가 끝나면 배포 단계에서 `launch_next_container.sh`을 실행합니다.
+실행 시 최신 빌드 성공한 commit이 반영 된 컨테이너가 실행됩니다.
+컨테이너가 올라가면, `sleep second` 동안 대기 후 성공적으로 컨테이너가 실행됬는지 확인합니다.
+
+이후 성공적으로 컨테이너가 구동 완료 되면 `switch_blue_green_container.sh`를 실행시켜 nginx가 새로운 컨테이너로 proxy_pass하도록 설정을 변경합니다.
+
+### 스크립트 실행 과정
+
+``` bash
+./launch_next_container.sh <적용 프로파일> <컨테이너 계정 명> <컨테이너 이미지 명>
+
+# blue -> green , green -> blue 전환
+./switch_blue_green_container.sh
+```
diff --git a/backend/deploy/default.conf b/backend/deploy/default.conf
new file mode 100644
index 000000000..6dfeddc86
--- /dev/null
+++ b/backend/deploy/default.conf
@@ -0,0 +1,14 @@
+upstream active_server {
+ server chongdae_backend:8080;
+}
+
+server {
+ listen 80;
+ location / {
+ proxy_pass http://active_server;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ }
+}
+
diff --git a/backend/deploy/launch_next_container.sh b/backend/deploy/launch_next_container.sh
new file mode 100755
index 000000000..287d02326
--- /dev/null
+++ b/backend/deploy/launch_next_container.sh
@@ -0,0 +1,84 @@
+#!/bin/bash
+
+GITHUB_SHA=$1
+PROFILE_ACTIVE=$2
+DOCKERHUB_USER_NAME=$3
+DOCKER_IMAGE_NAME=$4
+
+INITIAL_BLUE_GREEN_NETWORK_NAME="blue_green_network"
+BLUE_CONTAINER="chongdae_backend_blue"
+GREEN_CONTAINER="chongdae_backend_green"
+NGINX_CONTAINER="nginx"
+
+# parameter check
+if [ -z "$GITHUB_SHA" ] || [ -z "$PROFILE_ACTIVE" ] || [ -z "$DOCKERHUB_USER_NAME" ] || [ -z "$DOCKER_IMAGE_NAME" ]; then
+ echo "사용법: $0 "
+ exit 1
+fi
+
+get_active_container() {
+ local active_container_name=$(docker exec ${NGINX_CONTAINER} cat /etc/nginx/conf.d/default.conf | \
+ grep -P '^\s*server\s+\S+:' | \
+ grep -oP '(?<=server\s)[^:;]+' | \
+ sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
+
+ echo "$active_container_name"
+}
+
+get_next_container() {
+ local active_container=$1
+ if [[ "$active_container" == "$GREEN_CONTAINER" ]]; then
+ echo "$BLUE_CONTAINER"
+ return 0
+ fi
+
+ if [[ "$active_container" == "$BLUE_CONTAINER" ]]; then
+ echo "$GREEN_CONTAINER"
+ return 0
+ fi
+
+ return 1
+}
+
+health_check() {
+ local next_container=$1
+ local max_tries=$2
+ local sleep_seconds=$3
+
+ for ((i=1; i<=max_tries; i++)); do
+ echo "[INFO] Attempt $i: Checking health of $next_container"
+ local status=$(docker exec nginx curl -H "Host: localhost" -o /dev/null -s -w "%{http_code}\n" http://$next_container:8080/health-check)
+
+ if [[ "$status" -eq "200" ]]; then
+ echo "[INFO] Health check successful."
+ return 0
+ fi
+
+ echo "[WARNING] Health check failed. Attempt $i of $max_tries."
+ sleep $sleep_seconds
+ done
+
+ echo "[ERROR] All $max_tries health check attempts failed for $next_container. Exiting..."
+ return 1
+}
+
+ACTIVE_CONTAINER=$(get_active_container)
+NEXT_CONTAINER=$(get_next_container $ACTIVE_CONTAINER)
+
+echo "[+] launch $NEXT_CONTAINER"
+
+docker rm -f ${NEXT_CONTAINER} >/dev/null 2>&1
+
+docker run -d \
+ --network ${INITIAL_BLUE_GREEN_NETWORK_NAME} \
+ --name ${NEXT_CONTAINER} \
+ -v /logs:/logs \
+ -e SPRING_PROFILES_ACTIVE=${PROFILE_ACTIVE} \
+ ${DOCKERHUB_USER_NAME}/${DOCKER_IMAGE_NAME}:${GITHUB_SHA:0:7}
+
+MAX_TRIES=5
+SLEEP_SECONDS=10
+health_check "$NEXT_CONTAINER" "$MAX_TRIES" "$SLEEP_SECONDS"
+if [ $? -ne 0 ]; then
+ exit 1
+fi
diff --git a/backend/deploy/setup.sh b/backend/deploy/setup.sh
new file mode 100755
index 000000000..e9812c79c
--- /dev/null
+++ b/backend/deploy/setup.sh
@@ -0,0 +1,62 @@
+#!/bin/bash
+GITHUB_SHA=$1
+PROFILE_ACTIVE=$2
+DOCKERHUB_USER_NAME=$3
+DOCKER_IMAGE_NAME=$4
+
+# parameter check
+if [ -z "$GITHUB_SHA" ] || [ -z "$PROFILE_ACTIVE" ] || [ -z "$DOCKERHUB_USER_NAME" ] || [ -z "$DOCKER_IMAGE_NAME" ]; then
+ echo "사용법: $0 "
+ exit 1
+fi
+
+# intialize blue & gren
+INITIAL_INSTANCE_NAME="chongdae_backend_green"
+INITIAL_BLUE_GREEN_NETWORK_NAME="blue_green_network"
+NGINX_DEFAULT_CONF_PATH="default.conf"
+NGINX_NEW_CONF_PATH="new-default.conf"
+
+# 1. ADD DOCKER NETWORK
+if ! docker network ls | grep -q ${INITIAL_BLUE_GREEN_NETWORK_NAME}; then
+ echo "[+] Create Docker Netowrk ${INITIAL_BLUE_GREEN_NETWORK_NAME}"
+ docker network create --driver bridge "${INITIAL_BLUE_GREEN_NETWORK_NAME}"
+fi
+
+# 2. RUN INITIAL_CONTAINER
+echo "[+] stop and remove intial container"
+docker rm -f ${INITIAL_INSTANCE_NAME} >/dev/null 2>&1
+
+echo "[+] add chongdae_backend container"
+docker run -d \
+ --network ${INITIAL_BLUE_GREEN_NETWORK_NAME} \
+ --name ${INITIAL_INSTANCE_NAME} \
+ -v /logs:/logs \
+ -e SPRING_PROFILES_ACTIVE=${PROFILE_ACTIVE} \
+ ${DOCKERHUB_USER_NAME}/${DOCKER_IMAGE_NAME}:${GITHUB_SHA:0:7}
+
+if [ $? -ne 0 ]; then
+ exit 1
+fi
+
+# 3. CHANGE INITIAL UP STREAM SERVER
+cp ${NGINX_DEFAULT_CONF_PATH} ${NGINX_NEW_CONF_PATH}
+
+if grep -q "server chongdae_backend:" "${NGINX_NEW_CONF_PATH}"; then
+ sed -i "s/server chongdae_backend:/server ${INITIAL_INSTANCE_NAME}:/" "${NGINX_NEW_CONF_PATH}"
+fi
+
+# 4. RUN DOCKER NGINX
+echo "[+] RUN nginx server"
+docker run -d \
+ --network ${INITIAL_BLUE_GREEN_NETWORK_NAME} \
+ --name nginx \
+ -p 80:80 \
+ nginx:latest
+
+# 5. setup proxy in nginx container
+docker cp ${NGINX_NEW_CONF_PATH} nginx:/etc/nginx/conf.d/${NGINX_NEW_CONF_PATH}
+docker exec nginx mv /etc/nginx/conf.d/${NGINX_NEW_CONF_PATH} /etc/nginx/conf.d/default.conf
+docker exec nginx nginx -s reload
+
+# 6. clean up
+rm -rf ${NGINX_NEW_CONF_PATH}
diff --git a/backend/deploy/switch_blue_green_container.sh b/backend/deploy/switch_blue_green_container.sh
new file mode 100755
index 000000000..25402f9b0
--- /dev/null
+++ b/backend/deploy/switch_blue_green_container.sh
@@ -0,0 +1,86 @@
+#!/bin/bash
+NGINX_CONTAINER_NAME="nginx"
+BLUE_CONTAINER="chongdae_backend_blue"
+GREEN_CONTAINER="chongdae_backend_green"
+NGINX_DEFAULT_CONF="default.conf"
+NGINX_CHANGED_DEFAULT_CONF="new-default.conf"
+
+get_active_container() {
+ local active_container_name=$(docker exec nginx cat /etc/nginx/conf.d/default.conf | \
+ grep -P '^\s*server\s+\S+:' | \
+ grep -oP '(?<=server\s)[^:;]+' | \
+ sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
+
+ echo "$active_container_name"
+}
+
+get_next_container() {
+ local active_container=$1
+ if [[ "$active_container" == "$GREEN_CONTAINER" ]]; then
+ echo "$BLUE_CONTAINER"
+ return 0
+ fi
+
+ if [[ "$active_container" == "$BLUE_CONTAINER" ]]; then
+ echo "$GREEN_CONTAINER"
+ return 0
+ fi
+
+ return 1
+}
+
+change_blue_green_container() {
+ local next_container=$1
+ cp ${NGINX_DEFAULT_CONF} ${NGINX_CHANGED_DEFAULT_CONF}
+
+ if grep -q "server chongdae_backend:" "${NGINX_CHANGED_DEFAULT_CONF}"; then
+ sed -i "s/server chongdae_backend:/server ${next_container}:/" "${NGINX_CHANGED_DEFAULT_CONF}"
+ fi
+ echo "[+] change container $next_container"
+ docker cp ./${NGINX_CHANGED_DEFAULT_CONF} ${NGINX_CONTAINER_NAME}:/etc/nginx/conf.d/${NGINX_CHANGED_DEFAULT_CONF}
+ docker exec ${NGINX_CONTAINER_NAME} cp /etc/nginx/conf.d/${NGINX_CHANGED_DEFAULT_CONF} /etc/nginx/conf.d/default.conf
+ docker exec ${NGINX_CONTAINER_NAME} rm -rf /etc/nginx/conf.d/${NGINX_CHANGED_DEFAULT_CONF}
+ docker exec ${NGINX_CONTAINER_NAME} nginx -s reload
+
+ # clean up
+ rm -rf ${NGINX_CHANGED_DEFAULT_CONF}
+}
+
+
+health_check() {
+ local status=$(curl -o /dev/null -s -w "%{http_code}\n" http://localhost/health-check)
+ if [[ "$status" -eq "200" ]]; then
+ return 0
+ fi
+ return 1
+}
+
+remove_container() {
+ local removed_container_name=$1
+
+ docker rm -f $removed_container_name
+ if [ $? -eq 0 ]; then
+ echo "[-] stop $removed_container_name"
+ return 0
+ fi
+ return 1
+}
+
+ACTIVE_CONTAINER=$(get_active_container)
+NEXT_CONTAINER=$(get_next_container $ACTIVE_CONTAINER)
+
+if [ $? -eq 0 ]; then
+ change_blue_green_container $NEXT_CONTAINER
+fi
+
+health_check
+
+if [ $? -eq 0 ]; then
+ remove_container $ACTIVE_CONTAINER
+else
+ echo "[-] health check fail"
+ exit 1
+fi
+
+echo "[-] clean docker image"
+docker image prune -a -f
diff --git a/backend/src/main/java/com/zzang/chongdae/auth/controller/AuthController.java b/backend/src/main/java/com/zzang/chongdae/auth/controller/AuthController.java
index b454b7693..c8121bfd3 100644
--- a/backend/src/main/java/com/zzang/chongdae/auth/controller/AuthController.java
+++ b/backend/src/main/java/com/zzang/chongdae/auth/controller/AuthController.java
@@ -9,7 +9,6 @@
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
-import jakarta.validation.Valid;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
@@ -28,7 +27,7 @@ public class AuthController {
@LoggingMasked
@PostMapping("/auth/login/kakao")
public ResponseEntity kakaoLogin(
- @RequestBody @Valid KakaoLoginRequest request, HttpServletResponse servletResponse) {
+ @RequestBody KakaoLoginRequest request, HttpServletResponse servletResponse) {
AuthInfoDto authInfo = authService.kakaoLogin(request);
addTokenToHttpServletResponse(authInfo.authToken(), servletResponse);
LoginResponse response = new LoginResponse(authInfo.authMember());
diff --git a/backend/src/main/java/com/zzang/chongdae/auth/service/AuthService.java b/backend/src/main/java/com/zzang/chongdae/auth/service/AuthService.java
index 9adf578fa..43c21a42b 100644
--- a/backend/src/main/java/com/zzang/chongdae/auth/service/AuthService.java
+++ b/backend/src/main/java/com/zzang/chongdae/auth/service/AuthService.java
@@ -11,14 +11,19 @@
import com.zzang.chongdae.member.repository.MemberRepository;
import com.zzang.chongdae.member.repository.entity.MemberEntity;
import com.zzang.chongdae.member.service.NicknameGenerator;
+import com.zzang.chongdae.notification.service.FcmNotificationService;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+@Slf4j
@RequiredArgsConstructor
@Service
public class AuthService {
+ private final FcmNotificationService notificationService;
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
@@ -26,26 +31,47 @@ public class AuthService {
private final AuthClient authClient;
@WriterDatabase
+ @Transactional
public AuthInfoDto kakaoLogin(KakaoLoginRequest request) {
String loginId = authClient.getKakaoUserInfo(request.accessToken());
AuthProvider provider = AuthProvider.KAKAO;
MemberEntity member = memberRepository.findByLoginId(loginId)
- .orElseGet(() -> signup(provider, loginId));
- return login(member);
+ .orElseGet(() -> signup(provider, loginId, request.fcmToken()));
+ return login(member, request.fcmToken());
}
- private MemberEntity signup(AuthProvider provider, String loginId) {
+ private MemberEntity signup(AuthProvider provider, String loginId, String fcmToken) {
String password = passwordEncoder.encode(UUID.randomUUID().toString());
- MemberEntity member = new MemberEntity(nickNameGenerator.generate(), provider, loginId, password);
+ MemberEntity member = createMember(provider, loginId, fcmToken, password);
return memberRepository.save(member);
}
- private AuthInfoDto login(MemberEntity member) {
+ private MemberEntity createMember(AuthProvider provider, String loginId, String fcmToken, String password) {
+ if (fcmToken == null) {
+ return new MemberEntity(nickNameGenerator.generate(), provider, loginId, password);
+ }
+ return new MemberEntity(nickNameGenerator.generate(), provider, loginId, password, fcmToken);
+ }
+
+ private AuthInfoDto login(MemberEntity member, String fcmToken) {
AuthMemberDto authMember = new AuthMemberDto(member);
AuthTokenDto authToken = jwtTokenProvider.createAuthToken(member.getId().toString());
+ checkFcmToken(member, fcmToken);
+ notificationService.login(member);
return new AuthInfoDto(authMember, authToken);
}
+ private void checkFcmToken(MemberEntity member, String fcmToken) {
+ if (fcmToken == null) {
+ member.updateFcmTokenDefault();
+ return;
+ }
+ if (!memberRepository.existsByIdAndFcmToken(member.getId(), fcmToken)) {
+ log.info("토큰 갱신 사용자 id: {}", member.getId());
+ member.updateFcmToken(fcmToken);
+ }
+ }
+
public AuthTokenDto refresh(String refreshToken) {
Long memberId = jwtTokenProvider.getMemberIdByRefreshToken(refreshToken);
return jwtTokenProvider.createAuthToken(memberId.toString());
diff --git a/backend/src/main/java/com/zzang/chongdae/auth/service/dto/KakaoLoginRequest.java b/backend/src/main/java/com/zzang/chongdae/auth/service/dto/KakaoLoginRequest.java
index 769e67393..d775de281 100644
--- a/backend/src/main/java/com/zzang/chongdae/auth/service/dto/KakaoLoginRequest.java
+++ b/backend/src/main/java/com/zzang/chongdae/auth/service/dto/KakaoLoginRequest.java
@@ -1,4 +1,5 @@
package com.zzang.chongdae.auth.service.dto;
-public record KakaoLoginRequest(String accessToken) {
+public record KakaoLoginRequest(String accessToken,
+ String fcmToken) {
}
diff --git a/backend/src/main/java/com/zzang/chongdae/comment/service/CommentService.java b/backend/src/main/java/com/zzang/chongdae/comment/service/CommentService.java
index 11a00d412..aad4df9e7 100644
--- a/backend/src/main/java/com/zzang/chongdae/comment/service/CommentService.java
+++ b/backend/src/main/java/com/zzang/chongdae/comment/service/CommentService.java
@@ -14,6 +14,7 @@
import com.zzang.chongdae.global.config.WriterDatabase;
import com.zzang.chongdae.global.exception.MarketException;
import com.zzang.chongdae.member.repository.entity.MemberEntity;
+import com.zzang.chongdae.notification.service.FcmNotificationService;
import com.zzang.chongdae.offering.domain.CommentRoomStatus;
import com.zzang.chongdae.offering.domain.OfferingStatus;
import com.zzang.chongdae.offering.exception.OfferingErrorCode;
@@ -32,6 +33,7 @@
@Service
public class CommentService {
+ private final FcmNotificationService notificationService;
private final CommentRepository commentRepository;
private final OfferingRepository offeringRepository;
private final OfferingMemberRepository offeringMemberRepository;
@@ -43,6 +45,9 @@ public Long saveComment(CommentSaveRequest request, MemberEntity member) {
validateIsJoined(member, offering);
CommentEntity comment = new CommentEntity(member, offering, request.content());
CommentEntity savedComment = commentRepository.save(comment);
+
+ List offeringMembers = offeringMemberRepository.findAllByOffering(offering);
+ notificationService.saveComment(savedComment, offeringMembers);
return savedComment.getId();
}
@@ -98,6 +103,7 @@ public CommentRoomStatusResponse updateCommentRoomStatus(Long offeringId, Member
if (updatedStatus.isBuying()) { // TODO : 도메인으로 정리
offering.updateOfferingStatus(OfferingStatus.CONFIRMED);
}
+ notificationService.updateStatus(offering);
return new CommentRoomStatusResponse(updatedStatus);
}
diff --git a/backend/src/main/java/com/zzang/chongdae/member/repository/MemberRepository.java b/backend/src/main/java/com/zzang/chongdae/member/repository/MemberRepository.java
index d2bf2ec0d..678f4ddfb 100644
--- a/backend/src/main/java/com/zzang/chongdae/member/repository/MemberRepository.java
+++ b/backend/src/main/java/com/zzang/chongdae/member/repository/MemberRepository.java
@@ -13,4 +13,6 @@ public interface MemberRepository extends JpaRepository {
boolean existsByNickname(String nickname);
Optional findByLoginId(String loginId);
+
+ boolean existsByIdAndFcmToken(Long id, String fcmToken);
}
diff --git a/backend/src/main/java/com/zzang/chongdae/member/repository/entity/MemberEntity.java b/backend/src/main/java/com/zzang/chongdae/member/repository/entity/MemberEntity.java
index 03fe675bf..4e3674bec 100644
--- a/backend/src/main/java/com/zzang/chongdae/member/repository/entity/MemberEntity.java
+++ b/backend/src/main/java/com/zzang/chongdae/member/repository/entity/MemberEntity.java
@@ -11,6 +11,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
+import java.util.UUID;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
@@ -25,6 +26,8 @@
@Entity
public class MemberEntity extends BaseTimeEntity {
+ private static final String DEFAULT_FCM_TOKEN = "invalid";
+
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@@ -42,12 +45,28 @@ public class MemberEntity extends BaseTimeEntity {
@NotNull
private String password; // TODO: 일반 로그인 들어올 시 salt 추가
-
+
+ @NotNull
+ @Column(unique = true)
+ private String fcmToken;
+
+ public MemberEntity(String nickname, AuthProvider provider, String loginId, String password, String fcmToken) {
+ this(null, nickname, provider, loginId, password, fcmToken);
+ }
+
public MemberEntity(String nickname, AuthProvider provider, String loginId, String password) {
- this(null, nickname, provider, loginId, password);
+ this(null, nickname, provider, loginId, password, DEFAULT_FCM_TOKEN + UUID.randomUUID());
}
public boolean isSame(MemberEntity other) {
return this.equals(other);
}
+
+ public void updateFcmToken(String fcmToken) {
+ this.fcmToken = fcmToken;
+ }
+
+ public void updateFcmTokenDefault() {
+ this.fcmToken = DEFAULT_FCM_TOKEN + UUID.randomUUID();
+ }
}
diff --git a/backend/src/main/java/com/zzang/chongdae/notification/config/FcmConfig.java b/backend/src/main/java/com/zzang/chongdae/notification/config/FcmConfig.java
new file mode 100644
index 000000000..c8341ec7a
--- /dev/null
+++ b/backend/src/main/java/com/zzang/chongdae/notification/config/FcmConfig.java
@@ -0,0 +1,41 @@
+package com.zzang.chongdae.notification.config;
+
+import com.google.auth.oauth2.GoogleCredentials;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
+import java.io.IOException;
+import java.io.InputStream;
+import javax.annotation.PostConstruct;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+
+@Slf4j
+@Configuration
+public class FcmConfig {
+
+ @Value("${fcm.secret-key.path}")
+ private String secretKeyPath;
+
+ @PostConstruct
+ public void initialize() {
+ if (!FirebaseApp.getApps().isEmpty()) {
+ log.info("성공적으로 FCM 앱을 실행하였습니다.");
+ return;
+ }
+ try {
+ InputStream secretKey = this.getClass().getResourceAsStream(secretKeyPath);
+ FirebaseApp.initializeApp(fcmOptions(secretKey));
+ log.info("성공적으로 FCM 앱을 초기화하였습니다.");
+ } catch (IOException e) {
+ e.printStackTrace();
+ throw new RuntimeException(e);
+ }
+ }
+
+ private FirebaseOptions fcmOptions(InputStream secretKey) throws IOException {
+ return FirebaseOptions.builder()
+ .setCredentials(GoogleCredentials.fromStream(secretKey))
+ .build();
+ }
+}
diff --git a/backend/src/main/java/com/zzang/chongdae/notification/domain/FcmCondition.java b/backend/src/main/java/com/zzang/chongdae/notification/domain/FcmCondition.java
new file mode 100644
index 000000000..883046d71
--- /dev/null
+++ b/backend/src/main/java/com/zzang/chongdae/notification/domain/FcmCondition.java
@@ -0,0 +1,21 @@
+package com.zzang.chongdae.notification.domain;
+
+import com.zzang.chongdae.offering.repository.entity.OfferingEntity;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class FcmCondition {
+
+ private static final String CONDITION_FORMAT_TRUE_AND_FALSE = "'%s' in topics && !('%s' in topics)";
+
+ private final String value;
+
+ public static FcmCondition offeringCondition(OfferingEntity offering) {
+ FcmTopic memberTopic = FcmTopic.memberTopic();
+ FcmTopic proposerTopic = FcmTopic.proposerTopic(offering);
+ String value = CONDITION_FORMAT_TRUE_AND_FALSE.formatted(memberTopic.getValue(), proposerTopic.getValue());
+ return new FcmCondition(value);
+ }
+}
diff --git a/backend/src/main/java/com/zzang/chongdae/notification/domain/FcmData.java b/backend/src/main/java/com/zzang/chongdae/notification/domain/FcmData.java
new file mode 100644
index 000000000..98ae50a9c
--- /dev/null
+++ b/backend/src/main/java/com/zzang/chongdae/notification/domain/FcmData.java
@@ -0,0 +1,27 @@
+package com.zzang.chongdae.notification.domain;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class FcmData {
+
+ private final Map data = new HashMap<>();
+
+ public void addData(String key, Object value) {
+ data.put(key, value.toString());
+ }
+
+ public Map getData() {
+ data.forEach(this::logWithoutBody);
+ return Collections.unmodifiableMap(data);
+ }
+
+ private void logWithoutBody(String key, String value) {
+ if (!key.equals("body")) {
+ log.info("{} : {}", key, value);
+ }
+ }
+}
diff --git a/backend/src/main/java/com/zzang/chongdae/notification/domain/FcmToken.java b/backend/src/main/java/com/zzang/chongdae/notification/domain/FcmToken.java
new file mode 100644
index 000000000..f2e2936c3
--- /dev/null
+++ b/backend/src/main/java/com/zzang/chongdae/notification/domain/FcmToken.java
@@ -0,0 +1,14 @@
+package com.zzang.chongdae.notification.domain;
+
+import com.zzang.chongdae.member.repository.entity.MemberEntity;
+import lombok.Getter;
+
+@Getter
+public class FcmToken {
+
+ private final String value;
+
+ public FcmToken(MemberEntity member) {
+ this.value = member.getFcmToken();
+ }
+}
diff --git a/backend/src/main/java/com/zzang/chongdae/notification/domain/FcmTokens.java b/backend/src/main/java/com/zzang/chongdae/notification/domain/FcmTokens.java
new file mode 100644
index 000000000..2951feb15
--- /dev/null
+++ b/backend/src/main/java/com/zzang/chongdae/notification/domain/FcmTokens.java
@@ -0,0 +1,30 @@
+package com.zzang.chongdae.notification.domain;
+
+import com.zzang.chongdae.member.repository.entity.MemberEntity;
+import java.util.List;
+
+public class FcmTokens {
+
+ private final List tokens;
+
+ private FcmTokens(List tokens) {
+ this.tokens = tokens;
+ }
+
+ public static FcmTokens from(List members) {
+ List tokens = members.stream()
+ .map(FcmToken::new)
+ .toList();
+ return new FcmTokens(tokens);
+ }
+
+ public List getTokenValues() {
+ return tokens.stream()
+ .map(FcmToken::getValue)
+ .toList();
+ }
+
+ public boolean isEmpty() {
+ return tokens.isEmpty();
+ }
+}
diff --git a/backend/src/main/java/com/zzang/chongdae/notification/domain/FcmTopic.java b/backend/src/main/java/com/zzang/chongdae/notification/domain/FcmTopic.java
new file mode 100644
index 000000000..996e356a6
--- /dev/null
+++ b/backend/src/main/java/com/zzang/chongdae/notification/domain/FcmTopic.java
@@ -0,0 +1,31 @@
+package com.zzang.chongdae.notification.domain;
+
+import com.zzang.chongdae.offering.repository.entity.OfferingEntity;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
+public class FcmTopic {
+
+ private static final String TOPIC_FORMAT_MEMBER = "member";
+ private static final String TOPIC_FORMAT_OFFERING_PROPOSER = "proposer_of_offering_%d";
+ private static final String TOPIC_FORMAT_OFFERING_PARTICIPANT = "participant_of_offering_%d";
+
+ private final String value;
+
+ public static FcmTopic proposerTopic(OfferingEntity offering) {
+ String value = TOPIC_FORMAT_OFFERING_PROPOSER.formatted(offering.getId());
+ return new FcmTopic(value);
+ }
+
+ public static FcmTopic participantTopic(OfferingEntity offering) {
+ String value = TOPIC_FORMAT_OFFERING_PARTICIPANT.formatted(offering.getId());
+ return new FcmTopic(value);
+ }
+
+ public static FcmTopic memberTopic() {
+ return new FcmTopic(TOPIC_FORMAT_MEMBER);
+ }
+}
diff --git a/backend/src/main/java/com/zzang/chongdae/notification/exception/NotificationErrorCode.java b/backend/src/main/java/com/zzang/chongdae/notification/exception/NotificationErrorCode.java
new file mode 100644
index 000000000..c76881b97
--- /dev/null
+++ b/backend/src/main/java/com/zzang/chongdae/notification/exception/NotificationErrorCode.java
@@ -0,0 +1,25 @@
+package com.zzang.chongdae.notification.exception;
+
+import com.zzang.chongdae.global.exception.ErrorMessage;
+import com.zzang.chongdae.global.exception.ErrorResponse;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.springframework.http.HttpStatus;
+
+@Getter
+@AllArgsConstructor
+public enum NotificationErrorCode implements ErrorResponse {
+
+ CANNOT_SEND_ALARM(HttpStatus.INTERNAL_SERVER_ERROR, "FCM 알림 전송에 실패하였습니다."),
+ CANNOT_FIND_URL(HttpStatus.INTERNAL_SERVER_ERROR, "해당 URL을 찾을 수 없습니다."),
+ INVALID_COMMENT_ROOM_STATUS(HttpStatus.INTERNAL_SERVER_ERROR, "존재하지 않는 거래 상태입니다."),
+ ;
+
+ private final HttpStatus status;
+ private final String message;
+
+ @Override
+ public ErrorMessage getErrorMessage() {
+ return new ErrorMessage(this.message);
+ }
+}
diff --git a/backend/src/main/java/com/zzang/chongdae/notification/service/FcmNotificationSender.java b/backend/src/main/java/com/zzang/chongdae/notification/service/FcmNotificationSender.java
new file mode 100644
index 000000000..6acf1105f
--- /dev/null
+++ b/backend/src/main/java/com/zzang/chongdae/notification/service/FcmNotificationSender.java
@@ -0,0 +1,59 @@
+package com.zzang.chongdae.notification.service;
+
+import com.google.firebase.messaging.BatchResponse;
+import com.google.firebase.messaging.FirebaseMessaging;
+import com.google.firebase.messaging.FirebaseMessagingException;
+import com.google.firebase.messaging.Message;
+import com.google.firebase.messaging.MulticastMessage;
+import com.zzang.chongdae.global.exception.MarketException;
+import com.zzang.chongdae.notification.exception.NotificationErrorCode;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+public class FcmNotificationSender implements NotificationSender {
+
+ private static final String ERROR_MESSAGE_WHEN_INVALID_TOKEN = "The registration token is not a valid FCM registration token";
+ private static final String ERROR_MESSAGE_WHEN_OLD_TOKEN = "Requested entity was not found.";
+
+ @Override
+ public String send(Message message) {
+ try {
+ String response = FirebaseMessaging.getInstance().send(message);
+ log.info("알림 메시지 전송 성공: {}", response);
+ return response;
+ } catch (FirebaseMessagingException e) {
+ return sendWhenFail(e);
+ }
+ }
+
+ private String sendWhenFail(FirebaseMessagingException e) {
+ if (isInvalidToken(e)) {
+ log.error("알림 메시지 전송 실패: {}", "유효하지 않은 토큰");
+ return "";
+ }
+ log.error("알림 메시지 전송 실패: {}", e.getMessage());
+ e.printStackTrace();
+ throw new MarketException(NotificationErrorCode.CANNOT_SEND_ALARM);
+ }
+
+ private boolean isInvalidToken(FirebaseMessagingException e) {
+ return e.getMessage().contains(ERROR_MESSAGE_WHEN_INVALID_TOKEN)
+ || e.getMessage().contains(ERROR_MESSAGE_WHEN_OLD_TOKEN);
+ }
+
+ @Override
+ public BatchResponse send(MulticastMessage message) {
+ try {
+ BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast(message);
+ log.info("알림 메시지 전송 성공 개수: {}", response.getSuccessCount());
+ log.info("알림 메시지 전송 실패 개수: {}", response.getFailureCount());
+ return response;
+ } catch (FirebaseMessagingException e) {
+ log.error("알림 메시지 전송 실패: {}", e.getMessage());
+ e.printStackTrace();
+ throw new MarketException(NotificationErrorCode.CANNOT_SEND_ALARM);
+ }
+ }
+}
diff --git a/backend/src/main/java/com/zzang/chongdae/notification/service/FcmNotificationService.java b/backend/src/main/java/com/zzang/chongdae/notification/service/FcmNotificationService.java
new file mode 100644
index 000000000..ccb327c66
--- /dev/null
+++ b/backend/src/main/java/com/zzang/chongdae/notification/service/FcmNotificationService.java
@@ -0,0 +1,78 @@
+package com.zzang.chongdae.notification.service;
+
+import com.google.firebase.messaging.BatchResponse;
+import com.google.firebase.messaging.Message;
+import com.google.firebase.messaging.MulticastMessage;
+import com.zzang.chongdae.comment.repository.entity.CommentEntity;
+import com.zzang.chongdae.member.repository.entity.MemberEntity;
+import com.zzang.chongdae.notification.domain.FcmTopic;
+import com.zzang.chongdae.notification.service.message.CommentMessageManager;
+import com.zzang.chongdae.notification.service.message.OfferingMessageManager;
+import com.zzang.chongdae.notification.service.message.ParticipationMessageManager;
+import com.zzang.chongdae.notification.service.message.RoomStatusMessageManager;
+import com.zzang.chongdae.offering.repository.entity.OfferingEntity;
+import com.zzang.chongdae.offeringmember.repository.entity.OfferingMemberEntity;
+import java.util.List;
+import javax.annotation.Nullable;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class FcmNotificationService {
+
+ private final NotificationSender notificationSender;
+ private final NotificationSubscriber notificationSubscriber;
+ private final CommentMessageManager commentMessageManager;
+ private final OfferingMessageManager offeringMessageManager;
+ private final ParticipationMessageManager participationMessageManager;
+ private final RoomStatusMessageManager roomStatusMessageManager; // TODO: 의존성 리팩터링
+
+ public void participate(OfferingMemberEntity offeringMember) {
+ FcmTopic topic = FcmTopic.participantTopic(offeringMember.getOffering());
+ notificationSubscriber.subscribe(offeringMember.getMember(), topic);
+ Message message = participationMessageManager.messageWhenParticipate(offeringMember);
+ notificationSender.send(message);
+ }
+
+ public void cancelParticipation(OfferingMemberEntity offeringMember) {
+ FcmTopic topic = FcmTopic.participantTopic(offeringMember.getOffering());
+ notificationSubscriber.unsubscribe(offeringMember.getMember(), topic);
+ Message message = participationMessageManager.messageWhenCancelParticipate(offeringMember);
+ notificationSender.send(message);
+ }
+
+ public void updateStatus(OfferingEntity offering) {
+ Message message = roomStatusMessageManager.messageWhenUpdateStatus(offering);
+ notificationSender.send(message);
+ }
+
+ public void saveOffering(OfferingEntity offering) {
+ Message message = offeringMessageManager.messageWhenSaveOffering(offering);
+ FcmTopic topic = FcmTopic.proposerTopic(offering);
+ notificationSubscriber.subscribe(offering.getMember(), topic);
+ notificationSender.send(message);
+ }
+
+ public void deleteOffering(OfferingEntity offering) {
+ FcmTopic topic = FcmTopic.proposerTopic(offering);
+ notificationSubscriber.unsubscribe(offering.getMember(), topic);
+ }
+
+ @Nullable
+ public BatchResponse saveComment(CommentEntity comment,
+ List offeringMembers) { // todo: 참여자 도메인 추출
+ MulticastMessage message = commentMessageManager.messageWhenSaveComment(comment, offeringMembers);
+ if (message == null) {
+ return null;
+ }
+ return notificationSender.send(message);
+ }
+
+ public void login(MemberEntity member) {
+ FcmTopic topic = FcmTopic.memberTopic();
+ notificationSubscriber.subscribe(member, topic);
+ }
+}
diff --git a/backend/src/main/java/com/zzang/chongdae/notification/service/FcmNotificationSubscriber.java b/backend/src/main/java/com/zzang/chongdae/notification/service/FcmNotificationSubscriber.java
new file mode 100644
index 000000000..1283a78f5
--- /dev/null
+++ b/backend/src/main/java/com/zzang/chongdae/notification/service/FcmNotificationSubscriber.java
@@ -0,0 +1,45 @@
+package com.zzang.chongdae.notification.service;
+
+import com.google.firebase.messaging.FirebaseMessaging;
+import com.google.firebase.messaging.FirebaseMessagingException;
+import com.google.firebase.messaging.TopicManagementResponse;
+import com.zzang.chongdae.member.repository.entity.MemberEntity;
+import com.zzang.chongdae.notification.domain.FcmTopic;
+import java.util.List;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+@Slf4j
+@Service
+public class FcmNotificationSubscriber implements NotificationSubscriber {
+
+ @Override
+ public TopicManagementResponse subscribe(MemberEntity member, FcmTopic topic) {
+ try {
+ TopicManagementResponse response = FirebaseMessaging.getInstance()
+ .subscribeToTopic(List.of(member.getFcmToken()), topic.getValue());
+ log.info("구독 성공 개수: {}", response.getSuccessCount());
+ log.info("구독 실패 개수: {}", response.getFailureCount());
+ return response;
+ } catch (FirebaseMessagingException e) {
+ log.error("토픽 구독 실패: {}", e.getMessage());
+ e.printStackTrace();
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public TopicManagementResponse unsubscribe(MemberEntity member, FcmTopic topic) {
+ try {
+ TopicManagementResponse response = FirebaseMessaging.getInstance()
+ .unsubscribeFromTopic(List.of(member.getFcmToken()), topic.getValue());
+ log.info("구독 취소 성공 개수: {}", response.getSuccessCount());
+ log.info("구독 취소 실패 개수: {}", response.getFailureCount());
+ return response;
+ } catch (FirebaseMessagingException e) {
+ log.error("토픽 구독 실패: {}", e.getMessage());
+ e.printStackTrace();
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/backend/src/main/java/com/zzang/chongdae/notification/service/NotificationSender.java b/backend/src/main/java/com/zzang/chongdae/notification/service/NotificationSender.java
new file mode 100644
index 000000000..5dcf562f0
--- /dev/null
+++ b/backend/src/main/java/com/zzang/chongdae/notification/service/NotificationSender.java
@@ -0,0 +1,12 @@
+package com.zzang.chongdae.notification.service;
+
+import com.google.firebase.messaging.BatchResponse;
+import com.google.firebase.messaging.Message;
+import com.google.firebase.messaging.MulticastMessage;
+
+public interface NotificationSender {
+
+ String send(Message message);
+
+ BatchResponse send(MulticastMessage message);
+}
diff --git a/backend/src/main/java/com/zzang/chongdae/notification/service/NotificationSubscriber.java b/backend/src/main/java/com/zzang/chongdae/notification/service/NotificationSubscriber.java
new file mode 100644
index 000000000..81699d3fb
--- /dev/null
+++ b/backend/src/main/java/com/zzang/chongdae/notification/service/NotificationSubscriber.java
@@ -0,0 +1,12 @@
+package com.zzang.chongdae.notification.service;
+
+import com.google.firebase.messaging.TopicManagementResponse;
+import com.zzang.chongdae.member.repository.entity.MemberEntity;
+import com.zzang.chongdae.notification.domain.FcmTopic;
+
+public interface NotificationSubscriber {
+
+ TopicManagementResponse subscribe(MemberEntity member, FcmTopic topic);
+
+ TopicManagementResponse unsubscribe(MemberEntity member, FcmTopic topic);
+}
diff --git a/backend/src/main/java/com/zzang/chongdae/notification/service/message/CommentMessageManager.java b/backend/src/main/java/com/zzang/chongdae/notification/service/message/CommentMessageManager.java
new file mode 100644
index 000000000..94e39e6f7
--- /dev/null
+++ b/backend/src/main/java/com/zzang/chongdae/notification/service/message/CommentMessageManager.java
@@ -0,0 +1,43 @@
+package com.zzang.chongdae.notification.service.message;
+
+import com.google.firebase.messaging.MulticastMessage;
+import com.zzang.chongdae.comment.repository.entity.CommentEntity;
+import com.zzang.chongdae.member.repository.entity.MemberEntity;
+import com.zzang.chongdae.notification.domain.FcmData;
+import com.zzang.chongdae.notification.domain.FcmTokens;
+import com.zzang.chongdae.offeringmember.repository.entity.OfferingMemberEntity;
+import java.util.List;
+import javax.annotation.Nullable;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+@RequiredArgsConstructor
+@Component
+public class CommentMessageManager {
+
+ private static final String MESSAGE_BODY_FORMAT = "%s: %s";
+ private static final String MESSAGE_TYPE = "comment_detail";
+
+ private final FcmMessageCreator messageCreator;
+
+ @Nullable
+ public MulticastMessage messageWhenSaveComment(CommentEntity comment, List offeringMembers) {
+ FcmTokens tokens = FcmTokens.from(membersNotWriter(comment.getMember(), offeringMembers));
+ if (tokens.isEmpty()) {
+ return null;
+ }
+ FcmData data = new FcmData();
+ data.addData("title", comment.getOffering().getTitle());
+ data.addData("body", MESSAGE_BODY_FORMAT.formatted(comment.getMember().getNickname(), comment.getContent()));
+ data.addData("offering_id", comment.getOffering().getId());
+ data.addData("type", MESSAGE_TYPE);
+ return messageCreator.createMessages(tokens, data);
+ }
+
+ private List membersNotWriter(MemberEntity writer, List offeringMembers) {
+ return offeringMembers.stream()
+ .map(OfferingMemberEntity::getMember)
+ .filter(member -> !member.isSame(writer))
+ .toList();
+ }
+}
diff --git a/backend/src/main/java/com/zzang/chongdae/notification/service/message/FcmMessageCreator.java b/backend/src/main/java/com/zzang/chongdae/notification/service/message/FcmMessageCreator.java
new file mode 100644
index 000000000..c9fdce9a2
--- /dev/null
+++ b/backend/src/main/java/com/zzang/chongdae/notification/service/message/FcmMessageCreator.java
@@ -0,0 +1,42 @@
+package com.zzang.chongdae.notification.service.message;
+
+import com.google.firebase.messaging.Message;
+import com.google.firebase.messaging.MulticastMessage;
+import com.zzang.chongdae.notification.domain.FcmCondition;
+import com.zzang.chongdae.notification.domain.FcmData;
+import com.zzang.chongdae.notification.domain.FcmToken;
+import com.zzang.chongdae.notification.domain.FcmTokens;
+import com.zzang.chongdae.notification.domain.FcmTopic;
+import org.springframework.stereotype.Component;
+
+@Component
+public class FcmMessageCreator {
+
+ public Message createMessage(FcmToken token, FcmData data) {
+ return Message.builder()
+ .setToken(token.getValue())
+ .putAllData(data.getData())
+ .build();
+ }
+
+ public Message createMessage(FcmCondition condition, FcmData data) {
+ return Message.builder()
+ .setCondition(condition.getValue())
+ .putAllData(data.getData())
+ .build();
+ }
+
+ public Message createMessage(FcmTopic topic, FcmData data) {
+ return Message.builder()
+ .setTopic(topic.getValue())
+ .putAllData(data.getData())
+ .build();
+ }
+
+ public MulticastMessage createMessages(FcmTokens tokens, FcmData data) {
+ return MulticastMessage.builder()
+ .addAllTokens(tokens.getTokenValues())
+ .putAllData(data.getData())
+ .build();
+ }
+}
diff --git a/backend/src/main/java/com/zzang/chongdae/notification/service/message/OfferingMessageManager.java b/backend/src/main/java/com/zzang/chongdae/notification/service/message/OfferingMessageManager.java
new file mode 100644
index 000000000..fcabb615f
--- /dev/null
+++ b/backend/src/main/java/com/zzang/chongdae/notification/service/message/OfferingMessageManager.java
@@ -0,0 +1,28 @@
+package com.zzang.chongdae.notification.service.message;
+
+import com.google.firebase.messaging.Message;
+import com.zzang.chongdae.notification.domain.FcmCondition;
+import com.zzang.chongdae.notification.domain.FcmData;
+import com.zzang.chongdae.offering.repository.entity.OfferingEntity;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+@RequiredArgsConstructor
+@Component
+public class OfferingMessageManager {
+
+ private static final String MESSAGE_TITLE = "두근두근 새로운 공모를 확인해보세요!";
+ private static final String MESSAGE_TYPE = "offering_detail";
+
+ private final FcmMessageCreator messageCreator;
+
+ public Message messageWhenSaveOffering(OfferingEntity offering) {
+ FcmCondition condition = FcmCondition.offeringCondition(offering);
+ FcmData data = new FcmData();
+ data.addData("title", MESSAGE_TITLE);
+ data.addData("body", offering.getTitle());
+ data.addData("offering_id", offering.getId());
+ data.addData("type", MESSAGE_TYPE);
+ return messageCreator.createMessage(condition, data);
+ }
+}
diff --git a/backend/src/main/java/com/zzang/chongdae/notification/service/message/ParticipationMessageManager.java b/backend/src/main/java/com/zzang/chongdae/notification/service/message/ParticipationMessageManager.java
new file mode 100644
index 000000000..c2413403f
--- /dev/null
+++ b/backend/src/main/java/com/zzang/chongdae/notification/service/message/ParticipationMessageManager.java
@@ -0,0 +1,49 @@
+package com.zzang.chongdae.notification.service.message;
+
+import com.google.firebase.messaging.Message;
+import com.zzang.chongdae.member.repository.entity.MemberEntity;
+import com.zzang.chongdae.notification.domain.FcmData;
+import com.zzang.chongdae.notification.domain.FcmToken;
+import com.zzang.chongdae.offering.repository.entity.OfferingEntity;
+import com.zzang.chongdae.offeringmember.repository.entity.OfferingMemberEntity;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+@RequiredArgsConstructor
+@Component
+public class ParticipationMessageManager {
+
+ private static final String MESSAGE_BODY_FORMAT_PARTICIPATE = "%s 님이 참여했습니다.";
+ private static final String MESSAGE_BODY_FORMAT_CANCEL = "%s 님이 참여를 취소했습니다.";
+ private static final String MESSAGE_TYPE = "comment_detail";
+
+ private final FcmMessageCreator messageCreator;
+
+ public Message messageWhenParticipate(OfferingMemberEntity offeringMember) {
+ OfferingEntity offering = offeringMember.getOffering();
+ MemberEntity proposer = offering.getMember();
+ MemberEntity participant = offeringMember.getMember();
+
+ FcmToken token = new FcmToken(proposer);
+ FcmData data = new FcmData();
+ data.addData("title", offering.getTitle());
+ data.addData("body", MESSAGE_BODY_FORMAT_PARTICIPATE.formatted(participant.getNickname()));
+ data.addData("offering_id", offering.getId());
+ data.addData("type", MESSAGE_TYPE);
+ return messageCreator.createMessage(token, data);
+ }
+
+ public Message messageWhenCancelParticipate(OfferingMemberEntity offeringMember) {
+ OfferingEntity offering = offeringMember.getOffering();
+ MemberEntity proposer = offering.getMember();
+ MemberEntity participant = offeringMember.getMember();
+
+ FcmToken token = new FcmToken(proposer);
+ FcmData data = new FcmData();
+ data.addData("title", offering.getTitle());
+ data.addData("body", MESSAGE_BODY_FORMAT_CANCEL.formatted(participant.getNickname()));
+ data.addData("offering_id", offering.getId());
+ data.addData("type", MESSAGE_TYPE);
+ return messageCreator.createMessage(token, data);
+ }
+}
diff --git a/backend/src/main/java/com/zzang/chongdae/notification/service/message/RoomStatusMessageManager.java b/backend/src/main/java/com/zzang/chongdae/notification/service/message/RoomStatusMessageManager.java
new file mode 100644
index 000000000..3b370435d
--- /dev/null
+++ b/backend/src/main/java/com/zzang/chongdae/notification/service/message/RoomStatusMessageManager.java
@@ -0,0 +1,57 @@
+package com.zzang.chongdae.notification.service.message;
+
+import com.google.firebase.messaging.Message;
+import com.zzang.chongdae.global.exception.MarketException;
+import com.zzang.chongdae.notification.domain.FcmData;
+import com.zzang.chongdae.notification.domain.FcmTopic;
+import com.zzang.chongdae.notification.exception.NotificationErrorCode;
+import com.zzang.chongdae.offering.domain.CommentRoomStatus;
+import com.zzang.chongdae.offering.repository.entity.OfferingEntity;
+import java.util.Arrays;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+@RequiredArgsConstructor
+@Component
+public class RoomStatusMessageManager {
+
+ private static final String MESSAGE_TYPE = "comment_detail";
+
+ private final FcmMessageCreator messageCreator;
+
+ public Message messageWhenUpdateStatus(OfferingEntity offering) {
+ FcmTopic topic = FcmTopic.participantTopic(offering);
+ FcmData data = new FcmData();
+ data.addData("title", offering.getTitle());
+ data.addData("body", CommentRoomStatusMapper.getView(offering.getRoomStatus()));
+ data.addData("offering_id", offering.getId());
+ data.addData("type", MESSAGE_TYPE);
+ return messageCreator.createMessage(topic, data);
+ }
+
+ private enum CommentRoomStatusMapper {
+
+ DELETED(CommentRoomStatus.DELETED, ""),
+ GROUPING(CommentRoomStatus.GROUPING, ""),
+ BUYING(CommentRoomStatus.BUYING, "모집이 마감됐어요! 이제 총대가 물건을 구매할 거예요"),
+ TRADING(CommentRoomStatus.TRADING, "총대가 물건을 구매했어요! 이제 거래를 진행해보아요"),
+ DONE(CommentRoomStatus.DONE, "거래가 완료되었어요. 고마워요 :)"),
+ ;
+
+ private final CommentRoomStatus status;
+ private final String view;
+
+ CommentRoomStatusMapper(CommentRoomStatus status, String view) {
+ this.status = status;
+ this.view = view;
+ }
+
+ public static String getView(CommentRoomStatus roomStatus) {
+ return Arrays.stream(values())
+ .filter(v -> v.status.equals(roomStatus))
+ .findAny()
+ .orElseThrow(() -> new MarketException(NotificationErrorCode.INVALID_COMMENT_ROOM_STATUS))
+ .view;
+ }
+ }
+}
diff --git a/backend/src/main/java/com/zzang/chongdae/offering/domain/UpdatedOffering.java b/backend/src/main/java/com/zzang/chongdae/offering/domain/UpdatedOffering.java
index d68c1ef2c..398062bac 100644
--- a/backend/src/main/java/com/zzang/chongdae/offering/domain/UpdatedOffering.java
+++ b/backend/src/main/java/com/zzang/chongdae/offering/domain/UpdatedOffering.java
@@ -2,6 +2,7 @@
import com.zzang.chongdae.global.exception.MarketException;
import com.zzang.chongdae.offering.exception.OfferingErrorCode;
+import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.Getter;
@@ -34,8 +35,8 @@ public UpdatedOffering(String title, String productUrl, String thumbnailUrl, Int
}
private void validateMeetingDate() {
- LocalDateTime today = LocalDateTime.now();
- if (meetingDate.isBefore(today)) {
+ LocalDate today = LocalDate.now();
+ if (meetingDate.toLocalDate().isBefore(today)) {
throw new MarketException(OfferingErrorCode.CANNOT_UPDATE_BEFORE_NOW_MEETING_DATE);
}
}
diff --git a/backend/src/main/java/com/zzang/chongdae/offering/exception/OfferingErrorCode.java b/backend/src/main/java/com/zzang/chongdae/offering/exception/OfferingErrorCode.java
index 520ff8fb6..4990aec94 100644
--- a/backend/src/main/java/com/zzang/chongdae/offering/exception/OfferingErrorCode.java
+++ b/backend/src/main/java/com/zzang/chongdae/offering/exception/OfferingErrorCode.java
@@ -23,8 +23,8 @@ public enum OfferingErrorCode implements ErrorResponse {
CANNOT_DELETE_STATUS(BAD_REQUEST, "삭제할 수 없는 거래 상태입니다."),
CANNOT_ORIGIN_PRICE_LESS_THAN_DIVIDED_PRICE(BAD_REQUEST, "원가 가격이 n빵 가격보다 작을 수 없습니다."),
CANNOT_UPDATE_LESS_THAN_CURRENT_COUNT(BAD_REQUEST, "총 인원은 참여 인원수 미만으로 수정할 수 없습니다."),
- CANNOT_UPDATE_BEFORE_NOW_MEETING_DATE(BAD_REQUEST, "모집 날짜는 다음날보다 이전일 수 없습니다."),
- CANNOT_MEETING_DATE_BEFORE_THAN_TOMORROW(BAD_REQUEST, "거래 날짜는 내일부터 설정할 수 있습니다."),
+ CANNOT_UPDATE_BEFORE_NOW_MEETING_DATE(BAD_REQUEST, "거래 날짜는 오늘보다 이전일 수 없습니다."),
+ CANNOT_MEETING_DATE_BEFORE_THAN_TODAY(BAD_REQUEST, "거래 날짜는 오늘부터 설정할 수 있습니다."),
;
private final HttpStatus status;
diff --git a/backend/src/main/java/com/zzang/chongdae/offering/service/OfferingService.java b/backend/src/main/java/com/zzang/chongdae/offering/service/OfferingService.java
index 3d1d4b0b6..c7994bc1e 100644
--- a/backend/src/main/java/com/zzang/chongdae/offering/service/OfferingService.java
+++ b/backend/src/main/java/com/zzang/chongdae/offering/service/OfferingService.java
@@ -3,6 +3,7 @@
import com.zzang.chongdae.global.config.WriterDatabase;
import com.zzang.chongdae.global.exception.MarketException;
import com.zzang.chongdae.member.repository.entity.MemberEntity;
+import com.zzang.chongdae.notification.service.FcmNotificationService;
import com.zzang.chongdae.offering.domain.OfferingFilter;
import com.zzang.chongdae.offering.domain.OfferingJoinedCount;
import com.zzang.chongdae.offering.domain.OfferingMeeting;
@@ -45,6 +46,7 @@
@Service
public class OfferingService {
+ private final FcmNotificationService notificationService;
private final OfferingRepository offeringRepository;
private final OfferingMemberRepository offeringMemberRepository;
private final StorageService storageService;
@@ -131,6 +133,8 @@ public Long saveOffering(OfferingSaveRequest request, MemberEntity member) {
OfferingMemberEntity offeringMember = new OfferingMemberEntity(member, offering, OfferingMemberRole.PROPOSER);
offeringMemberRepository.save(offeringMember);
+ notificationService.saveOffering(savedOffering);
+
return savedOffering.getId();
}
@@ -138,7 +142,7 @@ private void validateMeetingDate(LocalDateTime offeringMeetingDateTime) {
LocalDate thresholdDate = LocalDate.now(clock);
LocalDate targetDate = offeringMeetingDateTime.toLocalDate();
if (targetDate.isBefore(thresholdDate)) {
- throw new MarketException(OfferingErrorCode.CANNOT_MEETING_DATE_BEFORE_THAN_TOMORROW);
+ throw new MarketException(OfferingErrorCode.CANNOT_MEETING_DATE_BEFORE_THAN_TODAY);
}
}
@@ -188,6 +192,7 @@ public void deleteOffering(Long offeringId, MemberEntity member) {
validateIsProposer(offering, member);
validateInProgress(offering);
offeringRepository.delete(offering);
+ notificationService.deleteOffering(offering);
}
private void validateInProgress(OfferingEntity offering) {
diff --git a/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java b/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java
index 342c27494..2b4a18cc8 100644
--- a/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java
+++ b/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java
@@ -3,6 +3,7 @@
import com.zzang.chongdae.global.config.WriterDatabase;
import com.zzang.chongdae.global.exception.MarketException;
import com.zzang.chongdae.member.repository.entity.MemberEntity;
+import com.zzang.chongdae.notification.service.FcmNotificationService;
import com.zzang.chongdae.offering.domain.CommentRoomStatus;
import com.zzang.chongdae.offering.domain.OfferingStatus;
import com.zzang.chongdae.offering.exception.OfferingErrorCode;
@@ -27,6 +28,7 @@
@Service
public class OfferingMemberService {
+ private final FcmNotificationService notificationService;
private final OfferingMemberRepository offeringMemberRepository;
private final OfferingRepository offeringRepository;
@@ -39,11 +41,12 @@ public Long participate(ParticipationRequest request, MemberEntity member) {
OfferingMemberEntity offeringMember = new OfferingMemberEntity(
member, offering, OfferingMemberRole.PARTICIPANT);
- offeringMemberRepository.save(offeringMember);
+ OfferingMemberEntity saved = offeringMemberRepository.save(offeringMember);
offering.participate();
OfferingStatus offeringStatus = offering.toOfferingJoinedCount().decideOfferingStatus();
offering.updateOfferingStatus(offeringStatus);
+ notificationService.participate(saved);
return offeringMember.getId();
}
@@ -73,9 +76,11 @@ public void cancelParticipate(Long offeringId, MemberEntity member) {
.orElseThrow(() -> new MarketException(OfferingMemberErrorCode.PARTICIPANT_NOT_FOUND));
validateCancel(offeringMember);
offeringMemberRepository.delete(offeringMember);
+
offering.leave();
OfferingStatus offeringStatus = offering.toOfferingJoinedCount().decideOfferingStatus();
offering.updateOfferingStatus(offeringStatus);
+ notificationService.cancelParticipation(offeringMember);
}
private void validateCancel(OfferingMemberEntity offeringMember) {
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
index c38275f89..afdf484dc 100644
--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@ -24,7 +24,7 @@ spring:
console:
enabled: true
path: /h2-console
-
+
springdoc:
swagger-ui:
path: swagger-ui.html
@@ -76,3 +76,7 @@ management:
path-mapping:
health: health-check
base-path: /
+
+fcm:
+ secret-key:
+ path: /fcm/chongdaemarket-fcm-key.json
diff --git a/backend/src/main/resources/data.sql b/backend/src/main/resources/data.sql
index 70cafa224..25a7e462a 100644
--- a/backend/src/main/resources/data.sql
+++ b/backend/src/main/resources/data.sql
@@ -1,26 +1,26 @@
-INSERT INTO MEMBER (NICKNAME, CREATED_AT, UPDATED_AT, PASSWORD, LOGIN_ID, PROVIDER)
+INSERT INTO MEMBER (NICKNAME, CREATED_AT, UPDATED_AT, PASSWORD, LOGIN_ID, PROVIDER, FCM_TOKEN)
VALUES ('dora', '2024-07-15 00:00:00', '2024-07-15 00:00:00', 'PtgtJCnn307FyCBvRprsy+42rX7dg00qVLWkPbl2Ag0=',
- 'KAKAO_dora1234', 'KAKAO'),
+ 'KAKAO_dora1234', 'KAKAO', 'fcmToken_dora1234'),
('poke', '2024-07-15 00:00:00', '2024-07-15 00:00:00', 'XimRQY0Y2avPH6KxGK4ZOXB4+MT3Sfb605ZEPidVNpQ=',
- 'KAKAO_poke1234', 'KAKAO'),
+ 'KAKAO_poke1234', 'KAKAO', 'fcmToken_poke1234'),
('mason', '2024-07-15 00:00:00', '2024-07-15 00:00:00', 'WBmuFn7FSb5jG03SKBB0K7MNk0mNg9FLPoHyTbi4Tl0=',
- 'KAKAO_mason1234', 'KAKAO'),
+ 'KAKAO_mason1234', 'KAKAO', 'fcmToken_mason1234'),
('ever', '2024-07-15 00:00:00', '2024-07-15 00:00:00', 'UhN2JlsRhvNn7XY2WYlfDwI9/d/XoRvr8Ls7tbeYZWg=',
- 'KAKAO_ever1234', 'KAKAO'),
+ 'KAKAO_ever1234', 'KAKAO', 'fcmToken_ever1234'),
('alsong', '2024-07-15 00:00:00', '2024-07-15 00:00:00', '+qY3Pnqyjj9amVGZ1Bu63iJX6cpon7kQiIvqAG0ExkE=',
- 'KAKAO_alsong1234', 'KAKAO'),
+ 'KAKAO_alsong1234', 'KAKAO', 'fcmToken_alsong1234'),
('seogi', '2024-07-15 00:00:00', '2024-07-15 00:00:00', '0CWUdyVQ1TP+GGlI9W2d5Gao/5HgT0MSeIwald0Qcsw=',
- 'KAKAO_seogi1234', 'KAKAO'),
+ 'KAKAO_seogi1234', 'KAKAO', 'fcmToken_seogi1234'),
('chaechae', '2024-07-15 00:00:00', '2024-07-15 00:00:00', 'WCkwnMjy/yW6odwkADguEIcHjFVELq+JLy+WeojvJ88=',
- 'KAKAO_chaechae1234', 'KAKAO'),
+ 'KAKAO_chaechae1234', 'KAKAO', 'fcmToken_chaechae1234'),
('tommy', '2024-07-15 00:00:00', '2024-07-15 00:00:00', 'FDCoOHIo4OPB8wBuYLLf1b3ZRTBSzNll45s8nmyQjdQ=',
- 'KAKAO_tommy1234', 'KAKAO'),
+ 'KAKAO_tommy1234', 'KAKAO', 'fcmToken_tommy1234'),
('james', '2024-07-15 00:00:00', '2024-07-15 00:00:00', 'Z2XFMZIARj17tEES+PIavOqFkT4xl5eIKPvQdtHTb00=',
- 'KAKAO_james1234', 'KAKAO'),
+ 'KAKAO_james1234', 'KAKAO', 'fcmToken_james1234'),
('jason', '2024-07-15 00:00:00', '2024-07-15 00:00:00', 'VQaSJjY9GBmpLaCga6sCpKYFBEC3G8VNva5seJzDwvg=',
- 'KAKAO_jason1234', 'KAKAO'),
+ 'KAKAO_jason1234', 'KAKAO', 'fcmToken_jason1234'),
('lisa', '2024-07-15 00:00:00', '2024-07-15 00:00:00', 'MaG6sq0cGDEXwmZ++LWXXtIcJ6QqQgTwyIll8xExJdk=',
- 'KAKAO_lisa1234', 'KAKAO');
+ 'KAKAO_lisa1234', 'KAKAO', 'fcmToken_lisa1234');
INSERT INTO OFFERING (TOTAL_COUNT, CURRENT_COUNT, TOTAL_PRICE, ORIGIN_PRICE, DISCOUNT_RATE, CREATED_AT, UPDATED_AT,
MEMBER_ID, MEETING_DATE, DESCRIPTION, MEETING_ADDRESS, MEETING_ADDRESS_DONG,
diff --git a/backend/src/test/java/com/zzang/chongdae/auth/integration/AuthIntegrationTest.java b/backend/src/test/java/com/zzang/chongdae/auth/integration/AuthIntegrationTest.java
index 255aff60b..e16e34d5d 100644
--- a/backend/src/test/java/com/zzang/chongdae/auth/integration/AuthIntegrationTest.java
+++ b/backend/src/test/java/com/zzang/chongdae/auth/integration/AuthIntegrationTest.java
@@ -41,7 +41,8 @@ class AuthIntegrationTest extends IntegrationTest {
class KakaoLogin {
List requestDescriptors = List.of(
- fieldWithPath("accessToken").description("카카오 인증 토큰")
+ fieldWithPath("accessToken").description("카카오 인증 토큰"),
+ fieldWithPath("fcmToken").description("FCM 토큰")
);
List responseDescriptors = List.of(
fieldWithPath("memberId").description("회원 id"),
@@ -76,7 +77,8 @@ void setUp() {
@Test
void should_loginSuccess_when_givenMemberCI() {
KakaoLoginRequest request = new KakaoLoginRequest(
- "whatever"
+ "whateverAccessToken",
+ "whateverFcmToken"
);
given(spec).log().all()
diff --git a/backend/src/test/java/com/zzang/chongdae/global/config/TestConfig.java b/backend/src/test/java/com/zzang/chongdae/global/config/TestConfig.java
index 479fc9257..4bf810949 100644
--- a/backend/src/test/java/com/zzang/chongdae/global/config/TestConfig.java
+++ b/backend/src/test/java/com/zzang/chongdae/global/config/TestConfig.java
@@ -1,11 +1,15 @@
package com.zzang.chongdae.global.config;
import com.zzang.chongdae.member.config.TestNicknameWordPickerConfig;
+import com.zzang.chongdae.notification.config.TestNotificationConfig;
import com.zzang.chongdae.offering.config.TestCrawlerConfig;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Import;
-@Import({TestCrawlerConfig.class, TestNicknameWordPickerConfig.class, TestClockConfig.class})
+@Import({TestCrawlerConfig.class,
+ TestNicknameWordPickerConfig.class,
+ TestClockConfig.class,
+ TestNotificationConfig.class})
@TestConfiguration
public class TestConfig {
}
diff --git a/backend/src/test/java/com/zzang/chongdae/global/domain/MemberFixture.java b/backend/src/test/java/com/zzang/chongdae/global/domain/MemberFixture.java
index cfa82bb56..26e3eb6fa 100644
--- a/backend/src/test/java/com/zzang/chongdae/global/domain/MemberFixture.java
+++ b/backend/src/test/java/com/zzang/chongdae/global/domain/MemberFixture.java
@@ -16,11 +16,16 @@ public class MemberFixture {
private MemberRepository memberRepository;
public MemberEntity createMember(String nickname) {
+ return createMember(nickname, "fcmToken_" + nickname);
+ }
+
+ public MemberEntity createMember(String nickname, String fcmToken) {
MemberEntity member = new MemberEntity(
nickname,
AuthProvider.KAKAO,
AuthProvider.KAKAO.buildLoginId(nickname),
- "1234");
+ "1234",
+ fcmToken);
return memberRepository.save(member);
}
diff --git a/backend/src/test/java/com/zzang/chongdae/notification/config/TestNotificationConfig.java b/backend/src/test/java/com/zzang/chongdae/notification/config/TestNotificationConfig.java
new file mode 100644
index 000000000..d59749f11
--- /dev/null
+++ b/backend/src/test/java/com/zzang/chongdae/notification/config/TestNotificationConfig.java
@@ -0,0 +1,27 @@
+package com.zzang.chongdae.notification.config;
+
+import com.zzang.chongdae.notification.service.FakeNotificationSender;
+import com.zzang.chongdae.notification.service.FakeNotificationSubscriber;
+import com.zzang.chongdae.notification.service.NotificationSender;
+import com.zzang.chongdae.notification.service.NotificationSubscriber;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Primary;
+
+@TestConfiguration
+public class TestNotificationConfig {
+
+ @Bean
+ @Primary
+ public NotificationSender testNotificationSender() {
+ return new FakeNotificationSender();
+ // return new FcmNotificationSender();
+ }
+
+ @Bean
+ @Primary
+ public NotificationSubscriber testNotificationSubscriber() {
+ return new FakeNotificationSubscriber();
+ // return new FcmNotificationSubscriber();
+ }
+}
diff --git a/backend/src/test/java/com/zzang/chongdae/notification/domain/RoomStatusMessageManagerTest.java b/backend/src/test/java/com/zzang/chongdae/notification/domain/RoomStatusMessageManagerTest.java
new file mode 100644
index 000000000..856e092f8
--- /dev/null
+++ b/backend/src/test/java/com/zzang/chongdae/notification/domain/RoomStatusMessageManagerTest.java
@@ -0,0 +1,34 @@
+package com.zzang.chongdae.notification.domain;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.google.firebase.messaging.Message;
+import com.zzang.chongdae.global.service.ServiceTest;
+import com.zzang.chongdae.member.repository.entity.MemberEntity;
+import com.zzang.chongdae.notification.service.message.FcmMessageCreator;
+import com.zzang.chongdae.notification.service.message.RoomStatusMessageManager;
+import com.zzang.chongdae.offering.repository.entity.OfferingEntity;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+
+class RoomStatusMessageManagerTest extends ServiceTest {
+
+ @Autowired
+ private FcmMessageCreator messageCreator;
+
+ @DisplayName("FCM에 전송할 메시지를 생성한다.")
+ @Test
+ void should_notNull_when_createMessage() {
+ // given
+ MemberEntity proposer = memberFixture.createMember("ever");
+ OfferingEntity offering = offeringFixture.createOffering(proposer);
+ RoomStatusMessageManager notification = new RoomStatusMessageManager(messageCreator);
+
+ // when
+ Message message = notification.messageWhenUpdateStatus(offering);
+
+ // then
+ assertThat(message).isNotNull();
+ }
+}
diff --git a/backend/src/test/java/com/zzang/chongdae/notification/service/FakeNotificationSender.java b/backend/src/test/java/com/zzang/chongdae/notification/service/FakeNotificationSender.java
new file mode 100644
index 000000000..6a2a9ba8a
--- /dev/null
+++ b/backend/src/test/java/com/zzang/chongdae/notification/service/FakeNotificationSender.java
@@ -0,0 +1,47 @@
+package com.zzang.chongdae.notification.service;
+
+import com.google.firebase.messaging.BatchResponse;
+import com.google.firebase.messaging.Message;
+import com.google.firebase.messaging.MulticastMessage;
+import com.google.firebase.messaging.SendResponse;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class FakeNotificationSender implements NotificationSender {
+
+ private static final Logger log = LoggerFactory.getLogger(FakeNotificationSender.class);
+
+ @Override
+ public String send(Message message) {
+ String response = "fakeMessageId";
+ log.info("알림 메시지 전송 성공: {}", response);
+ return response;
+ }
+
+ @Override
+ public BatchResponse send(MulticastMessage message) {
+ FakeBatchResponse response = new FakeBatchResponse();
+ log.info("알림 메시지 전송 성공 개수: {}", response.getSuccessCount());
+ log.info("알림 메시지 전송 실패 개수: {}", response.getFailureCount());
+ return response;
+ }
+
+ private static class FakeBatchResponse implements BatchResponse {
+
+ @Override
+ public List getResponses() {
+ return List.of();
+ }
+
+ @Override
+ public int getSuccessCount() {
+ return 1;
+ }
+
+ @Override
+ public int getFailureCount() {
+ return 0;
+ }
+ }
+}
diff --git a/backend/src/test/java/com/zzang/chongdae/notification/service/FakeNotificationSubscriber.java b/backend/src/test/java/com/zzang/chongdae/notification/service/FakeNotificationSubscriber.java
new file mode 100644
index 000000000..0f47df313
--- /dev/null
+++ b/backend/src/test/java/com/zzang/chongdae/notification/service/FakeNotificationSubscriber.java
@@ -0,0 +1,26 @@
+package com.zzang.chongdae.notification.service;
+
+import com.google.firebase.messaging.TopicManagementResponse;
+import com.zzang.chongdae.member.repository.entity.MemberEntity;
+import com.zzang.chongdae.notification.domain.FcmTopic;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class FakeNotificationSubscriber implements NotificationSubscriber {
+
+ private static final Logger log = LoggerFactory.getLogger(FakeNotificationSubscriber.class);
+
+ @Override
+ public TopicManagementResponse subscribe(MemberEntity member, FcmTopic topic) {
+ log.info("구독 성공 개수: {}", 1);
+ log.info("구독 실패 개수: {}", 0);
+ return null;
+ }
+
+ @Override
+ public TopicManagementResponse unsubscribe(MemberEntity member, FcmTopic topic) {
+ log.info("구독 취소 성공 개수: {}", 1);
+ log.info("구독 취소 실패 개수: {}", 0);
+ return null;
+ }
+}
diff --git a/backend/src/test/java/com/zzang/chongdae/notification/service/FcmNotificationSenderTest.java b/backend/src/test/java/com/zzang/chongdae/notification/service/FcmNotificationSenderTest.java
new file mode 100644
index 000000000..7cfd684bb
--- /dev/null
+++ b/backend/src/test/java/com/zzang/chongdae/notification/service/FcmNotificationSenderTest.java
@@ -0,0 +1,65 @@
+package com.zzang.chongdae.notification.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.google.firebase.messaging.BatchResponse;
+import com.google.firebase.messaging.Message;
+import com.google.firebase.messaging.MulticastMessage;
+import com.zzang.chongdae.global.service.ServiceTest;
+import com.zzang.chongdae.member.repository.entity.MemberEntity;
+import com.zzang.chongdae.notification.domain.FcmData;
+import com.zzang.chongdae.notification.domain.FcmToken;
+import com.zzang.chongdae.notification.domain.FcmTokens;
+import com.zzang.chongdae.notification.service.message.FcmMessageCreator;
+import java.util.List;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+
+class FcmNotificationSenderTest extends ServiceTest {
+
+ private final String proposerToken = "youHaveToChangeThis1";
+ private final String participant1Token = "youHaveToChangeThis2";
+ private final String participant2Token = "youHaveToChangeThis3";
+
+ @Autowired
+ private FcmMessageCreator messageCreator;
+
+ @Autowired
+ private FcmNotificationSender notificationSender;
+
+ @Disabled
+ @DisplayName("FCM에 메시지를 전송할 수 있다.")
+ @Test
+ void should_sendNotificationToFcm() {
+ // given
+ MemberEntity proposer = memberFixture.createMember("proposer", proposerToken);
+ Message message = messageCreator.createMessage(new FcmToken(proposer), new FcmData());
+
+ // when
+ String messageId = notificationSender.send(message);
+
+ // then
+ assertThat(messageId).contains("messages");
+ }
+
+ @Disabled
+ @DisplayName("FCM에 대량 메시지를 전송할 수 있다.")
+ @Test
+ void should_sendNotificationsToFcm() {
+ // given
+ MemberEntity participant1 = memberFixture.createMember("ever1", participant1Token);
+ MemberEntity participant2 = memberFixture.createMember("ever2", participant2Token);
+
+ FcmTokens tokens = FcmTokens.from(List.of(participant1, participant2));
+ MulticastMessage messages = messageCreator.createMessages(tokens, new FcmData());
+
+ // when
+ BatchResponse response = notificationSender.send(messages);
+
+ // then
+ assertThat(response).isNotNull();
+ assertThat(response.getSuccessCount()).isEqualTo(2);
+ }
+}
diff --git a/backend/src/test/java/com/zzang/chongdae/notification/service/FcmNotificationSubscriberTest.java b/backend/src/test/java/com/zzang/chongdae/notification/service/FcmNotificationSubscriberTest.java
new file mode 100644
index 000000000..6063521b8
--- /dev/null
+++ b/backend/src/test/java/com/zzang/chongdae/notification/service/FcmNotificationSubscriberTest.java
@@ -0,0 +1,45 @@
+package com.zzang.chongdae.notification.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.google.firebase.messaging.TopicManagementResponse;
+import com.zzang.chongdae.global.service.ServiceTest;
+import com.zzang.chongdae.member.repository.entity.MemberEntity;
+import com.zzang.chongdae.notification.domain.FcmTopic;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+class FcmNotificationSubscriberTest extends ServiceTest {
+
+ @DisplayName("유효하지 않은 토큰을 가진 사용자에 대해 주제를 구독할 경우 구독 성공 횟수 0, 실패 횟수 1이다.")
+ @Test
+ void should_getCount_when_successToSubscribe() {
+ // given
+ MemberEntity member = memberFixture.createMember("ever", "invalidFcmToken");
+ FcmNotificationSubscriber subscriber = new FcmNotificationSubscriber();
+
+ // when
+ TopicManagementResponse response = subscriber.subscribe(member, FcmTopic.memberTopic());
+
+ // then
+ assertThat(response.getSuccessCount()).isEqualTo(0);
+ assertThat(response.getFailureCount()).isEqualTo(1);
+ }
+
+ @Disabled
+ @DisplayName("유효한 토큰을 가진 사용자에 대해 주제를 구독할 경우 구독 성공 횟수 1, 실패 횟수 0이다.")
+ @Test
+ void should_getCount_when_failToSubscribe() {
+ // given
+ MemberEntity member = memberFixture.createMember("ever", "youHaveToChangeThis");
+ FcmNotificationSubscriber subscriber = new FcmNotificationSubscriber();
+
+ // when
+ TopicManagementResponse response = subscriber.subscribe(member, FcmTopic.memberTopic());
+
+ // then
+ assertThat(response.getSuccessCount()).isEqualTo(1);
+ assertThat(response.getFailureCount()).isEqualTo(0);
+ }
+}
diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml
index a39820fd9..47e4f02e4 100644
--- a/backend/src/test/resources/application-test.yml
+++ b/backend/src/test/resources/application-test.yml
@@ -18,3 +18,7 @@ security:
refresh-secret-key: refreshSecretKey
access-token-expired: 30m
refresh-token-expired: 14d
+
+fcm:
+ secret-key:
+ path: /fcm/chongdaemarket-fcm-key.json