diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2267871b..86b9abff 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -43,5 +43,15 @@
+
+
+
+
\ No newline at end of file
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 6b704ea9..26307f6b 100644
--- a/app/src/main/java/com/project200/undabang/main/MainActivity.kt
+++ b/app/src/main/java/com/project200/undabang/main/MainActivity.kt
@@ -143,6 +143,7 @@ class MainActivity : AppCompatActivity(), BottomNavigationController {
com.project200.undabang.feature.matching.R.id.exercisePlaceFragment,
com.project200.undabang.feature.matching.R.id.exercisePlaceSearchFragment,
com.project200.undabang.feature.matching.R.id.exercisePlaceRegisterFragment,
+ com.project200.undabang.feature.exercise.R.id.exerciseShareEditFragment,
com.project200.undabang.feature.matching.R.id.matchingGuideFragment,
com.project200.undabang.feature.chatting.R.id.chattingRoomFragment,
// ... 필요한 다른 프래그먼트 ID들 추가 ... //
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 00000000..f400777e
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/domain/src/main/java/com/project200/domain/usecase/GetExerciseTypesUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/GetExerciseTypesUseCase.kt
new file mode 100644
index 00000000..94e75416
--- /dev/null
+++ b/domain/src/main/java/com/project200/domain/usecase/GetExerciseTypesUseCase.kt
@@ -0,0 +1,14 @@
+package com.project200.domain.usecase
+
+import com.project200.domain.model.BaseResult
+import com.project200.domain.model.ExerciseType
+import com.project200.domain.repository.MemberRepository
+import javax.inject.Inject
+
+class GetExerciseTypesUseCase @Inject constructor(
+ private val repository: MemberRepository,
+) {
+ suspend operator fun invoke(): BaseResult> {
+ return repository.getPreferredExerciseTypes()
+ }
+}
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 440e0373..e1b2ce72 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
@@ -16,7 +16,6 @@ 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
@@ -26,6 +25,7 @@ import kotlinx.coroutines.launch
class ExerciseDetailFragment : BindingFragment(R.layout.fragment_exercise_detail) {
private val viewModel: ExerciseDetailViewModel by viewModels()
private val args: ExerciseDetailFragmentArgs by navArgs()
+ private var currentRecord: ExerciseRecord? = null
override fun getViewBinding(view: View): FragmentExerciseDetailBinding {
return FragmentExerciseDetailBinding.bind(view)
@@ -36,6 +36,16 @@ class ExerciseDetailFragment : BindingFragment(R.
binding.baseToolbar.apply {
setTitle(getString(R.string.exercise_detail))
showBackButton(true) { findNavController().navigateUp() }
+ setSecondarySubButton(R.drawable.ic_share) {
+ val record = currentRecord
+ if (record?.pictures.isNullOrEmpty()) {
+ Toast.makeText(requireContext(), R.string.share_image_required, Toast.LENGTH_SHORT).show()
+ } else {
+ findNavController().navigate(
+ ExerciseDetailFragmentDirections.actionExerciseDetailFragmentToExerciseShareEditFragment(args.recordId),
+ )
+ }
+ }
setSubButton(R.drawable.ic_menu) { showExerciseDetailMenu() }
}
}
@@ -53,6 +63,7 @@ class ExerciseDetailFragment : BindingFragment(R.
}
is UiState.Success -> {
binding.shimmerLayout.stopShimmer()
+ currentRecord = state.data
bindExerciseRecordData(state.data)
}
is UiState.Error -> {
@@ -138,7 +149,7 @@ class ExerciseDetailFragment : BindingFragment(R.
}
private fun showExerciseDetailMenu() {
- MenuBottomSheetDialog(
+ ExerciseMenuBottomSheetDialog(
onEditClicked = {
findNavController().navigate(
ExerciseDetailFragmentDirections
@@ -146,7 +157,7 @@ class ExerciseDetailFragment : BindingFragment(R.
)
},
onDeleteClicked = { showDeleteConfirmationDialog() },
- ).show(parentFragmentManager, MenuBottomSheetDialog::class.java.simpleName)
+ ).show(parentFragmentManager, ExerciseMenuBottomSheetDialog::class.java.simpleName)
}
private fun showDeleteConfirmationDialog() {
diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseMenuBottomSheetDialog.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseMenuBottomSheetDialog.kt
new file mode 100644
index 00000000..c272c6d3
--- /dev/null
+++ b/feature/exercise/src/main/java/com/project200/feature/exercise/detail/ExerciseMenuBottomSheetDialog.kt
@@ -0,0 +1,54 @@
+package com.project200.feature.exercise.detail
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.project200.undabang.feature.exercise.databinding.BottomSheetExerciseMenuBinding
+
+class ExerciseMenuBottomSheetDialog(
+ private val onEditClicked: () -> Unit,
+ private val onDeleteClicked: () -> Unit,
+) : BottomSheetDialogFragment() {
+ private var _binding: BottomSheetExerciseMenuBinding? = null
+ val binding get() = _binding!!
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setStyle(STYLE_NORMAL, com.project200.undabang.presentation.R.style.CustomBottomSheetDialogTheme)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ _binding = BottomSheetExerciseMenuBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.editBtn.setOnClickListener {
+ onEditClicked()
+ dismiss()
+ }
+
+ binding.deleteBtn.setOnClickListener {
+ onDeleteClicked()
+ dismiss()
+ }
+
+ binding.closeBtn.setOnClickListener { dismiss() }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/share/ExerciseShareEditFragment.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/share/ExerciseShareEditFragment.kt
new file mode 100644
index 00000000..40eda754
--- /dev/null
+++ b/feature/exercise/src/main/java/com/project200/feature/exercise/share/ExerciseShareEditFragment.kt
@@ -0,0 +1,149 @@
+package com.project200.feature.exercise.share
+
+import android.graphics.Bitmap
+import android.view.View
+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.bumptech.glide.Glide
+import com.project200.domain.model.ExerciseRecord
+import com.project200.feature.exercise.utils.ExerciseRecordStickerGenerator
+import com.project200.feature.exercise.utils.ExerciseShareHelper
+import com.project200.feature.exercise.utils.ShareEventData
+import com.project200.presentation.base.BindingFragment
+import com.project200.undabang.feature.exercise.R
+import com.project200.undabang.feature.exercise.databinding.FragmentExerciseShareEditBinding
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
+
+@AndroidEntryPoint
+class ExerciseShareEditFragment : BindingFragment(R.layout.fragment_exercise_share_edit) {
+ private val viewModel: ExerciseShareEditViewModel by viewModels()
+ private val args: ExerciseShareEditFragmentArgs by navArgs()
+
+ private var currentStickerBitmap: Bitmap? = null
+
+ override fun getViewBinding(view: View): FragmentExerciseShareEditBinding {
+ return FragmentExerciseShareEditBinding.bind(view)
+ }
+
+ override fun setupViews() {
+ viewModel.loadExerciseRecord(args.recordId)
+
+ binding.themeDarkBtn.setOnClickListener {
+ viewModel.selectTheme(StickerTheme.DARK)
+ }
+ binding.themeLightBtn.setOnClickListener {
+ viewModel.selectTheme(StickerTheme.LIGHT)
+ }
+ binding.themeMinimalBtn.setOnClickListener {
+ viewModel.selectTheme(StickerTheme.MINIMAL)
+ }
+
+ binding.cancelBtn.setOnClickListener {
+ findNavController().navigateUp()
+ }
+ binding.shareBtn.setOnClickListener {
+ viewModel.requestShare(binding.stickerPreview.getTransformInfo())
+ }
+ }
+
+ override fun setupObservers() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ launch {
+ viewModel.backgroundImageUrl.collect { url ->
+ url?.let { loadBackgroundImage(it) }
+ }
+ }
+
+ launch {
+ viewModel.selectedTheme.collect { theme ->
+ updateThemeButtonSelection(theme)
+ }
+ }
+
+ launch {
+ viewModel.stickerState.collect { state ->
+ updateStickerPreview(state.record, state.theme)
+ }
+ }
+
+ launch {
+ viewModel.isLoading.collect { isLoading ->
+ binding.loadingOverlay.visibility = if (isLoading) View.VISIBLE else View.GONE
+ }
+ }
+
+ launch {
+ viewModel.shareEvent.collect { data ->
+ shareImage(data)
+ }
+ }
+ }
+ }
+ }
+
+ private fun loadBackgroundImage(url: String) {
+ Glide.with(this)
+ .load(url)
+ .centerCrop()
+ .into(binding.backgroundImage)
+ }
+
+ private fun updateThemeButtonSelection(theme: StickerTheme) {
+ val selectedAlpha = 1.0f
+ val unselectedAlpha = 0.4f
+
+ binding.themeDarkBtn.alpha = if (theme == StickerTheme.DARK) selectedAlpha else unselectedAlpha
+ binding.themeLightBtn.alpha = if (theme == StickerTheme.LIGHT) selectedAlpha else unselectedAlpha
+ binding.themeMinimalBtn.alpha = if (theme == StickerTheme.MINIMAL) selectedAlpha else unselectedAlpha
+ }
+
+ private fun updateStickerPreview(
+ record: ExerciseRecord,
+ theme: StickerTheme,
+ ) {
+ val currentTransform = binding.stickerPreview.getTransformInfo()
+ val hasUserTransform = binding.stickerPreview.hasUserInteracted()
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ currentStickerBitmap?.recycle()
+ currentStickerBitmap =
+ ExerciseRecordStickerGenerator.generateStickerBitmap(
+ requireContext(),
+ record,
+ theme,
+ )
+
+ if (hasUserTransform) {
+ binding.stickerPreview.setPendingTransform(currentTransform)
+ }
+ binding.stickerPreview.setImageBitmap(currentStickerBitmap)
+ }
+ }
+
+ private fun shareImage(data: ShareEventData) {
+ viewLifecycleOwner.lifecycleScope.launch {
+ try {
+ ExerciseShareHelper.shareExerciseRecord(
+ requireContext(),
+ data.record,
+ data.theme,
+ data.transformInfo,
+ )
+ } finally {
+ viewModel.onShareCompleted()
+ }
+ }
+ }
+
+ override fun onDestroyView() {
+ currentStickerBitmap?.recycle()
+ currentStickerBitmap = null
+ super.onDestroyView()
+ }
+}
diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/share/ExerciseShareEditViewModel.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/share/ExerciseShareEditViewModel.kt
new file mode 100644
index 00000000..53444b01
--- /dev/null
+++ b/feature/exercise/src/main/java/com/project200/feature/exercise/share/ExerciseShareEditViewModel.kt
@@ -0,0 +1,80 @@
+package com.project200.feature.exercise.share
+
+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.GetExerciseRecordDetailUseCase
+import com.project200.feature.exercise.utils.ShareEventData
+import com.project200.feature.exercise.utils.StickerState
+import com.project200.feature.exercise.utils.StickerTransformInfo
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class ExerciseShareEditViewModel
+ @Inject
+ constructor(
+ private val getExerciseRecordDetailUseCase: GetExerciseRecordDetailUseCase,
+ ) : ViewModel() {
+ private val _selectedTheme = MutableStateFlow(StickerTheme.DARK)
+ val selectedTheme: StateFlow = _selectedTheme.asStateFlow()
+
+ private val _exerciseRecord = MutableStateFlow(null)
+ val exerciseRecord: StateFlow = _exerciseRecord.asStateFlow()
+
+ private val _backgroundImageUrl = MutableStateFlow(null)
+ val backgroundImageUrl: StateFlow = _backgroundImageUrl.asStateFlow()
+
+ private val _isLoading = MutableStateFlow(true)
+ val isLoading: StateFlow = _isLoading.asStateFlow()
+
+ private val _shareEvent = MutableSharedFlow()
+ val shareEvent: SharedFlow = _shareEvent.asSharedFlow()
+
+ val stickerState: Flow =
+ _exerciseRecord
+ .filterNotNull()
+ .combine(_selectedTheme) { record, theme -> StickerState(record, theme) }
+
+ fun loadExerciseRecord(recordId: Long) {
+ viewModelScope.launch {
+ _isLoading.value = true
+ when (val result = getExerciseRecordDetailUseCase(recordId)) {
+ is BaseResult.Success -> {
+ _exerciseRecord.value = result.data
+ _backgroundImageUrl.value = result.data.pictures?.firstOrNull()?.url
+ }
+ is BaseResult.Error -> {
+ }
+ }
+ _isLoading.value = false
+ }
+ }
+
+ fun selectTheme(theme: StickerTheme) {
+ _selectedTheme.value = theme
+ }
+
+ fun requestShare(transformInfo: StickerTransformInfo) {
+ val record = _exerciseRecord.value ?: return
+ viewModelScope.launch {
+ _isLoading.value = true
+ _shareEvent.emit(ShareEventData(record, _selectedTheme.value, transformInfo))
+ }
+ }
+
+ fun onShareCompleted() {
+ _isLoading.value = false
+ }
+ }
diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/share/StickerTheme.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/share/StickerTheme.kt
new file mode 100644
index 00000000..99ca50da
--- /dev/null
+++ b/feature/exercise/src/main/java/com/project200/feature/exercise/share/StickerTheme.kt
@@ -0,0 +1,30 @@
+package com.project200.feature.exercise.share
+
+import androidx.annotation.DrawableRes
+import com.project200.undabang.feature.exercise.R
+
+enum class StickerTheme(
+ @DrawableRes val backgroundDrawable: Int?,
+ val textColorRes: Int,
+ val subTextColorRes: Int,
+ val showMascot: Boolean,
+) {
+ DARK(
+ backgroundDrawable = R.drawable.bg_sticker_dark,
+ textColorRes = android.R.color.white,
+ subTextColorRes = com.project200.undabang.presentation.R.color.gray200,
+ showMascot = true,
+ ),
+ LIGHT(
+ backgroundDrawable = R.drawable.bg_sticker_light,
+ textColorRes = android.R.color.black,
+ subTextColorRes = com.project200.undabang.presentation.R.color.gray100,
+ showMascot = true,
+ ),
+ MINIMAL(
+ backgroundDrawable = null,
+ textColorRes = android.R.color.black,
+ subTextColorRes = com.project200.undabang.presentation.R.color.gray100,
+ showMascot = false,
+ ),
+}
diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/share/TransformableImageView.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/share/TransformableImageView.kt
new file mode 100644
index 00000000..6dd8de9a
--- /dev/null
+++ b/feature/exercise/src/main/java/com/project200/feature/exercise/share/TransformableImageView.kt
@@ -0,0 +1,339 @@
+package com.project200.feature.exercise.share
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.graphics.Matrix
+import android.graphics.PointF
+import android.util.AttributeSet
+import android.view.MotionEvent
+import android.view.ScaleGestureDetector
+import androidx.appcompat.widget.AppCompatImageView
+import com.project200.feature.exercise.utils.StickerTransformInfo
+
+/**
+ * 드래그, 핀치줌, 회전을 지원하는 커스텀 ImageView
+ *
+ * 스티커 편집 화면에서 사용자가 스티커의 위치, 크기, 회전을 조정할 수 있도록 함.
+ *
+ * 초기화 동작:
+ * 1. 이미지가 설정되면 부모 뷰 너비의 45% 크기로 자동 스케일링
+ * 2. 좌상단에서 5% 마진 위치에 배치
+ * 3. ScaleType.MATRIX 모드로 전환하여 직접 변환 제어
+ *
+ * 터치 제스처:
+ * - 한 손가락 드래그: 위치 이동
+ * - 두 손가락 핀치: 크기 조절 (초기 크기의 30% ~ 300%)
+ * - 두 손가락 회전: 회전 조절
+ *
+ * @see StickerTransformInfo 현재 변환 정보를 담는 data class
+ * @see ExerciseShareHelper 이 뷰의 변환 정보를 사용하여 최종 이미지 생성
+ */
+class TransformableImageView
+ @JvmOverloads
+ constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+ ) : AppCompatImageView(context, attrs, defStyleAttr) {
+ private val transformMatrix = Matrix()
+ private val savedMatrix = Matrix()
+
+ // 터치 모드 상태
+ private var mode = NONE
+ private val startPoint = PointF()
+ private val midPoint = PointF()
+
+ // 스케일 관련 변수
+ private var currentScale = 1f
+ private var savedScale = 1f
+ private var initialScale = 1f
+
+ // 스케일 제한: 초기 크기 대비 30% ~ 300%
+ private val minScaleRatio = 0.3f
+ private val maxScaleRatio = 3.0f
+
+ // 위치 (픽셀 단위)
+ private var translationX = 0f
+ private var translationY = 0f
+
+ // 회전 (도 단위)
+ private var currentRotation = 0f
+ private var savedRotation = 0f
+ private var startAngle = 0f
+
+ // 초기화 상태
+ private var isInitialized = false
+ private var hasUserInteracted = false
+ private var pendingTransform: StickerTransformInfo? = null
+
+ // 기본 설정: 부모 너비의 45% 크기, 5% 마진
+ private val defaultWidthRatio = 0.45f
+ private val defaultMarginRatio = 0.05f
+
+ /**
+ * 핀치줌 제스처 감지기
+ * 두 손가락으로 확대/축소 시 스케일 값을 조정
+ */
+ private val scaleDetector =
+ ScaleGestureDetector(
+ context,
+ object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
+ override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
+ savedScale = currentScale
+ return true
+ }
+
+ override fun onScale(detector: ScaleGestureDetector): Boolean {
+ val scaleFactor = detector.scaleFactor
+ val newScale = currentScale * scaleFactor
+
+ val minScale = initialScale * minScaleRatio
+ val maxScale = initialScale * maxScaleRatio
+
+ currentScale = newScale.coerceIn(minScale, maxScale)
+ applyTransform()
+ return true
+ }
+ },
+ )
+
+ private var onTransformChangedListener: OnTransformChangedListener? = null
+
+ interface OnTransformChangedListener {
+ fun onTransformChanged(
+ translationXRatio: Float,
+ translationYRatio: Float,
+ scale: Float,
+ )
+ }
+
+ fun setOnTransformChangedListener(listener: OnTransformChangedListener) {
+ onTransformChangedListener = listener
+ }
+
+ /**
+ * 비트맵 설정 시 초기화 플래그 리셋 후 변환 초기화 시도
+ */
+ override fun setImageBitmap(bm: android.graphics.Bitmap?) {
+ isInitialized = false
+ super.setImageBitmap(bm)
+ if (bm != null && width > 0 && height > 0) {
+ initializeTransform()
+ }
+ }
+
+ /**
+ * 뷰 크기 변경 시 초기화되지 않은 상태면 변환 초기화
+ */
+ override fun onSizeChanged(
+ w: Int,
+ h: Int,
+ oldw: Int,
+ oldh: Int,
+ ) {
+ super.onSizeChanged(w, h, oldw, oldh)
+ if (drawable != null && !isInitialized && w > 0 && h > 0) {
+ initializeTransform()
+ }
+ }
+
+ /**
+ * 초기 변환 설정
+ * - 스케일: 부모 너비의 45%가 되도록 계산
+ * - 위치: 좌상단에서 5% 마진
+ * - pendingTransform이 있으면 해당 값으로 복원
+ */
+ private fun initializeTransform() {
+ val drawableWidth = drawable?.intrinsicWidth ?: return
+ if (drawableWidth <= 0) return
+
+ val parentView = parent as? android.view.View ?: return
+ val parentWidth = parentView.width.toFloat()
+ val parentHeight = parentView.height.toFloat()
+ if (parentWidth <= 0 || parentHeight <= 0) return
+
+ val pending = pendingTransform
+ if (pending != null) {
+ val targetStickerWidth = parentWidth * pending.stickerWidthRatio
+ currentScale = targetStickerWidth / drawableWidth
+ savedScale = currentScale
+ initialScale = parentWidth * defaultWidthRatio / drawableWidth
+
+ translationX = pending.translationXRatio * parentWidth
+ translationY = pending.translationYRatio * parentHeight
+ currentRotation = pending.rotationDegrees
+ savedRotation = currentRotation
+
+ pendingTransform = null
+ } else {
+ val targetWidth = parentWidth * defaultWidthRatio
+ initialScale = targetWidth / drawableWidth
+ currentScale = initialScale
+ savedScale = initialScale
+
+ translationX = parentWidth * defaultMarginRatio
+ translationY = parentHeight * defaultMarginRatio
+ currentRotation = 0f
+ savedRotation = 0f
+ }
+
+ scaleType = ScaleType.MATRIX
+ applyTransform()
+ isInitialized = true
+ }
+
+ /**
+ * 터치 이벤트 처리
+ * - ACTION_DOWN: 드래그 시작점 저장
+ * - ACTION_POINTER_DOWN: 핀치줌 모드 전환
+ * - ACTION_MOVE: 드래그 시 위치 업데이트
+ * - ACTION_UP: 모드 리셋 및 리스너 콜백
+ */
+ @SuppressLint("ClickableViewAccessibility")
+ override fun onTouchEvent(event: MotionEvent): Boolean {
+ scaleDetector.onTouchEvent(event)
+
+ when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN -> {
+ savedMatrix.set(transformMatrix)
+ startPoint.set(event.x, event.y)
+ mode = DRAG
+ }
+
+ MotionEvent.ACTION_POINTER_DOWN -> {
+ if (event.pointerCount == 2) {
+ savedScale = currentScale
+ savedRotation = currentRotation
+ midPoint.set(
+ (event.getX(0) + event.getX(1)) / 2,
+ (event.getY(0) + event.getY(1)) / 2,
+ )
+ startAngle = calculateAngle(event)
+ mode = ZOOM
+ }
+ }
+
+ MotionEvent.ACTION_MOVE -> {
+ if (mode == DRAG && event.pointerCount == 1) {
+ val dx = event.x - startPoint.x
+ val dy = event.y - startPoint.y
+
+ translationX += dx
+ translationY += dy
+
+ startPoint.set(event.x, event.y)
+ applyTransform()
+ } else if (mode == ZOOM && event.pointerCount == 2) {
+ val currentAngle = calculateAngle(event)
+ currentRotation = savedRotation + (currentAngle - startAngle)
+ applyTransform()
+ }
+ }
+
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> {
+ mode = NONE
+ hasUserInteracted = true
+ notifyTransformChanged()
+ }
+ }
+
+ return true
+ }
+
+ /**
+ * 현재 스케일, 회전, 위치를 Matrix에 적용
+ * 회전은 이미지 중심을 기준으로 적용
+ */
+ private fun applyTransform() {
+ val drawableWidth = drawable?.intrinsicWidth?.toFloat() ?: 0f
+ val drawableHeight = drawable?.intrinsicHeight?.toFloat() ?: 0f
+
+ val scaledCenterX = (drawableWidth * currentScale) / 2
+ val scaledCenterY = (drawableHeight * currentScale) / 2
+
+ transformMatrix.reset()
+ transformMatrix.postScale(currentScale, currentScale)
+ transformMatrix.postRotate(currentRotation, scaledCenterX, scaledCenterY)
+ transformMatrix.postTranslate(translationX, translationY)
+ imageMatrix = transformMatrix
+ scaleType = ScaleType.MATRIX
+ }
+
+ private fun calculateAngle(event: MotionEvent): Float {
+ val dx = event.getX(1) - event.getX(0)
+ val dy = event.getY(1) - event.getY(0)
+ return Math.toDegrees(kotlin.math.atan2(dy.toDouble(), dx.toDouble())).toFloat()
+ }
+
+ private fun notifyTransformChanged() {
+ val parent = parent as? android.view.View ?: return
+ val parentWidth = parent.width.toFloat()
+ val parentHeight = parent.height.toFloat()
+
+ if (parentWidth > 0 && parentHeight > 0) {
+ val xRatio = translationX / parentWidth
+ val yRatio = translationY / parentHeight
+ onTransformChangedListener?.onTransformChanged(xRatio, yRatio, currentScale)
+ }
+ }
+
+ /**
+ * 현재 변환 정보를 비율 기반으로 반환
+ * ExerciseShareHelper에서 최종 이미지 생성 시 사용
+ *
+ * @return 위치(비율)와 스티커 너비 비율을 담은 StickerTransformInfo
+ */
+ fun getTransformInfo(): StickerTransformInfo {
+ val parent = parent as? android.view.View
+ val parentWidth = parent?.width?.toFloat() ?: 1f
+ val parentHeight = parent?.height?.toFloat() ?: 1f
+ val drawableWidth = drawable?.intrinsicWidth?.toFloat() ?: 1f
+
+ val stickerVisualWidth = drawableWidth * currentScale
+ val widthRatio = if (parentWidth > 0) stickerVisualWidth / parentWidth else defaultWidthRatio
+
+ return StickerTransformInfo(
+ translationXRatio = if (parentWidth > 0) translationX / parentWidth else 0f,
+ translationYRatio = if (parentHeight > 0) translationY / parentHeight else 0f,
+ stickerWidthRatio = widthRatio,
+ rotationDegrees = currentRotation,
+ )
+ }
+
+ /**
+ * 외부에서 변환 값을 직접 설정
+ * 저장된 상태 복원 시 사용 가능
+ */
+ fun setInitialTransform(
+ xRatio: Float,
+ yRatio: Float,
+ scale: Float,
+ ) {
+ post {
+ val parent = parent as? android.view.View ?: return@post
+ translationX = xRatio * parent.width
+ translationY = yRatio * parent.height
+ currentScale = scale
+ savedScale = scale
+ applyTransform()
+ }
+ }
+
+ /**
+ * 사용자가 드래그/핀치줌으로 조작했는지 여부
+ */
+ fun hasUserInteracted(): Boolean = hasUserInteracted
+
+ /**
+ * 비트맵 설정 전에 호출하여 초기화 시 해당 위치로 바로 설정
+ */
+ fun setPendingTransform(transformInfo: StickerTransformInfo) {
+ pendingTransform = transformInfo
+ }
+
+ companion object {
+ private const val NONE = 0
+ private const val DRAG = 1
+ private const val ZOOM = 2
+ }
+ }
diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/utils/ExerciseRecordStickerGenerator.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/utils/ExerciseRecordStickerGenerator.kt
new file mode 100644
index 00000000..4ed3ec9e
--- /dev/null
+++ b/feature/exercise/src/main/java/com/project200/feature/exercise/utils/ExerciseRecordStickerGenerator.kt
@@ -0,0 +1,158 @@
+package com.project200.feature.exercise.utils
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Path
+import android.graphics.RectF
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.content.ContextCompat
+import androidx.core.graphics.createBitmap
+import androidx.core.graphics.withClip
+import com.project200.domain.model.ExerciseRecord
+import com.project200.feature.exercise.share.StickerTheme
+import com.project200.undabang.feature.exercise.R
+import java.time.Duration
+import java.time.LocalDateTime
+import java.time.format.TextStyle
+import java.util.Locale
+
+object ExerciseRecordStickerGenerator {
+ private const val CORNER_RADIUS_DP = 20f
+
+ fun generateStickerBitmap(
+ context: Context,
+ record: ExerciseRecord,
+ theme: StickerTheme = StickerTheme.DARK,
+ ): Bitmap {
+ val inflater = LayoutInflater.from(context)
+ val stickerView = inflater.inflate(R.layout.layout_exercise_sticker, null, false)
+
+ applyTheme(context, stickerView, theme)
+ bindRecordToView(stickerView, record)
+
+ val rawBitmap = convertViewToBitmap(stickerView)
+ return applyRoundedClipping(context, rawBitmap)
+ }
+
+ private fun applyTheme(
+ context: Context,
+ view: View,
+ theme: StickerTheme,
+ ) {
+ val root = view.findViewById(R.id.sticker_root)
+ if (theme.backgroundDrawable != null) {
+ root.setBackgroundResource(theme.backgroundDrawable)
+ } else {
+ root.background = null
+ }
+
+ val textColor = ContextCompat.getColor(context, theme.textColorRes)
+ val subTextColor = ContextCompat.getColor(context, theme.subTextColorRes)
+
+ view.findViewById(R.id.sticker_total_time_label).setTextColor(subTextColor)
+ view.findViewById(R.id.sticker_time_value).setTextColor(textColor)
+ view.findViewById(R.id.sticker_exercise_info).setTextColor(textColor)
+ view.findViewById(R.id.sticker_type_text).setTextColor(textColor)
+ view.findViewById(R.id.sticker_location_text).setTextColor(textColor)
+
+ val mascot = view.findViewById(R.id.sticker_mascot)
+ mascot.visibility = if (theme.showMascot) View.VISIBLE else View.GONE
+ }
+
+ private fun bindRecordToView(
+ view: View,
+ record: ExerciseRecord,
+ ) {
+ val timeValue = view.findViewById(R.id.sticker_time_value)
+ timeValue.text = formatDuration(record.startedAt, record.endedAt)
+
+ val exerciseInfo = view.findViewById(R.id.sticker_exercise_info)
+ val dayOfWeek = record.startedAt.dayOfWeek.getDisplayName(TextStyle.FULL, Locale.KOREAN)
+ exerciseInfo.text = "$dayOfWeek ${record.title}"
+
+ val typeText = view.findViewById(R.id.sticker_type_text)
+ val typeIcon = view.findViewById(R.id.sticker_type_icon)
+ if (record.personalType.isNotBlank()) {
+ typeText.text = record.personalType
+ typeText.visibility = View.VISIBLE
+ typeIcon.visibility = View.VISIBLE
+ } else {
+ typeText.visibility = View.GONE
+ typeIcon.visibility = View.GONE
+ }
+
+ val locationText = view.findViewById(R.id.sticker_location_text)
+ val locationIcon = view.findViewById(R.id.sticker_location_icon)
+ if (record.location.isNotBlank()) {
+ locationText.text = record.location
+ locationText.visibility = View.VISIBLE
+ locationIcon.visibility = View.VISIBLE
+ } else {
+ locationText.visibility = View.GONE
+ locationIcon.visibility = View.GONE
+ }
+ }
+
+ private fun formatDuration(
+ startedAt: LocalDateTime,
+ endedAt: LocalDateTime,
+ ): String {
+ val duration = Duration.between(startedAt, endedAt)
+ val totalMinutes = duration.toMinutes()
+ val hours = totalMinutes / 60
+ val minutes = totalMinutes % 60
+ return String.format(Locale.getDefault(), "%02d:%02d", hours, minutes)
+ }
+
+ private fun convertViewToBitmap(view: View): Bitmap {
+ view.measure(
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
+ )
+
+ view.layout(0, 0, view.measuredWidth, view.measuredHeight)
+
+ val bitmap = createBitmap(view.measuredWidth, view.measuredHeight)
+
+ val canvas = Canvas(bitmap)
+ view.draw(canvas)
+
+ return bitmap
+ }
+
+ private fun applyRoundedClipping(
+ context: Context,
+ source: Bitmap,
+ ): Bitmap {
+ val density = context.resources.displayMetrics.density
+ val cornerRadius = CORNER_RADIUS_DP * density
+
+ val width = source.width
+ val height = source.height
+
+ val output = createBitmap(width, height)
+ val canvas = Canvas(output)
+
+ val clipPath =
+ Path().apply {
+ addRoundRect(
+ RectF(0f, 0f, width.toFloat(), height.toFloat()),
+ cornerRadius,
+ cornerRadius,
+ Path.Direction.CW,
+ )
+ }
+
+ canvas.withClip(clipPath) {
+ drawBitmap(source, 0f, 0f, null)
+ }
+
+ source.recycle()
+ return output
+ }
+}
diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/utils/ExerciseShareHelper.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/utils/ExerciseShareHelper.kt
new file mode 100644
index 00000000..84e38bcd
--- /dev/null
+++ b/feature/exercise/src/main/java/com/project200/feature/exercise/utils/ExerciseShareHelper.kt
@@ -0,0 +1,229 @@
+package com.project200.feature.exercise.utils
+
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Matrix
+import android.graphics.Paint
+import android.net.Uri
+import androidx.core.content.FileProvider
+import androidx.core.graphics.createBitmap
+import androidx.core.graphics.scale
+import com.bumptech.glide.Glide
+import com.project200.domain.model.ExerciseRecord
+import com.project200.feature.exercise.share.StickerTheme
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
+
+/**
+ * 운동 기록을 이미지로 공유하는 헬퍼 클래스
+ *
+ * 공유 이미지 생성 과정:
+ * 1. 배경 이미지 로드 (운동 기록의 첫 번째 사진)
+ * 2. 스티커 생성 (운동 시간, 정보 등)
+ * 3. 배경 위에 스티커 합성
+ * 4. 인스타그램 호환 비율로 조정 (여백 추가)
+ * 5. 시스템 공유 인텐트 실행
+ */
+object ExerciseShareHelper {
+ private const val SHARE_IMAGE_FILE_NAME = "exercise_share.jpg"
+ private const val FILE_PROVIDER_AUTHORITY_SUFFIX = ".fileprovider"
+
+ // 스티커 크기: 배경 이미지 너비의 45%
+ private const val DEFAULT_STICKER_WIDTH_RATIO = 0.45f
+
+ // 인스타그램 지원 비율
+ // 세로: 4:5 (0.8), 가로: 1.91:1
+ private const val INSTAGRAM_PORTRAIT_RATIO = 4f / 5f
+ private const val INSTAGRAM_LANDSCAPE_RATIO = 1.91f
+
+ // 공유 이미지 최대 크기 (성능 최적화)
+ private const val MAX_IMAGE_DIMENSION = 1080
+
+ suspend fun shareExerciseRecord(
+ context: Context,
+ record: ExerciseRecord,
+ theme: StickerTheme = StickerTheme.DARK,
+ transformInfo: StickerTransformInfo? = null,
+ ) {
+ val backgroundImageUrl = record.pictures?.firstOrNull()?.url
+
+ // 배경 이미지와 스티커를 병렬로 준비
+ val backgroundBitmap =
+ withContext(Dispatchers.IO) {
+ backgroundImageUrl?.let { loadBitmapFromUrl(context, it) }
+ }
+
+ val stickerBitmap =
+ withContext(Dispatchers.Main) {
+ ExerciseRecordStickerGenerator.generateStickerBitmap(context, record, theme)
+ }
+
+ val imageUri =
+ withContext(Dispatchers.IO) {
+ val combinedBitmap = createCombinedImage(stickerBitmap, backgroundBitmap, transformInfo)
+ val instagramReadyBitmap = adjustToInstagramRatio(combinedBitmap)
+ saveBitmapToCache(context, instagramReadyBitmap)
+ }
+
+ val intent =
+ Intent(Intent.ACTION_SEND).apply {
+ type = "image/*"
+ putExtra(Intent.EXTRA_STREAM, imageUri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+
+ context.startActivity(Intent.createChooser(intent, null))
+ }
+
+ /**
+ * 배경 이미지 위에 스티커를 합성
+ * 스티커는 위치, 크기, 회전 정보에 따라 배치됨
+ */
+ private fun createCombinedImage(
+ stickerBitmap: Bitmap,
+ backgroundBitmap: Bitmap?,
+ transformInfo: StickerTransformInfo?,
+ ): Bitmap {
+ if (backgroundBitmap == null) {
+ return stickerBitmap
+ }
+
+ val resultBitmap = backgroundBitmap.copy(Bitmap.Config.ARGB_8888, true)
+ val canvas = Canvas(resultBitmap)
+
+ val widthRatio = transformInfo?.stickerWidthRatio ?: DEFAULT_STICKER_WIDTH_RATIO
+ val targetStickerWidth = backgroundBitmap.width * widthRatio
+ val finalScale = targetStickerWidth / stickerBitmap.width
+
+ val scaledStickerWidth = (stickerBitmap.width * finalScale).toInt().coerceAtLeast(1)
+ val scaledStickerHeight = (stickerBitmap.height * finalScale).toInt().coerceAtLeast(1)
+
+ val scaledSticker = stickerBitmap.scale(scaledStickerWidth, scaledStickerHeight)
+
+ val posX = (transformInfo?.translationXRatio ?: 0f) * backgroundBitmap.width
+ val posY = (transformInfo?.translationYRatio ?: 0f) * backgroundBitmap.height
+ val rotation = transformInfo?.rotationDegrees ?: 0f
+
+ val matrix = Matrix()
+ matrix.postRotate(rotation, scaledStickerWidth / 2f, scaledStickerHeight / 2f)
+ matrix.postTranslate(posX, posY)
+
+ canvas.drawBitmap(scaledSticker, matrix, Paint(Paint.FILTER_BITMAP_FLAG))
+
+ scaledSticker.recycle()
+ return resultBitmap
+ }
+
+ /**
+ * 인스타그램 호환 비율로 이미지 조정
+ *
+ * 인스타그램 지원 비율:
+ * - 정사각형: 1:1
+ * - 세로: 4:5 (최대)
+ * - 가로: 1.91:1 (최대)
+ *
+ * 비율을 벗어나는 이미지는 검정 여백을 추가하여 조정
+ */
+ private fun adjustToInstagramRatio(source: Bitmap): Bitmap {
+ val sourceWidth = source.width.toFloat()
+ val sourceHeight = source.height.toFloat()
+ val sourceRatio = sourceWidth / sourceHeight
+
+ val isPortrait = sourceHeight >= sourceWidth
+
+ val targetRatio =
+ if (isPortrait) {
+ INSTAGRAM_PORTRAIT_RATIO
+ } else {
+ INSTAGRAM_LANDSCAPE_RATIO
+ }
+
+ // 이미 인스타그램 호환 비율인지 확인
+ val isWithinInstagramBounds =
+ if (isPortrait) {
+ sourceRatio >= INSTAGRAM_PORTRAIT_RATIO
+ } else {
+ sourceRatio <= INSTAGRAM_LANDSCAPE_RATIO
+ }
+
+ if (isWithinInstagramBounds) {
+ return source
+ }
+
+ // 새 캔버스 크기 계산
+ // 세로 이미지인 경우 너비는 동일하게, 높이는 비율에 맞게 조정
+ val newWidth: Int
+ val newHeight: Int
+
+ if (isPortrait) {
+ newWidth = sourceWidth.toInt()
+ newHeight = (sourceWidth / targetRatio).toInt()
+ } else {
+ newWidth = (sourceHeight * targetRatio).toInt()
+ newHeight = sourceHeight.toInt()
+ }
+
+ val resultBitmap = createBitmap(newWidth, newHeight)
+ val canvas = Canvas(resultBitmap)
+
+ canvas.drawColor(Color.BLACK)
+
+ canvas.drawBitmap(source, 0f, 0f, null)
+
+ if (source !== resultBitmap) {
+ source.recycle()
+ }
+
+ return resultBitmap
+ }
+
+ /**
+ * URL에서 이미지 로드
+ * 성능 최적화를 위해 최대 크기 제한 적용
+ */
+ private fun loadBitmapFromUrl(
+ context: Context,
+ url: String,
+ ): Bitmap? {
+ return try {
+ Glide.with(context)
+ .asBitmap()
+ .load(url)
+ .override(MAX_IMAGE_DIMENSION)
+ .submit()
+ .get()
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ /**
+ * 비트맵을 캐시에 저장하고 공유 가능한 URI 반환
+ * JPEG 형식으로 저장하여 파일 크기 및 저장 속도 최적화
+ */
+ private fun saveBitmapToCache(
+ context: Context,
+ bitmap: Bitmap,
+ ): Uri {
+ val cacheDir = File(context.cacheDir, "share")
+ if (!cacheDir.exists()) {
+ cacheDir.mkdirs()
+ }
+
+ val file = File(cacheDir, SHARE_IMAGE_FILE_NAME)
+ FileOutputStream(file).use { stream ->
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream)
+ }
+
+ return FileProvider.getUriForFile(
+ context,
+ context.packageName + FILE_PROVIDER_AUTHORITY_SUFFIX,
+ file,
+ )
+ }
+}
diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/utils/ShareEventData.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/utils/ShareEventData.kt
new file mode 100644
index 00000000..0e35feb9
--- /dev/null
+++ b/feature/exercise/src/main/java/com/project200/feature/exercise/utils/ShareEventData.kt
@@ -0,0 +1,15 @@
+package com.project200.feature.exercise.utils
+
+import com.project200.domain.model.ExerciseRecord
+import com.project200.feature.exercise.share.StickerTheme
+
+data class ShareEventData(
+ val record: ExerciseRecord,
+ val theme: StickerTheme,
+ val transformInfo: StickerTransformInfo,
+)
+
+data class StickerState(
+ val record: ExerciseRecord,
+ val theme: StickerTheme,
+)
diff --git a/feature/exercise/src/main/java/com/project200/feature/exercise/utils/StickerTransformInfo.kt b/feature/exercise/src/main/java/com/project200/feature/exercise/utils/StickerTransformInfo.kt
new file mode 100644
index 00000000..fccd0c9c
--- /dev/null
+++ b/feature/exercise/src/main/java/com/project200/feature/exercise/utils/StickerTransformInfo.kt
@@ -0,0 +1,17 @@
+package com.project200.feature.exercise.utils
+
+/**
+ * 스티커 변환 정보
+ * 모든 값은 부모 뷰 크기 대비 비율로 저장되어 해상도 독립적
+ *
+ * @property translationXRatio X 위치 (부모 너비 대비 비율, 0.0 = 왼쪽 끝)
+ * @property translationYRatio Y 위치 (부모 높이 대비 비율, 0.0 = 위쪽 끝)
+ * @property stickerWidthRatio 스티커 너비 (부모 너비 대비 비율, 0.45 = 45%)
+ * @property rotationDegrees 회전 각도 (도 단위, 0.0 = 회전 없음)
+ */
+data class StickerTransformInfo(
+ val translationXRatio: Float,
+ val translationYRatio: Float,
+ val stickerWidthRatio: Float,
+ val rotationDegrees: Float = 0f,
+)
diff --git a/feature/exercise/src/main/res/drawable/bg_sticker_dark.xml b/feature/exercise/src/main/res/drawable/bg_sticker_dark.xml
new file mode 100644
index 00000000..97ebd261
--- /dev/null
+++ b/feature/exercise/src/main/res/drawable/bg_sticker_dark.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/feature/exercise/src/main/res/drawable/bg_sticker_light.xml b/feature/exercise/src/main/res/drawable/bg_sticker_light.xml
new file mode 100644
index 00000000..81273d06
--- /dev/null
+++ b/feature/exercise/src/main/res/drawable/bg_sticker_light.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/feature/exercise/src/main/res/drawable/bg_sticker_minimal.xml b/feature/exercise/src/main/res/drawable/bg_sticker_minimal.xml
new file mode 100644
index 00000000..2c37cc83
--- /dev/null
+++ b/feature/exercise/src/main/res/drawable/bg_sticker_minimal.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/feature/exercise/src/main/res/drawable/bg_theme_button_dark.xml b/feature/exercise/src/main/res/drawable/bg_theme_button_dark.xml
new file mode 100644
index 00000000..ca0c52fa
--- /dev/null
+++ b/feature/exercise/src/main/res/drawable/bg_theme_button_dark.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/feature/exercise/src/main/res/drawable/bg_theme_button_light.xml b/feature/exercise/src/main/res/drawable/bg_theme_button_light.xml
new file mode 100644
index 00000000..623a20c0
--- /dev/null
+++ b/feature/exercise/src/main/res/drawable/bg_theme_button_light.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/feature/exercise/src/main/res/drawable/bg_theme_button_minimal.xml b/feature/exercise/src/main/res/drawable/bg_theme_button_minimal.xml
new file mode 100644
index 00000000..90153a93
--- /dev/null
+++ b/feature/exercise/src/main/res/drawable/bg_theme_button_minimal.xml
@@ -0,0 +1,20 @@
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+
+
+
diff --git a/feature/exercise/src/main/res/drawable/ic_share.xml b/feature/exercise/src/main/res/drawable/ic_share.xml
new file mode 100644
index 00000000..d0782bee
--- /dev/null
+++ b/feature/exercise/src/main/res/drawable/ic_share.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/feature/exercise/src/main/res/drawable/ic_sticker_location.xml b/feature/exercise/src/main/res/drawable/ic_sticker_location.xml
new file mode 100644
index 00000000..2693ea23
--- /dev/null
+++ b/feature/exercise/src/main/res/drawable/ic_sticker_location.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/feature/exercise/src/main/res/drawable/ic_undabang_character.xml b/feature/exercise/src/main/res/drawable/ic_undabang_character.xml
new file mode 100644
index 00000000..d38bffbe
--- /dev/null
+++ b/feature/exercise/src/main/res/drawable/ic_undabang_character.xml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/feature/exercise/src/main/res/font/nanumsquareneo_alt.ttf b/feature/exercise/src/main/res/font/nanumsquareneo_alt.ttf
new file mode 100644
index 00000000..59461879
Binary files /dev/null and b/feature/exercise/src/main/res/font/nanumsquareneo_alt.ttf differ
diff --git a/feature/exercise/src/main/res/font/nanumsquareneo_brg.ttf b/feature/exercise/src/main/res/font/nanumsquareneo_brg.ttf
new file mode 100644
index 00000000..8680fb38
Binary files /dev/null and b/feature/exercise/src/main/res/font/nanumsquareneo_brg.ttf differ
diff --git a/feature/exercise/src/main/res/font/nanumsquareneo_cbd.ttf b/feature/exercise/src/main/res/font/nanumsquareneo_cbd.ttf
new file mode 100644
index 00000000..ccde7f70
Binary files /dev/null and b/feature/exercise/src/main/res/font/nanumsquareneo_cbd.ttf differ
diff --git a/feature/exercise/src/main/res/font/nanumsquareneo_deb.ttf b/feature/exercise/src/main/res/font/nanumsquareneo_deb.ttf
new file mode 100644
index 00000000..0f3b2a81
Binary files /dev/null and b/feature/exercise/src/main/res/font/nanumsquareneo_deb.ttf differ
diff --git a/feature/exercise/src/main/res/font/nanumsquareneo_ehv.ttf b/feature/exercise/src/main/res/font/nanumsquareneo_ehv.ttf
new file mode 100644
index 00000000..51a02abf
Binary files /dev/null and b/feature/exercise/src/main/res/font/nanumsquareneo_ehv.ttf differ
diff --git a/feature/exercise/src/main/res/font/rammetto_one_regular.ttf b/feature/exercise/src/main/res/font/rammetto_one_regular.ttf
new file mode 100644
index 00000000..ff686441
Binary files /dev/null and b/feature/exercise/src/main/res/font/rammetto_one_regular.ttf differ
diff --git a/feature/exercise/src/main/res/layout/bottom_sheet_exercise_menu.xml b/feature/exercise/src/main/res/layout/bottom_sheet_exercise_menu.xml
new file mode 100644
index 00000000..223bfd18
--- /dev/null
+++ b/feature/exercise/src/main/res/layout/bottom_sheet_exercise_menu.xml
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 f7bb2deb..f91be0ce 100644
--- a/feature/exercise/src/main/res/layout/fragment_exercise_detail.xml
+++ b/feature/exercise/src/main/res/layout/fragment_exercise_detail.xml
@@ -196,4 +196,26 @@
android:layout_marginTop="8dp"/>
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/feature/exercise/src/main/res/layout/fragment_exercise_share_edit.xml b/feature/exercise/src/main/res/layout/fragment_exercise_share_edit.xml
new file mode 100644
index 00000000..b5d1ba58
--- /dev/null
+++ b/feature/exercise/src/main/res/layout/fragment_exercise_share_edit.xml
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/feature/exercise/src/main/res/layout/layout_exercise_sticker.xml b/feature/exercise/src/main/res/layout/layout_exercise_sticker.xml
new file mode 100644
index 00000000..22889d1d
--- /dev/null
+++ b/feature/exercise/src/main/res/layout/layout_exercise_sticker.xml
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/feature/exercise/src/main/res/navigation/exercise_nav_graph.xml b/feature/exercise/src/main/res/navigation/exercise_nav_graph.xml
index 8eb28986..8148aac8 100644
--- a/feature/exercise/src/main/res/navigation/exercise_nav_graph.xml
+++ b/feature/exercise/src/main/res/navigation/exercise_nav_graph.xml
@@ -44,6 +44,11 @@
android:name="recordId"
app:argType="long" />
+
+
+
+
+
\ No newline at end of file
diff --git a/feature/exercise/src/main/res/values/strings.xml b/feature/exercise/src/main/res/values/strings.xml
index 24c544a3..bd857372 100644
--- a/feature/exercise/src/main/res/values/strings.xml
+++ b/feature/exercise/src/main/res/values/strings.xml
@@ -86,4 +86,22 @@
이 날은 이미 점수를 획득했어요
점수를 획득할 수 있는 기간이 지났어요
점수가 최대치에 도달했어요!
+
+
+ TOTAL TIME
+ 운다방 마스코트
+ 운동 종류 아이콘
+ 장소 아이콘
+ 스티커 미리보기
+ 공유하기
+ 인스타그램 스토리
+ 인스타그램 피드
+ 다른 앱으로 공유
+ 공유하기
+ 취소
+ 다크 테마
+ 라이트 테마
+ 미니멀 테마
+ 사진 편집
+ 공유하려면 사진이 필요해요
\ No newline at end of file
diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/MapViewManager.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/MapViewManager.kt
index bfd7b743..947d4b91 100644
--- a/feature/matching/src/main/java/com/project200/feature/matching/map/MapViewManager.kt
+++ b/feature/matching/src/main/java/com/project200/feature/matching/map/MapViewManager.kt
@@ -107,7 +107,7 @@ class MapViewManager(
LabelOptions.from(LatLng.from(place.latitude, place.longitude))
.setStyles(R.drawable.ic_place_marker)
.setTag(place)
- labelManager.layer?.addLabel(options)
+ labelManager.layer?.addLabel(options)?.let { currentLabels.add(it) }
}
// 클러스터 마커 추가
diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapFragment.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapFragment.kt
index 3e3f8246..643a3c00 100644
--- a/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapFragment.kt
+++ b/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapFragment.kt
@@ -30,7 +30,6 @@ import com.project200.feature.matching.map.cluster.ClusterCalculator
import com.project200.feature.matching.map.cluster.MapClusterItem
import com.project200.feature.matching.map.filter.FilterBottomSheetDialog
import com.project200.feature.matching.map.filter.MatchingFilterRVAdapter
-import com.project200.feature.matching.utils.FilterUiMapper
import com.project200.feature.matching.utils.MatchingFilterType
import com.project200.presentation.base.BindingFragment
import com.project200.undabang.feature.matching.R
@@ -147,9 +146,6 @@ class MatchingMapFragment :
currentCenter = cameraPosition.position,
currentZoom = cameraPosition.zoomLevel,
)
-
- // 카메라 위치가 바뀌면 클러스터도 변경됨 -> 마커 redraw
- mapViewManager?.redrawMarkers(myPlaces = viewModel.combinedMapData.value.second, clusterCalculator)
}
/**
@@ -315,13 +311,6 @@ class MatchingMapFragment :
}
private fun showFilterBottomSheet(type: MatchingFilterType) {
- // 필터 옵션들을 UI 모델로 매핑
- val options =
- FilterUiMapper.mapToUiModels(
- type = type,
- currentState = viewModel.filterState.value,
- )
-
val bottomSheet =
FilterBottomSheetDialog(
filterType = type,
@@ -371,6 +360,7 @@ class MatchingMapFragment :
override fun onResume() {
super.onResume()
binding.mapView.resume()
+ viewModel.refreshExercisePlaces()
}
override fun onPause() {
diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapViewModel.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapViewModel.kt
index d0cf5467..8b6c5fd0 100644
--- a/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapViewModel.kt
+++ b/feature/matching/src/main/java/com/project200/feature/matching/map/MatchingMapViewModel.kt
@@ -12,13 +12,16 @@ import com.project200.domain.model.AgeGroup
import com.project200.domain.model.BaseResult
import com.project200.domain.model.DayOfWeek
import com.project200.domain.model.ExercisePlace
+import com.project200.domain.model.ExerciseType
import com.project200.domain.model.MapBounds
import com.project200.domain.model.MapPosition
import com.project200.domain.model.MatchingMember
import com.project200.domain.usecase.GetExercisePlaceUseCase
+import com.project200.domain.usecase.GetExerciseTypesUseCase
import com.project200.domain.usecase.GetLastMapPositionUseCase
import com.project200.domain.usecase.GetMatchingMembersUseCase
import com.project200.domain.usecase.SaveLastMapPositionUseCase
+import com.project200.feature.matching.utils.ExerciseTypeSelection
import com.project200.feature.matching.utils.FilterState
import com.project200.feature.matching.utils.MatchingFilterType
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -46,6 +49,7 @@ class MatchingMapViewModel
private val getLastMapPositionUseCase: GetLastMapPositionUseCase,
private val saveLastMapPositionUseCase: SaveLastMapPositionUseCase,
private val getExercisePlaceUseCase: GetExercisePlaceUseCase,
+ private val getExerciseTypesUseCase: GetExerciseTypesUseCase,
private val clockProvider: ClockProvider,
@DefaultPrefs private val sharedPreferences: SharedPreferences,
) : ViewModel() {
@@ -60,6 +64,9 @@ class MatchingMapViewModel
private val _errorEvents = MutableSharedFlow()
val errorEvents: SharedFlow = _errorEvents
+ private val _exerciseTypes = MutableStateFlow>(emptyList())
+ val exerciseTypes: StateFlow> = _exerciseTypes.asStateFlow()
+
// 필터 상태
private val _filterState = MutableStateFlow(FilterState())
val filterState: StateFlow = _filterState.asStateFlow()
@@ -76,17 +83,27 @@ class MatchingMapViewModel
private val _zoomLevelWarning = MutableSharedFlow()
val zoomLevelWarning: SharedFlow = _zoomLevelWarning
+ private val redrawMarkersEvent = MutableStateFlow(0L)
+
// 마지막으로 가져온 지도 위치 정보
private var lastFetchedCenter: LatLng? = null
private var lastFetchedZoom: Int? = null
private var wasZoomTooLow: Boolean = false
+ private val isZoomTooLow = MutableStateFlow(false)
+
val combinedMapData: StateFlow, List>> =
combine(
matchingMembers,
exercisePlaces,
_filterState,
- ) { membersResult, placesResult, filters ->
+ isZoomTooLow,
+ redrawMarkersEvent,
+ ) { membersResult, placesResult, filters, isZoomTooLow, _ ->
+ if (isZoomTooLow) {
+ return@combine Pair(emptyList(), emptyList())
+ }
+
// 성공 데이터만 추출, 실패 시 빈 리스트
val members = (membersResult as? BaseResult.Success)?.data ?: emptyList()
val places = (placesResult as? BaseResult.Success)?.data ?: emptyList()
@@ -123,15 +140,23 @@ class MatchingMapViewModel
private var isPlaceCheckDone = false // 최초 장소 검사가 완료되었는가?
init {
- // 최초 방문 여부를 먼저 확인합니다.
val isFirstVisit = checkFirstVisit()
- // 최초 방문이 아닐 때만 장소 검사(다이얼로그 표시 로직)를 실행합니다.
if (!isFirstVisit) {
checkExercisePlace()
}
loadInitialMapPosition()
+ loadExerciseTypes()
+ }
+
+ private fun loadExerciseTypes() {
+ viewModelScope.launch {
+ when (val result = getExerciseTypesUseCase()) {
+ is BaseResult.Success -> _exerciseTypes.value = result.data
+ is BaseResult.Error -> result.message?.let { _errorEvents.emit(it) }
+ }
+ }
}
/**
@@ -205,6 +230,12 @@ class MatchingMapViewModel
}
}
+ fun refreshExercisePlaces() {
+ viewModelScope.launch {
+ exercisePlaces.value = getExercisePlaceUseCase()
+ }
+ }
+
// 필터 버튼 클릭 시 호출
fun onFilterTypeClicked(type: MatchingFilterType) {
viewModelScope.launch {
@@ -240,14 +271,22 @@ class MatchingMapViewModel
MatchingFilterType.AGE -> current.copy(ageGroup = toggle(current.ageGroup, option))
MatchingFilterType.SKILL -> current.copy(skillLevel = toggle(current.skillLevel, option))
MatchingFilterType.SCORE -> current.copy(exerciseScore = toggle(current.exerciseScore, option))
+ MatchingFilterType.EXERCISE_TYPE -> {
+ val exerciseType = option as? ExerciseType
+ val newSelection =
+ if (exerciseType == null || current.selectedExerciseType?.id == exerciseType.id) {
+ null
+ } else {
+ ExerciseTypeSelection(exerciseType.id, exerciseType.name)
+ }
+ current.copy(selectedExerciseType = newSelection)
+ }
MatchingFilterType.DAY -> {
val newDays =
if (option == null) {
- // 전체 선택 시 모두 비움 (Empty == 전체)
emptySet()
} else {
val day = option as DayOfWeek
- // 요일 토글
if (day in current.days) current.days - day else current.days + day
}
current.copy(days = newDays)
@@ -265,19 +304,24 @@ class MatchingMapViewModel
member: MatchingMember,
filters: FilterState,
): Boolean {
- // 성별 필터 (선택 안됨(null)이면 통과, 선택되었으면 일치해야 함)
if (filters.gender != null && member.gender != filters.gender.name) {
return false
}
- // 나이대 필터 (AgeGroup 로직에 따라 구현)
if (filters.ageGroup != null) {
if (!isAgeInGroup(member.birthDate, filters.ageGroup)) {
return false
}
}
- // 운동 실력 필터: 선호 운동 중 하나라도 해당 숙련도가 있으면 통과
+ if (filters.selectedExerciseType != null) {
+ val hasMatchingExerciseType =
+ member.preferredExercises.any { exercise ->
+ exercise.exerciseTypeId == filters.selectedExerciseType.id
+ }
+ if (!hasMatchingExerciseType) return false
+ }
+
if (filters.skillLevel != null) {
val hasMatchingSkill =
member.preferredExercises.any {
@@ -286,14 +330,12 @@ class MatchingMapViewModel
if (!hasMatchingSkill) return false
}
- // 운동 점수 필터: 회원 점수가 필터 최소 점수 이상이면 통과
if (filters.exerciseScore != null) {
if (member.memberScore < filters.exerciseScore.minScore) {
return false
}
}
- // 요일 필터: 선호 운동 중 하나라도 선택된 요일에 운동하면 통과
if (filters.days.isNotEmpty()) {
val hasMatchingDay =
member.preferredExercises.any { exercise ->
@@ -344,6 +386,8 @@ class MatchingMapViewModel
currentZoom: Int,
) {
val isZoomTooLow = currentZoom < MIN_ZOOM_LEVEL_FOR_MEMBERS
+ this.isZoomTooLow.value = isZoomTooLow
+ redrawMarkersEvent.value = System.currentTimeMillis()
if (isZoomTooLow) {
matchingMembers.value = BaseResult.Success(emptyList())
@@ -419,6 +463,6 @@ class MatchingMapViewModel
private const val KEY_FIRST_MATCHING_VISIT = "key_first_matching_visit"
private const val THRESHOLD_RATE = 0.3
private const val FILTER_LOADING_DELAY_MS = 500L
- private const val MIN_ZOOM_LEVEL_FOR_MEMBERS = 12
+ private const val MIN_ZOOM_LEVEL_FOR_MEMBERS = 11
}
}
diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/filter/FilterBottomSheetDialog.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/filter/FilterBottomSheetDialog.kt
index 62f8f918..6d532b3d 100644
--- a/feature/matching/src/main/java/com/project200/feature/matching/map/filter/FilterBottomSheetDialog.kt
+++ b/feature/matching/src/main/java/com/project200/feature/matching/map/filter/FilterBottomSheetDialog.kt
@@ -15,6 +15,7 @@ import com.project200.feature.matching.utils.MatchingFilterType
import com.project200.undabang.feature.matching.databinding.DialogFilterBottomSheetBinding
import com.project200.undabang.presentation.R
import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
@AndroidEntryPoint
@@ -57,8 +58,19 @@ class FilterBottomSheetDialog(
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
- viewModel.filterState.collect { state ->
- adapter.submitList(FilterUiMapper.mapToUiModels(filterType, state))
+ if (filterType == MatchingFilterType.EXERCISE_TYPE) {
+ combine(
+ viewModel.filterState,
+ viewModel.exerciseTypes,
+ ) { state, exerciseTypes ->
+ FilterUiMapper.mapExerciseTypesToUiModels(exerciseTypes, state)
+ }.collect { options ->
+ adapter.submitList(options)
+ }
+ } else {
+ viewModel.filterState.collect { state ->
+ adapter.submitList(FilterUiMapper.mapToUiModels(filterType, state))
+ }
}
}
}
diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/filter/FilterOptionRVAdapter.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/filter/FilterOptionRVAdapter.kt
index d621a694..c1fee199 100644
--- a/feature/matching/src/main/java/com/project200/feature/matching/map/filter/FilterOptionRVAdapter.kt
+++ b/feature/matching/src/main/java/com/project200/feature/matching/map/filter/FilterOptionRVAdapter.kt
@@ -32,7 +32,7 @@ class FilterOptionRVAdapter(
inner class ViewHolder(private val binding: ItemFilterOptionBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: FilterOptionUiModel) {
val context = binding.root.context
- binding.optionTv.text = context.getString(item.labelResId)
+ binding.optionTv.text = item.labelText ?: item.labelResId?.let { context.getString(it) } ?: ""
binding.checkIv.isVisible = item.isSelected
TextViewCompat.setTextAppearance(binding.optionTv, if (item.isSelected) R.style.content_bold else R.style.content_regular)
binding.filterOptionLl.setOnClickListener { onClick(item.originalData) }
@@ -46,7 +46,7 @@ class FilterOptionRVAdapter(
oldItem: FilterOptionUiModel,
newItem: FilterOptionUiModel,
): Boolean {
- return oldItem.labelResId == newItem.labelResId
+ return oldItem.labelResId == newItem.labelResId && oldItem.labelText == newItem.labelText
}
override fun areContentsTheSame(
diff --git a/feature/matching/src/main/java/com/project200/feature/matching/map/filter/MatchingFilterViewHolders.kt b/feature/matching/src/main/java/com/project200/feature/matching/map/filter/MatchingFilterViewHolders.kt
index 593827f9..c910af16 100644
--- a/feature/matching/src/main/java/com/project200/feature/matching/map/filter/MatchingFilterViewHolders.kt
+++ b/feature/matching/src/main/java/com/project200/feature/matching/map/filter/MatchingFilterViewHolders.kt
@@ -34,16 +34,21 @@ class FilterViewHolder(
) {
val context = binding.root.context
- // 라벨 설정
- val labelResId =
- if (item.isMultiSelect) {
- item.labelResId
- } else {
- getSelectedOptionLabelResId(item, currentState) ?: item.labelResId
+ val labelText =
+ when {
+ item == MatchingFilterType.EXERCISE_TYPE && currentState.selectedExerciseType != null -> {
+ currentState.selectedExerciseType.name
+ }
+ item.isMultiSelect -> {
+ context.getString(item.labelResId)
+ }
+ else -> {
+ val selectedLabelResId = getSelectedOptionLabelResId(item, currentState)
+ context.getString(selectedLabelResId ?: item.labelResId)
+ }
}
- binding.filterTitleTv.text = context.getString(labelResId)
+ binding.filterTitleTv.text = labelText
- // 선택 상태 디자인
val isSelected = isFilterSelected(item, currentState)
binding.root.isSelected = isSelected
@@ -65,13 +70,13 @@ class FilterViewHolder(
return when (type) {
MatchingFilterType.GENDER -> state.gender != null
MatchingFilterType.AGE -> state.ageGroup != null
+ MatchingFilterType.EXERCISE_TYPE -> state.selectedExerciseType != null
MatchingFilterType.DAY -> state.days.isNotEmpty()
MatchingFilterType.SKILL -> state.skillLevel != null
MatchingFilterType.SCORE -> state.exerciseScore != null
}
}
- // 선택된 옵션의 라벨 리소스 ID를 반환
private fun getSelectedOptionLabelResId(
type: MatchingFilterType,
state: FilterState,
diff --git a/feature/matching/src/main/java/com/project200/feature/matching/utils/FilterUiMapper.kt b/feature/matching/src/main/java/com/project200/feature/matching/utils/FilterUiMapper.kt
index 919c47df..13242fbe 100644
--- a/feature/matching/src/main/java/com/project200/feature/matching/utils/FilterUiMapper.kt
+++ b/feature/matching/src/main/java/com/project200/feature/matching/utils/FilterUiMapper.kt
@@ -3,6 +3,7 @@ package com.project200.feature.matching.utils
import com.project200.domain.model.AgeGroup
import com.project200.domain.model.DayOfWeek
import com.project200.domain.model.ExerciseScore
+import com.project200.domain.model.ExerciseType
import com.project200.domain.model.Gender
import com.project200.domain.model.SkillLevel
import com.project200.presentation.utils.labelResId
@@ -29,10 +30,11 @@ object FilterUiMapper {
originalData = age,
)
}
+ MatchingFilterType.EXERCISE_TYPE ->
+ throw IllegalArgumentException("Use mapExerciseTypesToUiModels for EXERCISE_TYPE")
MatchingFilterType.DAY -> {
val list = mutableListOf()
- // 전체 옵션
list.add(
FilterOptionUiModel(
labelResId = com.project200.undabang.presentation.R.string.filter_all,
@@ -41,7 +43,6 @@ object FilterUiMapper {
),
)
- // 요일 옵션
list.addAll(
DayOfWeek.entries.map { day ->
FilterOptionUiModel(
@@ -72,4 +73,17 @@ object FilterUiMapper {
}
}
}
+
+ fun mapExerciseTypesToUiModels(
+ exerciseTypes: List,
+ currentState: FilterState,
+ ): List {
+ return exerciseTypes.map { exerciseType ->
+ FilterOptionUiModel(
+ labelText = exerciseType.name,
+ isSelected = currentState.selectedExerciseType?.id == exerciseType.id,
+ originalData = exerciseType,
+ )
+ }
+ }
}
diff --git a/feature/matching/src/main/java/com/project200/feature/matching/utils/FilterUiModel.kt b/feature/matching/src/main/java/com/project200/feature/matching/utils/FilterUiModel.kt
index fdee4c41..069a3ef2 100644
--- a/feature/matching/src/main/java/com/project200/feature/matching/utils/FilterUiModel.kt
+++ b/feature/matching/src/main/java/com/project200/feature/matching/utils/FilterUiModel.kt
@@ -19,6 +19,7 @@ enum class MatchingFilterType(
) {
GENDER(R.string.filter_gender), // 성별
AGE(R.string.filter_age), // 나이
+ EXERCISE_TYPE(R.string.filter_exercise_type), // 종목
DAY(R.string.filter_day, true), // 요일
SKILL(R.string.filter_skill), // 숙련도
SCORE(R.string.filter_score), // 점수
@@ -27,13 +28,20 @@ enum class MatchingFilterType(
data class FilterState(
val gender: Gender? = null,
val ageGroup: AgeGroup? = null,
+ val selectedExerciseType: ExerciseTypeSelection? = null,
val days: Set = emptySet(),
val skillLevel: SkillLevel? = null,
val exerciseScore: ExerciseScore? = null,
)
+data class ExerciseTypeSelection(
+ val id: Long,
+ val name: String,
+)
+
data class FilterOptionUiModel(
- val labelResId: Int,
+ val labelResId: Int? = null,
+ val labelText: String? = null,
val isSelected: Boolean,
- val originalData: Any?, // 선택된 Enum 객체 (Gender, AgeGroup 등)
+ val originalData: Any?,
)
diff --git a/feature/matching/src/main/res/navigation/matching_nav_graph.xml b/feature/matching/src/main/res/navigation/matching_nav_graph.xml
index 9c452391..b404ea68 100644
--- a/feature/matching/src/main/res/navigation/matching_nav_graph.xml
+++ b/feature/matching/src/main/res/navigation/matching_nav_graph.xml
@@ -26,9 +26,7 @@
app:destination="@id/exercisePlaceFragment" />
+ app:destination="@id/matchingGuideFragment" />
성별
나이
운동 요일
- 운동 종목
+ 종목
숙련도
운동 점수
diff --git a/presentation/src/main/java/com/project200/presentation/base/BaseToolbar.kt b/presentation/src/main/java/com/project200/presentation/base/BaseToolbar.kt
index 834f2cf6..34e6c645 100644
--- a/presentation/src/main/java/com/project200/presentation/base/BaseToolbar.kt
+++ b/presentation/src/main/java/com/project200/presentation/base/BaseToolbar.kt
@@ -49,4 +49,19 @@ class BaseToolbar
binding.subBtn.visibility = View.INVISIBLE
}
}
+
+ fun setSecondarySubButton(
+ iconRes: Int?,
+ onClick: ((View) -> Unit)? = null,
+ ) {
+ if (iconRes != null) {
+ binding.subBtnSecondary.apply {
+ setImageResource(iconRes)
+ visibility = View.VISIBLE
+ setOnClickListener { clickedView -> onClick?.invoke(clickedView) }
+ }
+ } else {
+ binding.subBtnSecondary.visibility = View.GONE
+ }
+ }
}
diff --git a/presentation/src/main/res/layout/view_base_toolbar.xml b/presentation/src/main/res/layout/view_base_toolbar.xml
index 6ecdd65e..883afd44 100644
--- a/presentation/src/main/res/layout/view_base_toolbar.xml
+++ b/presentation/src/main/res/layout/view_base_toolbar.xml
@@ -26,6 +26,14 @@
android:gravity="center"
android:paddingHorizontal="12dp" />
+
+