Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
60c862a
feat(home): HomeHeader 컴포저블 추가
etama123 Dec 19, 2025
3af132e
feat(Home): HomeFestivalInfo 컴포저블 추가
etama123 Dec 19, 2025
bb90581
feat(home): HomeLineupHeader 컴포저블 추가
etama123 Dec 19, 2025
19e9842
feat(home): HomePosterList 컴포저블 구현 및 무한 스크롤 페이저 적용
etama123 Dec 19, 2025
c73796e
feat(home): 홈 화면 아티스트 및 라인업 아이템 컴포저블 구현
etama123 Dec 22, 2025
9ef8418
feat(home): 홈 화면 Compose 전환 및 관련 로직 리팩토링
etama123 Dec 26, 2025
920ea5c
refactor(home): 홈 화면 컴포저블 및 코드 정리
etama123 Dec 27, 2025
0ce44cf
refactor(Home): 홈 화면 UI 컴포즈 컴포넌트 리팩토링 및 스타일 개선
etama123 Dec 28, 2025
e0c981e
refactor(Home): LineupUiState 데이터 구조 변경
etama123 Dec 28, 2025
a9d84b4
refactor(HomeLineupItem): 날짜 영역 레이아웃 및 디바이더 구현 방식 변경
etama123 Dec 28, 2025
f849f6f
refactor(Home): 홈 화면 문자열 리소스 정리 및 접근성 개선
etama123 Dec 28, 2025
1e98ebb
refactor(HomeViewModel): `@Inject` 어노테이션 위치 수정
etama123 Jan 2, 2026
9427a8f
refactor(MainActivity): BottomNavigationView의 중복 선택 방지 및 flow 수집 방식 변경
etama123 Jan 2, 2026
c8325fe
refactor(HomeFestivalInfo): Column 내부 padding 적용 및 개별 Text padding 제거
etama123 Jan 2, 2026
dba3acb
build: Landscapist 라이브러리 의존성 추가
etama123 Jan 7, 2026
db92686
feat(common): FestabookImage 공통 컴포저블 구현
etama123 Jan 7, 2026
aec51d3
feat(home): 홈 화면 UI 상태 관리 로직 개선 및 `FestabookImage` 적용
etama123 Jan 7, 2026
ff3745a
Merge branch 'develop' into feat/11
etama123 Jan 7, 2026
14522f3
style(MainActivity, HomeFragment, etc): 코드 포맷팅 및 스타일 가이드 적용
etama123 Jan 7, 2026
88cbab0
style(HomeArtistItem, HomeLineupItem, HomePosterList, FestabookImage)…
etama123 Jan 7, 2026
85477d6
refactor(Home): 디자인 시스템 적용 및 Fragment 주입 방식 리팩토링
etama123 Jan 7, 2026
69775f6
test(HomeViewModelTest): LiveData를 StateFlow로 전환함에 따른 테스트 코드 리팩토링
etama123 Jan 7, 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
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ dependencies {
implementation(libs.ui.tooling)
implementation(libs.androidx.material3)
implementation(libs.photoview.dialog)
implementation(libs.landscapist.coil3)
implementation(libs.landscapist.placeholder)
implementation(libs.landscapist.zoomable)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.androidx.core.testing)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package com.daedan.festabook.presentation.common.component

import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil3.request.ImageRequest
import com.daedan.festabook.BuildConfig
import com.daedan.festabook.presentation.theme.FestabookColor
import com.skydoves.landscapist.ImageOptions
import com.skydoves.landscapist.coil3.CoilImage
import com.skydoves.landscapist.components.rememberImageComponent
import com.skydoves.landscapist.placeholder.shimmer.Shimmer
import com.skydoves.landscapist.placeholder.shimmer.ShimmerPlugin
import com.skydoves.landscapist.zoomable.ZoomablePlugin
import com.skydoves.landscapist.zoomable.rememberZoomableState
import com.daedan.festabook.R

