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