Skip to content

Commit 30555df

Browse files
committed
[NDGL-69] feature: FollowPlaceDetail 화면 제작 및 관련 컴포넌트 추가
1 parent a56f126 commit 30555df

7 files changed

Lines changed: 925 additions & 0 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.yapp.ndgl.feature.travel.followtravel.placedetail
2+
3+
import com.yapp.ndgl.core.base.UiIntent
4+
import com.yapp.ndgl.core.base.UiSideEffect
5+
import com.yapp.ndgl.core.base.UiState
6+
import com.yapp.ndgl.core.util.formatDecimal
7+
import com.yapp.ndgl.feature.travel.model.AlternativePlace
8+
import com.yapp.ndgl.feature.travel.model.PlaceDetailTab
9+
import com.yapp.ndgl.feature.travel.model.PlacePhoto
10+
import com.yapp.ndgl.feature.travel.model.PlaceType
11+
import com.yapp.ndgl.feature.travel.model.PriceRange
12+
import com.yapp.ndgl.feature.travel.model.TipContent
13+
import kotlin.time.Duration
14+
import kotlin.time.Duration.Companion.hours
15+
16+
data class FollowPlaceDetailState(
17+
val placeInfo: FollowPlaceInfo = FollowPlaceInfo(),
18+
val selectedTab: PlaceDetailTab = PlaceDetailTab.INFO,
19+
val photos: List<PlacePhoto> = emptyList(),
20+
) : UiState
21+
22+
data class FollowPlaceInfo(
23+
val id: String = "",
24+
val name: String = "",
25+
val placeType: PlaceType = PlaceType.ATTRACTION,
26+
val priceRange: PriceRange? = null,
27+
val rating: Double? = null,
28+
val userRatingCount: Int? = null,
29+
val address: String? = null,
30+
val phoneNumber: String? = null,
31+
val openingHours: String? = null,
32+
val googleMapsUri: String? = null,
33+
val websiteUrl: String? = null,
34+
val estimatedDuration: Duration = 1.hours,
35+
val thumbnail: String = "",
36+
val tipContent: TipContent? = null,
37+
val latitude: Double = 0.0,
38+
val longitude: Double = 0.0,
39+
val alternativePlaces: List<AlternativePlace> = emptyList(),
40+
) {
41+
val formattedRatingCount: String
42+
get() = userRatingCount?.formatDecimal() ?: ""
43+
}
44+
45+
sealed interface FollowPlaceDetailIntent : UiIntent {
46+
data class SelectTab(val tab: PlaceDetailTab) : FollowPlaceDetailIntent
47+
data object ClickAddress : FollowPlaceDetailIntent
48+
data object ClickMenu : FollowPlaceDetailIntent
49+
}
50+
51+
sealed interface FollowPlaceDetailSideEffect : UiSideEffect {
52+
data class NavigateToBrowser(val url: String) : FollowPlaceDetailSideEffect
53+
}
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
package com.yapp.ndgl.feature.travel.followtravel.placedetail
2+
3+
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.layout.Arrangement
5+
import androidx.compose.foundation.layout.Box
6+
import androidx.compose.foundation.layout.Column
7+
import androidx.compose.foundation.layout.Row
8+
import androidx.compose.foundation.layout.Spacer
9+
import androidx.compose.foundation.layout.fillMaxSize
10+
import androidx.compose.foundation.layout.fillMaxWidth
11+
import androidx.compose.foundation.layout.height
12+
import androidx.compose.foundation.layout.padding
13+
import androidx.compose.foundation.lazy.LazyColumn
14+
import androidx.compose.foundation.lazy.rememberLazyListState
15+
import androidx.compose.foundation.shape.RoundedCornerShape
16+
import androidx.compose.material3.HorizontalDivider
17+
import androidx.compose.material3.Icon
18+
import androidx.compose.material3.Scaffold
19+
import androidx.compose.material3.Text
20+
import androidx.compose.runtime.Composable
21+
import androidx.compose.runtime.getValue
22+
import androidx.compose.runtime.mutableFloatStateOf
23+
import androidx.compose.runtime.remember
24+
import androidx.compose.runtime.setValue
25+
import androidx.compose.ui.Alignment
26+
import androidx.compose.ui.Modifier
27+
import androidx.compose.ui.draw.clip
28+
import androidx.compose.ui.draw.clipToBounds
29+
import androidx.compose.ui.geometry.Offset
30+
import androidx.compose.ui.graphics.Color
31+
import androidx.compose.ui.graphics.vector.ImageVector
32+
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
33+
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
34+
import androidx.compose.ui.input.nestedscroll.nestedScroll
35+
import androidx.compose.ui.layout.ContentScale
36+
import androidx.compose.ui.platform.LocalContext
37+
import androidx.compose.ui.platform.LocalDensity
38+
import androidx.compose.ui.res.stringResource
39+
import androidx.compose.ui.res.vectorResource
40+
import androidx.compose.ui.text.SpanStyle
41+
import androidx.compose.ui.text.buildAnnotatedString
42+
import androidx.compose.ui.text.withStyle
43+
import androidx.compose.ui.unit.dp
44+
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
45+
import coil3.compose.AsyncImage
46+
import com.yapp.ndgl.core.ui.R
47+
import com.yapp.ndgl.core.ui.designsystem.NDGLNavigationBar
48+
import com.yapp.ndgl.core.ui.designsystem.NDGLNavigationBarAttr
49+
import com.yapp.ndgl.core.ui.theme.NDGLTheme
50+
import com.yapp.ndgl.core.ui.util.launchBrowser
51+
import com.yapp.ndgl.feature.travel.followtravel.placedetail.component.FollowPlaceDetailTabRow
52+
import com.yapp.ndgl.feature.travel.followtravel.placedetail.component.FollowPlaceInfoTab
53+
import com.yapp.ndgl.feature.travel.followtravel.placedetail.component.FollowPlacePhotoTab
54+
import com.yapp.ndgl.feature.travel.model.PlaceDetailTab
55+
import com.yapp.ndgl.feature.travel.model.PlacePhoto
56+
57+
@Composable
58+
internal fun FollowPlaceDetailRoute(
59+
viewModel: FollowPlaceDetailViewModel = hiltViewModel(),
60+
navigateBack: () -> Unit,
61+
) {
62+
val state by viewModel.collectAsState()
63+
val context = LocalContext.current
64+
65+
viewModel.collectSideEffect { sideEffect ->
66+
when (sideEffect) {
67+
is FollowPlaceDetailSideEffect.NavigateToBrowser -> context.launchBrowser(sideEffect.url)
68+
}
69+
}
70+
71+
FollowPlaceDetailScreen(
72+
state = state,
73+
clickBackButton = navigateBack,
74+
selectTab = { viewModel.onIntent(FollowPlaceDetailIntent.SelectTab(it)) },
75+
clickAddress = { viewModel.onIntent(FollowPlaceDetailIntent.ClickAddress) },
76+
clickMenu = { viewModel.onIntent(FollowPlaceDetailIntent.ClickMenu) },
77+
)
78+
}
79+
80+
@Composable
81+
private fun FollowPlaceDetailScreen(
82+
state: FollowPlaceDetailState,
83+
clickBackButton: () -> Unit,
84+
selectTab: (PlaceDetailTab) -> Unit,
85+
clickAddress: () -> Unit,
86+
clickMenu: () -> Unit,
87+
) {
88+
val placeInfo = state.placeInfo
89+
val listState = rememberLazyListState()
90+
val density = LocalDensity.current
91+
val thumbnailHeight = 230.dp
92+
val navBarSectionHeight = 48.dp
93+
val thumbnailHeightPx = with(density) { thumbnailHeight.toPx() }
94+
val navBarSectionHeightPx = with(density) { navBarSectionHeight.toPx() }
95+
val maxCollapseHeightPx = navBarSectionHeightPx + thumbnailHeightPx
96+
97+
var collapseOffset by remember { mutableFloatStateOf(0f) }
98+
99+
val nestedScrollConnection = remember {
100+
object : NestedScrollConnection {
101+
// 아래로 스크롤 시 LazyColumn 보다 먼저 헤더를 축소
102+
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
103+
if (available.y < 0f) {
104+
val oldOffset = collapseOffset
105+
collapseOffset = (collapseOffset - available.y).coerceIn(0f, maxCollapseHeightPx)
106+
return Offset(0f, -(collapseOffset - oldOffset))
107+
}
108+
return Offset.Zero
109+
}
110+
111+
// 위로 스크롤 시 LazyColumn이 끝까지 올라간 후 헤더를 다시 펼침
112+
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
113+
if (available.y > 0f) {
114+
val oldOffset = collapseOffset
115+
collapseOffset = (collapseOffset - available.y).coerceIn(0f, maxCollapseHeightPx)
116+
return Offset(0f, oldOffset - collapseOffset)
117+
}
118+
return Offset.Zero
119+
}
120+
}
121+
}
122+
123+
val navBarProgress = (1f - collapseOffset / navBarSectionHeightPx).coerceIn(0f, 1f)
124+
val thumbnailProgress = (1f - (collapseOffset - navBarSectionHeightPx).coerceAtLeast(0f) / thumbnailHeightPx).coerceIn(0f, 1f)
125+
126+
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
127+
Box(
128+
modifier = Modifier
129+
.fillMaxSize()
130+
.background(NDGLTheme.colors.white)
131+
.padding(innerPadding),
132+
) {
133+
Column(
134+
modifier = Modifier
135+
.fillMaxSize()
136+
.nestedScroll(nestedScrollConnection),
137+
) {
138+
if (navBarProgress > 0f) {
139+
Column(
140+
modifier = Modifier
141+
.fillMaxWidth()
142+
.height((navBarSectionHeight * navBarProgress).coerceAtLeast(0.dp))
143+
.clipToBounds(),
144+
) {
145+
NDGLNavigationBar(
146+
textAlignType = NDGLNavigationBarAttr.TextAlignType.CENTER,
147+
leadingIcon = R.drawable.ic_28_chevron_left,
148+
onLeadingIconClick = clickBackButton,
149+
)
150+
Spacer(Modifier.height(20.dp))
151+
}
152+
}
153+
154+
Column(
155+
Modifier
156+
.fillMaxWidth()
157+
.background(NDGLTheme.colors.white)
158+
.padding(horizontal = 24.dp)
159+
.padding(top = if (navBarProgress == 0f) 8.dp else 0.dp),
160+
verticalArrangement = Arrangement.spacedBy(8.dp),
161+
) {
162+
Text(placeInfo.name, color = NDGLTheme.colors.black800, style = NDGLTheme.typography.titleMdSemiBold)
163+
Row(
164+
modifier = Modifier.fillMaxWidth(),
165+
horizontalArrangement = Arrangement.spacedBy(2.dp),
166+
verticalAlignment = Alignment.CenterVertically,
167+
) {
168+
Icon(
169+
imageVector = ImageVector.vectorResource(placeInfo.placeType.iconRes),
170+
contentDescription = null,
171+
tint = Color.Unspecified,
172+
)
173+
val placeTypeLabel = stringResource(placeInfo.placeType.labelRes)
174+
val reviewLabel = if (placeInfo.rating != null) {
175+
stringResource(R.string.place_detail_review_format, placeInfo.rating)
176+
} else {
177+
null
178+
}
179+
Text(
180+
text = buildAnnotatedString {
181+
withStyle(style = SpanStyle(color = NDGLTheme.colors.black500)) {
182+
append(placeTypeLabel)
183+
placeInfo.priceRange?.let { priceRange ->
184+
append("" + priceRange.formattedPriceRange)
185+
}
186+
append(reviewLabel?.let { "$it" })
187+
}
188+
if (placeInfo.userRatingCount != null) {
189+
withStyle(style = SpanStyle(color = NDGLTheme.colors.black300)) {
190+
append(" (${placeInfo.formattedRatingCount})")
191+
}
192+
}
193+
},
194+
style = NDGLTheme.typography.bodyMdMedium,
195+
)
196+
}
197+
}
198+
Spacer(
199+
Modifier
200+
.fillMaxWidth()
201+
.height(20.dp)
202+
.background(NDGLTheme.colors.white),
203+
)
204+
205+
if (thumbnailProgress > 0f) {
206+
AsyncImage(
207+
model = state.placeInfo.thumbnail,
208+
contentDescription = null,
209+
modifier = Modifier
210+
.fillMaxWidth()
211+
.height(thumbnailHeight * thumbnailProgress)
212+
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
213+
.background(Color.LightGray),
214+
contentScale = ContentScale.Crop,
215+
)
216+
}
217+
218+
Column(Modifier.background(NDGLTheme.colors.white)) {
219+
FollowPlaceDetailTabRow(
220+
selectedTab = state.selectedTab,
221+
onTabSelected = selectTab,
222+
)
223+
HorizontalDivider(thickness = 1.dp, color = NDGLTheme.colors.black200)
224+
}
225+
226+
LazyColumn(
227+
modifier = Modifier.weight(1f),
228+
state = listState,
229+
) {
230+
when (state.selectedTab) {
231+
PlaceDetailTab.INFO -> {
232+
item {
233+
Spacer(Modifier.height(24.dp))
234+
FollowPlaceInfoTab(
235+
placeInfo = state.placeInfo,
236+
clickAddress = clickAddress,
237+
clickMenu = clickMenu,
238+
)
239+
}
240+
}
241+
242+
PlaceDetailTab.PHOTO -> {
243+
val (leftPhotos, rightPhotos) = state.photos.foldIndexed(
244+
initial = mutableListOf<PlacePhoto>() to mutableListOf<PlacePhoto>(),
245+
) { index, lists, photo ->
246+
if (index % 2 == 0) {
247+
lists.first.add(photo)
248+
} else {
249+
lists.second.add(photo)
250+
}
251+
lists
252+
}
253+
254+
item {
255+
Spacer(Modifier.height(20.dp))
256+
FollowPlacePhotoTab(leftPhotos = leftPhotos, rightPhotos = rightPhotos)
257+
}
258+
}
259+
}
260+
261+
item { Spacer(Modifier.height(60.dp)) }
262+
}
263+
}
264+
}
265+
}
266+
}

0 commit comments

Comments
 (0)