@Composable
fun FestabookImage(
modifier: Modifier = Modifier,
imageUrl: String?,
contentDescription: String? = null,
contentScale: ContentScale = ContentScale.Crop,
isZoomable: Boolean = false,
enablePopUp: Boolean = false,
builder: ImageRequest.Builder.() -> Unit = {},
) {
val context = LocalContext.current
val zoomableState = rememberZoomableState()
val convertedUrl = imageUrl.convertImageUrl()

var isPopUpOpen by remember { mutableStateOf(false) }

Box(
modifier = modifier.then(
if (enablePopUp) Modifier.clickable { isPopUpOpen = true }
else Modifier
)
) {
CoilImage(
imageRequest = {
ImageRequest.Builder(context)
.data(convertedUrl)
.apply(builder)
.build()

},
modifier = modifier.fillMaxSize(),
imageOptions = ImageOptions(
contentScale = contentScale,
alignment = Alignment.Center,
contentDescription = contentDescription
),
component = rememberImageComponent {
+ShimmerPlugin(
Shimmer.Flash(
baseColor = FestabookColor.gray100.copy(alpha = 0.5f),
highlightColor = FestabookColor.gray200.copy(alpha = 0.3f)),
)
if (isZoomable) {
+ZoomablePlugin(state = zoomableState)
}
},
failure = {
Image(
painter = painterResource(id = R.drawable.img_fallback),
contentDescription = "fallback_image",
modifier = Modifier.align(Alignment.Center),
contentScale = contentScale
)
}
)
}
if (isPopUpOpen && enablePopUp) {
FestabookImageZoomPopup(
imageUrl = imageUrl,
onDismiss = { isPopUpOpen = false }
)
}
}

@Composable
private fun FestabookImageZoomPopup(
imageUrl: String?,
onDismiss: () -> Unit
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
Copy link
Contributor

Choose a reason for hiding this comment

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

public 으로 열어두는게 더 확장성이 높을 것 같은데 어떻게 생각하시나요?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

해당 컴포저블을 열어 두게 되면 외부에서 이미지 컴포저블을 사용할 때 오히려 혼동이 생길 수도 있겠다고 생각해요!!
그래서 이미지 컴포저블을 사용하는 통로를 하나로 제한하고 대신 기존 festabookimage에서 이게 팝업화면으로 띄울 건지 아닌지 속성으로 지정하도록 한 거였습니다!😳

) {
Box(
modifier = Modifier
.fillMaxSize()
.background(FestabookColor.black.copy(alpha = 0.8f))
) {
FestabookImage(
imageUrl = imageUrl,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
isZoomable = true,
enablePopUp = false
)

IconButton(
onClick = onDismiss,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "close the popup",
tint = FestabookColor.white,
)
}
}
}
}

@Preview(showBackground = true)
@Composable
fun FestabookImageTestPreview() {
FestabookImage(
imageUrl = ""
)
}
@Preview(showBackground = true)
@Composable
fun DiaplogPreview() {
FestabookImageZoomPopup(
imageUrl = ""
) { }
}

fun String?.convertImageUrl() = if (this != null && this.startsWith("/images/")) {
BuildConfig.FESTABOOK_URL.removeSuffix("/api/") + this
} else {
this
}
173 changes: 19 additions & 154 deletions app/src/main/java/com/daedan/festabook/presentation/home/HomeFragment.kt
Original file line number Diff line number Diff line change
@@ -1,184 +1,49 @@
package com.daedan.festabook.presentation.home

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
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.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView
import com.daedan.festabook.R
import com.daedan.festabook.databinding.FragmentHomeBinding
import com.daedan.festabook.di.fragment.FragmentKey
import com.daedan.festabook.logging.logger
import com.daedan.festabook.logging.model.home.ExploreClickLogData
import com.daedan.festabook.logging.model.home.HomeViewLogData
import com.daedan.festabook.logging.model.home.ScheduleClickLogData
import com.daedan.festabook.presentation.common.BaseFragment
import com.daedan.festabook.presentation.common.formatFestivalPeriod
import com.daedan.festabook.presentation.common.showErrorSnackBar
import com.daedan.festabook.presentation.explore.ExploreActivity
import com.daedan.festabook.presentation.home.adapter.CenterItemMotionEnlarger
import com.daedan.festabook.presentation.home.adapter.FestivalUiState
import com.daedan.festabook.presentation.home.adapter.LineUpItemOfDayAdapter
import com.daedan.festabook.presentation.home.adapter.PosterAdapter
import com.daedan.festabook.presentation.home.component.HomeScreen
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.binding
import timber.log.Timber

