Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions feature/exercise/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,7 @@ dependencies {

// lottie animation
implementation(libs.lottie)

// Shimmer
implementation(libs.shimmer)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,46 +4,62 @@ 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
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.UiState
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

@AndroidEntryPoint
class ExerciseDetailFragment : BindingFragment<FragmentExerciseDetailBinding>(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() }
setSubButton(R.drawable.ic_menu) { showExerciseDetailMenu() }
}
}

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()
}
}
}
}
}
Expand All @@ -58,6 +74,15 @@ class ExerciseDetailFragment : BindingFragment<FragmentExerciseDetailBinding>(R.
}
}
}

// 이전 화면에서 새로고침 요청이 있을 경우에만 데이터를 새로고침합니다.
val savedStateHandle = findNavController().currentBackStackEntry?.savedStateHandle
savedStateHandle?.getLiveData<Boolean>(KEY_RECORD_UPDATED)?.observe(viewLifecycleOwner) { shouldRefresh ->
if (shouldRefresh) {
viewModel.getExerciseRecord(args.recordId)
savedStateHandle.remove<Boolean>(KEY_RECORD_UPDATED)
}
}
}

private fun bindExerciseRecordData(record: ExerciseRecord) {
Expand Down Expand Up @@ -117,7 +142,7 @@ class ExerciseDetailFragment : BindingFragment<FragmentExerciseDetailBinding>(R.
onEditClicked = {
findNavController().navigate(
ExerciseDetailFragmentDirections
.actionExerciseDetailFragmentToExerciseFormFragment(viewModel.recordId),
.actionExerciseDetailFragmentToExerciseFormFragment(args.recordId),
)
},
onDeleteClicked = { showDeleteConfirmationDialog() },
Expand All @@ -129,12 +154,12 @@ class ExerciseDetailFragment : BindingFragment<FragmentExerciseDetailBinding>(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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,56 @@ 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

@HiltViewModel
class ExerciseDetailViewModel
@Inject
constructor(
savedStateHandle: SavedStateHandle,
private val exerciseRecordDetailUseCase: GetExerciseRecordDetailUseCase,
private val deleteExerciseRecordUseCase: DeleteExerciseRecordUseCase,
) : ViewModel() {
val recordId: Long =
savedStateHandle.get<Long>("recordId")
?: throw IllegalStateException("recordId is required for ExerciseDetailViewModel")

private val _exerciseRecord = MutableLiveData<BaseResult<ExerciseRecord>>()
val exerciseRecord: LiveData<BaseResult<ExerciseRecord>> = _exerciseRecord
private val _exerciseRecord = MutableStateFlow<UiState<ExerciseRecord>>(UiState.Loading)
val exerciseRecord: StateFlow<UiState<ExerciseRecord>> = _exerciseRecord

private val _deleteResult = MutableLiveData<BaseResult<Unit>>()
val deleteResult: LiveData<BaseResult<Unit>> = _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
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -12,12 +11,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
Expand All @@ -38,6 +39,7 @@ import java.util.Calendar
@AndroidEntryPoint
class ExerciseFormFragment : BindingFragment<FragmentExerciseFormBinding>(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 =
Expand Down Expand Up @@ -84,15 +86,6 @@ class ExerciseFormFragment : BindingFragment<FragmentExerciseFormBinding>(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(
Expand All @@ -118,15 +111,29 @@ class ExerciseFormFragment : BindingFragment<FragmentExerciseFormBinding>(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) }

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(),
Expand All @@ -135,6 +142,11 @@ class ExerciseFormFragment : BindingFragment<FragmentExerciseFormBinding>(R.layo
}
}

/**
* 키보드에 따른 레이아웃 조정
* 키보드가 올라올 때 ScrollView의 패딩을 키보드 높이만큼 조정
* 키보드가 내려갈 때는 네비게이션 바 높이만큼 패딩 조정
*/
private fun setupKeyboardAdjustments() {
ViewCompat.setOnApplyWindowInsetsListener(binding.scrollView) { v, insets ->
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EdgeToEdge가 활성화 되어있나요?

Copy link
Copy Markdown
Contributor Author

@edv-Shin edv-Shin Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MainActivity에 활성화 설정을 추가했습니다.

+키보드에 맞게 패딩을 추가하는 로직이 프로필 편집화면에도 필요한 것을 확인했습니다.
하단 패딩 조절 로직을 KeyboardAdjustHelper로 공통화해서 사용할 수 있도록 수정했습니다.
8157524
6fb0adb

Timber.tag("ExerciseFormFragment").d("setupKeyboardAdjustments called")
Expand All @@ -147,7 +159,6 @@ class ExerciseFormFragment : BindingFragment<FragmentExerciseFormBinding>(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
Expand Down Expand Up @@ -217,12 +228,7 @@ class ExerciseFormFragment : BindingFragment<FragmentExerciseFormBinding>(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 ->
Expand Down Expand Up @@ -250,14 +256,26 @@ class ExerciseFormFragment : BindingFragment<FragmentExerciseFormBinding>(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 -> { // 내용 수정, 이미지 삭제/업로드 실패
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Long>("recordId")

private val _startTime = MutableLiveData<LocalDateTime?>()
val startTime: LiveData<LocalDateTime?> = _startTime

Expand Down Expand Up @@ -84,8 +80,8 @@ class ExerciseFormViewModel
val scoreGuidanceState: LiveData<ScoreGuidanceState> = _scoreGuidanceState

/** 초기 데이터 설정 */
fun loadInitialRecord() {
if (recordId == -1L || recordId == null) {
fun loadInitialRecord(recordId: Long) {
if (recordId == -1L) {
// 생성 모드
isEditMode = false
initialRecord = null
Expand Down Expand Up @@ -193,6 +189,7 @@ class ExerciseFormViewModel

/** 기록 생성 또는 수정 */
fun submitRecord(
recordId: Long,
title: String,
type: String,
location: String,
Expand Down Expand Up @@ -232,7 +229,7 @@ class ExerciseFormViewModel
?.map { it.uri.toString() } ?: emptyList()

if (isEditMode) {
editExerciseRecord(recordToSubmit, newImageUris)
editExerciseRecord(recordId, recordToSubmit, newImageUris)
} else {
createExerciseRecord(recordToSubmit, newImageUris)
}
Expand Down Expand Up @@ -285,13 +282,14 @@ class ExerciseFormViewModel

/** 기록 수정 */
private fun editExerciseRecord(
recordId: Long,
record: ExerciseRecord,
newImageUris: List<String>,
) {
viewModelScope.launch {
_editResult.value =
editExerciseRecordUseCase(
recordId = recordId!!,
recordId = recordId,
recordToUpdate = record,
isContentChanges = hasContentChanges(record),
imagesToDelete = removedPictureIds,
Expand Down
Loading