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/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomViewModel.kt b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomViewModel.kt
index 46002238..d76628c5 100644
--- a/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomViewModel.kt
+++ b/feature/chatting/src/main/java/com/project200/feature/chatting/chattingRoom/ChattingRoomViewModel.kt
@@ -11,7 +11,6 @@ import com.project200.domain.usecase.ExitChatRoomUseCase
import com.project200.domain.usecase.GetChatMessagesUseCase
import com.project200.domain.usecase.GetNewChatMessagesUseCase
import com.project200.domain.usecase.ObserveOpponentStatusUseCase
-import com.project200.domain.usecase.ObserveSocketErrorsUseCase
import com.project200.domain.usecase.ObserveSocketMessagesUseCase
import com.project200.domain.usecase.SendSocketMessageUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
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..0301ca1a 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,11 @@ class ExerciseDetailFragment : BindingFragment(R.
binding.baseToolbar.apply {
setTitle(getString(R.string.exercise_detail))
showBackButton(true) { findNavController().navigateUp() }
+ setSecondarySubButton(R.drawable.ic_share) {
+ findNavController().navigate(
+ ExerciseDetailFragmentDirections.actionExerciseDetailFragmentToExerciseShareEditFragment(args.recordId),
+ )
+ }
setSubButton(R.drawable.ic_menu) { showExerciseDetailMenu() }
}
}
@@ -53,6 +58,7 @@ class ExerciseDetailFragment : BindingFragment(R.
}
is UiState.Success -> {
binding.shimmerLayout.stopShimmer()
+ currentRecord = state.data
bindExerciseRecordData(state.data)
}
is UiState.Error -> {
@@ -138,7 +144,7 @@ class ExerciseDetailFragment : BindingFragment(R.
}
private fun showExerciseDetailMenu() {
- MenuBottomSheetDialog(
+ ExerciseMenuBottomSheetDialog(
onEditClicked = {
findNavController().navigate(
ExerciseDetailFragmentDirections
@@ -146,7 +152,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..0b49b368 100644
--- a/feature/exercise/src/main/res/values/strings.xml
+++ b/feature/exercise/src/main/res/values/strings.xml
@@ -86,4 +86,21 @@
이 날은 이미 점수를 획득했어요
점수를 획득할 수 있는 기간이 지났어요
점수가 최대치에 도달했어요!
+
+
+ TOTAL TIME
+ 운다방 마스코트
+ 운동 종류 아이콘
+ 장소 아이콘
+ 스티커 미리보기
+ 공유하기
+ 인스타그램 스토리
+ 인스타그램 피드
+ 다른 앱으로 공유
+ 공유하기
+ 취소
+ 다크 테마
+ 라이트 테마
+ 미니멀 테마
+ 사진 편집
\ No newline at end of file
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" />
+
+