Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ dependencies {
implementation libs.glide
ksp libs.glide.ksp
implementation libs.coil
implementation libs.coil.compose
implementation libs.coil.network.okhttp

// Firebase
Expand Down
Original file line number Diff line number Diff line change
@@ -1,155 +1,153 @@
package com.runnect.runnect.presentation.mypage

import android.app.Activity
import android.app.Activity.RESULT_OK
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.isVisible
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.commit
import androidx.fragment.app.replace
import coil3.load
import com.kakao.sdk.common.util.KakaoCustomTabsClient
import com.kakao.sdk.talk.TalkApiClient
import com.runnect.runnect.BuildConfig
import com.runnect.runnect.R
import com.runnect.runnect.binding.BaseVisitorFragment
import com.runnect.runnect.databinding.FragmentMyPageBinding
import com.runnect.runnect.presentation.event.VisitorModeManager
import com.runnect.runnect.presentation.login.LoginActivity
import com.runnect.runnect.presentation.mypage.editname.MyPageEditNameActivity
import com.runnect.runnect.presentation.mypage.history.MyHistoryActivity
import com.runnect.runnect.presentation.mypage.reward.MyRewardActivity
import com.runnect.runnect.presentation.mypage.setting.MySettingFragment
import com.runnect.runnect.presentation.mypage.upload.MyUploadActivity
import com.runnect.runnect.presentation.ui.theme.RunnectTheme
import com.runnect.runnect.util.analytics.Analytics
import com.runnect.runnect.util.analytics.EventName.EVENT_CLICK_GOAL_REWARD
import com.runnect.runnect.util.analytics.EventName.EVENT_CLICK_RUNNING_RECORD
import com.runnect.runnect.util.analytics.EventName.EVENT_CLICK_UPLOADED_COURSE
import com.runnect.runnect.util.extension.getStampResId
import com.runnect.runnect.util.extension.repeatOnStarted
import com.runnect.runnect.util.extension.showSnackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import javax.inject.Inject

@AndroidEntryPoint
class MyPageFragment : BaseVisitorFragment<FragmentMyPageBinding>(R.layout.fragment_my_page) {
class MyPageFragment : Fragment() {
@Inject
lateinit var visitorModeManager: VisitorModeManager

private val viewModel: MyPageViewModel by activityViewModels()
private lateinit var resultEditNameLauncher: ActivityResultLauncher<Intent>

override val visitorContainer by lazy { binding.clVisitorMode }
override val contentViews by lazy { listOf(binding.constraintInside) }

override fun onContentModeInit() {
binding.lifecycleOwner = this@MyPageFragment.viewLifecycleOwner
viewModel.intent(MyPageIntent.LoadUserInfo)
addListener()
addObserver()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setResultEditNameLauncher()
}

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ComposeView 호스트 패턴

기존 Fragment 구조(ViewPager)를 유지하면서 내부 UI만 Compose로 교체하는 방식입니다.

Fragment.onCreateView() → ComposeView 반환 → setContent { Composable }
  • ComposeView: Android View 계층 안에 Compose UI를 삽입하는 브릿지
  • setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed): Fragment의 View 생명주기에 맞춰 Compose를 자동 해제. ViewPager에서 Fragment가 재사용될 때 메모리 릭 방지
  • setContent { }: 이 블록 안에서 Composable 함수를 호출하면 Compose UI가 렌더링됨

ComposeView 공식 문서

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed) 이게 컴포즈가 아닌 xml 기반이었다고 하면 어떤 개념과 유사한지, 이 코드를 추가했을 때 사이드 이펙트 등 문제가 생길 여지는 없는지.

코드 짤 때 누락하면 안 되는 필수적인 부분으로 보이는데 그렇다면 이걸 프리셋으로 설정해놓은 베이스나 유틸 코드를 만들어 보는 건 어떤지

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

XML 대응 개념

setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed)는 XML 기반에서 onDestroyView()에서 리소스 해제하는 것과 동일한 역할입니다.

Fragment에서 View가 파괴될 때(onDestroyView) Compose의 composition도 함께 해제하여:

  • 메모리 릭 방지 (Compose가 파괴된 View를 계속 참조하는 것 방지)
  • ViewPager에서 Fragment가 재생성될 때 깨끗한 상태로 시작

사이드 이펙트

없습니다. 오히려 빠뜨리면 메모리 릭이 생기는 필수 코드입니다.

베이스 유틸 제안에 대해

좋은 아이디어입니다. Fragment에서 ComposeView를 호스팅하는 패턴이 반복될 것이므로:

// 향후 만들 수 있는 베이스
abstract class ComposeFragment : Fragment() {
    override fun onCreateView(...): View {
        return ComposeView(requireContext()).apply {
            setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed)
            setContent { RunnectTheme { Content() } }
        }
    }
    @Composable abstract fun Content()
}

