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="다이얼로그 메세지" /> --> -