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" /> + +