@ContributesIntoMap(scope = AppScope::class, binding = binding<Fragment>())
@FragmentKey(HomeFragment::class)
class HomeFragment @Inject constructor(
private val centerItemMotionEnlarger: RecyclerView.OnScrollListener,
) : BaseFragment<FragmentHomeBinding>() {
class HomeFragment @Inject constructor() : BaseFragment<FragmentHomeBinding>() {
override val layoutId: Int = R.layout.fragment_home

@Inject
override lateinit var defaultViewModelProviderFactory: ViewModelProvider.Factory
private val viewModel: HomeViewModel by viewModels({ requireActivity() })

private val posterAdapter: PosterAdapter by lazy {
PosterAdapter()
}

private val lineupOfDayAdapter: LineUpItemOfDayAdapter by lazy {
LineUpItemOfDayAdapter()
}

override fun onViewCreated(
view: View,
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
setupObservers()
setupAdapters()
setupNavigateToScheduleButton()
setupNavigateToExploreButton()
}

private fun setupNavigateToExploreButton() {
binding.layoutTitleWithIcon.setOnClickListener {
binding.logger.log(ExploreClickLogData(binding.logger.getBaseLogData()))

startActivity(ExploreActivity.newIntent(requireContext()))
}
}

private fun setupNavigateToScheduleButton() {
binding.btnNavigateToSchedule.setOnClickListener {
binding.logger.log(
ScheduleClickLogData(
baseLogData = binding.logger.getBaseLogData(),
),
)

viewModel.navigateToScheduleClick()
}
}

private fun setupObservers() {
viewModel.festivalUiState.observe(viewLifecycleOwner) { festivalUiState ->
when (festivalUiState) {
is FestivalUiState.Loading -> {}
is FestivalUiState.Success -> handleSuccessState(festivalUiState)
is FestivalUiState.Error -> {
showErrorSnackBar(festivalUiState.throwable)
Timber.w(
festivalUiState.throwable,
"HomeFragment: ${festivalUiState.throwable.message}",
)
}
}
}
viewModel.lineupUiState.observe(viewLifecycleOwner) { lineupUiState ->
when (lineupUiState) {
is LineupUiState.Loading -> {}
is LineupUiState.Success -> {
lineupOfDayAdapter.submitList(lineupUiState.lineups.getLineupItems())
}

is LineupUiState.Error -> {
showErrorSnackBar(lineupUiState.throwable)
Timber.w(
lineupUiState.throwable,
"HomeFragment: ${lineupUiState.throwable.message}",
)
}
}
}
}

private fun setupAdapters() {
binding.rvHomePoster.adapter = posterAdapter
binding.rvHomeLineup.adapter = lineupOfDayAdapter
attachSnapHelper()
addScrollEffectListener()
}

private fun handleSuccessState(festivalUiState: FestivalUiState.Success) {
binding.tvHomeOrganizationTitle.text =
festivalUiState.organization.universityName
binding.tvHomeFestivalTitle.text =
festivalUiState.organization.festival.festivalName
binding.tvHomeFestivalDate.text =
formatFestivalPeriod(
festivalUiState.organization.festival.startDate,
festivalUiState.organization.festival.endDate,
)

val posterUrls =
festivalUiState.organization.festival.festivalImages
.sortedBy { it.sequence }
.map { it.imageUrl }

if (posterUrls.isNotEmpty()) {
posterAdapter.submitList(posterUrls) {
scrollToInitialPosition(posterUrls.size)
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
HomeScreen(
viewModel = viewModel,
onNavigateToExplore = {
startActivity(ExploreActivity.newIntent(requireContext()))
},
)
}
}
binding.logger.log(
HomeViewLogData(
baseLogData = binding.logger.getBaseLogData(),
universityName = festivalUiState.organization.universityName,
festivalId = festivalUiState.organization.id,
),
)
}

private fun attachSnapHelper() {
PagerSnapHelper().attachToRecyclerView(binding.rvHomePoster)
}

private fun scrollToInitialPosition(size: Int) {
val safeMaxValue = Int.MAX_VALUE / INFINITE_SCROLL_SAFETY_FACTOR
val initialPosition = safeMaxValue - (safeMaxValue % size)

val layoutManager = binding.rvHomePoster.layoutManager as? LinearLayoutManager ?: return

val itemWidth = resources.getDimensionPixelSize(R.dimen.poster_item_width)
val offset = (binding.rvHomePoster.width / 2) - (itemWidth / 2)

layoutManager.scrollToPositionWithOffset(initialPosition, offset)

binding.rvHomePoster.post {
(centerItemMotionEnlarger as CenterItemMotionEnlarger).expandCenterItem(binding.rvHomePoster)
}
}

private fun addScrollEffectListener() {
binding.rvHomePoster.addOnScrollListener(centerItemMotionEnlarger)
}

override fun onDestroyView() {
binding.rvHomePoster.clearOnScrollListeners()
super.onDestroyView()
}

companion object {
private const val INFINITE_SCROLL_SAFETY_FACTOR = 4
}
}
Loading