-
Notifications
You must be signed in to change notification settings - Fork 1
StorageScrapFragment Compose 마이그레이션 및 ViewModel 테스트 추가 #392
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
ae6f188
cab9c8a
2321b18
a807d66
8d786bc
135edb2
aa42315
bd95fd5
d47f44c
f41f46b
fe30826
f780884
a8c2938
484725a
5f5a0fd
dfdfd45
b9a66c4
15c93d5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,227 +1,139 @@ | ||
| package com.runnect.runnect.presentation.storage | ||
|
|
||
| import android.content.ContentValues | ||
| import android.content.Intent | ||
| import android.os.Bundle | ||
| import android.view.LayoutInflater | ||
| import android.view.View | ||
| import androidx.core.view.isVisible | ||
| import android.view.ViewGroup | ||
| import androidx.compose.runtime.LaunchedEffect | ||
| import androidx.compose.runtime.getValue | ||
| import androidx.compose.runtime.mutableStateOf | ||
| import androidx.compose.runtime.remember | ||
| import androidx.compose.runtime.livedata.observeAsState | ||
| import androidx.compose.runtime.setValue | ||
| import androidx.compose.ui.platform.ComposeView | ||
| import androidx.compose.ui.platform.ViewCompositionStrategy | ||
| import androidx.fragment.app.Fragment | ||
| import androidx.fragment.app.viewModels | ||
| import androidx.lifecycle.lifecycleScope | ||
| import androidx.recyclerview.widget.GridLayoutManager | ||
| import com.runnect.runnect.R | ||
| import com.runnect.runnect.binding.BindingFragment | ||
| import com.runnect.runnect.domain.entity.MyScrapCourse | ||
| import com.runnect.runnect.databinding.FragmentStorageScrapBinding | ||
| import com.runnect.runnect.presentation.MainActivity | ||
| import com.runnect.runnect.presentation.detail.CourseDetailActivity | ||
| import com.runnect.runnect.presentation.detail.CourseDetailRootScreen | ||
| import com.runnect.runnect.presentation.event.ScreenRefreshEvent | ||
| import com.runnect.runnect.presentation.event.ScreenRefreshEventBus | ||
| import com.runnect.runnect.presentation.detail.CourseDetailRootScreen | ||
| import com.runnect.runnect.presentation.state.UiStateV2 | ||
| import com.runnect.runnect.presentation.storage.adapter.StorageScrapAdapter | ||
| import com.runnect.runnect.presentation.ui.theme.RunnectTheme | ||
| import com.runnect.runnect.util.analytics.Analytics | ||
| import com.runnect.runnect.util.analytics.EventName | ||
| import com.runnect.runnect.util.analytics.EventName.Param | ||
| import com.runnect.runnect.util.custom.deco.GridSpacingItemDecoration | ||
| import com.runnect.runnect.util.callback.ItemCount | ||
| import com.runnect.runnect.util.callback.listener.OnHeartButtonClick | ||
| import com.runnect.runnect.util.callback.listener.OnScrapItemClick | ||
| import com.runnect.runnect.util.extension.showSnackbar | ||
| import dagger.hilt.android.AndroidEntryPoint | ||
| import kotlinx.coroutines.launch | ||
| import timber.log.Timber | ||
| import javax.inject.Inject | ||
|
|
||
| @AndroidEntryPoint | ||
| class StorageScrapFragment : | ||
| BindingFragment<FragmentStorageScrapBinding>(R.layout.fragment_storage_scrap), | ||
| OnHeartButtonClick, | ||
| OnScrapItemClick, | ||
| ItemCount { | ||
| class StorageScrapFragment : Fragment() { | ||
| @Inject | ||
| lateinit var screenRefreshEventBus: ScreenRefreshEventBus | ||
|
|
||
| val viewModel: StorageViewModel by viewModels() | ||
| private lateinit var storageScrapAdapter: StorageScrapAdapter | ||
| private val viewModel: StorageViewModel by viewModels() | ||
|
|
||
| override fun onCreateView( | ||
| inflater: LayoutInflater, | ||
| container: ViewGroup?, | ||
| savedInstanceState: Bundle? | ||
| ): View { | ||
| return ComposeView(requireContext()).apply { | ||
| setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) | ||
| setContent { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ComposeView + setContent — Fragment 안에 Compose 트리 심기
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
RunnectTheme { /* Composable 트리 시작 */ }
}
}핵심 개념:
|
||
| RunnectTheme { | ||
| val getState by viewModel.myScrapCourseGetState.observeAsState() | ||
| val scrapState by viewModel.courseScrapState.observeAsState() | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. observeAsState() — LiveData를 Compose의 State로 변환
val getState by viewModel.myScrapCourseGetState.observeAsState()핵심 개념:
|
||
|
|
||
| var courses by remember { mutableStateOf(emptyList<MyScrapCourse>()) } | ||
| var errorMessage by remember { mutableStateOf<String?>(null) } | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. remember { mutableStateOf(...) } — Composable이 자기 상태를 기억하는 법Composable 함수는 recomposition(재구성)될 때마다 처음부터 다시 실행됩니다. 그냥 var courses by remember { mutableStateOf(emptyList<MyScrapCourse>()) }핵심 개념:
|
||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| LaunchedEffect(getState) { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. LaunchedEffect — Composable 안에서 안전하게 "한 번만" 실행하기Composable 함수 본문에 직접 LaunchedEffect(getState) {
when (val current = getState) {
is UiStateV2.Success -> { courses = current.data; Analytics.logEvent(...) }
is UiStateV2.Failure -> errorMessage = current.msg
else -> Unit
}
}핵심 개념:
|
||
| when (val current = getState) { | ||
| is UiStateV2.Success -> { | ||
| courses = current.data | ||
| Analytics.logEvent( | ||
| EventName.VIEW_STORAGE_SCRAP, | ||
| Param.COURSE_COUNT to current.data.size | ||
| ) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| is UiStateV2.Failure -> errorMessage = current.msg | ||
| else -> Unit | ||
| } | ||
| } | ||
|
|
||
| LaunchedEffect(scrapState) { | ||
| when (val current = scrapState) { | ||
| is UiStateV2.Success -> { | ||
| courses = courses.filterNot { | ||
| it.publicCourseId.toLong() == current.data.publicCourseId | ||
| } | ||
| } | ||
|
|
||
| is UiStateV2.Failure -> errorMessage = current.msg | ||
| else -> Unit | ||
| } | ||
| } | ||
|
|
||
| StorageScrapScreen( | ||
| state = StorageScrapUiState( | ||
| courses = courses, | ||
| isLoading = getState is UiStateV2.Loading, | ||
| errorMessage = errorMessage | ||
| ), | ||
| onRefresh = { viewModel.getMyScrapCourses() }, | ||
| onScrapItemClick = { course -> navigateToCourseDetail(course) }, | ||
| onHeartClick = { course -> | ||
| viewModel.postCourseScrap(id = course.publicCourseId, scrapTF = false) | ||
| }, | ||
| onGoToScrapClick = { navigateToDiscover() }, | ||
| onErrorShown = { errorMessage = null } | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||
| super.onViewCreated(view, savedInstanceState) | ||
| binding.lifecycleOwner = viewLifecycleOwner | ||
|
|
||
| Analytics.logEvent(EventName.VIEW_STORAGE_SCRAP) | ||
| getMyScrapCourses() | ||
| initLayout() | ||
| initAdapter() | ||
| addListener() | ||
| addObserver() | ||
| } | ||
|
|
||
| fun getMyScrapCourses() { | ||
| viewModel.getMyScrapCourses() | ||
| } | ||
|
|
||
| private fun initLayout() { | ||
| binding.rvStorageScrap.apply { | ||
| val context = context ?: return | ||
| layoutManager = GridLayoutManager(context, 2) | ||
| addItemDecoration( | ||
| GridSpacingItemDecoration( | ||
| context = context, | ||
| spanCount = 2, | ||
| horizontalSpaceSize = 6, | ||
| topSpaceSize = 20 | ||
| ) | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private fun initAdapter() { | ||
| storageScrapAdapter = StorageScrapAdapter( | ||
| onScrapItemClick = this, | ||
| onHeartButtonClick = this, | ||
| itemCount = this | ||
| ).apply { | ||
| binding.rvStorageScrap.adapter = this | ||
| } | ||
| } | ||
|
|
||
| private fun addListener() { | ||
| initGoToScrapButtonClickListener() | ||
| initRefreshLayoutListener() | ||
| } | ||
|
|
||
| private fun initGoToScrapButtonClickListener() { | ||
| binding.btnStorageNoScrap.setOnClickListener { | ||
| val intent = Intent(activity, MainActivity::class.java).apply { | ||
| putExtra(EXTRA_FRAGMENT_REPLACEMENT_DIRECTION, "fromMyScrap") | ||
| } | ||
| startActivity(intent) | ||
| requireActivity().overridePendingTransition( | ||
| R.anim.slide_in_right, | ||
| R.anim.slide_out_left | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private fun initRefreshLayoutListener() { | ||
| binding.refreshLayout.setOnRefreshListener { | ||
| getMyScrapCourses() | ||
| binding.refreshLayout.isRefreshing = false | ||
| } | ||
| } | ||
|
|
||
| private fun addObserver() { | ||
| setupItemSizeObserver() | ||
| setupMyScrapCourseGetStateObserver() | ||
| setupCourseScrapStateObserver() | ||
| collectScreenRefreshEvents() | ||
| } | ||
|
|
||
| private fun collectScreenRefreshEvents() { | ||
| viewLifecycleOwner.lifecycleScope.launch { | ||
| screenRefreshEventBus.events.collect { event -> | ||
| if (event is ScreenRefreshEvent.RefreshStorageScrap) { | ||
| getMyScrapCourses() | ||
| viewModel.getMyScrapCourses() | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun setupCourseScrapStateObserver() { | ||
| viewModel.courseScrapState.observe(viewLifecycleOwner) { state -> | ||
| when (state) { | ||
| is UiStateV2.Loading -> { | ||
| showLoadingProgressBar() | ||
| } | ||
|
|
||
| is UiStateV2.Success -> { | ||
| dismissLoadingProgressBar() | ||
| storageScrapAdapter.removeCourseItem() | ||
| } | ||
|
|
||
| is UiStateV2.Failure -> { | ||
| dismissLoadingProgressBar() | ||
| context?.showSnackbar( | ||
| anchorView = binding.root, | ||
| message = state.msg | ||
| ) | ||
| } | ||
|
|
||
| else -> {} | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun setupItemSizeObserver() { | ||
| viewModel.itemSize.observe(viewLifecycleOwner) { itemSize -> | ||
| val isEmpty = (itemSize == 0) | ||
| updateEmptyView(isEmpty, itemSize) | ||
| private fun navigateToCourseDetail(course: MyScrapCourse) { | ||
| val intent = Intent(activity, CourseDetailActivity::class.java).apply { | ||
| putExtra(EXTRA_PUBLIC_COURSE_ID, course.publicCourseId) | ||
| putExtra(EXTRA_ROOT_SCREEN, CourseDetailRootScreen.COURSE_STORAGE_SCRAP) | ||
| } | ||
| startActivity(intent) | ||
| requireActivity().overridePendingTransition( | ||
| R.anim.slide_in_right, | ||
| R.anim.slide_out_left | ||
| ) | ||
| } | ||
|
|
||
| private fun updateEmptyView(isEmpty: Boolean, itemSize: Int) { | ||
| binding.apply { | ||
| clMyDrawNoScrap.isVisible = isEmpty | ||
| rvStorageScrap.isVisible = !isEmpty | ||
| tvStorageScrapCount.isVisible = !isEmpty | ||
| tvStorageScrapCount.text = if (!isEmpty) "총 코스 ${itemSize}개" else "" | ||
| private fun navigateToDiscover() { | ||
| val intent = Intent(activity, MainActivity::class.java).apply { | ||
| putExtra(EXTRA_FRAGMENT_REPLACEMENT_DIRECTION, "fromMyScrap") | ||
| } | ||
| } | ||
|
|
||
| private fun setupMyScrapCourseGetStateObserver() { | ||
| viewModel.myScrapCourseGetState.observe(viewLifecycleOwner) { state -> | ||
| when (state) { | ||
| is UiStateV2.Loading -> { | ||
| showLoadingProgressBar() | ||
| } | ||
|
|
||
| is UiStateV2.Success -> { | ||
| dismissLoadingProgressBar() | ||
|
|
||
| val scrapCourses = state.data | ||
| updateEmptyView(scrapCourses.isEmpty(), scrapCourses.size) | ||
| storageScrapAdapter.submitList(scrapCourses) | ||
| Analytics.logEvent( | ||
| EventName.VIEW_STORAGE_SCRAP, | ||
| Param.COURSE_COUNT to scrapCourses.size | ||
| ) | ||
| } | ||
|
|
||
| is UiStateV2.Failure -> { | ||
| dismissLoadingProgressBar() | ||
| context?.showSnackbar( | ||
| anchorView = binding.root, | ||
| message = state.msg | ||
| ) | ||
| } | ||
|
|
||
| else -> {} | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun showLoadingProgressBar() { | ||
| binding.pbStorageScrapLoading.isVisible = true | ||
| } | ||
|
|
||
| private fun dismissLoadingProgressBar() { | ||
| binding.pbStorageScrapLoading.isVisible = false | ||
| } | ||
|
|
||
| override fun calcItemSize(itemCount: Int) { | ||
| viewModel.itemSize.value = itemCount | ||
| } | ||
|
|
||
| override fun scrapCourse(id: Int, scrapTF: Boolean) { | ||
| viewModel.postCourseScrap(id, scrapTF) | ||
| } | ||
|
|
||
| override fun selectItem(item: MyScrapCourse) { | ||
| Timber.tag(ContentValues.TAG).d("코스 아이디 : ${item.publicCourseId}") | ||
|
|
||
| val intent = Intent(activity, CourseDetailActivity::class.java) | ||
| intent.putExtra(EXTRA_PUBLIC_COURSE_ID, item.publicCourseId) | ||
| intent.putExtra(EXTRA_ROOT_SCREEN, CourseDetailRootScreen.COURSE_STORAGE_SCRAP) | ||
| startActivity(intent) | ||
| requireActivity().overridePendingTransition( | ||
| R.anim.slide_in_right, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major
🧩 Analysis chain
🏁 Script executed:
Repository: Runnect/Runnect-Android
Length of output: 203
🏁 Script executed:
sed -n '1,140p' .github/workflows/CI.ymlRepository: Runnect/Runnect-Android
Length of output: 4364
🏁 Script executed:
sed -n '1,220p' app/build.gradleRepository: Runnect/Runnect-Android
Length of output: 6577
Pin
actions/setup-javato a commit SHAactions/setup-java@v4is still a mutable tag. Pin it to the exact commit SHA your workflow allows instead of using a floating version.🧰 Tools
🪛 zizmor (1.26.1)
[error] 23-23: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)
(unpinned-uses)
🤖 Prompt for AI Agents
Source: Linters/SAST tools
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
좋은 제안입니다. CI 워크플로 전체에 영향 주는 변경이라 이번 PR 범위 밖으로 보고 별도 이슈로 분리하겠습니다.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@unam98이해했습니다. 이번 PR 범위에서는 분리하는 판단이 합리적입니다.원하시면 제가 후속 GitHub 이슈로 정리해 드릴게요.
actions/setup-java를 고정 SHA로 pinning 하는 작업 범위와 수용 기준까지 포함해서 작성할 수 있습니다.(•ᵕ•)🐇
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
네, 부탁드립니다. 이슈로 열어주세요.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Length of output: 1806
@unam98후속 이슈 생성해두었습니다:#393.github/workflows/CI.yml의actions/setup-java@v4를 immutable commit SHA로 pinning#392/ 해당 리뷰 코멘트 백링크, assignee 지정필요하시면 이어서 이 PR의 해당 리뷰 코멘트도 제가 정리해서 마무리 방향 제안드릴게요.