Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
bde4782
feat: 운동 기록 스티커 개발 #447
edv-Shin Jan 26, 2026
081ff54
feat: 사진 크기에 따라 스티커 크기도 수정 #447
edv-Shin Jan 26, 2026
976bb8f
feat: 호환되지 않는 비율의 이미지에 여백 추가 #447
edv-Shin Jan 26, 2026
8eefbde
feat: 스티커 배경 xml로 수정 #447
edv-Shin Jan 26, 2026
289364a
fix: ktlint 문법 수정 #447
edv-Shin Jan 27, 2026
92bb771
feat: 운동 기록 공유 편집 화면 구현 #449
edv-Shin Jan 27, 2026
12a7b51
feat: 이미지 위치/크기 조정 구현 #449
edv-Shin Jan 27, 2026
10b8912
chore: 주석 추가 #449
edv-Shin Jan 27, 2026
2fb28fa
feat: data class 파일 분리 #449
edv-Shin Jan 27, 2026
06bb936
fix: import 수정 #449
edv-Shin Jan 27, 2026
3cf3b7a
feat: 테마 변경 개선 #449
edv-Shin Jan 27, 2026
d5ffa65
feat: 스티커 회전 구현 #449
edv-Shin Jan 27, 2026
d986611
refactor: .value 직접 접근 제거 #449
edv-Shin Feb 1, 2026
4534e4d
fix: ktlint 문법 수정 #447
edv-Shin Feb 5, 2026
ec4e478
Merge pull request #460 from projects200/feat/sticker-447
edv-Shin Feb 5, 2026
43a98a0
feat: 운동 종류 필터 추가 #467
edv-Shin Feb 5, 2026
2190aea
fix: ktlint 문법 수정 #467
edv-Shin Feb 5, 2026
a79ac3a
feat: 운동 기록 공유 시 이미지가 없는 경우 제한
edv-Shin Feb 5, 2026
7c8f763
fix: 마커 표시되는 지도 줌 레벨 수정
edv-Shin Feb 11, 2026
6bbf99d
fix: 줌 레벨 축소 시 내 장소 마커도 안보이게 수정
edv-Shin Feb 11, 2026
abcc16e
fix: 가이드에서 운동 장소 등록 후 스택 오류 수정
edv-Shin Feb 11, 2026
dcf7e2c
fix: 운동 장소 등록 후 내 장소 갱신
edv-Shin Feb 11, 2026
4eb08bf
feat: ktlint 문법 수정
edv-Shin Feb 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,15 @@
<meta-data
android:name="com.kakao.sdk.AppKey"
android:value="${KAKAO_NATIVE_APP_KEY}" />

<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -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들 추가 ... //
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/res/xml/file_paths.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path
name="share"
path="share/" />
</paths>
Original file line number Diff line number Diff line change
@@ -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<List<ExerciseType>> {
return repository.getPreferredExerciseTypes()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,6 +25,7 @@ import kotlinx.coroutines.launch
class ExerciseDetailFragment : BindingFragment<FragmentExerciseDetailBinding>(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)
Expand All @@ -36,6 +36,16 @@ class ExerciseDetailFragment : BindingFragment<FragmentExerciseDetailBinding>(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() }
}
}
Expand All @@ -53,6 +63,7 @@ class ExerciseDetailFragment : BindingFragment<FragmentExerciseDetailBinding>(R.
}
is UiState.Success -> {
binding.shimmerLayout.stopShimmer()
currentRecord = state.data
bindExerciseRecordData(state.data)
}
is UiState.Error -> {
Expand Down Expand Up @@ -138,15 +149,15 @@ class ExerciseDetailFragment : BindingFragment<FragmentExerciseDetailBinding>(R.
}

private fun showExerciseDetailMenu() {
MenuBottomSheetDialog(
ExerciseMenuBottomSheetDialog(
onEditClicked = {
findNavController().navigate(
ExerciseDetailFragmentDirections
.actionExerciseDetailFragmentToExerciseFormFragment(args.recordId),
)
},
onDeleteClicked = { showDeleteConfirmationDialog() },
).show(parentFragmentManager, MenuBottomSheetDialog::class.java.simpleName)
).show(parentFragmentManager, ExerciseMenuBottomSheetDialog::class.java.simpleName)
}

private fun showDeleteConfirmationDialog() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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<FragmentExerciseShareEditBinding>(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()
}
}
Original file line number Diff line number Diff line change
@@ -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<StickerTheme> = _selectedTheme.asStateFlow()

private val _exerciseRecord = MutableStateFlow<ExerciseRecord?>(null)
val exerciseRecord: StateFlow<ExerciseRecord?> = _exerciseRecord.asStateFlow()

private val _backgroundImageUrl = MutableStateFlow<String?>(null)
val backgroundImageUrl: StateFlow<String?> = _backgroundImageUrl.asStateFlow()

private val _isLoading = MutableStateFlow(true)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()

private val _shareEvent = MutableSharedFlow<ShareEventData>()
val shareEvent: SharedFlow<ShareEventData> = _shareEvent.asSharedFlow()

val stickerState: Flow<StickerState> =
_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
}
}
Loading