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/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 cb29cfbe..2d99cd76 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 @@ -25,7 +25,6 @@ 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 import com.project200.presentation.base.BindingFragment import com.project200.presentation.utils.KeyboardControlInterface import com.project200.presentation.utils.KeyboardUtils.hideKeyboard @@ -35,7 +34,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 @@ -57,8 +55,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 @@ -72,8 +68,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() { @@ -383,11 +377,6 @@ class ChattingRoomFragment : BindingFragment(R.layo return !sendButtonRect.contains(x, y) } - override fun onDestroyView() { - keyboardHelper.stop() - super.onDestroyView() - } - override fun onResume() { super.onResume() // 현재 채팅방을 활성 채팅방으로 설정 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"> (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) } override fun setupViews() { + viewModel.getExerciseRecord(args.recordId) binding.baseToolbar.apply { setTitle(getString(R.string.exercise_detail)) showBackButton(true) { findNavController().navigateUp() } @@ -31,19 +40,26 @@ class ExerciseDetailFragment : BindingFragment(R. } } - override fun onResume() { - super.onResume() - viewModel.getExerciseRecord() - } - 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() + } + } } } } @@ -58,6 +74,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) { @@ -117,7 +142,7 @@ class ExerciseDetailFragment : BindingFragment(R. onEditClicked = { findNavController().navigate( ExerciseDetailFragmentDirections - .actionExerciseDetailFragmentToExerciseFormFragment(viewModel.recordId), + .actionExerciseDetailFragmentToExerciseFormFragment(args.recordId), ) }, onDeleteClicked = { showDeleteConfirmationDialog() }, @@ -129,12 +154,12 @@ 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) } 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/detail/ExerciseDetailViewModel.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseDetailViewModel.kt index 81ce6868..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,18 @@ 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.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 @@ -17,29 +21,37 @@ 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 _exerciseRecord = MutableStateFlow>(UiState.Loading) + val exerciseRecord: StateFlow> = _exerciseRecord private val _deleteResult = MutableLiveData>() val deleteResult: LiveData> = _deleteResult - fun getExerciseRecord() { + 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) + } + } } } - fun deleteExerciseRecord() { + fun deleteExerciseRecord(recordId: Long) { viewModelScope.launch { _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 fdaa2c59..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 @@ -1,29 +1,28 @@ 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 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 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 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 @@ -38,6 +37,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 = @@ -84,15 +84,6 @@ class ExerciseFormFragment : BindingFragment(R.layo return FragmentExerciseFormBinding.bind(view) } - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - viewModel.loadInitialRecord() - setupKeyboardAdjustments() - } - private fun setupRVAdapter(calculatedItemSize: Int) { imageAdapter = ExerciseImageAdapter( @@ -118,15 +109,29 @@ class ExerciseFormFragment : BindingFragment(R.layo } override fun setupViews() { - binding.baseToolbar.showBackButton(true) { findNavController().navigateUp() } - + binding.root.applyEdgeToEdgeInsets() + 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) 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) } 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(), @@ -135,29 +140,6 @@ class ExerciseFormFragment : BindingFragment(R.layo } } - 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)를 더한 값 - // 이 값은 dpToPx를 사용하여 픽셀로 변환해야 합니다. - 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)) } @@ -217,12 +199,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 -> @@ -250,14 +227,26 @@ 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 -> { // 내용 수정, 이미지 삭제/업로드 실패 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..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 @@ -37,15 +36,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 +80,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 +189,7 @@ class ExerciseFormViewModel /** 기록 생성 또는 수정 */ fun submitRecord( + recordId: Long, title: String, type: String, location: String, @@ -232,7 +229,7 @@ class ExerciseFormViewModel ?.map { it.uri.toString() } ?: emptyList() if (isEditMode) { - editExerciseRecord(recordToSubmit, newImageUris) + editExerciseRecord(recordId, recordToSubmit, newImageUris) } else { createExerciseRecord(recordToSubmit, newImageUris) } @@ -285,13 +282,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, 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/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..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 @@ -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 @@ -62,7 +63,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,32 +79,44 @@ 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).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 - viewModel.getExerciseRecord() + viewModel.getExerciseRecord(recordId) testDispatcher.scheduler.advanceUntilIdle() // 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 @@ -114,7 +127,7 @@ class ExerciseDetailViewModelTest { coEvery { mockDeleteExerciseUseCase.invoke(recordId) } returns successResult // When - viewModel.deleteExerciseRecord() + viewModel.deleteExerciseRecord(recordId) testDispatcher.scheduler.advanceUntilIdle() // Then @@ -132,7 +145,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 결과가 반영되었는지 검증 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() } 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index f2ce19b1..058af6be 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,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" @@ -111,6 +112,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/KeyboardAdjustHelper.kt b/presentation/src/main/java/com/project200/presentation/utils/KeyboardAdjustHelper.kt new file mode 100644 index 00000000..85539908 --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/utils/KeyboardAdjustHelper.kt @@ -0,0 +1,32 @@ +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 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 + } + } +} 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..c24af385 --- /dev/null +++ b/presentation/src/main/java/com/project200/presentation/utils/UiState.kt @@ -0,0 +1,55 @@ +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) + } +} 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