다만 플랜 원칙에 따라 5~6개 화면 전환 후 패턴이 확정되면 그때 추출하는 게 안전합니다. 지금은 1개 화면이라 섣부른 추상화 위험.

ViewCompositionStrategy 공식 문서

return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
RunnectTheme {
if (visitorModeManager.isVisitorMode) {
VisitorModeScreen(
onSignUpClick = { navigateToLogin() }
)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

collectAsState()

ViewModel의 StateFlow<MyPageUiState>를 Compose가 관찰하는 핵심 연결점입니다.

val state by viewModel.state.collectAsState()
  • collectAsState(): StateFlow를 Compose의 State로 변환. 값이 바뀔 때마다 이 값을 사용하는 Composable만 자동으로 다시 그려짐 (recomposition)
  • by 위임: state.value 대신 state로 직접 접근 가능하게 해주는 Kotlin 문법
  • LiveData의 observe() + XML 바인딩을 이 한 줄이 대체함

collectAsState 공식 문서

} else {
val state by viewModel.state.collectAsState()

val stampResId = if (!state.isLoading) {
getStampResourceId(state.stampId)
} else {
R.drawable.user_profile_basic
}

MyPageScreen(
state = state.copy(profileImgResId = stampResId),
onEditProfileClick = { navigateToEditName() },
onHistoryClick = {
Analytics.logClickedItemEvent(EVENT_CLICK_RUNNING_RECORD)
navigateTo<MyHistoryActivity>()
},
onRewardClick = {
Analytics.logClickedItemEvent(EVENT_CLICK_GOAL_REWARD)
navigateTo<MyRewardActivity>()
},
onUploadClick = {
Analytics.logClickedItemEvent(EVENT_CLICK_UPLOADED_COURSE)
navigateTo<MyUploadActivity>()
},
onSettingClick = { moveToSettingFragment() },
onKakaoInquiryClick = { inquiryKakao() }
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}
}
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if (!visitorModeManager.isVisitorMode) {
viewModel.intent(MyPageIntent.LoadUserInfo)
}
}

private fun setResultEditNameLauncher() {
resultEditNameLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
if (result.resultCode == Activity.RESULT_OK) {
val name = result.data?.getStringExtra(EXTRA_NICK_NAME)
?: viewModel.currentState.nickname
viewModel.intent(MyPageIntent.UpdateNickname(name))
}
}
}

