From 9cb249388f575accb001db79d1e28c10564ef0ce Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Wed, 12 Nov 2025 03:34:24 +0900 Subject: [PATCH 01/37] =?UTF-8?q?feat:=20=EA=B0=9C=EB=B3=84=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=83=81=ED=83=9C=20=EC=A1=B0=ED=9A=8C=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=20#383?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/project200/data/api/ApiService.kt | 9 ++++ .../project200/data/di/RepositoryModule.kt | 6 +++ .../project200/data/dto/NotificationDTO.kt | 6 +++ .../project200/data/impl/FcmRepositoryImpl.kt | 9 ++-- .../data/impl/NotificationRepositoryImpl.kt | 30 +++++++++++++ .../data/mapper/NotificationMapper.kt | 9 ++++ .../domain/model/NotificationModel.kt | 11 +++++ .../domain/repository/FcmRepository.kt | 1 + .../repository/NotificationRepository.kt | 8 ++++ .../usecase/GetNotificationStateUseCase.kt | 14 ++++++ .../profile/setting/NotificationFragment.kt | 45 ++++++++++--------- .../profile/setting/NotificationViewModel.kt | 43 +++++++++++------- .../main/res/layout/fragment_notification.xml | 42 ++++++++++++----- .../profile/src/main/res/values/strings.xml | 4 +- 14 files changed, 187 insertions(+), 50 deletions(-) create mode 100644 data/src/main/java/com/project200/data/dto/NotificationDTO.kt create mode 100644 data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt create mode 100644 data/src/main/java/com/project200/data/mapper/NotificationMapper.kt create mode 100644 domain/src/main/java/com/project200/domain/model/NotificationModel.kt create mode 100644 domain/src/main/java/com/project200/domain/repository/NotificationRepository.kt create mode 100644 domain/src/main/java/com/project200/domain/usecase/GetNotificationStateUseCase.kt diff --git a/data/src/main/java/com/project200/data/api/ApiService.kt b/data/src/main/java/com/project200/data/api/ApiService.kt index d855e21f..9693ab1e 100644 --- a/data/src/main/java/com/project200/data/api/ApiService.kt +++ b/data/src/main/java/com/project200/data/api/ApiService.kt @@ -19,6 +19,7 @@ import com.project200.data.dto.GetIsRegisteredData import com.project200.data.dto.GetMatchingMembersDto import com.project200.data.dto.GetMatchingProfileDTO import com.project200.data.dto.GetNewChattingMessagesDTO +import com.project200.data.dto.GetNotificationStateDTO import com.project200.data.dto.GetOpenChatUrlDTO import com.project200.data.dto.GetProfileDTO import com.project200.data.dto.GetProfileImageResponseDto @@ -411,4 +412,12 @@ interface ApiService { @Path("chatroomId") chatRoomId: Long, @Body content: PostChatMessageRequest, ): BaseResponse + + /** 알림 */ + // 알림 상태 조회 + @GET("api/v1/notification-settings/device?fcmToken={fcmToken}") + @AccessTokenApi + suspend fun getNotiState( + @Path ("fcmToken") fcmToken: String, + ): BaseResponse } diff --git a/data/src/main/java/com/project200/data/di/RepositoryModule.kt b/data/src/main/java/com/project200/data/di/RepositoryModule.kt index fe21a26a..7ec6f901 100644 --- a/data/src/main/java/com/project200/data/di/RepositoryModule.kt +++ b/data/src/main/java/com/project200/data/di/RepositoryModule.kt @@ -8,6 +8,7 @@ import com.project200.data.impl.ExerciseRecordRepositoryImpl import com.project200.data.impl.FcmRepositoryImpl import com.project200.data.impl.MatchingRepositoryImpl import com.project200.data.impl.MemberRepositoryImpl +import com.project200.data.impl.NotificationRepositoryImpl import com.project200.data.impl.PolicyRepositoryImpl import com.project200.data.impl.ScoreRepositoryImpl import com.project200.data.impl.TimerRepositoryImpl @@ -19,6 +20,7 @@ import com.project200.domain.repository.ExerciseRecordRepository import com.project200.domain.repository.FcmRepository import com.project200.domain.repository.MatchingRepository import com.project200.domain.repository.MemberRepository +import com.project200.domain.repository.NotificationRepository import com.project200.domain.repository.PolicyRepository import com.project200.domain.repository.ScoreRepository import com.project200.domain.repository.TimerRepository @@ -74,4 +76,8 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindChattingRepository(chattingRepositoryImpl: ChattingRepositoryImpl): ChattingRepository + + @Binds + @Singleton + abstract fun bindNotificationRepository(notificationRepositoryImpl: NotificationRepositoryImpl): NotificationRepository } diff --git a/data/src/main/java/com/project200/data/dto/NotificationDTO.kt b/data/src/main/java/com/project200/data/dto/NotificationDTO.kt new file mode 100644 index 00000000..ab346dce --- /dev/null +++ b/data/src/main/java/com/project200/data/dto/NotificationDTO.kt @@ -0,0 +1,6 @@ +package com.project200.data.dto + +data class GetNotificationStateDTO( + val exerciseEncouragement: Boolean, + val chatAlarm: Boolean, +) \ No newline at end of file diff --git a/data/src/main/java/com/project200/data/impl/FcmRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/FcmRepositoryImpl.kt index edbe7139..4deb18bb 100644 --- a/data/src/main/java/com/project200/data/impl/FcmRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/FcmRepositoryImpl.kt @@ -9,6 +9,7 @@ import com.project200.domain.model.BaseResult import com.project200.domain.repository.FcmRepository import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext import javax.inject.Inject class FcmRepositoryImpl @@ -19,9 +20,11 @@ class FcmRepositoryImpl @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : FcmRepository { // FCM 토큰을 SharedPreferences에서 가져오는 함수 - private fun getFcmTokenFromPrefs(): String? { - val sharedPrefs = context.getSharedPreferences("undabangPrefs", Context.MODE_PRIVATE) - return sharedPrefs.getString("fcmToken", null) + override suspend fun getFcmTokenFromPrefs(): String? { + return withContext(ioDispatcher) { + val sharedPrefs = context.getSharedPreferences("undabangPrefs", Context.MODE_PRIVATE) + sharedPrefs.getString("fcmToken", null) + } } override suspend fun sendFcmToken(): BaseResult { diff --git a/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt new file mode 100644 index 00000000..e97fef11 --- /dev/null +++ b/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt @@ -0,0 +1,30 @@ +package com.project200.data.impl + +import com.project200.common.di.IoDispatcher +import com.project200.data.api.ApiService +import com.project200.data.mapper.toModel +import com.project200.data.utils.apiCallBuilder +import com.project200.domain.model.BaseResult +import com.project200.domain.model.NotificationState +import com.project200.domain.repository.FcmRepository +import com.project200.domain.repository.NotificationRepository +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +class NotificationRepositoryImpl @Inject constructor( + private val apiService: ApiService, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val fcmRepository: FcmRepository +): NotificationRepository { + override suspend fun getNotiState(): BaseResult { + return apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { + val fcmTokenResult = fcmRepository.getFcmTokenFromPrefs() ?: throw NoSuchElementException("FCM token is missing.") + apiService.getNotiState(fcmTokenResult) }, + mapper = { dto -> + dto?.toModel() ?: throw NoSuchElementException("Notification state data is missing.") + }, + ) + } +} \ No newline at end of file diff --git a/data/src/main/java/com/project200/data/mapper/NotificationMapper.kt b/data/src/main/java/com/project200/data/mapper/NotificationMapper.kt new file mode 100644 index 00000000..27884da5 --- /dev/null +++ b/data/src/main/java/com/project200/data/mapper/NotificationMapper.kt @@ -0,0 +1,9 @@ +package com.project200.data.mapper + +import com.project200.data.dto.GetNotificationStateDTO +import com.project200.domain.model.NotificationState + +fun GetNotificationStateDTO.toModel() = NotificationState( + exerciseEncouragement = this.exerciseEncouragement, + chatAlarm = this.chatAlarm, +) \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/model/NotificationModel.kt b/domain/src/main/java/com/project200/domain/model/NotificationModel.kt new file mode 100644 index 00000000..8587adf4 --- /dev/null +++ b/domain/src/main/java/com/project200/domain/model/NotificationModel.kt @@ -0,0 +1,11 @@ +package com.project200.domain.model + +data class NotificationState( + val exerciseEncouragement: Boolean, + val chatAlarm: Boolean, +) + +enum class NotificationType { + CHAT_MESSAGE, // 채팅 알림 + WORKOUT_REMINDER // 운동 독려 알림 +} \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/repository/FcmRepository.kt b/domain/src/main/java/com/project200/domain/repository/FcmRepository.kt index c9b41bde..b5fbf1f2 100644 --- a/domain/src/main/java/com/project200/domain/repository/FcmRepository.kt +++ b/domain/src/main/java/com/project200/domain/repository/FcmRepository.kt @@ -4,4 +4,5 @@ import com.project200.domain.model.BaseResult interface FcmRepository { suspend fun sendFcmToken(): BaseResult + suspend fun getFcmTokenFromPrefs(): String? } diff --git a/domain/src/main/java/com/project200/domain/repository/NotificationRepository.kt b/domain/src/main/java/com/project200/domain/repository/NotificationRepository.kt new file mode 100644 index 00000000..8aaa8a1e --- /dev/null +++ b/domain/src/main/java/com/project200/domain/repository/NotificationRepository.kt @@ -0,0 +1,8 @@ +package com.project200.domain.repository + +import com.project200.domain.model.BaseResult +import com.project200.domain.model.NotificationState + +interface NotificationRepository { + suspend fun getNotiState(): BaseResult +} \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/usecase/GetNotificationStateUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/GetNotificationStateUseCase.kt new file mode 100644 index 00000000..76e06108 --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/GetNotificationStateUseCase.kt @@ -0,0 +1,14 @@ +package com.project200.domain.usecase + +import com.project200.domain.model.BaseResult +import com.project200.domain.model.NotificationState +import com.project200.domain.repository.NotificationRepository +import javax.inject.Inject + +class GetNotificationStateUseCase @Inject constructor( + private val notificationRepository: NotificationRepository +) { + suspend operator fun invoke(): BaseResult { + return notificationRepository.getNotiState() + } +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt index 8ee3e663..2c444646 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt @@ -27,12 +27,10 @@ class NotificationFragment : BindingFragment(R.layo private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> if (isGranted) { - // 권한이 허용되면 스위치를 활성화 - viewModel.setNotificationState(true) + // 권한이 허용되면 Toast.makeText(requireContext(), getString(R.string.noti_active), Toast.LENGTH_SHORT).show() } else { - // 권한이 거부되면 스위치를 비활성화 - viewModel.setNotificationState(false) + // 권한이 거부 Toast.makeText(requireContext(), getString(R.string.noti_deactive), Toast.LENGTH_SHORT).show() } } @@ -49,12 +47,21 @@ class NotificationFragment : BindingFragment(R.layo override fun setupViews() { binding.backBtnIv.setOnClickListener { requireActivity().onBackPressedDispatcher.onBackPressed() } - binding.notificationSwitch.setOnClickListener { + binding.exerciseNotiSwitch.setOnClickListener { // 스위치가 켜지는 경우 (알림 활성화) - if (binding.notificationSwitch.isChecked) { + if (binding.exerciseNotiSwitch.isChecked) { requestNotificationPermission() } else { // 스위치가 꺼지는 경우 (알림 비활성화) - openAppSettings() + // TODO: 운동 알림 비활성화 로직 구현 + } + } + + binding.chattingNotiSwitch.setOnClickListener { + // 스위치가 켜지는 경우 (알림 활성화) + if (binding.chattingNotiSwitch.isChecked) { + requestNotificationPermission() + } else { // 스위치가 꺼지는 경우 (알림 비활성화) + // TODO: 채팅 알림 비활성화 로직 구현 } } } @@ -63,28 +70,29 @@ class NotificationFragment : BindingFragment(R.layo viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { launch { - viewModel.isNotiActive.collect { isActive -> - binding.notificationSwitch.isChecked = isActive + viewModel.isExerciseOn.collect { isEnabled -> + binding.exerciseNotiSwitch.isEnabled = isEnabled } } + launch { - viewModel.isSwitchEnabled.collect { isEnabled -> - binding.notificationSwitch.isEnabled = isEnabled + viewModel.isChatOn.collect { isEnabled -> + binding.chattingNotiSwitch.isEnabled = isEnabled } } } } } - // 현재 알림 권한 상태를 확인하고 ViewModel에 반영 + // 현재 알림 권한 상태를 확인 private fun checkNotificationPermission() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val permissionStatus = ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS) - viewModel.setNotificationState(permissionStatus == PackageManager.PERMISSION_GRANTED) + val isGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED } else { - // Android 13 미만에서는 기본적으로 알림이 활성화된 것으로 간주 - viewModel.setNotificationState(true) + true } + // 확인된 권한 상태를 ViewModel에 전달하여 초기 로직을 수행하도록 합니다. + viewModel.initNotificationState(isGranted) } // 알림 권한 요청 @@ -97,12 +105,10 @@ class NotificationFragment : BindingFragment(R.layo Manifest.permission.POST_NOTIFICATIONS, ) == PackageManager.PERMISSION_GRANTED -> { // 이미 권한이 있는 경우 - viewModel.setNotificationState(true) } shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> { // 권한이 명시적으로 거부된 경우, 사용자에게 설명 후 설정으로 유도 Toast.makeText(requireContext(), getString(R.string.noti_permission_announce), Toast.LENGTH_LONG).show() - viewModel.setNotificationState(false) openAppSettings() } else -> { @@ -112,7 +118,6 @@ class NotificationFragment : BindingFragment(R.layo } } else { // Android 13 미만 버전에서는 별도의 런타임 권한이 필요 없음 - viewModel.setNotificationState(true) } } diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt index 310840f3..6455a917 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt @@ -2,8 +2,10 @@ package com.project200.undabang.profile.setting import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.project200.domain.model.BaseResult +import com.project200.domain.model.NotificationType +import com.project200.domain.usecase.GetNotificationStateUseCase import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -12,25 +14,36 @@ import javax.inject.Inject @HiltViewModel class NotificationViewModel @Inject - constructor() : ViewModel() { - private val _isNotiActive = MutableStateFlow(false) // 초기값은 false로 설정 - val isNotiActive: StateFlow = _isNotiActive + constructor( + private val getNotiStateUseCase: GetNotificationStateUseCase + ) : ViewModel() { + private val _isExerciseOn = MutableStateFlow(false) + val isExerciseOn: StateFlow = _isExerciseOn - private val _isSwitchEnabled = MutableStateFlow(true) - val isSwitchEnabled: StateFlow = _isSwitchEnabled + private val _isChatOn = MutableStateFlow(false) + val isChatOn: StateFlow = _isChatOn - fun setNotificationState(isActive: Boolean) { - _isNotiActive.value = isActive + fun initNotificationState(hasPermission: Boolean) { + if (hasPermission) { + // 권한이 있으면 서버에서 상태를 가져옵니다. + getNotificationState() + } else { + // 권한이 없으면 모든 스위치를 끕니다. + _isExerciseOn.value = false + _isChatOn.value = false + } } - fun onSwitchToggled() { + private fun getNotificationState() { viewModelScope.launch { - if (_isSwitchEnabled.value) { - _isSwitchEnabled.value = false - // UI가 즉시 바뀌도록 상태 변경 - _isNotiActive.value = !_isNotiActive.value - delay(1000L) // 중복 클릭 방지를 위한 딜레이 - _isSwitchEnabled.value = true + when (val result = getNotiStateUseCase()) { + is BaseResult.Success -> { + _isExerciseOn.value = result.data.exerciseEncouragement + _isChatOn.value = result.data.chatAlarm + } + is BaseResult.Error -> { + //TODO: 에러 처리 + } } } } diff --git a/feature/profile/src/main/res/layout/fragment_notification.xml b/feature/profile/src/main/res/layout/fragment_notification.xml index 92904a08..a1e6520b 100644 --- a/feature/profile/src/main/res/layout/fragment_notification.xml +++ b/feature/profile/src/main/res/layout/fragment_notification.xml @@ -29,21 +29,14 @@ app:layout_constraintTop_toTopOf="@id/back_btn_iv" /> - - + android:text="@string/exercise_notification" /> + + + + + + + \ No newline at end of file diff --git a/feature/profile/src/main/res/values/strings.xml b/feature/profile/src/main/res/values/strings.xml index e219d218..393f262e 100644 --- a/feature/profile/src/main/res/values/strings.xml +++ b/feature/profile/src/main/res/values/strings.xml @@ -10,6 +10,8 @@ 버전정보 알림 설정 알림 + 운동 독려 알림 + 채팅 알림 정말 로그아웃 하시겠어요? 로그아웃 중 오류가 발생했습니다 @@ -98,7 +100,7 @@ 알림이 활성화되었습니다. 알림을 받으려면 앱 설정에서 권한을 허용해주세요. - 알림이 활성화되었습니다. + 알림이 비활성화되었습니다. 차단 해제 아직 차단된 계정이 없어요 From b97879e9415ca68eda441f46bca7e18a3766b57d Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Wed, 12 Nov 2025 22:01:40 +0900 Subject: [PATCH 02/37] =?UTF-8?q?fix:=20=EC=8B=B1=EA=B8=80=ED=86=A4?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=84=A0=EC=96=B8=EB=90=9C=20sharedPref?= =?UTF-8?q?=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20#383?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/project200/data/impl/FcmRepositoryImpl.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/data/src/main/java/com/project200/data/impl/FcmRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/FcmRepositoryImpl.kt index 4deb18bb..301ca3c6 100644 --- a/data/src/main/java/com/project200/data/impl/FcmRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/FcmRepositoryImpl.kt @@ -1,7 +1,10 @@ package com.project200.data.impl import android.content.Context +import android.content.SharedPreferences +import com.project200.common.constants.FcmConstants.KEY_FCM_TOKEN import com.project200.common.di.IoDispatcher +import com.project200.common.utils.EncryptedPrefs import com.project200.data.api.ApiService import com.project200.data.dto.BaseResponse import com.project200.data.utils.apiCallBuilder @@ -16,14 +19,13 @@ class FcmRepositoryImpl @Inject constructor( private val apiService: ApiService, - @ApplicationContext private val context: Context, + @EncryptedPrefs private var sharedPreferences: SharedPreferences, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : FcmRepository { // FCM 토큰을 SharedPreferences에서 가져오는 함수 override suspend fun getFcmTokenFromPrefs(): String? { return withContext(ioDispatcher) { - val sharedPrefs = context.getSharedPreferences("undabangPrefs", Context.MODE_PRIVATE) - sharedPrefs.getString("fcmToken", null) + sharedPreferences.getString(KEY_FCM_TOKEN, null) } } From a97e1d6db4f4d534e98030517e0dae1f01b73788 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Wed, 12 Nov 2025 22:02:36 +0900 Subject: [PATCH 03/37] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B5=AC=ED=98=84=20#383?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/project200/data/api/ApiService.kt | 12 ++- .../project200/data/dto/NotificationDTO.kt | 7 ++ .../data/impl/NotificationRepositoryImpl.kt | 16 ++++ .../repository/NotificationRepository.kt | 2 + .../usecase/UpdateNotificationStateUseCase.kt | 16 ++++ .../profile/setting/NotificationFragment.kt | 48 +++++----- .../profile/setting/NotificationViewModel.kt | 88 +++++++++++++++++-- 7 files changed, 156 insertions(+), 33 deletions(-) create mode 100644 domain/src/main/java/com/project200/domain/usecase/UpdateNotificationStateUseCase.kt diff --git a/data/src/main/java/com/project200/data/api/ApiService.kt b/data/src/main/java/com/project200/data/api/ApiService.kt index 9693ab1e..a83d5bf0 100644 --- a/data/src/main/java/com/project200/data/api/ApiService.kt +++ b/data/src/main/java/com/project200/data/api/ApiService.kt @@ -27,6 +27,7 @@ import com.project200.data.dto.GetScoreDTO import com.project200.data.dto.GetSimpleTimersDTO import com.project200.data.dto.PatchCustomTimerTitleRequest import com.project200.data.dto.PatchExerciseRequestDto +import com.project200.data.dto.PatchNotificationStateRequest import com.project200.data.dto.PolicyGroupDTO import com.project200.data.dto.PostChatMessageRequest import com.project200.data.dto.PostChatRoomRequest @@ -48,6 +49,7 @@ import okhttp3.MultipartBody import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.Header import retrofit2.http.Multipart import retrofit2.http.PATCH import retrofit2.http.POST @@ -418,6 +420,14 @@ interface ApiService { @GET("api/v1/notification-settings/device?fcmToken={fcmToken}") @AccessTokenApi suspend fun getNotiState( - @Path ("fcmToken") fcmToken: String, + @Query ("fcmToken") fcmToken: String, ): BaseResponse + + // 알림 상태 수정 + @PATCH("api/v1/notification-settings/device") + @AccessTokenApi + suspend fun patchNotiState( + @Header("X-Fcm-Token") fcmToken: String, + @Body notiRequest: PatchNotificationStateRequest, + ): BaseResponse } diff --git a/data/src/main/java/com/project200/data/dto/NotificationDTO.kt b/data/src/main/java/com/project200/data/dto/NotificationDTO.kt index ab346dce..117a650c 100644 --- a/data/src/main/java/com/project200/data/dto/NotificationDTO.kt +++ b/data/src/main/java/com/project200/data/dto/NotificationDTO.kt @@ -1,6 +1,13 @@ package com.project200.data.dto +import com.project200.domain.model.NotificationState + data class GetNotificationStateDTO( val exerciseEncouragement: Boolean, val chatAlarm: Boolean, +) + +data class PatchNotificationStateRequest( + val type: String, + val enabled: Boolean, ) \ No newline at end of file diff --git a/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt index e97fef11..3a8886da 100644 --- a/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt @@ -2,10 +2,12 @@ package com.project200.data.impl import com.project200.common.di.IoDispatcher import com.project200.data.api.ApiService +import com.project200.data.dto.PatchNotificationStateRequest import com.project200.data.mapper.toModel import com.project200.data.utils.apiCallBuilder import com.project200.domain.model.BaseResult import com.project200.domain.model.NotificationState +import com.project200.domain.model.NotificationType import com.project200.domain.repository.FcmRepository import com.project200.domain.repository.NotificationRepository import kotlinx.coroutines.CoroutineDispatcher @@ -27,4 +29,18 @@ class NotificationRepositoryImpl @Inject constructor( }, ) } + + override suspend fun updateNotiState(type: NotificationType, enabled: Boolean): BaseResult { + return apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { + val fcmTokenResult = fcmRepository.getFcmTokenFromPrefs() ?: throw NoSuchElementException("FCM token is missing.") + apiService.patchNotiState( + fcmToken = fcmTokenResult, + notiRequest = PatchNotificationStateRequest(type = type.name, enabled = enabled) + ) + }, + mapper = { Unit }, + ) + } } \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/repository/NotificationRepository.kt b/domain/src/main/java/com/project200/domain/repository/NotificationRepository.kt index 8aaa8a1e..e653471d 100644 --- a/domain/src/main/java/com/project200/domain/repository/NotificationRepository.kt +++ b/domain/src/main/java/com/project200/domain/repository/NotificationRepository.kt @@ -2,7 +2,9 @@ package com.project200.domain.repository import com.project200.domain.model.BaseResult import com.project200.domain.model.NotificationState +import com.project200.domain.model.NotificationType interface NotificationRepository { suspend fun getNotiState(): BaseResult + suspend fun updateNotiState(type: NotificationType, enabled: Boolean): BaseResult } \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/usecase/UpdateNotificationStateUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/UpdateNotificationStateUseCase.kt new file mode 100644 index 00000000..cd7e2832 --- /dev/null +++ b/domain/src/main/java/com/project200/domain/usecase/UpdateNotificationStateUseCase.kt @@ -0,0 +1,16 @@ +package com.project200.domain.usecase + +import com.project200.domain.model.BaseResult +import com.project200.domain.model.NotificationState +import com.project200.domain.model.NotificationType +import com.project200.domain.repository.NotificationRepository +import jdk.jfr.Enabled +import javax.inject.Inject + +class UpdateNotificationStateUseCase @Inject constructor( + private val notificationRepository: NotificationRepository +) { + suspend operator fun invoke(type: NotificationType, enabled: Boolean): BaseResult { + return notificationRepository.updateNotiState(type, enabled) + } +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt index 2c444646..f7ab15b0 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt @@ -13,11 +13,13 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import com.project200.domain.model.NotificationType import com.project200.presentation.base.BindingFragment import com.project200.undabang.feature.profile.R import com.project200.undabang.feature.profile.databinding.FragmentNotificationBinding import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import timber.log.Timber @AndroidEntryPoint class NotificationFragment : BindingFragment(R.layout.fragment_notification) { @@ -26,12 +28,10 @@ class NotificationFragment : BindingFragment(R.layo // 알림 권한 요청을 위한 ActivityResultLauncher private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> - if (isGranted) { - // 권한이 허용되면 - Toast.makeText(requireContext(), getString(R.string.noti_active), Toast.LENGTH_SHORT).show() - } else { - // 권한이 거부 - Toast.makeText(requireContext(), getString(R.string.noti_deactive), Toast.LENGTH_SHORT).show() + viewModel.onPermissionStateChecked(isGranted) + if(!isGranted) { + binding.exerciseNotiSwitch.isChecked = false + binding.chattingNotiSwitch.isChecked = false } } @@ -48,21 +48,13 @@ class NotificationFragment : BindingFragment(R.layo override fun setupViews() { binding.backBtnIv.setOnClickListener { requireActivity().onBackPressedDispatcher.onBackPressed() } binding.exerciseNotiSwitch.setOnClickListener { - // 스위치가 켜지는 경우 (알림 활성화) - if (binding.exerciseNotiSwitch.isChecked) { - requestNotificationPermission() - } else { // 스위치가 꺼지는 경우 (알림 비활성화) - // TODO: 운동 알림 비활성화 로직 구현 - } + Timber.tag("NotificationFragment").d("Exercise Switch Toggled: ${binding.exerciseNotiSwitch.isChecked}") + viewModel.onSwitchToggled(NotificationType.WORKOUT_REMINDER, binding.exerciseNotiSwitch.isChecked) } binding.chattingNotiSwitch.setOnClickListener { - // 스위치가 켜지는 경우 (알림 활성화) - if (binding.chattingNotiSwitch.isChecked) { - requestNotificationPermission() - } else { // 스위치가 꺼지는 경우 (알림 비활성화) - // TODO: 채팅 알림 비활성화 로직 구현 - } + Timber.tag("NotificationFragment").d("Chat Switch Toggled: ${binding.chattingNotiSwitch.isChecked}") + viewModel.onSwitchToggled(NotificationType.CHAT_MESSAGE, binding.chattingNotiSwitch.isChecked) } } @@ -70,14 +62,23 @@ class NotificationFragment : BindingFragment(R.layo viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { launch { - viewModel.isExerciseOn.collect { isEnabled -> - binding.exerciseNotiSwitch.isEnabled = isEnabled + viewModel.isExerciseOn.collect { isChecked -> + binding.exerciseNotiSwitch.isChecked = isChecked + } + } + + launch { + viewModel.isChatOn.collect { isChecked -> + binding.chattingNotiSwitch.isChecked = isChecked } } launch { - viewModel.isChatOn.collect { isEnabled -> - binding.chattingNotiSwitch.isEnabled = isEnabled + viewModel.permissionRequestTrigger.collect { needsRequest -> + if (needsRequest) { + requestNotificationPermission() + viewModel.onPermissionRequestHandled() + } } } } @@ -92,7 +93,7 @@ class NotificationFragment : BindingFragment(R.layo true } // 확인된 권한 상태를 ViewModel에 전달하여 초기 로직을 수행하도록 합니다. - viewModel.initNotificationState(isGranted) + viewModel.onPermissionStateChecked(isGranted) } // 알림 권한 요청 @@ -105,6 +106,7 @@ class NotificationFragment : BindingFragment(R.layo Manifest.permission.POST_NOTIFICATIONS, ) == PackageManager.PERMISSION_GRANTED -> { // 이미 권한이 있는 경우 + Timber.tag("NotificationFragment").d("Notification permission already granted.") } shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> { // 권한이 명시적으로 거부된 경우, 사용자에게 설명 후 설정으로 유도 diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt index 6455a917..78e6483f 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt @@ -5,17 +5,20 @@ import androidx.lifecycle.viewModelScope import com.project200.domain.model.BaseResult import com.project200.domain.model.NotificationType import com.project200.domain.usecase.GetNotificationStateUseCase +import com.project200.domain.usecase.UpdateNotificationStateUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @HiltViewModel class NotificationViewModel @Inject constructor( - private val getNotiStateUseCase: GetNotificationStateUseCase + private val getNotiStateUseCase: GetNotificationStateUseCase, + private val updateNotiSettingUseCase: UpdateNotificationStateUseCase, ) : ViewModel() { private val _isExerciseOn = MutableStateFlow(false) val isExerciseOn: StateFlow = _isExerciseOn @@ -23,17 +26,42 @@ class NotificationViewModel private val _isChatOn = MutableStateFlow(false) val isChatOn: StateFlow = _isChatOn - fun initNotificationState(hasPermission: Boolean) { - if (hasPermission) { - // 권한이 있으면 서버에서 상태를 가져옵니다. - getNotificationState() - } else { - // 권한이 없으면 모든 스위치를 끕니다. - _isExerciseOn.value = false - _isChatOn.value = false + private val _permissionRequestTrigger = MutableStateFlow(false) + val permissionRequestTrigger: StateFlow = _permissionRequestTrigger + + // 디바이스 권한 상태 + private var hasDevicePermission: Boolean = false + + // 대기 중인 알림 설정 타입 + private var pendingSettingType: NotificationType? = null + + private var isInitialized = false + + fun onPermissionStateChecked(isGranted: Boolean) { + val wasGranted = this.hasDevicePermission + this.hasDevicePermission = isGranted + + // 권한 허용으로 변경된 경우 + if (!wasGranted && isGranted) { + pendingSettingType?.let { + updateSetting(it, true) + pendingSettingType = null + } + } + + // 한 번만 실행 + if (!isInitialized) { + if (isGranted) { + getNotificationState() + } else { + _isExerciseOn.value = false + _isChatOn.value = false + } + isInitialized = true } } + private fun getNotificationState() { viewModelScope.launch { when (val result = getNotiStateUseCase()) { @@ -47,4 +75,46 @@ class NotificationViewModel } } } + + fun onSwitchToggled(type: NotificationType, isEnabled: Boolean) { + // 권한이 없는 경우 + if (isEnabled && !hasDevicePermission) { + // 권한 허용 후에 적용할 설정을 저장 + pendingSettingType = type + _permissionRequestTrigger.value = true + + // 스위치 원상복구 + _isExerciseOn.value = false + _isChatOn.value = false + + return + } + updateSetting(type, isEnabled) + } + + private fun updateSetting(type: NotificationType, enabled: Boolean) { + viewModelScope.launch { + // UI를 먼저 변경 + when(type) { + NotificationType.WORKOUT_REMINDER -> { _isExerciseOn.value = enabled } + NotificationType.CHAT_MESSAGE -> { _isChatOn.value = enabled } + } + + when (updateNotiSettingUseCase(type, enabled)) { + is BaseResult.Success -> { /* 성공 시 별도 처리 없음 */ } + is BaseResult.Error -> { + Timber.tag("NotificationViewModel").e("알림 설정 변경 실패: ${type}, enabled: ${enabled}") + // 실패 시 스위치 원상복구 + when(type) { + NotificationType.WORKOUT_REMINDER -> _isExerciseOn.value = !enabled + NotificationType.CHAT_MESSAGE -> _isChatOn.value = !enabled + } + } + } + } + } + + fun onPermissionRequestHandled() { + _permissionRequestTrigger.value = false + } } From 7eb953f0f60d0a1ec32c5ae12bd19cc13eac2d8f Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Thu, 13 Nov 2025 03:45:05 +0900 Subject: [PATCH 04/37] =?UTF-8?q?refactor:=20api=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=ED=98=95=EC=8B=9D=20=EB=8C=80=EC=9D=91=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20?= =?UTF-8?q?#383?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/project200/data/api/ApiService.kt | 11 ++- .../project200/data/dto/NotificationDTO.kt | 9 +- .../data/impl/NotificationRepositoryImpl.kt | 18 ++-- .../data/mapper/NotificationMapper.kt | 26 ++++-- .../domain/model/NotificationModel.kt | 7 +- .../repository/NotificationRepository.kt | 5 +- .../usecase/GetNotificationStateUseCase.kt | 2 +- .../usecase/UpdateNotificationStateUseCase.kt | 5 +- .../profile/setting/NotificationFragment.kt | 32 ++++--- .../profile/setting/NotificationViewModel.kt | 90 ++++++++++--------- 10 files changed, 114 insertions(+), 91 deletions(-) diff --git a/data/src/main/java/com/project200/data/api/ApiService.kt b/data/src/main/java/com/project200/data/api/ApiService.kt index a83d5bf0..79cb6837 100644 --- a/data/src/main/java/com/project200/data/api/ApiService.kt +++ b/data/src/main/java/com/project200/data/api/ApiService.kt @@ -19,7 +19,7 @@ import com.project200.data.dto.GetIsRegisteredData import com.project200.data.dto.GetMatchingMembersDto import com.project200.data.dto.GetMatchingProfileDTO import com.project200.data.dto.GetNewChattingMessagesDTO -import com.project200.data.dto.GetNotificationStateDTO +import com.project200.data.dto.NotificationStateDTO import com.project200.data.dto.GetOpenChatUrlDTO import com.project200.data.dto.GetProfileDTO import com.project200.data.dto.GetProfileImageResponseDto @@ -27,7 +27,6 @@ import com.project200.data.dto.GetScoreDTO import com.project200.data.dto.GetSimpleTimersDTO import com.project200.data.dto.PatchCustomTimerTitleRequest import com.project200.data.dto.PatchExerciseRequestDto -import com.project200.data.dto.PatchNotificationStateRequest import com.project200.data.dto.PolicyGroupDTO import com.project200.data.dto.PostChatMessageRequest import com.project200.data.dto.PostChatRoomRequest @@ -417,17 +416,17 @@ interface ApiService { /** 알림 */ // 알림 상태 조회 - @GET("api/v1/notification-settings/device?fcmToken={fcmToken}") + @GET("api/v1/notification-settings/device") @AccessTokenApi suspend fun getNotiState( - @Query ("fcmToken") fcmToken: String, - ): BaseResponse + @Header("X-Fcm-Token") fcmToken: String, + ): BaseResponse> // 알림 상태 수정 @PATCH("api/v1/notification-settings/device") @AccessTokenApi suspend fun patchNotiState( @Header("X-Fcm-Token") fcmToken: String, - @Body notiRequest: PatchNotificationStateRequest, + @Body notiRequest: List, ): BaseResponse } diff --git a/data/src/main/java/com/project200/data/dto/NotificationDTO.kt b/data/src/main/java/com/project200/data/dto/NotificationDTO.kt index 117a650c..e011e0b0 100644 --- a/data/src/main/java/com/project200/data/dto/NotificationDTO.kt +++ b/data/src/main/java/com/project200/data/dto/NotificationDTO.kt @@ -1,13 +1,6 @@ package com.project200.data.dto -import com.project200.domain.model.NotificationState - -data class GetNotificationStateDTO( - val exerciseEncouragement: Boolean, - val chatAlarm: Boolean, -) - -data class PatchNotificationStateRequest( +data class NotificationStateDTO( val type: String, val enabled: Boolean, ) \ No newline at end of file diff --git a/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt index 3a8886da..c543b352 100644 --- a/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt @@ -2,7 +2,8 @@ package com.project200.data.impl import com.project200.common.di.IoDispatcher import com.project200.data.api.ApiService -import com.project200.data.dto.PatchNotificationStateRequest +import com.project200.data.dto.NotificationStateDTO +import com.project200.data.mapper.toDTO import com.project200.data.mapper.toModel import com.project200.data.utils.apiCallBuilder import com.project200.domain.model.BaseResult @@ -18,29 +19,30 @@ class NotificationRepositoryImpl @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val fcmRepository: FcmRepository ): NotificationRepository { - override suspend fun getNotiState(): BaseResult { + override suspend fun getNotiState(): BaseResult> { return apiCallBuilder( ioDispatcher = ioDispatcher, apiCall = { val fcmTokenResult = fcmRepository.getFcmTokenFromPrefs() ?: throw NoSuchElementException("FCM token is missing.") apiService.getNotiState(fcmTokenResult) }, - mapper = { dto -> - dto?.toModel() ?: throw NoSuchElementException("Notification state data is missing.") + mapper = { dtoList -> + dtoList?.map { it.toModel() } ?: throw NoSuchElementException("Notification state data is missing.") }, ) } - override suspend fun updateNotiState(type: NotificationType, enabled: Boolean): BaseResult { - return apiCallBuilder( + override suspend fun updateNotiState(notiState: List): BaseResult { +/* return apiCallBuilder( ioDispatcher = ioDispatcher, apiCall = { val fcmTokenResult = fcmRepository.getFcmTokenFromPrefs() ?: throw NoSuchElementException("FCM token is missing.") apiService.patchNotiState( fcmToken = fcmTokenResult, - notiRequest = PatchNotificationStateRequest(type = type.name, enabled = enabled) + notiRequest = notiState.map { it.toDTO() } ) }, mapper = { Unit }, - ) + )*/ + return BaseResult.Success(Unit) /* 성공으로 더미 반환 */ } } \ No newline at end of file diff --git a/data/src/main/java/com/project200/data/mapper/NotificationMapper.kt b/data/src/main/java/com/project200/data/mapper/NotificationMapper.kt index 27884da5..ad9318e6 100644 --- a/data/src/main/java/com/project200/data/mapper/NotificationMapper.kt +++ b/data/src/main/java/com/project200/data/mapper/NotificationMapper.kt @@ -1,9 +1,25 @@ package com.project200.data.mapper -import com.project200.data.dto.GetNotificationStateDTO +import com.project200.data.dto.NotificationStateDTO import com.project200.domain.model.NotificationState +import com.project200.domain.model.NotificationType -fun GetNotificationStateDTO.toModel() = NotificationState( - exerciseEncouragement = this.exerciseEncouragement, - chatAlarm = this.chatAlarm, -) \ No newline at end of file +fun NotificationStateDTO.toModel(): NotificationState { + val type = when (this.type) { + "CHAT_MESSAGE" -> NotificationType.CHAT_MESSAGE + "WORKOUT_REMINDER" -> NotificationType.WORKOUT_REMINDER + else -> NotificationType.UNKNOWN + } + + return NotificationState( + type = type, + enabled = this.enabled + ) +} + +fun NotificationState.toDTO(): NotificationStateDTO { + return NotificationStateDTO( + type = this.type.name, + enabled = this.enabled + ) +} \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/model/NotificationModel.kt b/domain/src/main/java/com/project200/domain/model/NotificationModel.kt index 8587adf4..e3ea35dd 100644 --- a/domain/src/main/java/com/project200/domain/model/NotificationModel.kt +++ b/domain/src/main/java/com/project200/domain/model/NotificationModel.kt @@ -1,11 +1,12 @@ package com.project200.domain.model data class NotificationState( - val exerciseEncouragement: Boolean, - val chatAlarm: Boolean, + val type: NotificationType, + val enabled: Boolean, ) enum class NotificationType { CHAT_MESSAGE, // 채팅 알림 - WORKOUT_REMINDER // 운동 독려 알림 + WORKOUT_REMINDER, // 운동 독려 알림 + UNKNOWN, // 알 수 없는 타입 } \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/repository/NotificationRepository.kt b/domain/src/main/java/com/project200/domain/repository/NotificationRepository.kt index e653471d..3d5eb615 100644 --- a/domain/src/main/java/com/project200/domain/repository/NotificationRepository.kt +++ b/domain/src/main/java/com/project200/domain/repository/NotificationRepository.kt @@ -2,9 +2,8 @@ package com.project200.domain.repository import com.project200.domain.model.BaseResult import com.project200.domain.model.NotificationState -import com.project200.domain.model.NotificationType interface NotificationRepository { - suspend fun getNotiState(): BaseResult - suspend fun updateNotiState(type: NotificationType, enabled: Boolean): BaseResult + suspend fun getNotiState(): BaseResult> + suspend fun updateNotiState(notiState: List): BaseResult } \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/usecase/GetNotificationStateUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/GetNotificationStateUseCase.kt index 76e06108..56c98fd6 100644 --- a/domain/src/main/java/com/project200/domain/usecase/GetNotificationStateUseCase.kt +++ b/domain/src/main/java/com/project200/domain/usecase/GetNotificationStateUseCase.kt @@ -8,7 +8,7 @@ import javax.inject.Inject class GetNotificationStateUseCase @Inject constructor( private val notificationRepository: NotificationRepository ) { - suspend operator fun invoke(): BaseResult { + suspend operator fun invoke(): BaseResult> { return notificationRepository.getNotiState() } } \ No newline at end of file diff --git a/domain/src/main/java/com/project200/domain/usecase/UpdateNotificationStateUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/UpdateNotificationStateUseCase.kt index cd7e2832..3e89eff1 100644 --- a/domain/src/main/java/com/project200/domain/usecase/UpdateNotificationStateUseCase.kt +++ b/domain/src/main/java/com/project200/domain/usecase/UpdateNotificationStateUseCase.kt @@ -4,13 +4,12 @@ import com.project200.domain.model.BaseResult import com.project200.domain.model.NotificationState import com.project200.domain.model.NotificationType import com.project200.domain.repository.NotificationRepository -import jdk.jfr.Enabled import javax.inject.Inject class UpdateNotificationStateUseCase @Inject constructor( private val notificationRepository: NotificationRepository ) { - suspend operator fun invoke(type: NotificationType, enabled: Boolean): BaseResult { - return notificationRepository.updateNotiState(type, enabled) + suspend operator fun invoke(notiState: List): BaseResult { + return notificationRepository.updateNotiState(notiState) } } \ No newline at end of file diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt index f7ab15b0..4e9021e1 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt @@ -28,8 +28,9 @@ class NotificationFragment : BindingFragment(R.layo // 알림 권한 요청을 위한 ActivityResultLauncher private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> - viewModel.onPermissionStateChecked(isGranted) + viewModel.updateNotiStateByPermission(isGranted) if(!isGranted) { + Toast.makeText(requireContext(), getString(R.string.noti_permission_announce), Toast.LENGTH_LONG).show() binding.exerciseNotiSwitch.isChecked = false binding.chattingNotiSwitch.isChecked = false } @@ -42,6 +43,7 @@ class NotificationFragment : BindingFragment(R.layo override fun onResume() { super.onResume() // 화면에 다시 돌아왔을 때 현재 알림 권한 상태를 체크하여 UI를 업데이트 + Timber.tag("NotificationFragment").d("onResume - Checking notification permission") checkNotificationPermission() } @@ -62,14 +64,19 @@ class NotificationFragment : BindingFragment(R.layo viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { launch { - viewModel.isExerciseOn.collect { isChecked -> - binding.exerciseNotiSwitch.isChecked = isChecked - } - } + viewModel.notificationStates.collect { states -> + Timber.tag("NotificationFragment").d("기존 설정: ${binding.exerciseNotiSwitch.isChecked} ${binding.chattingNotiSwitch.isChecked}\n새 설정: $states") + // 운동 알림 스위치 상태 설정 + val isExerciseOn = states.find { it.type == NotificationType.WORKOUT_REMINDER }?.enabled ?: false + if (binding.exerciseNotiSwitch.isChecked != isExerciseOn) { + binding.exerciseNotiSwitch.isChecked = isExerciseOn + } - launch { - viewModel.isChatOn.collect { isChecked -> - binding.chattingNotiSwitch.isChecked = isChecked + // 채팅 알림 스위치 상태 설정 + val isChatOn = states.find { it.type == NotificationType.CHAT_MESSAGE }?.enabled ?: false + if (binding.chattingNotiSwitch.isChecked != isChatOn) { + binding.chattingNotiSwitch.isChecked = isChatOn + } } } @@ -93,7 +100,12 @@ class NotificationFragment : BindingFragment(R.layo true } // 확인된 권한 상태를 ViewModel에 전달하여 초기 로직을 수행하도록 합니다. - viewModel.onPermissionStateChecked(isGranted) + viewModel.updateNotiStateByPermission(isGranted) + if(!isGranted) { + Toast.makeText(requireContext(), getString(R.string.noti_permission_announce), Toast.LENGTH_LONG).show() + binding.exerciseNotiSwitch.isChecked = false + binding.chattingNotiSwitch.isChecked = false + } } // 알림 권한 요청 @@ -109,8 +121,6 @@ class NotificationFragment : BindingFragment(R.layo Timber.tag("NotificationFragment").d("Notification permission already granted.") } shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> { - // 권한이 명시적으로 거부된 경우, 사용자에게 설명 후 설정으로 유도 - Toast.makeText(requireContext(), getString(R.string.noti_permission_announce), Toast.LENGTH_LONG).show() openAppSettings() } else -> { diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt index 78e6483f..f311451c 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt @@ -3,6 +3,7 @@ package com.project200.undabang.profile.setting import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.project200.domain.model.BaseResult +import com.project200.domain.model.NotificationState import com.project200.domain.model.NotificationType import com.project200.domain.usecase.GetNotificationStateUseCase import com.project200.domain.usecase.UpdateNotificationStateUseCase @@ -20,11 +21,9 @@ class NotificationViewModel private val getNotiStateUseCase: GetNotificationStateUseCase, private val updateNotiSettingUseCase: UpdateNotificationStateUseCase, ) : ViewModel() { - private val _isExerciseOn = MutableStateFlow(false) - val isExerciseOn: StateFlow = _isExerciseOn - private val _isChatOn = MutableStateFlow(false) - val isChatOn: StateFlow = _isChatOn + private val _notificationStates = MutableStateFlow>(emptyList()) + val notificationStates: StateFlow> = _notificationStates private val _permissionRequestTrigger = MutableStateFlow(false) val permissionRequestTrigger: StateFlow = _permissionRequestTrigger @@ -37,28 +36,39 @@ class NotificationViewModel private var isInitialized = false - fun onPermissionStateChecked(isGranted: Boolean) { + fun initNotiState(isGranted: Boolean) { + if (isGranted) { + getNotificationState() + } else { + _notificationStates.value = listOf( + NotificationState(NotificationType.WORKOUT_REMINDER, false), + NotificationState(NotificationType.CHAT_MESSAGE, false) + ) + } + isInitialized = true + } + + fun updateNotiStateByPermission(isGranted: Boolean) { + if(!isInitialized) { + initNotiState(isGranted) + return + } + val wasGranted = this.hasDevicePermission this.hasDevicePermission = isGranted - // 권한 허용으로 변경된 경우 - if (!wasGranted && isGranted) { + // 권한 거부 + if (!isGranted) { + // 모든 알림 설정을 false로 조정 + Timber.tag("NotificationViewModel").e("권한 거부로 모든 알림 설정 비활성화") + _notificationStates.value = _notificationStates.value.map { it.copy(enabled = false) } + } else if (!wasGranted) { // 권한 거부에서 허용으로 변경된 경우 + Timber.tag("NotificationViewModel").e("권한 허용으로 변경") pendingSettingType?.let { updateSetting(it, true) pendingSettingType = null } } - - // 한 번만 실행 - if (!isInitialized) { - if (isGranted) { - getNotificationState() - } else { - _isExerciseOn.value = false - _isChatOn.value = false - } - isInitialized = true - } } @@ -66,8 +76,7 @@ class NotificationViewModel viewModelScope.launch { when (val result = getNotiStateUseCase()) { is BaseResult.Success -> { - _isExerciseOn.value = result.data.exerciseEncouragement - _isChatOn.value = result.data.chatAlarm + _notificationStates.value = result.data } is BaseResult.Error -> { //TODO: 에러 처리 @@ -82,37 +91,32 @@ class NotificationViewModel // 권한 허용 후에 적용할 설정을 저장 pendingSettingType = type _permissionRequestTrigger.value = true - - // 스위치 원상복구 - _isExerciseOn.value = false - _isChatOn.value = false - return } updateSetting(type, isEnabled) } - private fun updateSetting(type: NotificationType, enabled: Boolean) { - viewModelScope.launch { - // UI를 먼저 변경 - when(type) { - NotificationType.WORKOUT_REMINDER -> { _isExerciseOn.value = enabled } - NotificationType.CHAT_MESSAGE -> { _isChatOn.value = enabled } - } - - when (updateNotiSettingUseCase(type, enabled)) { - is BaseResult.Success -> { /* 성공 시 별도 처리 없음 */ } - is BaseResult.Error -> { - Timber.tag("NotificationViewModel").e("알림 설정 변경 실패: ${type}, enabled: ${enabled}") - // 실패 시 스위치 원상복구 - when(type) { - NotificationType.WORKOUT_REMINDER -> _isExerciseOn.value = !enabled - NotificationType.CHAT_MESSAGE -> _isChatOn.value = !enabled - } - } + private fun updateSetting(type: NotificationType, enabled: Boolean) { + Timber.tag("NotificationViewModel").e("알림 설정 변경 요청: ${type}, enabled: ${enabled}") + viewModelScope.launch { + // UI를 먼저 변경 + val originalStates = _notificationStates.value + val newStates = originalStates.map { + // 전달받은 type에 해당하는 아이템의 enabled 값만 변경 + if (it.type == type) it.copy(enabled = enabled) else it + } + _notificationStates.value = newStates + + when (updateNotiSettingUseCase(newStates)) { + is BaseResult.Success -> { /* 성공 시 별도 처리 없음 */ } + is BaseResult.Error -> { + Timber.tag("NotificationViewModel").e("알림 설정 변경 실패: ${type}, enabled: ${enabled}") + // 실패 시 스위치 원상복구 + _notificationStates.value = originalStates } } } + } fun onPermissionRequestHandled() { _permissionRequestTrigger.value = false From 8a020a27ccffcd2dad5a03b526ad94e2b56cd82d Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Sun, 16 Nov 2025 16:46:29 +0900 Subject: [PATCH 05/37] =?UTF-8?q?feat:=20fcm=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=ED=97=A4=EB=93=9C=EC=97=85=20=EC=95=8C=EB=A6=BC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20#384?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 2 + .../project200/undabang/fcm/FcmConstant.kt | 6 ++ .../com/project200/undabang/fcm/FcmService.kt | 59 ++++++++++++++++++- app/src/main/res/drawable/bg_chat_noti.xml | 6 ++ .../main/res/drawable/ic_chat_noti_logo.xml | 30 ++++++++++ .../res/layout/custom_notification_chat.xml | 6 ++ app/src/main/res/navigation/nav_graph.xml | 26 -------- 7 files changed, 108 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/project200/undabang/fcm/FcmConstant.kt create mode 100644 app/src/main/res/drawable/bg_chat_noti.xml create mode 100644 app/src/main/res/drawable/ic_chat_noti_logo.xml create mode 100644 app/src/main/res/layout/custom_notification_chat.xml delete mode 100644 app/src/main/res/navigation/nav_graph.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a3ed8f77..02e13801 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -42,5 +42,7 @@ + + \ No newline at end of file diff --git a/app/src/main/java/com/project200/undabang/fcm/FcmConstant.kt b/app/src/main/java/com/project200/undabang/fcm/FcmConstant.kt new file mode 100644 index 00000000..f382718f --- /dev/null +++ b/app/src/main/java/com/project200/undabang/fcm/FcmConstant.kt @@ -0,0 +1,6 @@ +package com.project200.undabang.fcm + +object FcmConstant { + const val CHAT_NOTI_CHANNEL_ID = "chat_notification_channel" + const val CHAT_NOTI_CHANNEL_NAME = "채팅 알림" +} \ No newline at end of file diff --git a/app/src/main/java/com/project200/undabang/fcm/FcmService.kt b/app/src/main/java/com/project200/undabang/fcm/FcmService.kt index 07f2151c..03ec6639 100644 --- a/app/src/main/java/com/project200/undabang/fcm/FcmService.kt +++ b/app/src/main/java/com/project200/undabang/fcm/FcmService.kt @@ -1,14 +1,24 @@ package com.project200.undabang.fcm +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Intent import android.content.SharedPreferences +import android.net.Uri +import androidx.core.app.NotificationCompat import androidx.core.content.edit import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import com.project200.common.constants.FcmConstants.KEY_FCM_TOKEN import com.project200.common.utils.EncryptedPrefs +import com.project200.undabang.fcm.FcmConstant.CHAT_NOTI_CHANNEL_ID +import com.project200.undabang.fcm.FcmConstant.CHAT_NOTI_CHANNEL_NAME +import com.project200.undabang.main.MainActivity import dagger.hilt.android.AndroidEntryPoint import timber.log.Timber import javax.inject.Inject +import androidx.core.net.toUri @AndroidEntryPoint class FcmService : FirebaseMessagingService() { @@ -43,7 +53,54 @@ class FcmService : FirebaseMessagingService() { // 백엔드에서 보낸 데이터 페이로드(Key-Value)를 확인합니다. if (remoteMessage.data.isNotEmpty()) { - Timber.tag(TAG).d("Data Payload: ${remoteMessage.data}") + sendNotification(remoteMessage.data) + } + } + + private fun sendNotification(data: Map) { + val chatRoomId = data["chatRoomId"] + val nickname = data["nickname"] + val memberId = data["memberId"] + + if (chatRoomId == null || nickname == null || memberId == null) return + + // 알림 식별을 위한 고유 ID + val uniqueId = chatRoomId.hashCode() + + // 채팅방으로 이동할 딥링크 URI 생성 + val deepLinkUri = "app://chatting/room/$chatRoomId/$nickname/$memberId".toUri() + + // 클릭 시 이동할 Activity 설정 + val intent = Intent(Intent.ACTION_VIEW, deepLinkUri).apply { + `package` = this@FcmService.packageName + } + + // PendingIntent 생성 + val pendingIntent = PendingIntent.getActivity( + this, uniqueId, intent, + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + ) + + // 알림 생성 + val notificationBuilder = NotificationCompat.Builder(this, CHAT_NOTI_CHANNEL_ID) + .setSmallIcon(com.project200.undabang.presentation.R.drawable.app_icon) // 알림 아이콘 + .setContentTitle(data["nickname"]) // 알림 제목 (닉네임) + .setContentText(data["content"]) // 알림 본문 (내용) + .setAutoCancel(true) // 클릭 시 알림 자동 삭제 + .setPriority(NotificationCompat.PRIORITY_HIGH) // 헤드업 알림을 위한 우선순위 설정 + .setContentIntent(pendingIntent) // 클릭 시 이동할 인텐트 설정 + + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + + + // 채널 생성 및 알림 발송 + notificationManager.apply { + createNotificationChannel(NotificationChannel( + CHAT_NOTI_CHANNEL_ID, + CHAT_NOTI_CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH // 헤드업 알림을 위한 중요도 설정 + )) + notify(uniqueId, notificationBuilder.build()) } } diff --git a/app/src/main/res/drawable/bg_chat_noti.xml b/app/src/main/res/drawable/bg_chat_noti.xml new file mode 100644 index 00000000..5631add6 --- /dev/null +++ b/app/src/main/res/drawable/bg_chat_noti.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_chat_noti_logo.xml b/app/src/main/res/drawable/ic_chat_noti_logo.xml new file mode 100644 index 00000000..6965614f --- /dev/null +++ b/app/src/main/res/drawable/ic_chat_noti_logo.xml @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/custom_notification_chat.xml b/app/src/main/res/layout/custom_notification_chat.xml new file mode 100644 index 00000000..cdc89f25 --- /dev/null +++ b/app/src/main/res/layout/custom_notification_chat.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml deleted file mode 100644 index f7ce48b1..00000000 --- a/app/src/main/res/navigation/nav_graph.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - \ No newline at end of file From 53136617c6d96b0d92e63c151affbf1eb7040166 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Sun, 16 Nov 2025 17:02:06 +0900 Subject: [PATCH 06/37] =?UTF-8?q?fix:=20manifest=20nav-graph=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EC=88=98=EC=A0=95=20#384?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 02e13801..2267871b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -25,6 +25,7 @@ + - - \ No newline at end of file From d72c62f7c9892f42fb511f37c0222f29d9d3fe70 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Sun, 16 Nov 2025 17:02:15 +0900 Subject: [PATCH 07/37] =?UTF-8?q?fix:=20ktlint=20=EB=AC=B8=EB=B2=95=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#384?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project200/undabang/fcm/FcmConstant.kt | 2 +- .../com/project200/undabang/fcm/FcmService.kt | 50 ++++++++++--------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/project200/undabang/fcm/FcmConstant.kt b/app/src/main/java/com/project200/undabang/fcm/FcmConstant.kt index f382718f..bf87574a 100644 --- a/app/src/main/java/com/project200/undabang/fcm/FcmConstant.kt +++ b/app/src/main/java/com/project200/undabang/fcm/FcmConstant.kt @@ -3,4 +3,4 @@ package com.project200.undabang.fcm object FcmConstant { const val CHAT_NOTI_CHANNEL_ID = "chat_notification_channel" const val CHAT_NOTI_CHANNEL_NAME = "채팅 알림" -} \ No newline at end of file +} diff --git a/app/src/main/java/com/project200/undabang/fcm/FcmService.kt b/app/src/main/java/com/project200/undabang/fcm/FcmService.kt index 03ec6639..fe811ba9 100644 --- a/app/src/main/java/com/project200/undabang/fcm/FcmService.kt +++ b/app/src/main/java/com/project200/undabang/fcm/FcmService.kt @@ -5,20 +5,18 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Intent import android.content.SharedPreferences -import android.net.Uri import androidx.core.app.NotificationCompat import androidx.core.content.edit +import androidx.core.net.toUri import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import com.project200.common.constants.FcmConstants.KEY_FCM_TOKEN import com.project200.common.utils.EncryptedPrefs import com.project200.undabang.fcm.FcmConstant.CHAT_NOTI_CHANNEL_ID import com.project200.undabang.fcm.FcmConstant.CHAT_NOTI_CHANNEL_NAME -import com.project200.undabang.main.MainActivity import dagger.hilt.android.AndroidEntryPoint import timber.log.Timber import javax.inject.Inject -import androidx.core.net.toUri @AndroidEntryPoint class FcmService : FirebaseMessagingService() { @@ -71,35 +69,41 @@ class FcmService : FirebaseMessagingService() { val deepLinkUri = "app://chatting/room/$chatRoomId/$nickname/$memberId".toUri() // 클릭 시 이동할 Activity 설정 - val intent = Intent(Intent.ACTION_VIEW, deepLinkUri).apply { - `package` = this@FcmService.packageName - } + val intent = + Intent(Intent.ACTION_VIEW, deepLinkUri).apply { + `package` = this@FcmService.packageName + } // PendingIntent 생성 - val pendingIntent = PendingIntent.getActivity( - this, uniqueId, intent, - PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE - ) + val pendingIntent = + PendingIntent.getActivity( + this, + uniqueId, + intent, + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE, + ) // 알림 생성 - val notificationBuilder = NotificationCompat.Builder(this, CHAT_NOTI_CHANNEL_ID) - .setSmallIcon(com.project200.undabang.presentation.R.drawable.app_icon) // 알림 아이콘 - .setContentTitle(data["nickname"]) // 알림 제목 (닉네임) - .setContentText(data["content"]) // 알림 본문 (내용) - .setAutoCancel(true) // 클릭 시 알림 자동 삭제 - .setPriority(NotificationCompat.PRIORITY_HIGH) // 헤드업 알림을 위한 우선순위 설정 - .setContentIntent(pendingIntent) // 클릭 시 이동할 인텐트 설정 + val notificationBuilder = + NotificationCompat.Builder(this, CHAT_NOTI_CHANNEL_ID) + .setSmallIcon(com.project200.undabang.presentation.R.drawable.app_icon) // 알림 아이콘 + .setContentTitle(data["nickname"]) // 알림 제목 (닉네임) + .setContentText(data["content"]) // 알림 본문 (내용) + .setAutoCancel(true) // 클릭 시 알림 자동 삭제 + .setPriority(NotificationCompat.PRIORITY_HIGH) // 헤드업 알림을 위한 우선순위 설정 + .setContentIntent(pendingIntent) // 클릭 시 이동할 인텐트 설정 val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - // 채널 생성 및 알림 발송 notificationManager.apply { - createNotificationChannel(NotificationChannel( - CHAT_NOTI_CHANNEL_ID, - CHAT_NOTI_CHANNEL_NAME, - NotificationManager.IMPORTANCE_HIGH // 헤드업 알림을 위한 중요도 설정 - )) + createNotificationChannel( + NotificationChannel( + CHAT_NOTI_CHANNEL_ID, + CHAT_NOTI_CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH, // 헤드업 알림을 위한 중요도 설정 + ), + ) notify(uniqueId, notificationBuilder.build()) } } From ceac949d8a4c68527801c9e1af2f4a92d29f1084 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Sun, 16 Nov 2025 17:06:12 +0900 Subject: [PATCH 08/37] =?UTF-8?q?fix:=20ktlint=20=EB=AC=B8=EB=B2=95=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#383?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/project200/data/api/ApiService.kt | 2 +- .../project200/data/dto/NotificationDTO.kt | 2 +- .../project200/data/impl/FcmRepositoryImpl.kt | 2 - .../data/impl/NotificationRepositoryImpl.kt | 44 +++++++------- .../data/mapper/NotificationMapper.kt | 17 +++--- .../profile/setting/NotificationFragment.kt | 22 +++---- .../profile/setting/NotificationViewModel.kt | 58 ++++++++++--------- 7 files changed, 76 insertions(+), 71 deletions(-) diff --git a/data/src/main/java/com/project200/data/api/ApiService.kt b/data/src/main/java/com/project200/data/api/ApiService.kt index 79cb6837..b21c2b7b 100644 --- a/data/src/main/java/com/project200/data/api/ApiService.kt +++ b/data/src/main/java/com/project200/data/api/ApiService.kt @@ -19,12 +19,12 @@ import com.project200.data.dto.GetIsRegisteredData import com.project200.data.dto.GetMatchingMembersDto import com.project200.data.dto.GetMatchingProfileDTO import com.project200.data.dto.GetNewChattingMessagesDTO -import com.project200.data.dto.NotificationStateDTO import com.project200.data.dto.GetOpenChatUrlDTO import com.project200.data.dto.GetProfileDTO import com.project200.data.dto.GetProfileImageResponseDto import com.project200.data.dto.GetScoreDTO import com.project200.data.dto.GetSimpleTimersDTO +import com.project200.data.dto.NotificationStateDTO import com.project200.data.dto.PatchCustomTimerTitleRequest import com.project200.data.dto.PatchExerciseRequestDto import com.project200.data.dto.PolicyGroupDTO diff --git a/data/src/main/java/com/project200/data/dto/NotificationDTO.kt b/data/src/main/java/com/project200/data/dto/NotificationDTO.kt index e011e0b0..2a601720 100644 --- a/data/src/main/java/com/project200/data/dto/NotificationDTO.kt +++ b/data/src/main/java/com/project200/data/dto/NotificationDTO.kt @@ -3,4 +3,4 @@ package com.project200.data.dto data class NotificationStateDTO( val type: String, val enabled: Boolean, -) \ No newline at end of file +) diff --git a/data/src/main/java/com/project200/data/impl/FcmRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/FcmRepositoryImpl.kt index 301ca3c6..850ac201 100644 --- a/data/src/main/java/com/project200/data/impl/FcmRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/FcmRepositoryImpl.kt @@ -1,6 +1,5 @@ package com.project200.data.impl -import android.content.Context import android.content.SharedPreferences import com.project200.common.constants.FcmConstants.KEY_FCM_TOKEN import com.project200.common.di.IoDispatcher @@ -10,7 +9,6 @@ import com.project200.data.dto.BaseResponse import com.project200.data.utils.apiCallBuilder import com.project200.domain.model.BaseResult import com.project200.domain.repository.FcmRepository -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import javax.inject.Inject diff --git a/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt index c543b352..980a7b4c 100644 --- a/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt @@ -2,36 +2,36 @@ package com.project200.data.impl import com.project200.common.di.IoDispatcher import com.project200.data.api.ApiService -import com.project200.data.dto.NotificationStateDTO -import com.project200.data.mapper.toDTO import com.project200.data.mapper.toModel import com.project200.data.utils.apiCallBuilder import com.project200.domain.model.BaseResult import com.project200.domain.model.NotificationState -import com.project200.domain.model.NotificationType import com.project200.domain.repository.FcmRepository import com.project200.domain.repository.NotificationRepository import kotlinx.coroutines.CoroutineDispatcher import javax.inject.Inject -class NotificationRepositoryImpl @Inject constructor( - private val apiService: ApiService, - @IoDispatcher private val ioDispatcher: CoroutineDispatcher, - private val fcmRepository: FcmRepository -): NotificationRepository { - override suspend fun getNotiState(): BaseResult> { - return apiCallBuilder( - ioDispatcher = ioDispatcher, - apiCall = { - val fcmTokenResult = fcmRepository.getFcmTokenFromPrefs() ?: throw NoSuchElementException("FCM token is missing.") - apiService.getNotiState(fcmTokenResult) }, - mapper = { dtoList -> - dtoList?.map { it.toModel() } ?: throw NoSuchElementException("Notification state data is missing.") - }, - ) - } +class NotificationRepositoryImpl + @Inject + constructor( + private val apiService: ApiService, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val fcmRepository: FcmRepository, + ) : NotificationRepository { + override suspend fun getNotiState(): BaseResult> { + return apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { + val fcmTokenResult = fcmRepository.getFcmTokenFromPrefs() ?: throw NoSuchElementException("FCM token is missing.") + apiService.getNotiState(fcmTokenResult) + }, + mapper = { dtoList -> + dtoList?.map { it.toModel() } ?: throw NoSuchElementException("Notification state data is missing.") + }, + ) + } - override suspend fun updateNotiState(notiState: List): BaseResult { + override suspend fun updateNotiState(notiState: List): BaseResult { /* return apiCallBuilder( ioDispatcher = ioDispatcher, apiCall = { @@ -43,6 +43,6 @@ class NotificationRepositoryImpl @Inject constructor( }, mapper = { Unit }, )*/ - return BaseResult.Success(Unit) /* 성공으로 더미 반환 */ + return BaseResult.Success(Unit) // 성공으로 더미 반환 + } } -} \ No newline at end of file diff --git a/data/src/main/java/com/project200/data/mapper/NotificationMapper.kt b/data/src/main/java/com/project200/data/mapper/NotificationMapper.kt index ad9318e6..037d67a3 100644 --- a/data/src/main/java/com/project200/data/mapper/NotificationMapper.kt +++ b/data/src/main/java/com/project200/data/mapper/NotificationMapper.kt @@ -5,21 +5,22 @@ import com.project200.domain.model.NotificationState import com.project200.domain.model.NotificationType fun NotificationStateDTO.toModel(): NotificationState { - val type = when (this.type) { - "CHAT_MESSAGE" -> NotificationType.CHAT_MESSAGE - "WORKOUT_REMINDER" -> NotificationType.WORKOUT_REMINDER - else -> NotificationType.UNKNOWN - } + val type = + when (this.type) { + "CHAT_MESSAGE" -> NotificationType.CHAT_MESSAGE + "WORKOUT_REMINDER" -> NotificationType.WORKOUT_REMINDER + else -> NotificationType.UNKNOWN + } return NotificationState( type = type, - enabled = this.enabled + enabled = this.enabled, ) } fun NotificationState.toDTO(): NotificationStateDTO { return NotificationStateDTO( type = this.type.name, - enabled = this.enabled + enabled = this.enabled, ) -} \ No newline at end of file +} diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt index 4e9021e1..cc952503 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt @@ -29,7 +29,7 @@ class NotificationFragment : BindingFragment(R.layo private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> viewModel.updateNotiStateByPermission(isGranted) - if(!isGranted) { + if (!isGranted) { Toast.makeText(requireContext(), getString(R.string.noti_permission_announce), Toast.LENGTH_LONG).show() binding.exerciseNotiSwitch.isChecked = false binding.chattingNotiSwitch.isChecked = false @@ -43,19 +43,16 @@ class NotificationFragment : BindingFragment(R.layo override fun onResume() { super.onResume() // 화면에 다시 돌아왔을 때 현재 알림 권한 상태를 체크하여 UI를 업데이트 - Timber.tag("NotificationFragment").d("onResume - Checking notification permission") checkNotificationPermission() } override fun setupViews() { binding.backBtnIv.setOnClickListener { requireActivity().onBackPressedDispatcher.onBackPressed() } binding.exerciseNotiSwitch.setOnClickListener { - Timber.tag("NotificationFragment").d("Exercise Switch Toggled: ${binding.exerciseNotiSwitch.isChecked}") viewModel.onSwitchToggled(NotificationType.WORKOUT_REMINDER, binding.exerciseNotiSwitch.isChecked) } binding.chattingNotiSwitch.setOnClickListener { - Timber.tag("NotificationFragment").d("Chat Switch Toggled: ${binding.chattingNotiSwitch.isChecked}") viewModel.onSwitchToggled(NotificationType.CHAT_MESSAGE, binding.chattingNotiSwitch.isChecked) } } @@ -65,7 +62,6 @@ class NotificationFragment : BindingFragment(R.layo repeatOnLifecycle(Lifecycle.State.STARTED) { launch { viewModel.notificationStates.collect { states -> - Timber.tag("NotificationFragment").d("기존 설정: ${binding.exerciseNotiSwitch.isChecked} ${binding.chattingNotiSwitch.isChecked}\n새 설정: $states") // 운동 알림 스위치 상태 설정 val isExerciseOn = states.find { it.type == NotificationType.WORKOUT_REMINDER }?.enabled ?: false if (binding.exerciseNotiSwitch.isChecked != isExerciseOn) { @@ -94,14 +90,18 @@ class NotificationFragment : BindingFragment(R.layo // 현재 알림 권한 상태를 확인 private fun checkNotificationPermission() { - val isGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED - } else { - true - } + val isGranted = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission( + requireContext(), + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } // 확인된 권한 상태를 ViewModel에 전달하여 초기 로직을 수행하도록 합니다. viewModel.updateNotiStateByPermission(isGranted) - if(!isGranted) { + if (!isGranted) { Toast.makeText(requireContext(), getString(R.string.noti_permission_announce), Toast.LENGTH_LONG).show() binding.exerciseNotiSwitch.isChecked = false binding.chattingNotiSwitch.isChecked = false diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt index f311451c..269d494f 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt @@ -21,7 +21,6 @@ class NotificationViewModel private val getNotiStateUseCase: GetNotificationStateUseCase, private val updateNotiSettingUseCase: UpdateNotificationStateUseCase, ) : ViewModel() { - private val _notificationStates = MutableStateFlow>(emptyList()) val notificationStates: StateFlow> = _notificationStates @@ -40,16 +39,17 @@ class NotificationViewModel if (isGranted) { getNotificationState() } else { - _notificationStates.value = listOf( - NotificationState(NotificationType.WORKOUT_REMINDER, false), - NotificationState(NotificationType.CHAT_MESSAGE, false) - ) + _notificationStates.value = + listOf( + NotificationState(NotificationType.WORKOUT_REMINDER, false), + NotificationState(NotificationType.CHAT_MESSAGE, false), + ) } isInitialized = true } fun updateNotiStateByPermission(isGranted: Boolean) { - if(!isInitialized) { + if (!isInitialized) { initNotiState(isGranted) return } @@ -71,7 +71,6 @@ class NotificationViewModel } } - private fun getNotificationState() { viewModelScope.launch { when (val result = getNotiStateUseCase()) { @@ -79,13 +78,16 @@ class NotificationViewModel _notificationStates.value = result.data } is BaseResult.Error -> { - //TODO: 에러 처리 + // TODO: 에러 처리 } } } } - fun onSwitchToggled(type: NotificationType, isEnabled: Boolean) { + fun onSwitchToggled( + type: NotificationType, + isEnabled: Boolean, + ) { // 권한이 없는 경우 if (isEnabled && !hasDevicePermission) { // 권한 허용 후에 적용할 설정을 저장 @@ -96,27 +98,31 @@ class NotificationViewModel updateSetting(type, isEnabled) } - private fun updateSetting(type: NotificationType, enabled: Boolean) { - Timber.tag("NotificationViewModel").e("알림 설정 변경 요청: ${type}, enabled: ${enabled}") - viewModelScope.launch { - // UI를 먼저 변경 - val originalStates = _notificationStates.value - val newStates = originalStates.map { - // 전달받은 type에 해당하는 아이템의 enabled 값만 변경 - if (it.type == type) it.copy(enabled = enabled) else it - } - _notificationStates.value = newStates + private fun updateSetting( + type: NotificationType, + enabled: Boolean, + ) { + Timber.tag("NotificationViewModel").e("알림 설정 변경 요청: $type, enabled: $enabled") + viewModelScope.launch { + // UI를 먼저 변경 + val originalStates = _notificationStates.value + val newStates = + originalStates.map { + // 전달받은 type에 해당하는 아이템의 enabled 값만 변경 + if (it.type == type) it.copy(enabled = enabled) else it + } + _notificationStates.value = newStates - when (updateNotiSettingUseCase(newStates)) { - is BaseResult.Success -> { /* 성공 시 별도 처리 없음 */ } - is BaseResult.Error -> { - Timber.tag("NotificationViewModel").e("알림 설정 변경 실패: ${type}, enabled: ${enabled}") - // 실패 시 스위치 원상복구 - _notificationStates.value = originalStates + when (updateNotiSettingUseCase(newStates)) { + is BaseResult.Success -> { /* 성공 시 별도 처리 없음 */ } + is BaseResult.Error -> { + Timber.tag("NotificationViewModel").e("알림 설정 변경 실패: $type, enabled: $enabled") + // 실패 시 스위치 원상복구 + _notificationStates.value = originalStates + } } } } - } fun onPermissionRequestHandled() { _permissionRequestTrigger.value = false From 23da341e10fd23998775d8fb06da0c64f0669aa8 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Sun, 16 Nov 2025 18:14:30 +0900 Subject: [PATCH 09/37] =?UTF-8?q?feat:=20=ED=98=84=EC=9E=AC=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=ED=95=9C=20=EC=B1=84=ED=8C=85=EB=B0=A9=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=98=A8=20=EC=95=8C=EB=A6=BC=EC=9D=B8=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=20=EC=83=9D=EB=9E=B5=20#390?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../undabang/di/RepositoryModule.kt | 20 ++++++++++++++++++ .../fcm/ChatRoomStateRepositoryImpl.kt | 18 ++++++++++++++++ .../com/project200/undabang/fcm/FcmService.kt | 7 +++++++ .../common/utils/ChatRoomStateRepository.kt | 21 +++++++++++++++++++ .../chattingRoom/ChattingRoomFragment.kt | 19 +++++++++++++++++ 5 files changed, 85 insertions(+) create mode 100644 app/src/main/java/com/project200/undabang/di/RepositoryModule.kt create mode 100644 app/src/main/java/com/project200/undabang/fcm/ChatRoomStateRepositoryImpl.kt create mode 100644 common/src/main/java/com/project200/common/utils/ChatRoomStateRepository.kt diff --git a/app/src/main/java/com/project200/undabang/di/RepositoryModule.kt b/app/src/main/java/com/project200/undabang/di/RepositoryModule.kt new file mode 100644 index 00000000..2b228da6 --- /dev/null +++ b/app/src/main/java/com/project200/undabang/di/RepositoryModule.kt @@ -0,0 +1,20 @@ +package com.project200.undabang.di + +import com.project200.common.utils.ChatRoomStateRepository +import com.project200.undabang.fcm.ChatRoomStateRepositoryImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + + @Binds + @Singleton + abstract fun bindChatRoomStateRepository( + impl: ChatRoomStateRepositoryImpl + ): ChatRoomStateRepository +} \ No newline at end of file diff --git a/app/src/main/java/com/project200/undabang/fcm/ChatRoomStateRepositoryImpl.kt b/app/src/main/java/com/project200/undabang/fcm/ChatRoomStateRepositoryImpl.kt new file mode 100644 index 00000000..471417e1 --- /dev/null +++ b/app/src/main/java/com/project200/undabang/fcm/ChatRoomStateRepositoryImpl.kt @@ -0,0 +1,18 @@ +package com.project200.undabang.fcm + +import com.project200.common.utils.ChatRoomStateRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ChatRoomStateRepositoryImpl @Inject constructor() : ChatRoomStateRepository { + + private val _activeChatRoomId = MutableStateFlow(null) + override val activeChatRoomId = _activeChatRoomId.asStateFlow() + + override fun setActiveChatRoomId(roomId: Long?) { + _activeChatRoomId.value = roomId + } +} \ No newline at end of file diff --git a/app/src/main/java/com/project200/undabang/fcm/FcmService.kt b/app/src/main/java/com/project200/undabang/fcm/FcmService.kt index fe811ba9..664f208a 100644 --- a/app/src/main/java/com/project200/undabang/fcm/FcmService.kt +++ b/app/src/main/java/com/project200/undabang/fcm/FcmService.kt @@ -11,6 +11,7 @@ import androidx.core.net.toUri import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import com.project200.common.constants.FcmConstants.KEY_FCM_TOKEN +import com.project200.common.utils.ChatRoomStateRepository import com.project200.common.utils.EncryptedPrefs import com.project200.undabang.fcm.FcmConstant.CHAT_NOTI_CHANNEL_ID import com.project200.undabang.fcm.FcmConstant.CHAT_NOTI_CHANNEL_NAME @@ -24,6 +25,9 @@ class FcmService : FirebaseMessagingService() { @EncryptedPrefs lateinit var sharedPreferences: SharedPreferences + @Inject + lateinit var chatRoomStateRepository: ChatRoomStateRepository + /** * 새로운 FCM 토큰이 발급되거나 갱신될 때 호출됩니다. * 이 토큰은 백엔드 서버로 전송되어 특정 기기에 알림을 보내는 데 사용됩니다. @@ -62,6 +66,9 @@ class FcmService : FirebaseMessagingService() { if (chatRoomId == null || nickname == null || memberId == null) return + // 현재 활성화된 채팅방과 동일한 채팅방에서 온 알림이면 무시 + if (chatRoomId.toLong() == chatRoomStateRepository.activeChatRoomId.value) return + // 알림 식별을 위한 고유 ID val uniqueId = chatRoomId.hashCode() diff --git a/common/src/main/java/com/project200/common/utils/ChatRoomStateRepository.kt b/common/src/main/java/com/project200/common/utils/ChatRoomStateRepository.kt new file mode 100644 index 00000000..fa0066b3 --- /dev/null +++ b/common/src/main/java/com/project200/common/utils/ChatRoomStateRepository.kt @@ -0,0 +1,21 @@ +package com.project200.common.utils + +import kotlinx.coroutines.flow.StateFlow + +/** + * 앱 전체에서 현재 활성화된 채팅방의 상태를 관리하는 리포지토리 인터페이스 + */ +interface ChatRoomStateRepository { + + /** + * 현재 활성화된 채팅방의 ID를 StateFlow 형태로 제공합니다. + * 활성화된 채팅방이 없으면 null입니다. + */ + val activeChatRoomId: StateFlow + + /** + * 현재 활성화된 채팅방의 ID를 설정합니다. + * @param roomId 채팅방에서 나갈 때는 null을 전달합니다. + */ + fun setActiveChatRoomId(roomId: Long?) +} \ No newline at end of file diff --git a/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt index 298faefd..cb29cfbe 100644 --- a/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt +++ b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt @@ -22,6 +22,7 @@ import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar +import com.project200.common.utils.ChatRoomStateRepository import com.project200.common.utils.CommonDateTimeFormatters.YYYY_MM_DD_KR import com.project200.feature.chatting.chattingRoom.adapter.ChatRVAdapter import com.project200.feature.chatting.utils.KeyboardVisibilityHelper @@ -38,6 +39,7 @@ import kotlinx.coroutines.flow.collect import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.time.LocalDate +import javax.inject.Inject @AndroidEntryPoint class ChattingRoomFragment : BindingFragment(R.layout.fragment_chatting_room), KeyboardControlInterface { @@ -45,6 +47,9 @@ class ChattingRoomFragment : BindingFragment(R.layo private lateinit var chatAdapter: ChatRVAdapter private val args: ChattingRoomFragmentArgs by navArgs() + @Inject + lateinit var chatRoomStateRepository: ChatRoomStateRepository + // 이전 메시지 로드 상태를 추적하는 플래그 private var isPaging = false @@ -383,6 +388,20 @@ class ChattingRoomFragment : BindingFragment(R.layo super.onDestroyView() } + override fun onResume() { + super.onResume() + // 현재 채팅방을 활성 채팅방으로 설정 + chatRoomStateRepository.setActiveChatRoomId(args.roomId) + } + + override fun onPause() { + super.onPause() + // 채팅방을 나갈 때 활성 채팅방 ID를 null로 설정 + if (chatRoomStateRepository.activeChatRoomId.value == args.roomId) { + chatRoomStateRepository.setActiveChatRoomId(null) + } + } + companion object { const val POLLING_PERIOD = 2000L } From 0f061d4b1d7fef2c42360e54af673cfb5c827c20 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Mon, 17 Nov 2025 03:43:21 +0900 Subject: [PATCH 10/37] =?UTF-8?q?refactor:=20=EC=9A=B4=EB=8F=99=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20id=EB=A5=BC=20navArgs=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EA=B0=80=EC=A0=B8=EC=98=A4=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20#386?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exercise/detail/ExerciseDetailFragment.kt | 8 +++++--- .../exercise/detail/ExerciseDetailViewModel.kt | 9 ++------- .../feature/exercise/form/ExerciseFormFragment.kt | 4 +++- .../feature/exercise/form/ExerciseFormViewModel.kt | 13 ++++++------- 4 files changed, 16 insertions(+), 18 deletions(-) diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt index e389be5b..a84de051 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt @@ -5,6 +5,7 @@ import android.widget.TextView import android.widget.Toast import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs import com.project200.common.utils.CommonDateTimeFormatters import com.project200.domain.model.BaseResult import com.project200.domain.model.ExerciseRecord @@ -18,6 +19,7 @@ import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class ExerciseDetailFragment : BindingFragment(R.layout.fragment_exercise_detail) { private val viewModel: ExerciseDetailViewModel by viewModels() + private val args: ExerciseDetailFragmentArgs by navArgs() override fun getViewBinding(view: View): FragmentExerciseDetailBinding { return FragmentExerciseDetailBinding.bind(view) @@ -33,7 +35,7 @@ class ExerciseDetailFragment : BindingFragment(R. override fun onResume() { super.onResume() - viewModel.getExerciseRecord() + viewModel.getExerciseRecord(args.recordId) } override fun setupObservers() { @@ -117,7 +119,7 @@ class ExerciseDetailFragment : BindingFragment(R. onEditClicked = { findNavController().navigate( ExerciseDetailFragmentDirections - .actionExerciseDetailFragmentToExerciseFormFragment(viewModel.recordId), + .actionExerciseDetailFragmentToExerciseFormFragment(args.recordId), ) }, onDeleteClicked = { showDeleteConfirmationDialog() }, @@ -129,7 +131,7 @@ class ExerciseDetailFragment : BindingFragment(R. title = getString(R.string.exercise_record_delete_alert), desc = null, onConfirmClicked = { - viewModel.deleteExerciseRecord() + viewModel.deleteExerciseRecord(args.recordId) }, ).show(parentFragmentManager, BaseAlertDialog::class.java.simpleName) } diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailViewModel.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailViewModel.kt index 81ce6868..fe1bd914 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailViewModel.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailViewModel.kt @@ -17,27 +17,22 @@ import javax.inject.Inject class ExerciseDetailViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, private val exerciseRecordDetailUseCase: GetExerciseRecordDetailUseCase, private val deleteExerciseRecordUseCase: DeleteExerciseRecordUseCase, ) : ViewModel() { - val recordId: Long = - savedStateHandle.get("recordId") - ?: throw IllegalStateException("recordId is required for ExerciseDetailViewModel") - private val _exerciseRecord = MutableLiveData>() val exerciseRecord: LiveData> = _exerciseRecord private val _deleteResult = MutableLiveData>() val deleteResult: LiveData> = _deleteResult - fun getExerciseRecord() { + fun getExerciseRecord(recordId: Long) { viewModelScope.launch { _exerciseRecord.value = exerciseRecordDetailUseCase(recordId) } } - fun deleteExerciseRecord() { + fun deleteExerciseRecord(recordId: Long) { viewModelScope.launch { _deleteResult.value = deleteExerciseRecordUseCase(recordId) } diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt index fdaa2c59..100262b4 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt @@ -38,6 +38,7 @@ import java.util.Calendar @AndroidEntryPoint class ExerciseFormFragment : BindingFragment(R.layout.fragment_exercise_form) { private val viewModel: ExerciseFormViewModel by viewModels() + private val args: ExerciseFormFragmentArgs by navArgs() private lateinit var imageAdapter: ExerciseImageAdapter private val pickMultipleMediaLauncher = @@ -89,7 +90,7 @@ class ExerciseFormFragment : BindingFragment(R.layo savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - viewModel.loadInitialRecord() + viewModel.loadInitialRecord(args.recordId) setupKeyboardAdjustments() } @@ -127,6 +128,7 @@ class ExerciseFormFragment : BindingFragment(R.layo binding.recordCompleteBtn.setOnClickListener { viewModel.submitRecord( + recordId = args.recordId, title = binding.recordTitleEt.text.toString().trim(), type = binding.recordTypeEt.text.toString().trim(), location = binding.recordLocationEt.text.toString().trim(), diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormViewModel.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormViewModel.kt index 2b354297..dc683b86 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormViewModel.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormViewModel.kt @@ -37,15 +37,12 @@ sealed class ScoreGuidanceState { class ExerciseFormViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, private val getExerciseRecordDetailUseCase: GetExerciseRecordDetailUseCase, private val createExerciseRecordUseCase: CreateExerciseRecordUseCase, private val uploadExerciseRecordImagesUseCase: UploadExerciseRecordImagesUseCase, private val editExerciseRecordUseCase: EditExerciseRecordUseCase, private val getExpectedScoreInfoUseCase: GetExpectedScoreInfoUseCase, ) : ViewModel() { - val recordId: Long? = savedStateHandle.get("recordId") - private val _startTime = MutableLiveData() val startTime: LiveData = _startTime @@ -84,8 +81,8 @@ class ExerciseFormViewModel val scoreGuidanceState: LiveData = _scoreGuidanceState /** 초기 데이터 설정 */ - fun loadInitialRecord() { - if (recordId == -1L || recordId == null) { + fun loadInitialRecord(recordId: Long) { + if (recordId == -1L) { // 생성 모드 isEditMode = false initialRecord = null @@ -193,6 +190,7 @@ class ExerciseFormViewModel /** 기록 생성 또는 수정 */ fun submitRecord( + recordId: Long, title: String, type: String, location: String, @@ -232,7 +230,7 @@ class ExerciseFormViewModel ?.map { it.uri.toString() } ?: emptyList() if (isEditMode) { - editExerciseRecord(recordToSubmit, newImageUris) + editExerciseRecord(recordId, recordToSubmit, newImageUris) } else { createExerciseRecord(recordToSubmit, newImageUris) } @@ -285,13 +283,14 @@ class ExerciseFormViewModel /** 기록 수정 */ private fun editExerciseRecord( + recordId: Long, record: ExerciseRecord, newImageUris: List, ) { viewModelScope.launch { _editResult.value = editExerciseRecordUseCase( - recordId = recordId!!, + recordId = recordId, recordToUpdate = record, isContentChanges = hasContentChanges(record), imagesToDelete = removedPictureIds, From 883e8eff826a8fbf93d0e7c437ffdee79954cb54 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Mon, 17 Nov 2025 03:44:12 +0900 Subject: [PATCH 11/37] =?UTF-8?q?refactor:=20=EA=B0=B1=EC=8B=A0=EC=9D=B4?= =?UTF-8?q?=20=ED=95=84=EC=9A=94=ED=95=A0=20=EB=95=8C=EB=A7=8C=20api?= =?UTF-8?q?=EB=A5=BC=20=ED=98=B8=EC=B6=9C=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#386?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exercise/detail/ExerciseDetailFragment.kt | 18 ++++++++++++------ .../exercise/form/ExerciseFormFragment.kt | 11 +++++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt index a84de051..17413f70 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt @@ -15,6 +15,7 @@ import com.project200.presentation.view.MenuBottomSheetDialog import com.project200.undabang.feature.exercise.R import com.project200.undabang.feature.exercise.databinding.FragmentExerciseDetailBinding import dagger.hilt.android.AndroidEntryPoint +import timber.log.Timber @AndroidEntryPoint class ExerciseDetailFragment : BindingFragment(R.layout.fragment_exercise_detail) { @@ -26,6 +27,7 @@ class ExerciseDetailFragment : BindingFragment(R. } override fun setupViews() { + viewModel.getExerciseRecord(args.recordId) binding.baseToolbar.apply { setTitle(getString(R.string.exercise_detail)) showBackButton(true) { findNavController().navigateUp() } @@ -33,11 +35,6 @@ class ExerciseDetailFragment : BindingFragment(R. } } - override fun onResume() { - super.onResume() - viewModel.getExerciseRecord(args.recordId) - } - override fun setupObservers() { viewModel.exerciseRecord.observe(viewLifecycleOwner) { result -> when (result) { @@ -60,6 +57,15 @@ class ExerciseDetailFragment : BindingFragment(R. } } } + + // 이전 화면에서 새로고침 요청이 있을 경우에만 데이터를 새로고침합니다. + val savedStateHandle = findNavController().currentBackStackEntry?.savedStateHandle + savedStateHandle?.getLiveData(KEY_RECORD_UPDATED)?.observe(viewLifecycleOwner) { shouldRefresh -> + if (shouldRefresh) { + viewModel.getExerciseRecord(args.recordId) + savedStateHandle.remove(KEY_RECORD_UPDATED) + } + } } private fun bindExerciseRecordData(record: ExerciseRecord) { @@ -137,6 +143,6 @@ class ExerciseDetailFragment : BindingFragment(R. } companion object { - const val TAG = "ExerciseDetailFragment" + const val KEY_RECORD_UPDATED = "record_updated" } } diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt index 100262b4..3b2d6228 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt @@ -12,12 +12,14 @@ import androidx.core.view.isVisible import androidx.core.view.marginBottom import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.GridLayoutManager import com.project200.common.constants.RuleConstants.ALLOWED_EXTENSIONS import com.project200.common.constants.RuleConstants.MAX_IMAGE import com.project200.domain.model.ExerciseEditResult import com.project200.domain.model.ExerciseRecord import com.project200.domain.model.SubmissionResult +import com.project200.feature.exercise.detail.ExerciseDetailFragment import com.project200.presentation.base.BindingFragment import com.project200.presentation.utils.ImageUtils.compressImage import com.project200.presentation.utils.ImageValidator @@ -252,14 +254,23 @@ class ExerciseFormFragment : BindingFragment(R.layo viewModel.editResult.observe(viewLifecycleOwner) { result -> when (result) { is ExerciseEditResult.Success -> { // 기록 수정, 이미지 삭제/업로드 성공 + findNavController().previousBackStackEntry?.savedStateHandle?.set( + ExerciseDetailFragment.KEY_RECORD_UPDATED, true + ) findNavController().popBackStack() } is ExerciseEditResult.ContentFailure -> { // 내용 수정 실패 Toast.makeText(requireContext(), result.message, Toast.LENGTH_SHORT).show() + findNavController().previousBackStackEntry?.savedStateHandle?.set( + ExerciseDetailFragment.KEY_RECORD_UPDATED, true + ) findNavController().popBackStack() } is ExerciseEditResult.ImageFailure -> { // 이미지 삭제/업로드 실패 Toast.makeText(requireContext(), result.message, Toast.LENGTH_SHORT).show() + findNavController().previousBackStackEntry?.savedStateHandle?.set( + ExerciseDetailFragment.KEY_RECORD_UPDATED, true + ) findNavController().popBackStack() } is ExerciseEditResult.Failure -> { // 내용 수정, 이미지 삭제/업로드 실패 From de1ae9899aa9d967cc9e65aeb19b07f0e86acb68 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Mon, 17 Nov 2025 04:07:10 +0900 Subject: [PATCH 12/37] =?UTF-8?q?refactor:=20navArgs=EB=A1=9C=20toolbar=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81=20#386?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exercise/form/ExerciseFormFragment.kt | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt index 3b2d6228..91d72db5 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt @@ -87,15 +87,6 @@ class ExerciseFormFragment : BindingFragment(R.layo return FragmentExerciseFormBinding.bind(view) } - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - viewModel.loadInitialRecord(args.recordId) - setupKeyboardAdjustments() - } - private fun setupRVAdapter(calculatedItemSize: Int) { imageAdapter = ExerciseImageAdapter( @@ -121,10 +112,20 @@ class ExerciseFormFragment : BindingFragment(R.layo } override fun setupViews() { - binding.baseToolbar.showBackButton(true) { findNavController().navigateUp() } - + binding.baseToolbar.apply { + showBackButton(true) { findNavController().navigateUp() } + binding.baseToolbar.setTitle( + if (args.recordId == -1L) getString(R.string.record_exercise) + else getString(R.string.edit_exercise) + ) + } + viewModel.loadInitialRecord(args.recordId) + setupKeyboardAdjustments() setupRVAdapter((getScreenWidthPx(requireActivity()) - dpToPx(requireContext(), GRID_SPAN_MARGIN)) / GRID_SPAN_COUNT) + initClickListeners() + } + private fun initClickListeners() { binding.startTimeBtn.setOnClickListener { showTimePickerDialog(true) } binding.endTimeBtn.setOnClickListener { showTimePickerDialog(false) } @@ -139,6 +140,11 @@ class ExerciseFormFragment : BindingFragment(R.layo } } + /** + * 키보드에 따른 레이아웃 조정 + * 키보드가 올라올 때 ScrollView의 패딩을 키보드 높이만큼 조정 + * 키보드가 내려갈 때는 네비게이션 바 높이만큼 패딩 조정 + */ private fun setupKeyboardAdjustments() { ViewCompat.setOnApplyWindowInsetsListener(binding.scrollView) { v, insets -> Timber.tag("ExerciseFormFragment").d("setupKeyboardAdjustments called") @@ -151,7 +157,6 @@ class ExerciseFormFragment : BindingFragment(R.layo imeHeight } else { // record_complete_btn의 높이 (btn_height)와 layout_marginBottom (32dp)를 더한 값 - // 이 값은 dpToPx를 사용하여 픽셀로 변환해야 합니다. val buttonHeight = dpToPx(requireContext(), binding.recordCompleteBtn.height.toFloat()) val buttonMarginBottom = dpToPx(requireContext(), binding.recordCompleteBtn.marginBottom.toFloat()) buttonHeight + buttonMarginBottom + navigationBarHeight @@ -221,12 +226,7 @@ class ExerciseFormFragment : BindingFragment(R.layo } viewModel.initialDataLoaded.observe(viewLifecycleOwner) { record -> - if (record != null) { - setupInitialData(record) - binding.baseToolbar.setTitle(getString(R.string.edit_exercise)) - } else { - binding.baseToolbar.setTitle(getString(R.string.record_exercise)) - } + if (record != null) { setupInitialData(record) } } viewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> From 6e16395fcded1351dc9336697acebf8134966bce Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Mon, 17 Nov 2025 23:01:23 +0900 Subject: [PATCH 13/37] =?UTF-8?q?feat:=20=EC=9A=B4=EB=8F=99=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=83=81=EC=84=B8=EC=A1=B0=ED=9A=8C=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=8A=A4=EC=BC=88=EB=A0=88=ED=86=A4=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20#386?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- feature/exercise/build.gradle.kts | 3 ++ .../exercise/detail/ExerciseDetailFragment.kt | 34 ++++++++++--- .../detail/ExerciseDetailViewModel.kt | 24 ++++++++-- .../res/layout/fragment_exercise_detail.xml | 10 ++++ .../layout_exercise_detail_skeleton.xml | 40 ++++++++++++++++ gradle/libs.versions.toml | 2 + .../project200/presentation/utils/UiState.kt | 48 +++++++++++++++++++ presentation/src/main/res/values/dimens.xml | 2 +- presentation/src/main/res/values/strings.xml | 5 ++ 9 files changed, 157 insertions(+), 11 deletions(-) create mode 100644 feature/exercise/src/main/res/layout/layout_exercise_detail_skeleton.xml create mode 100644 presentation/src/main/java/com/project200/presentation/utils/UiState.kt diff --git a/feature/exercise/build.gradle.kts b/feature/exercise/build.gradle.kts index db040afe..e3528d00 100644 --- a/feature/exercise/build.gradle.kts +++ b/feature/exercise/build.gradle.kts @@ -51,4 +51,7 @@ dependencies { // lottie animation implementation(libs.lottie) + + // Shimmer + implementation(libs.shimmer) } diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt index 17413f70..b89a6961 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt @@ -4,6 +4,9 @@ import android.view.View import android.widget.TextView import android.widget.Toast import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.project200.common.utils.CommonDateTimeFormatters @@ -11,10 +14,15 @@ import com.project200.domain.model.BaseResult import com.project200.domain.model.ExerciseRecord import com.project200.presentation.base.BaseAlertDialog import com.project200.presentation.base.BindingFragment +import com.project200.presentation.utils.Failure +import com.project200.presentation.utils.UiState +import com.project200.presentation.utils.mapCodeToFailure +import com.project200.presentation.utils.mapFailureToString import com.project200.presentation.view.MenuBottomSheetDialog import com.project200.undabang.feature.exercise.R import com.project200.undabang.feature.exercise.databinding.FragmentExerciseDetailBinding import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import timber.log.Timber @AndroidEntryPoint @@ -36,13 +44,25 @@ class ExerciseDetailFragment : BindingFragment(R. } override fun setupObservers() { - viewModel.exerciseRecord.observe(viewLifecycleOwner) { result -> - when (result) { - is BaseResult.Success -> { - bindExerciseRecordData(result.data) - } - is BaseResult.Error -> { - Toast.makeText(requireContext(), result.message, Toast.LENGTH_SHORT).show() + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.exerciseRecord.collect { state -> + binding.shimmerLayout.visibility = if (state is UiState.Loading) View.VISIBLE else View.GONE + binding.scrollView.visibility = if (state is UiState.Success) View.VISIBLE else View.GONE + + when (state) { + is UiState.Loading -> { + binding.shimmerLayout.startShimmer() + } + is UiState.Success -> { + binding.shimmerLayout.stopShimmer() + bindExerciseRecordData(state.data) + } + is UiState.Error -> { + binding.shimmerLayout.stopShimmer() + Toast.makeText(requireContext(), requireContext().mapFailureToString(state.failure), Toast.LENGTH_SHORT).show() + } + } } } } diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailViewModel.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailViewModel.kt index fe1bd914..90361cc0 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailViewModel.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailViewModel.kt @@ -9,7 +9,13 @@ import com.project200.domain.model.BaseResult import com.project200.domain.model.ExerciseRecord import com.project200.domain.usecase.DeleteExerciseRecordUseCase import com.project200.domain.usecase.GetExerciseRecordDetailUseCase +import com.project200.presentation.utils.UiEvent +import com.project200.presentation.utils.UiState +import com.project200.presentation.utils.mapCodeToFailure import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -20,15 +26,24 @@ class ExerciseDetailViewModel private val exerciseRecordDetailUseCase: GetExerciseRecordDetailUseCase, private val deleteExerciseRecordUseCase: DeleteExerciseRecordUseCase, ) : ViewModel() { - private val _exerciseRecord = MutableLiveData>() - val exerciseRecord: LiveData> = _exerciseRecord + private val _exerciseRecord = MutableStateFlow>(UiState.Loading) + val exerciseRecord: StateFlow> = _exerciseRecord private val _deleteResult = MutableLiveData>() val deleteResult: LiveData> = _deleteResult fun getExerciseRecord(recordId: Long) { viewModelScope.launch { - _exerciseRecord.value = exerciseRecordDetailUseCase(recordId) + delay(LOADING_DELAY) + when (val result = exerciseRecordDetailUseCase(recordId)) { + is BaseResult.Success -> { + _exerciseRecord.value = UiState.Success(result.data) + } + is BaseResult.Error -> { + val failure = mapCodeToFailure(result.errorCode, result.message) + _exerciseRecord.value = UiState.Error(failure) + } + } } } @@ -37,4 +52,7 @@ class ExerciseDetailViewModel _deleteResult.value = deleteExerciseRecordUseCase(recordId) } } + companion object { + private const val LOADING_DELAY = 300L + } } diff --git a/feature/exercise/src/main/res/layout/fragment_exercise_detail.xml b/feature/exercise/src/main/res/layout/fragment_exercise_detail.xml index c2a55d23..f7bb2deb 100644 --- a/feature/exercise/src/main/res/layout/fragment_exercise_detail.xml +++ b/feature/exercise/src/main/res/layout/fragment_exercise_detail.xml @@ -11,10 +11,20 @@ android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent"/> + + + diff --git a/feature/exercise/src/main/res/layout/layout_exercise_detail_skeleton.xml b/feature/exercise/src/main/res/layout/layout_exercise_detail_skeleton.xml new file mode 100644 index 00000000..e30d5ac6 --- /dev/null +++ b/feature/exercise/src/main/res/layout/layout_exercise_detail_skeleton.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fbe99842..7c863a69 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,7 @@ androidxArchCoreTesting = "2.2.0" # 테스트용 androidxMedia = "1.7.0" lottie = "6.6.6" circleImageView = "3.1.0" +shimmer = "0.5.0" # 스켈레톤 # Google (SDK 34 호환 버전) material = "1.11.0" @@ -110,6 +111,7 @@ androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview" androidx-media = { group = "androidx.media", name = "media", version.ref = "androidxMedia" } google-android-material = { group = "com.google.android.material", name = "material", version.ref = "material" } lottie = { module = "com.airbnb.android:lottie", version.ref = "lottie" } +shimmer = { group = "com.facebook.shimmer", name = "shimmer", version.ref = "shimmer" } # AndroidX Lifecycle (ViewModel, LiveData, Runtime) androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "androidxLifecycle" } diff --git a/presentation/src/main/java/com/project200/presentation/utils/UiState.kt b/presentation/src/main/java/com/project200/presentation/utils/UiState.kt new file mode 100644 index 00000000..2e831c28 --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/utils/UiState.kt @@ -0,0 +1,48 @@ +package com.project200.presentation.utils + +import android.content.Context +import androidx.core.content.ContextCompat.getString + +sealed interface Failure { + data object NetworkError : Failure + data class ServerError(val code: String?, val message: String?) : Failure + data object Unknown : Failure +} + +sealed interface UiState { + data object Loading : UiState + data class Success(val data: T) : UiState + data class Error(val failure: Failure) : UiState +} + +sealed interface UiEvent { + data class ShowToast(val failure: Failure) : UiEvent +} + +fun mapCodeToFailure(code: String?, message: String?): Failure { + return when (code) { + "NETWORK_ERROR" -> Failure.NetworkError + "UNKNOWN_ERROR" -> Failure.Unknown + else -> Failure.ServerError(code, message) + } +} + +/** + * Failure 객체를 문자열로 변환합니다. + * @param failure 변환할 Failure 객체 + * @param onServerError ServerError 타입일 경우 실행할 커스텀 로직. null이면 기본 메시지를 사용합니다. + */ +fun Context.mapFailureToString( + failure: Failure, + onServerError: ((serverError: Failure.ServerError) -> String)? = null +): String { + return when (failure) { + is Failure.NetworkError -> getString(com.project200.undabang.presentation.R.string.network_error) + is Failure.ServerError -> { + onServerError?.invoke(failure) + ?: failure.message + ?: getString(com.project200.undabang.presentation.R.string.server_error) + } + is Failure.Unknown -> getString(com.project200.undabang.presentation.R.string.unknown_error) + } +} \ No newline at end of file diff --git a/presentation/src/main/res/values/dimens.xml b/presentation/src/main/res/values/dimens.xml index ba406206..62835cda 100644 --- a/presentation/src/main/res/values/dimens.xml +++ b/presentation/src/main/res/values/dimens.xml @@ -4,5 +4,5 @@ 65dp 20dp 240dp - 288dp + 308dp \ No newline at end of file diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 1e51c3eb..905f07b3 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -29,4 +29,9 @@ 회원 차단 차단 차단하면 차단한 회원이 보내는 메세지를 받을 수 없습니다. 또한, 매칭 지도에서 차단한 회원을 조회할 수 없습니다. + + + 알 수 없는 오류가 발생했습니다. + 서버 에러가 발생했습니다. + 네트워크 연결에 실패했습니다. \ No newline at end of file From 1fb92cd78989d65b36f56650c74983bc4cc2784c Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Tue, 18 Nov 2025 02:50:06 +0900 Subject: [PATCH 14/37] =?UTF-8?q?feat:=20api=20=EC=97=B0=EA=B2=B0=20#383?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/impl/NotificationRepositoryImpl.kt | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt index 980a7b4c..93827e47 100644 --- a/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt @@ -2,6 +2,7 @@ package com.project200.data.impl import com.project200.common.di.IoDispatcher import com.project200.data.api.ApiService +import com.project200.data.mapper.toDTO import com.project200.data.mapper.toModel import com.project200.data.utils.apiCallBuilder import com.project200.domain.model.BaseResult @@ -32,17 +33,16 @@ class NotificationRepositoryImpl } override suspend fun updateNotiState(notiState: List): BaseResult { -/* return apiCallBuilder( - ioDispatcher = ioDispatcher, - apiCall = { - val fcmTokenResult = fcmRepository.getFcmTokenFromPrefs() ?: throw NoSuchElementException("FCM token is missing.") - apiService.patchNotiState( - fcmToken = fcmTokenResult, - notiRequest = notiState.map { it.toDTO() } - ) - }, - mapper = { Unit }, - )*/ - return BaseResult.Success(Unit) // 성공으로 더미 반환 + return apiCallBuilder( + ioDispatcher = ioDispatcher, + apiCall = { + val fcmTokenResult = fcmRepository.getFcmTokenFromPrefs() ?: throw NoSuchElementException("FCM token is missing.") + apiService.patchNotiState( + fcmToken = fcmTokenResult, + notiRequest = notiState.map { it.toDTO() } + ) + }, + mapper = { Unit }, + ) } } From 5f6567a1ad813f89294db89d26e9849e923c9325 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Tue, 18 Nov 2025 03:04:02 +0900 Subject: [PATCH 15/37] =?UTF-8?q?feat:=20=EC=97=90=EB=9F=AC=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80=20#383?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project200/data/impl/NotificationRepositoryImpl.kt | 2 +- .../undabang/profile/setting/NotificationFragment.kt | 8 ++++++++ .../undabang/profile/setting/NotificationViewModel.kt | 8 ++++++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt index 93827e47..509decff 100644 --- a/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/NotificationRepositoryImpl.kt @@ -39,7 +39,7 @@ class NotificationRepositoryImpl val fcmTokenResult = fcmRepository.getFcmTokenFromPrefs() ?: throw NoSuchElementException("FCM token is missing.") apiService.patchNotiState( fcmToken = fcmTokenResult, - notiRequest = notiState.map { it.toDTO() } + notiRequest = notiState.map { it.toDTO() }, ) }, mapper = { Unit }, diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt index cc952503..e16f3d9c 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationFragment.kt @@ -84,6 +84,14 @@ class NotificationFragment : BindingFragment(R.layo } } } + + launch { + viewModel.toastMessage.collect { message -> + message?.let { + Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show() + } + } + } } } } diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt index 269d494f..1e8cce74 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/NotificationViewModel.kt @@ -27,6 +27,9 @@ class NotificationViewModel private val _permissionRequestTrigger = MutableStateFlow(false) val permissionRequestTrigger: StateFlow = _permissionRequestTrigger + private val _toastMessage = MutableStateFlow(null) + val toastMessage: StateFlow = _toastMessage + // 디바이스 권한 상태 private var hasDevicePermission: Boolean = false @@ -78,7 +81,7 @@ class NotificationViewModel _notificationStates.value = result.data } is BaseResult.Error -> { - // TODO: 에러 처리 + _toastMessage.value = result.message } } } @@ -113,12 +116,13 @@ class NotificationViewModel } _notificationStates.value = newStates - when (updateNotiSettingUseCase(newStates)) { + when (val result = updateNotiSettingUseCase(newStates)) { is BaseResult.Success -> { /* 성공 시 별도 처리 없음 */ } is BaseResult.Error -> { Timber.tag("NotificationViewModel").e("알림 설정 변경 실패: $type, enabled: $enabled") // 실패 시 스위치 원상복구 _notificationStates.value = originalStates + _toastMessage.value = result.message } } } From 71f9e418e0273c6088475f9ef808f6a53b16f3bb Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Tue, 18 Nov 2025 03:49:54 +0900 Subject: [PATCH 16/37] =?UTF-8?q?feat:=20Crashlytics=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20#387?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 3 +++ gradle/libs.versions.toml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index afb3e12e..e543b0fa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,7 @@ import java.util.Properties plugins { id("convention.android.application") alias(libs.plugins.navigation.safeargs) + alias(libs.plugins.firebase.crashlytics) } val localProperties = Properties() @@ -111,4 +112,6 @@ dependencies { // Kakao Map implementation(libs.kakao.map) + + implementation(libs.firebase.crashlytics.ktx) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fbe99842..f2ce19b1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ versionName = "0.5.5" firebaseBom = "32.7.0" googleServicesPlugin = "4.4.2" firebasePerfPlugin = "1.4.2" +firebaseCrashlyticsPlugin = "3.0.6" # AndroidX Core & UI (SDK 34 호환 버전) androidxCore = "1.13.1" @@ -172,6 +173,7 @@ firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.r firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx" } firebase-performance = { group = "com.google.firebase", name = "firebase-perf-ktx" } firebase-config-ktx = { group = "com.google.firebase", name = "firebase-config-ktx", version.ref = "firebaseConfigKtx" } +firebase-crashlytics-ktx = { group = "com.google.firebase", name = "firebase-crashlytics-ktx" } firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" } # Cognito @@ -221,6 +223,7 @@ navigation-safeargs = { id = "androidx.navigation.safeargs.kotlin", version.ref # Firebase 플러그인 google-services = { id = "com.google.gms.google-services", version.ref = "googleServicesPlugin" } firebase-performance = { id = "com.google.firebase.firebase-perf", version.ref = "firebasePerfPlugin" } +firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin" } # Jacoco Plugin jacoco = { id = "org.gradle.jacoco", version.ref = "jacoco" } From f7991c996804b00087c9c9b8236600539554012c Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Tue, 18 Nov 2025 03:57:11 +0900 Subject: [PATCH 17/37] =?UTF-8?q?feat:=20=EB=94=94=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=EC=97=90=EC=84=9C=EB=8A=94=20Crashlytics=20?= =?UTF-8?q?=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94=20#387?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/java/com/project200/undabang/ApplicationClass.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/com/project200/undabang/ApplicationClass.kt b/app/src/main/java/com/project200/undabang/ApplicationClass.kt index 03185eb7..76520803 100644 --- a/app/src/main/java/com/project200/undabang/ApplicationClass.kt +++ b/app/src/main/java/com/project200/undabang/ApplicationClass.kt @@ -5,6 +5,7 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context import androidx.appcompat.app.AppCompatDelegate +import com.google.firebase.crashlytics.FirebaseCrashlytics import com.kakao.vectormap.KakaoMapSdk import dagger.hilt.android.HiltAndroidApp import timber.log.Timber @@ -22,6 +23,9 @@ class ApplicationClass : Application() { createNotificationChannel() KakaoMapSdk.init(this, BuildConfig.KAKAO_NATIVE_APP_KEY) + + // Crashlytics 디버그 모드에서는 비활성화 + FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(!BuildConfig.DEBUG) } /** From e35fae498628aa27bddafb4d2e7dc006c4727884 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Tue, 18 Nov 2025 19:04:00 +0900 Subject: [PATCH 18/37] =?UTF-8?q?fix:=20ktlint=20=EB=AC=B8=EB=B2=95=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#386?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exercise/detail/ExerciseDetailFragment.kt | 3 --- .../detail/ExerciseDetailViewModel.kt | 3 +-- .../exercise/form/ExerciseFormFragment.kt | 19 ++++++++++++------- .../exercise/form/ExerciseFormViewModel.kt | 1 - .../project200/presentation/utils/UiState.kt | 13 ++++++++++--- 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt index b89a6961..440e0373 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailFragment.kt @@ -14,16 +14,13 @@ import com.project200.domain.model.BaseResult import com.project200.domain.model.ExerciseRecord import com.project200.presentation.base.BaseAlertDialog import com.project200.presentation.base.BindingFragment -import com.project200.presentation.utils.Failure import com.project200.presentation.utils.UiState -import com.project200.presentation.utils.mapCodeToFailure import com.project200.presentation.utils.mapFailureToString import com.project200.presentation.view.MenuBottomSheetDialog import com.project200.undabang.feature.exercise.R import com.project200.undabang.feature.exercise.databinding.FragmentExerciseDetailBinding import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch -import timber.log.Timber @AndroidEntryPoint class ExerciseDetailFragment : BindingFragment(R.layout.fragment_exercise_detail) { diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailViewModel.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailViewModel.kt index 90361cc0..19d3c8af 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailViewModel.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailViewModel.kt @@ -2,14 +2,12 @@ package com.project200.feature.exercise.detail import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.project200.domain.model.BaseResult import com.project200.domain.model.ExerciseRecord import com.project200.domain.usecase.DeleteExerciseRecordUseCase import com.project200.domain.usecase.GetExerciseRecordDetailUseCase -import com.project200.presentation.utils.UiEvent import com.project200.presentation.utils.UiState import com.project200.presentation.utils.mapCodeToFailure import dagger.hilt.android.lifecycle.HiltViewModel @@ -52,6 +50,7 @@ class ExerciseDetailViewModel _deleteResult.value = deleteExerciseRecordUseCase(recordId) } } + companion object { private const val LOADING_DELAY = 300L } diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt index 91d72db5..8ce6c9ac 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt @@ -1,7 +1,6 @@ package com.project200.feature.exercise.form import android.net.Uri -import android.os.Bundle import android.view.View import android.widget.Toast import androidx.activity.result.PickVisualMediaRequest @@ -115,8 +114,11 @@ class ExerciseFormFragment : BindingFragment(R.layo binding.baseToolbar.apply { showBackButton(true) { findNavController().navigateUp() } binding.baseToolbar.setTitle( - if (args.recordId == -1L) getString(R.string.record_exercise) - else getString(R.string.edit_exercise) + if (args.recordId == -1L) { + getString(R.string.record_exercise) + } else { + getString(R.string.edit_exercise) + }, ) } viewModel.loadInitialRecord(args.recordId) @@ -226,7 +228,7 @@ class ExerciseFormFragment : BindingFragment(R.layo } viewModel.initialDataLoaded.observe(viewLifecycleOwner) { record -> - if (record != null) { setupInitialData(record) } + if (record != null) setupInitialData(record) } viewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> @@ -255,21 +257,24 @@ class ExerciseFormFragment : BindingFragment(R.layo when (result) { is ExerciseEditResult.Success -> { // 기록 수정, 이미지 삭제/업로드 성공 findNavController().previousBackStackEntry?.savedStateHandle?.set( - ExerciseDetailFragment.KEY_RECORD_UPDATED, true + ExerciseDetailFragment.KEY_RECORD_UPDATED, + true, ) findNavController().popBackStack() } is ExerciseEditResult.ContentFailure -> { // 내용 수정 실패 Toast.makeText(requireContext(), result.message, Toast.LENGTH_SHORT).show() findNavController().previousBackStackEntry?.savedStateHandle?.set( - ExerciseDetailFragment.KEY_RECORD_UPDATED, true + ExerciseDetailFragment.KEY_RECORD_UPDATED, + true, ) findNavController().popBackStack() } is ExerciseEditResult.ImageFailure -> { // 이미지 삭제/업로드 실패 Toast.makeText(requireContext(), result.message, Toast.LENGTH_SHORT).show() findNavController().previousBackStackEntry?.savedStateHandle?.set( - ExerciseDetailFragment.KEY_RECORD_UPDATED, true + ExerciseDetailFragment.KEY_RECORD_UPDATED, + true, ) findNavController().popBackStack() } diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormViewModel.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormViewModel.kt index dc683b86..1a022ad3 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormViewModel.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormViewModel.kt @@ -3,7 +3,6 @@ package com.project200.feature.exercise.form import android.net.Uri import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.project200.common.constants.RuleConstants.MAX_IMAGE diff --git a/presentation/src/main/java/com/project200/presentation/utils/UiState.kt b/presentation/src/main/java/com/project200/presentation/utils/UiState.kt index 2e831c28..c24af385 100644 --- a/presentation/src/main/java/com/project200/presentation/utils/UiState.kt +++ b/presentation/src/main/java/com/project200/presentation/utils/UiState.kt @@ -5,13 +5,17 @@ import androidx.core.content.ContextCompat.getString sealed interface Failure { data object NetworkError : Failure + data class ServerError(val code: String?, val message: String?) : Failure + data object Unknown : Failure } sealed interface UiState { data object Loading : UiState + data class Success(val data: T) : UiState + data class Error(val failure: Failure) : UiState } @@ -19,7 +23,10 @@ sealed interface UiEvent { data class ShowToast(val failure: Failure) : UiEvent } -fun mapCodeToFailure(code: String?, message: String?): Failure { +fun mapCodeToFailure( + code: String?, + message: String?, +): Failure { return when (code) { "NETWORK_ERROR" -> Failure.NetworkError "UNKNOWN_ERROR" -> Failure.Unknown @@ -34,7 +41,7 @@ fun mapCodeToFailure(code: String?, message: String?): Failure { */ fun Context.mapFailureToString( failure: Failure, - onServerError: ((serverError: Failure.ServerError) -> String)? = null + onServerError: ((serverError: Failure.ServerError) -> String)? = null, ): String { return when (failure) { is Failure.NetworkError -> getString(com.project200.undabang.presentation.R.string.network_error) @@ -45,4 +52,4 @@ fun Context.mapFailureToString( } is Failure.Unknown -> getString(com.project200.undabang.presentation.R.string.unknown_error) } -} \ No newline at end of file +} From df86536a784e00847f3771352893f7d8a2e7cdde Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Tue, 18 Nov 2025 19:37:36 +0900 Subject: [PATCH 19/37] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20#386?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exercise/ExerciseDetailViewModelTest.kt | 12 +++---- .../exercise/ExerciseFormViewModelTest.kt | 32 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseDetailViewModelTest.kt b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseDetailViewModelTest.kt index d19948e4..3f8aea78 100644 --- a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseDetailViewModelTest.kt +++ b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseDetailViewModelTest.kt @@ -62,7 +62,7 @@ class ExerciseDetailViewModelTest { fun setUp() { Dispatchers.setMain(testDispatcher) savedStateHandle = SavedStateHandle().apply { set("recordId", recordId) } - viewModel = ExerciseDetailViewModel(savedStateHandle, mockGetExerciseUseCase, mockDeleteExerciseUseCase) + viewModel = ExerciseDetailViewModel(mockGetExerciseUseCase, mockDeleteExerciseUseCase) } @After @@ -78,14 +78,14 @@ class ExerciseDetailViewModelTest { coEvery { mockGetExerciseUseCase.invoke(recordId) } returns successResult // When - viewModel.getExerciseRecord() + viewModel.getExerciseRecord(recordId) testDispatcher.scheduler.advanceUntilIdle() // Then coVerify(exactly = 1) { mockGetExerciseUseCase.invoke(recordId) } val actualResult = viewModel.exerciseRecord.value assertThat(actualResult).isEqualTo(successResult) - assertThat((actualResult as BaseResult.Success).data.title).isEqualTo("아침 조깅") + assertThat((actualResult as BaseResult.Success).data.title).isEqualTo("아침 조깅") } @Test @@ -96,7 +96,7 @@ class ExerciseDetailViewModelTest { coEvery { mockGetExerciseUseCase.invoke(recordId) } returns errorResult // When - viewModel.getExerciseRecord() + viewModel.getExerciseRecord(recordId) testDispatcher.scheduler.advanceUntilIdle() // Then @@ -114,7 +114,7 @@ class ExerciseDetailViewModelTest { coEvery { mockDeleteExerciseUseCase.invoke(recordId) } returns successResult // When - viewModel.deleteExerciseRecord() + viewModel.deleteExerciseRecord(recordId) testDispatcher.scheduler.advanceUntilIdle() // Then @@ -132,7 +132,7 @@ class ExerciseDetailViewModelTest { coEvery { mockDeleteExerciseUseCase.invoke(recordId) } returns errorResult // When - viewModel.deleteExerciseRecord() + viewModel.deleteExerciseRecord(recordId) testDispatcher.scheduler.advanceUntilIdle() // Then diff --git a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseFormViewModelTest.kt b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseFormViewModelTest.kt index 5869f8ef..7a407391 100644 --- a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseFormViewModelTest.kt +++ b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseFormViewModelTest.kt @@ -113,7 +113,6 @@ class ExerciseFormViewModelTest { savedStateHandle = SavedStateHandle().apply { set("recordId", -1L) } viewModel = ExerciseFormViewModel( - savedStateHandle = savedStateHandle, getExerciseRecordDetailUseCase = mockGetDetailUseCase, createExerciseRecordUseCase = mockCreateUseCase, uploadExerciseRecordImagesUseCase = mockUploadUseCase, @@ -126,7 +125,6 @@ class ExerciseFormViewModelTest { savedStateHandle = SavedStateHandle().apply { set("recordId", recordId) } viewModel = ExerciseFormViewModel( - savedStateHandle, mockGetDetailUseCase, mockCreateUseCase, mockUploadUseCase, @@ -135,7 +133,7 @@ class ExerciseFormViewModelTest { ) // 수정 모드 테스트를 위해 초기 데이터를 미리 로드합니다. coEvery { mockGetDetailUseCase(recordId) } returns BaseResult.Success(sampleRecord) - viewModel.loadInitialRecord() + viewModel.loadInitialRecord(recordId) runTest { testDispatcher.scheduler.advanceUntilIdle() } } @@ -149,7 +147,7 @@ class ExerciseFormViewModelTest { setupViewModelForCreateMode() // When: 초기 데이터 로드 실행 - viewModel.loadInitialRecord() + viewModel.loadInitialRecord(recordId) // Then: 수정과 관련된 데이터는 null 또는 초기 상태여야 함 assertThat(viewModel.initialDataLoaded.value).isNull() @@ -205,7 +203,7 @@ class ExerciseFormViewModelTest { // Then: removedPictureIds는 비어있어야 하고, submit 시에도 빈 리스트로 전달되어야 함 coEvery { mockEditUseCase(any(), any(), any(), any(), any()) } returns ExerciseEditResult.Success(recordId) - viewModel.submitRecord("제목 변경", "타입", "장소", "상세") // hasChanges=true 만들기 + viewModel.submitRecord(recordId, "제목 변경", "타입", "장소", "상세") // hasChanges=true 만들기 runTest { testDispatcher.scheduler.advanceUntilIdle() } coVerify { @@ -229,6 +227,7 @@ class ExerciseFormViewModelTest { // When: 다른 내용은 그대로 두고, 종료 시간만 변경하여 제출 viewModel.setEndTime(sampleRecord.endedAt.plusHours(1)) viewModel.submitRecord( + recordId = recordId, title = sampleRecord.title, type = sampleRecord.personalType, location = sampleRecord.location, @@ -251,6 +250,7 @@ class ExerciseFormViewModelTest { // When: 내용 변경 없이, 새 이미지만 추가하여 제출 viewModel.addImage(listOf(mockk())) viewModel.submitRecord( + recordId = recordId, title = sampleRecord.title, type = sampleRecord.personalType, location = sampleRecord.location, @@ -295,7 +295,7 @@ class ExerciseFormViewModelTest { coEvery { mockCreateUseCase(any()) } returns BaseResult.Success(ExerciseRecordCreationResult(recordId, earnedPoints)) // 3점 획득 // When - viewModel.submitRecord("제목", "타입", "장소", "상세") + viewModel.submitRecord(recordId, "제목", "타입", "장소", "상세") testDispatcher.scheduler.advanceUntilIdle() // Then @@ -327,7 +327,7 @@ class ExerciseFormViewModelTest { // When viewModel.addImage(listOf(mockUri)) - viewModel.submitRecord("제목", "타입", "장소", "상세") + viewModel.submitRecord(recordId, "제목", "타입", "장소", "상세") testDispatcher.scheduler.advanceUntilIdle() // Then @@ -357,7 +357,7 @@ class ExerciseFormViewModelTest { viewModel.addImage(listOf(mockUri)) // When - viewModel.submitRecord("제목", "타입", "장소", "상세") + viewModel.submitRecord(recordId, "제목", "타입", "장소", "상세") testDispatcher.scheduler.advanceUntilIdle() // Then @@ -391,7 +391,7 @@ class ExerciseFormViewModelTest { // Given: 수정 모드로 ViewModel을 설정하고, 초기 데이터를 로드 setupViewModelForEditMode() coEvery { mockGetDetailUseCase(recordId) } returns BaseResult.Success(sampleRecord) - viewModel.loadInitialRecord() + viewModel.loadInitialRecord(recordId) testDispatcher.scheduler.advanceUntilIdle() // And: editUseCase가 recordId와 함께 Success를 반환하도록 설정 @@ -399,7 +399,7 @@ class ExerciseFormViewModelTest { // When: 제목을 변경하여 기록 제출 val updatedTitle = "Updated Title" - viewModel.submitRecord(updatedTitle, sampleRecord.personalType, sampleRecord.location, sampleRecord.detail) + viewModel.submitRecord(recordId, updatedTitle, sampleRecord.personalType, sampleRecord.location, sampleRecord.detail) testDispatcher.scheduler.advanceUntilIdle() // Then: editUseCase가 콘텐츠 변경 사항과 함께 올바르게 호출되었는지 검증 @@ -416,11 +416,11 @@ class ExerciseFormViewModelTest { // Given: 수정 모드로 ViewModel을 설정하고, 초기 데이터를 로드 setupViewModelForEditMode() coEvery { mockGetDetailUseCase(recordId) } returns BaseResult.Success(sampleRecord) - viewModel.loadInitialRecord() + viewModel.loadInitialRecord(recordId) testDispatcher.scheduler.advanceUntilIdle() // When: 변경 없이 동일한 내용으로 기록 제출 - viewModel.submitRecord(sampleRecord.title, sampleRecord.personalType, sampleRecord.location, sampleRecord.detail) + viewModel.submitRecord(recordId, sampleRecord.title, sampleRecord.personalType, sampleRecord.location, sampleRecord.detail) testDispatcher.scheduler.advanceUntilIdle() // Then: 변경 사항이 없으므로 useCase가 호출되지 않고, 토스트 메시지가 표시되는지 검증 @@ -436,7 +436,7 @@ class ExerciseFormViewModelTest { val recordWithPicture = sampleRecord.copy(pictures = listOf(picture)) setupViewModelForEditMode() coEvery { mockGetDetailUseCase(recordId) } returns BaseResult.Success(recordWithPicture) - viewModel.loadInitialRecord() + viewModel.loadInitialRecord(recordId) testDispatcher.scheduler.advanceUntilIdle() // And: editUseCase가 성공을 반환하도록 설정 @@ -446,7 +446,7 @@ class ExerciseFormViewModelTest { val existingImageItem = viewModel.imageItems.value?.find { it is ExerciseImageListItem.ExistingImageItem } assertThat(existingImageItem).isNotNull() viewModel.removeImage(existingImageItem!!) - viewModel.submitRecord("new title", "type", "loc", "detail") + viewModel.submitRecord(recordId, "new title", "type", "loc", "detail") testDispatcher.scheduler.advanceUntilIdle() // Then: editUseCase가 호출될 때, 삭제된 이미지 ID 목록이 올바르게 전달되었는지 검증 @@ -459,7 +459,7 @@ class ExerciseFormViewModelTest { // Given: 수정 모드이고, 초기 데이터 로드 완료 setupViewModelForEditMode() coEvery { mockGetDetailUseCase(recordId) } returns BaseResult.Success(sampleRecord) - viewModel.loadInitialRecord() + viewModel.loadInitialRecord(recordId) testDispatcher.scheduler.advanceUntilIdle() // And: editUseCase가 Failure를 반환하도록 설정 @@ -467,7 +467,7 @@ class ExerciseFormViewModelTest { coEvery { mockEditUseCase(recordId, any(), true, emptyList(), emptyList()) } returns ExerciseEditResult.Failure(failureMessage) // When: 제목을 변경하여 기록 제출 - viewModel.submitRecord("달라진 제목", "타입", "장소", "상세") + viewModel.submitRecord(recordId, "달라진 제목", "타입", "장소", "상세") testDispatcher.scheduler.advanceUntilIdle() // Then: LiveData에 Failure 결과가 반영되었는지 검증 From a6fc7c99378b43b3fdfe9bc9633a9fce846bf30c Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Tue, 18 Nov 2025 20:06:42 +0900 Subject: [PATCH 20/37] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20#386?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exercise/ExerciseDetailViewModelTest.kt | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseDetailViewModelTest.kt b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseDetailViewModelTest.kt index 3f8aea78..6cddb6c7 100644 --- a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseDetailViewModelTest.kt +++ b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseDetailViewModelTest.kt @@ -9,6 +9,7 @@ import com.project200.domain.model.ExerciseRecordPicture import com.project200.domain.usecase.DeleteExerciseRecordUseCase import com.project200.domain.usecase.GetExerciseRecordDetailUseCase import com.project200.feature.exercise.detail.ExerciseDetailViewModel +import com.project200.presentation.utils.UiState import io.mockk.coEvery import io.mockk.coVerify import io.mockk.impl.annotations.MockK @@ -84,15 +85,19 @@ class ExerciseDetailViewModelTest { // Then coVerify(exactly = 1) { mockGetExerciseUseCase.invoke(recordId) } val actualResult = viewModel.exerciseRecord.value - assertThat(actualResult).isEqualTo(successResult) - assertThat((actualResult as BaseResult.Success).data.title).isEqualTo("아침 조깅") + + assertThat(actualResult).isInstanceOf(UiState.Success::class.java) + + val actualData = (actualResult as UiState.Success).data + assertThat(actualData).isEqualTo(sampleRecord) + assertThat(actualData.title).isEqualTo("아침 조깅") } @Test - fun `getExerciseRecord 호출 시 UseCase가 에러를 반환하면 LiveData에 에러 상태 반영`() = + fun `getExerciseRecord 호출 시 UseCase가 에러를 반환하면 UiState Error로 LiveData에 에러 상태 반영`() = runTest(testDispatcher) { // Given - val errorResult = BaseResult.Error("500", "Network error") + val errorResult = BaseResult.Error("500", "Server error") coEvery { mockGetExerciseUseCase.invoke(recordId) } returns errorResult // When @@ -102,8 +107,15 @@ class ExerciseDetailViewModelTest { // Then coVerify(exactly = 1) { mockGetExerciseUseCase.invoke(recordId) } val actualResult = viewModel.exerciseRecord.value - assertThat(actualResult).isEqualTo(errorResult) - assertThat((actualResult as BaseResult.Error).message).isEqualTo("Network error") + assertThat(actualResult).isInstanceOf(UiState.Error::class.java) + + val actualMessage = (actualResult as UiState.Error).failure.let { + when (it) { + is com.project200.presentation.utils.Failure.ServerError -> it.message + else -> null + } + } + assertThat(actualMessage).isEqualTo("Server error") } @Test From ed9fe43e9ee361866e78d16efdf8461ea2e3c6a2 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Tue, 18 Nov 2025 20:16:29 +0900 Subject: [PATCH 21/37] =?UTF-8?q?fix:=20ktlint=20=EB=AC=B8=EB=B2=95=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#386?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/exercise/ExerciseDetailViewModelTest.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseDetailViewModelTest.kt b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseDetailViewModelTest.kt index 6cddb6c7..e0d06826 100644 --- a/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseDetailViewModelTest.kt +++ b/feature/exercise/src/test/java/com/project200/feature/exercise/ExerciseDetailViewModelTest.kt @@ -109,12 +109,13 @@ class ExerciseDetailViewModelTest { val actualResult = viewModel.exerciseRecord.value assertThat(actualResult).isInstanceOf(UiState.Error::class.java) - val actualMessage = (actualResult as UiState.Error).failure.let { - when (it) { - is com.project200.presentation.utils.Failure.ServerError -> it.message - else -> null + val actualMessage = + (actualResult as UiState.Error).failure.let { + when (it) { + is com.project200.presentation.utils.Failure.ServerError -> it.message + else -> null + } } - } assertThat(actualMessage).isEqualTo("Server error") } From 12167562591d843ba817b66139b8de89a696b3a7 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Wed, 19 Nov 2025 18:28:36 +0900 Subject: [PATCH 22/37] =?UTF-8?q?fix:=20sharedPref=20val=20=EB=B3=80?= =?UTF-8?q?=EC=88=98=EB=A1=9C=20=EC=88=98=EC=A0=95=20#383?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/project200/data/impl/FcmRepositoryImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/src/main/java/com/project200/data/impl/FcmRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/FcmRepositoryImpl.kt index 850ac201..682693be 100644 --- a/data/src/main/java/com/project200/data/impl/FcmRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/FcmRepositoryImpl.kt @@ -17,7 +17,7 @@ class FcmRepositoryImpl @Inject constructor( private val apiService: ApiService, - @EncryptedPrefs private var sharedPreferences: SharedPreferences, + @EncryptedPrefs private val sharedPreferences: SharedPreferences, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : FcmRepository { // FCM 토큰을 SharedPreferences에서 가져오는 함수 From 232eb7bc26f8ba12d7123a6783eeb0ec7353abe8 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Thu, 20 Nov 2025 02:45:03 +0900 Subject: [PATCH 23/37] =?UTF-8?q?refactor:=20DeepLinkManager=EB=A1=9C=20?= =?UTF-8?q?=EB=94=A5=EB=A7=81=ED=81=AC=20=EA=B4=80=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20#384?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/project200/undabang/fcm/FcmService.kt | 4 +-- .../presentation/utils/DeepLinkManager.kt | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 presentation/src/main/java/com/project200/presentation/utils/DeepLinkManager.kt diff --git a/app/src/main/java/com/project200/undabang/fcm/FcmService.kt b/app/src/main/java/com/project200/undabang/fcm/FcmService.kt index fe811ba9..358cfc21 100644 --- a/app/src/main/java/com/project200/undabang/fcm/FcmService.kt +++ b/app/src/main/java/com/project200/undabang/fcm/FcmService.kt @@ -7,11 +7,11 @@ import android.content.Intent import android.content.SharedPreferences import androidx.core.app.NotificationCompat import androidx.core.content.edit -import androidx.core.net.toUri import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import com.project200.common.constants.FcmConstants.KEY_FCM_TOKEN import com.project200.common.utils.EncryptedPrefs +import com.project200.presentation.utils.DeepLinkManager import com.project200.undabang.fcm.FcmConstant.CHAT_NOTI_CHANNEL_ID import com.project200.undabang.fcm.FcmConstant.CHAT_NOTI_CHANNEL_NAME import dagger.hilt.android.AndroidEntryPoint @@ -66,7 +66,7 @@ class FcmService : FirebaseMessagingService() { val uniqueId = chatRoomId.hashCode() // 채팅방으로 이동할 딥링크 URI 생성 - val deepLinkUri = "app://chatting/room/$chatRoomId/$nickname/$memberId".toUri() + val deepLinkUri = DeepLinkManager.createChatRoomUri(chatRoomId, nickname, memberId) // 클릭 시 이동할 Activity 설정 val intent = diff --git a/presentation/src/main/java/com/project200/presentation/utils/DeepLinkManager.kt b/presentation/src/main/java/com/project200/presentation/utils/DeepLinkManager.kt new file mode 100644 index 00000000..404a4a57 --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/utils/DeepLinkManager.kt @@ -0,0 +1,26 @@ +package com.project200.presentation.utils + +import android.net.Uri + +object DeepLinkManager { + private const val SCHEME = "app" + private const val AUTHORITY_CHATTING = "chatting" + + /** + * 채팅방으로 이동하는 딥링크 Uri를 생성합니다. + * @param chatRoomId 채팅방의 고유 ID + * @param nickname 상대방의 닉네임 + * @param memberId 상대방의 멤버 ID + * @return 생성된 Uri 객체 (예: app://chatting/room/123/라이언/456) + */ + fun createChatRoomUri(chatRoomId: String, nickname: String, memberId: String): Uri { + return Uri.Builder() + .scheme(SCHEME) + .authority(AUTHORITY_CHATTING) + .appendPath("room") + .appendPath(chatRoomId) + .appendPath(nickname) + .appendPath(memberId) + .build() + } +} From 0c4ea546c7e8b0b38ae1b5868231677f14aa947c Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Thu, 20 Nov 2025 03:01:46 +0900 Subject: [PATCH 24/37] =?UTF-8?q?fix:=20ktlint=20=EB=AC=B8=EB=B2=95=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#384?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/project200/presentation/utils/DeepLinkManager.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/presentation/src/main/java/com/project200/presentation/utils/DeepLinkManager.kt b/presentation/src/main/java/com/project200/presentation/utils/DeepLinkManager.kt index 404a4a57..258df2a6 100644 --- a/presentation/src/main/java/com/project200/presentation/utils/DeepLinkManager.kt +++ b/presentation/src/main/java/com/project200/presentation/utils/DeepLinkManager.kt @@ -13,7 +13,11 @@ object DeepLinkManager { * @param memberId 상대방의 멤버 ID * @return 생성된 Uri 객체 (예: app://chatting/room/123/라이언/456) */ - fun createChatRoomUri(chatRoomId: String, nickname: String, memberId: String): Uri { + fun createChatRoomUri( + chatRoomId: String, + nickname: String, + memberId: String, + ): Uri { return Uri.Builder() .scheme(SCHEME) .authority(AUTHORITY_CHATTING) From 6fb0adb6d8623895018191ac66f238f6a667c013 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Fri, 21 Nov 2025 02:45:30 +0900 Subject: [PATCH 25/37] =?UTF-8?q?feat:=20EdgeToEdge=20=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94,=20imePadding=20=EC=84=A4=EC=A0=95=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=ED=99=94=20#386?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project200/undabang/main/MainActivity.kt | 2 + .../exercise/form/ExerciseFormFragment.kt | 30 +- .../main/res/layout/fragment_profile_edit.xml | 434 +++++++++--------- .../utils/KeyboardAdjustHelper.kt | 33 ++ 4 files changed, 259 insertions(+), 240 deletions(-) create mode 100644 presentation/src/main/java/com/project200/presentation/utils/KeyboardAdjustHelper.kt diff --git a/app/src/main/java/com/project200/undabang/main/MainActivity.kt b/app/src/main/java/com/project200/undabang/main/MainActivity.kt index d5525354..a0de897c 100644 --- a/app/src/main/java/com/project200/undabang/main/MainActivity.kt +++ b/app/src/main/java/com/project200/undabang/main/MainActivity.kt @@ -6,6 +6,7 @@ import android.os.Build import android.os.Bundle import android.view.MotionEvent import android.widget.Toast +import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels @@ -50,6 +51,7 @@ class MainActivity : AppCompatActivity(), BottomNavigationController { private lateinit var requestNotificationPermissionLauncher: ActivityResultLauncher override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt index 8ce6c9ac..33da4588 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt @@ -25,6 +25,7 @@ import com.project200.presentation.utils.ImageValidator import com.project200.presentation.utils.ImageValidator.FAIL_TO_READ import com.project200.presentation.utils.ImageValidator.INVALID_TYPE import com.project200.presentation.utils.ImageValidator.OVERSIZE +import com.project200.presentation.utils.KeyboardAdjustHelper.applyEdgeToEdgeInsets import com.project200.presentation.utils.UiUtils.dpToPx import com.project200.presentation.utils.UiUtils.getScreenWidthPx import com.project200.undabang.feature.exercise.R @@ -111,6 +112,7 @@ class ExerciseFormFragment : BindingFragment(R.layo } override fun setupViews() { + binding.root.applyEdgeToEdgeInsets() binding.baseToolbar.apply { showBackButton(true) { findNavController().navigateUp() } binding.baseToolbar.setTitle( @@ -122,7 +124,6 @@ class ExerciseFormFragment : BindingFragment(R.layo ) } viewModel.loadInitialRecord(args.recordId) - setupKeyboardAdjustments() setupRVAdapter((getScreenWidthPx(requireActivity()) - dpToPx(requireContext(), GRID_SPAN_MARGIN)) / GRID_SPAN_COUNT) initClickListeners() } @@ -142,33 +143,6 @@ class ExerciseFormFragment : BindingFragment(R.layo } } - /** - * 키보드에 따른 레이아웃 조정 - * 키보드가 올라올 때 ScrollView의 패딩을 키보드 높이만큼 조정 - * 키보드가 내려갈 때는 네비게이션 바 높이만큼 패딩 조정 - */ - private fun setupKeyboardAdjustments() { - ViewCompat.setOnApplyWindowInsetsListener(binding.scrollView) { v, insets -> - Timber.tag("ExerciseFormFragment").d("setupKeyboardAdjustments called") - val imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom - val navigationBarHeight = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom - - // 키보드가 올라와 있으면 키보드 높이만큼, 아니면 네비게이션 바 높이만큼 패딩 적용 - val paddingBottom = - if (imeHeight > 0) { - imeHeight - } else { - // record_complete_btn의 높이 (btn_height)와 layout_marginBottom (32dp)를 더한 값 - val buttonHeight = dpToPx(requireContext(), binding.recordCompleteBtn.height.toFloat()) - val buttonMarginBottom = dpToPx(requireContext(), binding.recordCompleteBtn.marginBottom.toFloat()) - buttonHeight + buttonMarginBottom + navigationBarHeight - } - - v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, paddingBottom) - insets - } - } - private fun launchGallery() { pickMultipleMediaLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) } diff --git a/feature/profile/src/main/res/layout/fragment_profile_edit.xml b/feature/profile/src/main/res/layout/fragment_profile_edit.xml index dad79a4b..d97ab10b 100644 --- a/feature/profile/src/main/res/layout/fragment_profile_edit.xml +++ b/feature/profile/src/main/res/layout/fragment_profile_edit.xml @@ -24,217 +24,227 @@ app:layout_constraintBottom_toBottomOf="@+id/base_toolbar" app:layout_constraintEnd_toEndOf="@+id/base_toolbar" app:layout_constraintTop_toTopOf="@+id/base_toolbar" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="0dp" + android:fillViewport="true" + app:layout_constraintTop_toBottomOf="@id/base_toolbar" + app:layout_constraintBottom_toBottomOf="parent"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/java/com/project200/presentation/utils/KeyboardAdjustHelper.kt b/presentation/src/main/java/com/project200/presentation/utils/KeyboardAdjustHelper.kt new file mode 100644 index 00000000..cd478c0f --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/utils/KeyboardAdjustHelper.kt @@ -0,0 +1,33 @@ +package com.project200.presentation.utils + +import android.view.View +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.recyclerview.widget.RecyclerView +import kotlin.math.max + +object KeyboardAdjustHelper { + /** + * 키보드가 올라올 때 키보드 높이와 시스템 바 높이 중 더 큰 값으로 하단 패딩을 적용합니다. + * @param targetView 패딩을 적용할 뷰 + */ + fun View.applyEdgeToEdgeInsets() { + ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> + val systemBarInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val imeInsets = windowInsets.getInsets(WindowInsetsCompat.Type.ime()) + + // 키보드(IME) 높이와 시스템 바 높이 중 더 큰 값을 하단 패딩으로 사용 + val bottomPadding = max(systemBarInsets.bottom, imeInsets.bottom) + + view.updatePadding( + left = systemBarInsets.left, + top = systemBarInsets.top, + right = systemBarInsets.right, + bottom = bottomPadding + ) + + WindowInsetsCompat.CONSUMED + } + } +} \ No newline at end of file From d5ca81adc27f644e5c10f140a37dc3c85ea47d32 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Fri, 21 Nov 2025 02:46:44 +0900 Subject: [PATCH 26/37] =?UTF-8?q?fix:=20ktlint=20=EB=AC=B8=EB=B2=95=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#386?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project200/feature/exercise/form/ExerciseFormFragment.kt | 3 --- .../project200/presentation/utils/KeyboardAdjustHelper.kt | 5 ++--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt index 33da4588..d36f5ca0 100644 --- a/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt +++ b/feature/exercise/src/main/java/com/project200/feature/exercise/form/ExerciseFormFragment.kt @@ -5,10 +5,7 @@ import android.view.View import android.widget.Toast import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible -import androidx.core.view.marginBottom import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs diff --git a/presentation/src/main/java/com/project200/presentation/utils/KeyboardAdjustHelper.kt b/presentation/src/main/java/com/project200/presentation/utils/KeyboardAdjustHelper.kt index cd478c0f..85539908 100644 --- a/presentation/src/main/java/com/project200/presentation/utils/KeyboardAdjustHelper.kt +++ b/presentation/src/main/java/com/project200/presentation/utils/KeyboardAdjustHelper.kt @@ -4,7 +4,6 @@ import android.view.View import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding -import androidx.recyclerview.widget.RecyclerView import kotlin.math.max object KeyboardAdjustHelper { @@ -24,10 +23,10 @@ object KeyboardAdjustHelper { left = systemBarInsets.left, top = systemBarInsets.top, right = systemBarInsets.right, - bottom = bottomPadding + bottom = bottomPadding, ) WindowInsetsCompat.CONSUMED } } -} \ No newline at end of file +} From 8157524ecec93fb76cef24cf874e014323d7c10e Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Fri, 21 Nov 2025 02:53:55 +0900 Subject: [PATCH 27/37] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=ED=99=94=EB=A9=B4=EC=97=90=20=ED=82=A4?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=ED=95=98=EB=8B=A8=20=ED=8C=A8=EB=94=A9=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=88=20=EC=B6=94=EA=B0=80=20#386?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project200/undabang/profile/mypage/ProfileEditFragment.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt index e0b7236e..d72d645b 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/mypage/ProfileEditFragment.kt @@ -16,6 +16,7 @@ import com.project200.common.constants.RuleConstants import com.project200.presentation.base.BindingFragment import com.project200.presentation.utils.ImageUtils.compressImage import com.project200.presentation.utils.ImageValidator +import com.project200.presentation.utils.KeyboardAdjustHelper.applyEdgeToEdgeInsets import com.project200.undabang.feature.profile.R import com.project200.undabang.feature.profile.databinding.FragmentProfileEditBinding import com.project200.undabang.profile.utils.NicknameValidationState @@ -67,6 +68,7 @@ class ProfileEditFragment : } override fun setupViews() { + binding.root.applyEdgeToEdgeInsets() binding.baseToolbar.apply { setTitle(getString(R.string.profile_edit)) showBackButton(true) { findNavController().navigateUp() } From 15342237bc508b26e12016c1f5f8be3010fb7d60 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Mon, 24 Nov 2025 01:01:28 +0900 Subject: [PATCH 28/37] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=ED=82=A4=EB=B3=B4=EB=93=9C=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20#386?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatting/chattingRoom/ChattingRoomFragment.kt | 11 ----------- .../src/main/res/layout/fragment_chatting_room.xml | 1 + 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt index 298faefd..441320af 100644 --- a/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt +++ b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt @@ -24,7 +24,6 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar import com.project200.common.utils.CommonDateTimeFormatters.YYYY_MM_DD_KR import com.project200.feature.chatting.chattingRoom.adapter.ChatRVAdapter -import com.project200.feature.chatting.utils.KeyboardVisibilityHelper import com.project200.presentation.base.BindingFragment import com.project200.presentation.utils.KeyboardControlInterface import com.project200.presentation.utils.KeyboardUtils.hideKeyboard @@ -34,7 +33,6 @@ import com.project200.undabang.feature.chatting.R import com.project200.undabang.feature.chatting.databinding.FragmentChattingRoomBinding import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.time.LocalDate @@ -52,8 +50,6 @@ class ChattingRoomFragment : BindingFragment(R.layo private var firstVisibleItemPositionBeforeLoad = 0 private var firstVisibleItemOffsetBeforeLoad = 0 - private lateinit var keyboardHelper: KeyboardVisibilityHelper - private lateinit var gestureDetector: GestureDetector private var lastDisplayedDate: LocalDate? = null @@ -67,8 +63,6 @@ class ChattingRoomFragment : BindingFragment(R.layo setupListeners() viewModel.setId(args.roomId, args.memberId) updateSendButtonState(false) - keyboardHelper = KeyboardVisibilityHelper(binding.root, binding.chattingMessageRv) - keyboardHelper.start() } private fun setupListeners() { @@ -378,11 +372,6 @@ class ChattingRoomFragment : BindingFragment(R.layo return !sendButtonRect.contains(x, y) } - override fun onDestroyView() { - keyboardHelper.stop() - super.onDestroyView() - } - companion object { const val POLLING_PERIOD = 2000L } diff --git a/feature/chatting/src/main/res/layout/fragment_chatting_room.xml b/feature/chatting/src/main/res/layout/fragment_chatting_room.xml index 00474009..157fe591 100644 --- a/feature/chatting/src/main/res/layout/fragment_chatting_room.xml +++ b/feature/chatting/src/main/res/layout/fragment_chatting_room.xml @@ -37,6 +37,7 @@ android:minHeight="44dp" android:maxHeight="150dp" android:background="@color/white300" + app:layout_constraintTop_toBottomOf="@id/chatting_message_rv" app:layout_constraintBottom_toBottomOf="parent"> Date: Mon, 24 Nov 2025 01:53:17 +0900 Subject: [PATCH 29/37] =?UTF-8?q?feat:=20ktlint=20=EB=AC=B8=EB=B2=95=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#390?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project200/undabang/di/RepositoryModule.kt | 7 ++----- .../undabang/fcm/ChatRoomStateRepositoryImpl.kt | 15 ++++++++------- .../common/utils/ChatRoomStateRepository.kt | 3 +-- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/project200/undabang/di/RepositoryModule.kt b/app/src/main/java/com/project200/undabang/di/RepositoryModule.kt index 2b228da6..a1fbadac 100644 --- a/app/src/main/java/com/project200/undabang/di/RepositoryModule.kt +++ b/app/src/main/java/com/project200/undabang/di/RepositoryModule.kt @@ -11,10 +11,7 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { - @Binds @Singleton - abstract fun bindChatRoomStateRepository( - impl: ChatRoomStateRepositoryImpl - ): ChatRoomStateRepository -} \ No newline at end of file + abstract fun bindChatRoomStateRepository(impl: ChatRoomStateRepositoryImpl): ChatRoomStateRepository +} diff --git a/app/src/main/java/com/project200/undabang/fcm/ChatRoomStateRepositoryImpl.kt b/app/src/main/java/com/project200/undabang/fcm/ChatRoomStateRepositoryImpl.kt index 471417e1..25710cdc 100644 --- a/app/src/main/java/com/project200/undabang/fcm/ChatRoomStateRepositoryImpl.kt +++ b/app/src/main/java/com/project200/undabang/fcm/ChatRoomStateRepositoryImpl.kt @@ -7,12 +7,13 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class ChatRoomStateRepositoryImpl @Inject constructor() : ChatRoomStateRepository { +class ChatRoomStateRepositoryImpl + @Inject + constructor() : ChatRoomStateRepository { + private val _activeChatRoomId = MutableStateFlow(null) + override val activeChatRoomId = _activeChatRoomId.asStateFlow() - private val _activeChatRoomId = MutableStateFlow(null) - override val activeChatRoomId = _activeChatRoomId.asStateFlow() - - override fun setActiveChatRoomId(roomId: Long?) { - _activeChatRoomId.value = roomId + override fun setActiveChatRoomId(roomId: Long?) { + _activeChatRoomId.value = roomId + } } -} \ No newline at end of file diff --git a/common/src/main/java/com/project200/common/utils/ChatRoomStateRepository.kt b/common/src/main/java/com/project200/common/utils/ChatRoomStateRepository.kt index fa0066b3..b988d0df 100644 --- a/common/src/main/java/com/project200/common/utils/ChatRoomStateRepository.kt +++ b/common/src/main/java/com/project200/common/utils/ChatRoomStateRepository.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.flow.StateFlow * 앱 전체에서 현재 활성화된 채팅방의 상태를 관리하는 리포지토리 인터페이스 */ interface ChatRoomStateRepository { - /** * 현재 활성화된 채팅방의 ID를 StateFlow 형태로 제공합니다. * 활성화된 채팅방이 없으면 null입니다. @@ -18,4 +17,4 @@ interface ChatRoomStateRepository { * @param roomId 채팅방에서 나갈 때는 null을 전달합니다. */ fun setActiveChatRoomId(roomId: Long?) -} \ No newline at end of file +} From fced0eba8ccba3b49690aa85dbe71c300635db97 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Thu, 27 Nov 2025 17:13:36 +0900 Subject: [PATCH 30/37] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20id?= =?UTF-8?q?=20=EB=B3=80=EC=88=98=EB=AA=85=20=EB=B3=80=EA=B2=BD=20#394?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/java/com/project200/undabang/fcm/FcmService.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/project200/undabang/fcm/FcmService.kt b/app/src/main/java/com/project200/undabang/fcm/FcmService.kt index 3e265ea1..b8945885 100644 --- a/app/src/main/java/com/project200/undabang/fcm/FcmService.kt +++ b/app/src/main/java/com/project200/undabang/fcm/FcmService.kt @@ -60,11 +60,12 @@ class FcmService : FirebaseMessagingService() { } private fun sendNotification(data: Map) { - val chatRoomId = data["chatRoomId"] + val chatRoomId = data["chatroomId"] val nickname = data["nickname"] val memberId = data["memberId"] + val content = data["content"] - if (chatRoomId == null || nickname == null || memberId == null) return + if (chatRoomId == null || nickname == null || memberId == null || content == null) return // 현재 활성화된 채팅방과 동일한 채팅방에서 온 알림이면 무시 if (chatRoomId.toLong() == chatRoomStateRepository.activeChatRoomId.value) return From fcc49f8ac1fa8440593e4913b71ac8f8f8b84cc1 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Tue, 2 Dec 2025 02:24:54 +0900 Subject: [PATCH 31/37] =?UTF-8?q?feat:=20=EC=B6=94=EC=A0=81=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20#403?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project200/undabang/di/FirebaseModule.kt | 21 +++++++++++++++++++ feature/chatting/build.gradle.kts | 4 ++++ .../chattingRoom/ChattingRoomFragment.kt | 11 ++++++++++ 3 files changed, 36 insertions(+) create mode 100644 app/src/main/java/com/project200/undabang/di/FirebaseModule.kt diff --git a/app/src/main/java/com/project200/undabang/di/FirebaseModule.kt b/app/src/main/java/com/project200/undabang/di/FirebaseModule.kt new file mode 100644 index 00000000..96d18b23 --- /dev/null +++ b/app/src/main/java/com/project200/undabang/di/FirebaseModule.kt @@ -0,0 +1,21 @@ +package com.project200.undabang.di + +import android.content.Context +import com.google.firebase.analytics.FirebaseAnalytics +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object FirebaseModule { + + @Provides + @Singleton + fun provideFirebaseAnalytics(@ApplicationContext context: Context): FirebaseAnalytics { + return FirebaseAnalytics.getInstance(context) + } +} \ No newline at end of file diff --git a/feature/chatting/build.gradle.kts b/feature/chatting/build.gradle.kts index ef0f0ef5..28535341 100644 --- a/feature/chatting/build.gradle.kts +++ b/feature/chatting/build.gradle.kts @@ -36,6 +36,10 @@ dependencies { // CircleImageView implementation(libs.circleimageview) + // Google Analytics + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.analytics) + // Glide implementation(libs.glide) ksp(libs.glide.compiler.ksp) diff --git a/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt index 2d99cd76..150dcbaa 100644 --- a/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt +++ b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt @@ -1,6 +1,7 @@ package com.project200.feature.chatting.chattingRoom import android.graphics.Rect +import android.os.Bundle import android.view.ContextThemeWrapper import android.view.GestureDetector import android.view.Gravity @@ -22,6 +23,7 @@ import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar +import com.google.firebase.analytics.FirebaseAnalytics import com.project200.common.utils.ChatRoomStateRepository import com.project200.common.utils.CommonDateTimeFormatters.YYYY_MM_DD_KR import com.project200.feature.chatting.chattingRoom.adapter.ChatRVAdapter @@ -45,6 +47,9 @@ class ChattingRoomFragment : BindingFragment(R.layo private lateinit var chatAdapter: ChatRVAdapter private val args: ChattingRoomFragmentArgs by navArgs() + @Inject + lateinit var firebaseAnalytics: FirebaseAnalytics + @Inject lateinit var chatRoomStateRepository: ChatRoomStateRepository @@ -85,6 +90,12 @@ class ChattingRoomFragment : BindingFragment(R.layo binding.sendBtn.setOnClickListener { val messageText = binding.chattingMessageEt.text.toString() if (messageText.isNotBlank()) { + // Firebase Analytics 이벤트 로깅 + val bundle = Bundle().apply { + putLong("timestamp", System.currentTimeMillis()) + } + firebaseAnalytics.logEvent("chat_send_message", bundle) + viewModel.sendMessage(messageText) binding.chattingMessageEt.text.clear() // EditText 초기화 } From 9f18110ff3f8bb96032b6c6150dcc7f48a6ad41f Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Tue, 2 Dec 2025 02:25:17 +0900 Subject: [PATCH 32/37] =?UTF-8?q?fix:=20=EC=9A=B4=EB=8F=99=20=EC=BA=98?= =?UTF-8?q?=EB=A6=B0=EB=8D=94=20empty=20string=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exercise/src/main/res/layout/fragment_exercise_main.xml | 5 ++--- feature/exercise/src/main/res/values/strings.xml | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/feature/exercise/src/main/res/layout/fragment_exercise_main.xml b/feature/exercise/src/main/res/layout/fragment_exercise_main.xml index a0b0e05f..a8090003 100644 --- a/feature/exercise/src/main/res/layout/fragment_exercise_main.xml +++ b/feature/exercise/src/main/res/layout/fragment_exercise_main.xml @@ -202,11 +202,10 @@ android:layout_height="wrap_content" android:layout_gravity="center" android:gravity="center" - android:paddingTop="50dp" - + android:paddingTop="100dp" android:textColor="@color/gray200" style="@style/content_regular" - android:text="@string/exercise_create_get_score"/> + android:text="@string/exercise_empty"/> diff --git a/feature/exercise/src/main/res/values/strings.xml b/feature/exercise/src/main/res/values/strings.xml index f383a488..1820d968 100644 --- a/feature/exercise/src/main/res/values/strings.xml +++ b/feature/exercise/src/main/res/values/strings.xml @@ -23,7 +23,7 @@ 삭제하기 수정하기 운동 기록하고 점수 얻기 - 아직 등록된 기록이 없습니다. + 이 날짜에 등록된 기록이 없습니다 %d회 From 66b3c02378e5e7fcdf8e356bab1453f09707d276 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Tue, 2 Dec 2025 02:38:03 +0900 Subject: [PATCH 33/37] =?UTF-8?q?fix:=20ktlint=20=EB=AC=B8=EB=B2=95=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#403?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/project200/undabang/di/FirebaseModule.kt | 9 +++++---- .../chatting/chattingRoom/ChattingRoomFragment.kt | 7 ++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/project200/undabang/di/FirebaseModule.kt b/app/src/main/java/com/project200/undabang/di/FirebaseModule.kt index 96d18b23..db5ec84d 100644 --- a/app/src/main/java/com/project200/undabang/di/FirebaseModule.kt +++ b/app/src/main/java/com/project200/undabang/di/FirebaseModule.kt @@ -5,17 +5,18 @@ import com.google.firebase.analytics.FirebaseAnalytics import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object FirebaseModule { - @Provides @Singleton - fun provideFirebaseAnalytics(@ApplicationContext context: Context): FirebaseAnalytics { + fun provideFirebaseAnalytics( + @ApplicationContext context: Context, + ): FirebaseAnalytics { return FirebaseAnalytics.getInstance(context) } -} \ No newline at end of file +} diff --git a/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt index 150dcbaa..b20dcc5e 100644 --- a/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt +++ b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomFragment.kt @@ -91,9 +91,10 @@ class ChattingRoomFragment : BindingFragment(R.layo val messageText = binding.chattingMessageEt.text.toString() if (messageText.isNotBlank()) { // Firebase Analytics 이벤트 로깅 - val bundle = Bundle().apply { - putLong("timestamp", System.currentTimeMillis()) - } + val bundle = + Bundle().apply { + putLong("timestamp", System.currentTimeMillis()) + } firebaseAnalytics.logEvent("chat_send_message", bundle) viewModel.sendMessage(messageText) From ed58290d18ab5b8bb59e9b4beacfe2ea444c5d1a Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Sun, 7 Dec 2025 23:32:37 +0900 Subject: [PATCH 34/37] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=A0=95=EB=B3=B4=20request=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/src/main/java/com/project200/data/api/ApiService.kt | 5 ++++- data/src/main/java/com/project200/data/dto/AuthDTO.kt | 6 ++++++ .../java/com/project200/data/impl/AuthRepositoryImpl.kt | 3 ++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/data/src/main/java/com/project200/data/api/ApiService.kt b/data/src/main/java/com/project200/data/api/ApiService.kt index b21c2b7b..9564aa00 100644 --- a/data/src/main/java/com/project200/data/api/ApiService.kt +++ b/data/src/main/java/com/project200/data/api/ApiService.kt @@ -35,6 +35,7 @@ import com.project200.data.dto.PostCustomTimerRequest import com.project200.data.dto.PostExercisePlaceDTO import com.project200.data.dto.PostExerciseRequestDto import com.project200.data.dto.PostExerciseResponseDTO +import com.project200.data.dto.PostLoginRequest import com.project200.data.dto.PostMessageResponse import com.project200.data.dto.PostSignUpData import com.project200.data.dto.PostSignUpRequest @@ -63,7 +64,9 @@ interface ApiService { // 로그인 @POST("api/v1/login") @AccessTokenWithFcmApi - suspend fun postLogin(): BaseResponse + suspend fun postLogin( + @Body accessInfo: PostLoginRequest, + ): BaseResponse // 로그아웃 @POST("api/v1/logout") diff --git a/data/src/main/java/com/project200/data/dto/AuthDTO.kt b/data/src/main/java/com/project200/data/dto/AuthDTO.kt index 331e488d..aafb6290 100644 --- a/data/src/main/java/com/project200/data/dto/AuthDTO.kt +++ b/data/src/main/java/com/project200/data/dto/AuthDTO.kt @@ -9,6 +9,12 @@ data class LoginRequest( val nickname: String, ) +@JsonClass(generateAdapter = true) +data class PostLoginRequest( + val platform: String, + val accessMode: String, +) + @JsonClass(generateAdapter = true) data class GetIsRegisteredData( val memberId: String, diff --git a/data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt index 6031a8dc..3ec7d9be 100644 --- a/data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt @@ -3,6 +3,7 @@ package com.project200.data.impl import com.project200.common.di.IoDispatcher import com.project200.data.api.ApiService import com.project200.data.dto.GetIsNicknameDuplicated +import com.project200.data.dto.PostLoginRequest import com.project200.data.dto.PostSignUpRequest import com.project200.data.local.PreferenceManager import com.project200.data.utils.apiCallBuilder @@ -38,7 +39,7 @@ class AuthRepositoryImpl override suspend fun login(): BaseResult { return apiCallBuilder( ioDispatcher = ioDispatcher, - apiCall = { apiService.postLogin() }, + apiCall = { apiService.postLogin(PostLoginRequest("ANDROID", "APP")) }, mapper = { Unit }, ) } From 02d830558028ba3b6cbcca6ee7945b2bdad61207 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Wed, 10 Dec 2025 05:23:47 +0900 Subject: [PATCH 35/37] =?UTF-8?q?fix:=20=ED=86=A0=ED=81=B0=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EA=B0=95=EC=A0=9C?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20#415?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/project200/undabang/oauth/AuthManager.kt | 9 ++++----- .../com/project200/undabang/oauth/AuthStateManager.kt | 4 ++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/core/oauth/src/main/java/com/project200/undabang/oauth/AuthManager.kt b/core/oauth/src/main/java/com/project200/undabang/oauth/AuthManager.kt index 650c27e3..a02f45ea 100644 --- a/core/oauth/src/main/java/com/project200/undabang/oauth/AuthManager.kt +++ b/core/oauth/src/main/java/com/project200/undabang/oauth/AuthManager.kt @@ -258,14 +258,13 @@ class AuthManager continuation.resume(TokenRefreshResult.Success(tokenResponse)) } else { Timber.tag(TAG_DEBUG).e(ex, "Token refresh failed: ${ex?.errorDescription}") - // invalid_grant 등의 에러 발생 시, authStateManager에서 로컬 상태를 clear 할 수 있음 + // invalid_grant 에러(리프레시 토큰 만료/무효)는 복구 불가능한 에러로 간주 if (ex?.type == AuthorizationException.TYPE_OAUTH_TOKEN_ERROR && ex.error == "invalid_grant") { - Timber.tag(TAG_DEBUG).w("Refresh token is invalid (invalid_grant). Clearing local AuthState.") + Timber.tag(TAG_DEBUG).w("Refresh token is invalid (invalid_grant). Clearing local AuthState and forcing logout.") authStateManager.clearAuthState() // 리프레시 토큰이 무효하므로 로컬 상태 삭제 + _forceLogoutFlow.tryEmit(Unit) // 강제 로그아웃 트리거 } - - // 토큰 갱신 실패 시 강제 로그아웃 트리거 - _forceLogoutFlow.tryEmit(Unit) + // 그 외 다른 에러(예: 네트워크 일시 오류)는 강제 로그아웃 없이 에러 상태만 반환 continuation.resume(TokenRefreshResult.Error(ex)) } } diff --git a/core/oauth/src/main/java/com/project200/undabang/oauth/AuthStateManager.kt b/core/oauth/src/main/java/com/project200/undabang/oauth/AuthStateManager.kt index 2ba4dd98..d223c682 100644 --- a/core/oauth/src/main/java/com/project200/undabang/oauth/AuthStateManager.kt +++ b/core/oauth/src/main/java/com/project200/undabang/oauth/AuthStateManager.kt @@ -79,7 +79,7 @@ class AuthStateManager prefs.edit { putString(cognitoConfig.authStatePrefKey, currentAuthState.jsonSerializeString()) } - Timber.tag(TAG).i("AuthState saved (encrypted) to ${cognitoConfig.authPrefsName}.") + Timber.tag(TAG).i("AuthState saved (encrypted) to ${cognitoConfig.authPrefsName}. AccessTokenExpirationTime: ${currentAuthState.accessTokenExpirationTime}.") } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to serialize/save auth state to ${cognitoConfig.authPrefsName}") } @@ -90,7 +90,7 @@ class AuthStateManager val storedAuthStateString = prefs.getString(cognitoConfig.authStatePrefKey, null) if (storedAuthStateString != null) { currentAuthState = AuthState.jsonDeserialize(storedAuthStateString) - Timber.tag(TAG).i("AuthState restored (decrypted) from ${cognitoConfig.authPrefsName}.") + Timber.tag(TAG).i("AuthState restored (decrypted) from ${cognitoConfig.authPrefsName}. AccessTokenExpirationTime: ${currentAuthState.accessTokenExpirationTime}.") } else { currentAuthState = AuthState() Timber.tag(TAG).i("No AuthState found in ${cognitoConfig.authPrefsName}, initialized a new one.") From 2bd4f28bfeec99ed08b5a9d67bd72ce79e363bfd Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Thu, 11 Dec 2025 03:59:35 +0900 Subject: [PATCH 36/37] =?UTF-8?q?fix:=20ktlint=20=EB=AC=B8=EB=B2=95=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/project200/undabang/oauth/AuthManager.kt | 4 +++- .../project200/undabang/oauth/AuthStateManager.kt | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/core/oauth/src/main/java/com/project200/undabang/oauth/AuthManager.kt b/core/oauth/src/main/java/com/project200/undabang/oauth/AuthManager.kt index a02f45ea..be438163 100644 --- a/core/oauth/src/main/java/com/project200/undabang/oauth/AuthManager.kt +++ b/core/oauth/src/main/java/com/project200/undabang/oauth/AuthManager.kt @@ -260,7 +260,9 @@ class AuthManager Timber.tag(TAG_DEBUG).e(ex, "Token refresh failed: ${ex?.errorDescription}") // invalid_grant 에러(리프레시 토큰 만료/무효)는 복구 불가능한 에러로 간주 if (ex?.type == AuthorizationException.TYPE_OAUTH_TOKEN_ERROR && ex.error == "invalid_grant") { - Timber.tag(TAG_DEBUG).w("Refresh token is invalid (invalid_grant). Clearing local AuthState and forcing logout.") + Timber.tag( + TAG_DEBUG, + ).w("Refresh token is invalid (invalid_grant). Clearing local AuthState and forcing logout.") authStateManager.clearAuthState() // 리프레시 토큰이 무효하므로 로컬 상태 삭제 _forceLogoutFlow.tryEmit(Unit) // 강제 로그아웃 트리거 } diff --git a/core/oauth/src/main/java/com/project200/undabang/oauth/AuthStateManager.kt b/core/oauth/src/main/java/com/project200/undabang/oauth/AuthStateManager.kt index d223c682..e99e5182 100644 --- a/core/oauth/src/main/java/com/project200/undabang/oauth/AuthStateManager.kt +++ b/core/oauth/src/main/java/com/project200/undabang/oauth/AuthStateManager.kt @@ -79,7 +79,12 @@ class AuthStateManager prefs.edit { putString(cognitoConfig.authStatePrefKey, currentAuthState.jsonSerializeString()) } - Timber.tag(TAG).i("AuthState saved (encrypted) to ${cognitoConfig.authPrefsName}. AccessTokenExpirationTime: ${currentAuthState.accessTokenExpirationTime}.") + Timber.tag( + TAG, + ).i( + "AuthState saved (encrypted) to ${cognitoConfig.authPrefsName}. " + + "AccessTokenExpirationTime: ${currentAuthState.accessTokenExpirationTime}.", + ) } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to serialize/save auth state to ${cognitoConfig.authPrefsName}") } @@ -90,7 +95,12 @@ class AuthStateManager val storedAuthStateString = prefs.getString(cognitoConfig.authStatePrefKey, null) if (storedAuthStateString != null) { currentAuthState = AuthState.jsonDeserialize(storedAuthStateString) - Timber.tag(TAG).i("AuthState restored (decrypted) from ${cognitoConfig.authPrefsName}. AccessTokenExpirationTime: ${currentAuthState.accessTokenExpirationTime}.") + Timber.tag( + TAG, + ).i( + "AuthState restored (decrypted) from ${cognitoConfig.authPrefsName}. " + + "AccessTokenExpirationTime: ${currentAuthState.accessTokenExpirationTime}.", + ) } else { currentAuthState = AuthState() Timber.tag(TAG).i("No AuthState found in ${cognitoConfig.authPrefsName}, initialized a new one.") From 48411f7c380016b8c0d1706244202f2f912633d2 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Fri, 12 Dec 2025 03:18:36 +0900 Subject: [PATCH 37/37] =?UTF-8?q?feat:=20=EB=B2=84=EC=A0=84=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 058af6be..612cba78 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,8 +8,8 @@ ksp = "1.9.23-1.0.19" compileSdk = "35" minSdk = "26" targetSdk = "35" -versionCode = "20" -versionName = "0.5.5" +versionCode = "21" +versionName = "0.5.6" firebaseBom = "32.7.0" googleServicesPlugin = "4.4.2" firebasePerfPlugin = "1.4.2"