private fun addListener() {
with(binding) {
ivMyPageEditFrame.setOnClickListener {
val intent = Intent(requireContext(), MyPageEditNameActivity::class.java)
intent.putExtra(EXTRA_NICK_NAME, viewModel.currentState.nickname)
val stampResId = getStampResourceId()
intent.putExtra(EXTRA_PROFILE, stampResId)
resultEditNameLauncher.launch(intent)
}

viewMyPageMainRewardFrame.setOnClickListener {
Analytics.logClickedItemEvent(EVENT_CLICK_GOAL_REWARD)
navigateTo<MyRewardActivity>()
}
viewMyPageMainHistoryFrame.setOnClickListener {
Analytics.logClickedItemEvent(EVENT_CLICK_RUNNING_RECORD)
navigateTo<MyHistoryActivity>()
}

viewMyPageMainUploadFrame.setOnClickListener {
Analytics.logClickedItemEvent(EVENT_CLICK_UPLOADED_COURSE)
navigateTo<MyUploadActivity>()
}
viewMyPageMainSettingFrame.setOnClickListener {
moveToSettingFragment()
}
viewMyPageMainKakaoChannelInquiryFrame.setOnClickListener {
inquiryKakao()
}
}
private fun navigateToEditName() {
val intent = Intent(requireContext(), MyPageEditNameActivity::class.java)
intent.putExtra(EXTRA_NICK_NAME, viewModel.currentState.nickname)
val stampResId = getStampResourceId(viewModel.currentState.stampId)
intent.putExtra(EXTRA_PROFILE, stampResId)
resultEditNameLauncher.launch(intent)
Comment on lines 105 to +121
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="$(fd 'MyPageEditNameActivity\.kt$' app/src/main/java | head -n1)"
if [[ -n "${file:-}" ]]; then
  echo "== $file =="
  sed -n '1,260p' "$file"
fi

echo
echo "== Result extras / profile update usages =="
rg -n -C3 'EXTRA_PROFILE|EXTRA_NICK_NAME|UpdateProfileImg|setResult\(|putExtra\(' app/src/main/java

Repository: Runnect/Runnect-Android

Length of output: 42919


Profile image changes in MyPageEditNameActivity are not returned to the caller.

The activity receives EXTRA_PROFILE during initialization and calls viewModel.setProfileImg(), but when it succeeds, setResult() only includes EXTRA_NICK_NAME. The fragment's launcher callback ignores EXTRA_PROFILE in the result and never invokes MyPageIntent.UpdateProfileImg(), so any avatar edits made in the activity are dropped and won't display until the next full reload.

Evidence from MyPageEditNameActivity.kt
// Lines 69-72: Result only includes nickname
setResult(
    RESULT_OK,
    Intent().putExtra(EXTRA_NICK_NAME, viewModel.nickName.value)
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt`
around lines 105 - 121, The fragment never receives profile changes because
MyPageEditNameActivity's setResult only includes EXTRA_NICK_NAME and the
fragment's resultEditNameLauncher ignores EXTRA_PROFILE; update the activity
(MyPageEditNameActivity) to include the profile payload (EXTRA_PROFILE — same
format you pass in navigateToEditName) in the RESULT_OK Intent, and update the
fragment's registerForActivityResult callback in setResultEditNameLauncher to
read EXTRA_PROFILE from result.data (if present) and call
viewModel.intent(MyPageIntent.UpdateProfileImg(profileValue)) in addition to
MyPageIntent.UpdateNickname(name).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 코드 유지 — MyPageEditNameActivity의 setResult에 닉네임만 포함하는 것은 이 PR 이전부터 동일한 pre-existing 동작. getUserInfo() API 재호출로 동기화되는 기존 구조.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@unam98, 이해했습니다! MyPageEditNameActivitysetResult에 닉네임만 포함하는 것이 이 PR 이전부터 의도된 동작이며, 프로필 이미지 동기화는 getUserInfo() API 재호출로 처리되는 기존 구조임을 확인했습니다. 코드를 그대로 유지하는 것이 맞겠습니다.


✏️ Learnings added
Learnt from: unam98
Repo: Runnect/Runnect-Android PR: 374
File: app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt:105-121
Timestamp: 2026-04-03T01:14:47.736Z
Learning: In the Runnect-Android project, `MyPageEditNameActivity.setResult()` intentionally includes only `EXTRA_NICK_NAME` (not `EXTRA_PROFILE`) in the `RESULT_OK` Intent. Profile image synchronization after editing is handled by re-calling the `getUserInfo()` API rather than passing the updated profile image back through the activity result. This is a pre-existing design pattern predating the Compose migration (PR `#374`), not a bug.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: unam98
Repo: Runnect/Runnect-Android PR: 373
File: app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt:102-108
Timestamp: 2026-04-02T17:59:56.729Z
Learning: In `MyPageFragment.kt` (Runnect-Android), the team deliberately uses `state.error` (in `MyPageUiState`) for persistent error display via Snackbar in `bindState()`, and reserves `MyPageEffect` (e.g., `ShowError`) for one-time actions such as navigation. This is an intentional MVI design decision: state for persistent UI errors, effects for one-time events.

}

private fun moveToSettingFragment() {
val bundle = Bundle().apply { putString(ACCOUNT_INFO_TAG, viewModel.currentState.email) }
requireActivity().supportFragmentManager.commit {
this.setCustomAnimations(R.anim.slide_in_right, R.anim.slide_out_left)
setCustomAnimations(R.anim.slide_in_right, R.anim.slide_out_left)
replace<MySettingFragment>(R.id.fl_main, args = bundle)
}
}

private fun addObserver() {
repeatOnStarted {
viewModel.state.collectLatest { state ->
bindState(state)
}
}
}

private fun bindState(state: MyPageUiState) {
setLoadingState(state.isLoading)

if (!state.isLoading && state.error == null) {
with(binding) {
tvMyPageUserName.text = state.nickname
tvMyPageUserLv.text = state.level
pbMyPageProgress.progress = state.levelPercent
tvMyPageProgressCurrent.text = state.levelPercent.toString()
ivMyPageProfile.load(state.profileImgResId)
}

val stampResId = getStampResourceId()
viewModel.intent(MyPageIntent.UpdateProfileImg(stampResId))
}

state.error?.let {
context?.showSnackbar(anchorView = binding.root, message = it)
}
}

private fun inquiryKakao() {
val url = TalkApiClient.instance.channelChatUrl(BuildConfig.KAKAO_CHANNEL_ID)
KakaoCustomTabsClient.openWithDefault(requireActivity(), url)
}

private fun getStampResourceId(): Int {
private fun navigateToLogin() {
startActivity(Intent(requireContext(), LoginActivity::class.java))
requireActivity().finish()
}

private fun getStampResourceId(stampId: String): Int {
return requireContext().getStampResId(
stampId = viewModel.currentState.stampId,
stampId = stampId,
resNameParam = RES_NAME,
resType = RES_STAMP_TYPE,
packageName = requireContext().packageName
)
}

private fun setLoadingState(isLoading: Boolean) {
with(binding) {
indeterminateBar.isVisible = isLoading
ivMyPageEditFrame.isClickable = !isLoading
viewMyPageMainSettingFrame.isClickable = !isLoading
}
}

private inline fun <reified T : Activity> navigateTo() {
startActivity(Intent(requireContext(), T::class.java))
requireActivity().overridePendingTransition(
Expand Down
Loading
Loading