Skip to content

Commit 15bfa16

Browse files
committed
[NDGL-66] feature: AddPlace 화면 제작 및 관련 컴포넌트 추가
1 parent 6246974 commit 15bfa16

6 files changed

Lines changed: 763 additions & 0 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.yapp.ndgl.feature.travel.addplace
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.PlaceDetailTab
8+
import com.yapp.ndgl.feature.travel.model.PlacePhoto
9+
import com.yapp.ndgl.feature.travel.model.PlaceType
10+
import com.yapp.ndgl.feature.travel.model.PriceRange
11+
import kotlin.time.Duration
12+
import kotlin.time.Duration.Companion.hours
13+
14+
data class AddPlaceState(
15+
val placeInfo: AddPlaceInfo = AddPlaceInfo(),
16+
val selectedTab: PlaceDetailTab = PlaceDetailTab.INFO,
17+
val photos: List<PlacePhoto> = emptyList(),
18+
) : UiState
19+
20+
data class AddPlaceInfo(
21+
val id: String = "",
22+
val name: String = "",
23+
val placeType: PlaceType = PlaceType.ATTRACTION,
24+
val priceRange: PriceRange? = null,
25+
val rating: Double? = null,
26+
val userRatingCount: Int? = null,
27+
val address: String? = null,
28+
val phoneNumber: String? = null,
29+
val openingHours: String? = null,
30+
val googleMapsUri: String? = null,
31+
val websiteUrl: String? = null,
32+
val estimatedDuration: Duration = 1.hours,
33+
val thumbnail: String? = null,
34+
val latitude: Double = 0.0,
35+
val longitude: Double = 0.0,
36+
) {
37+
val formattedRatingCount: String
38+
get() = userRatingCount?.formatDecimal() ?: ""
39+
}
40+
41+
sealed interface AddPlaceIntent : UiIntent {
42+
data class SelectTab(val tab: PlaceDetailTab) : AddPlaceIntent
43+
data object ClickAddress : AddPlaceIntent
44+
data object ClickMenu : AddPlaceIntent
45+
data object ClickBack : AddPlaceIntent
46+
data object ClickAddItinerary : AddPlaceIntent
47+
}
48+
49+
sealed interface AddPlaceSideEffect : UiSideEffect {
50+
data object NavigateBack : AddPlaceSideEffect
51+
data class NavigateToBrowser(val url: String) : AddPlaceSideEffect
52+
}
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
package com.yapp.ndgl.feature.travel.addplace
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.PaddingValues
8+
import androidx.compose.foundation.layout.Row
9+
import androidx.compose.foundation.layout.Spacer
10+
import androidx.compose.foundation.layout.fillMaxSize
11+
import androidx.compose.foundation.layout.fillMaxWidth
12+
import androidx.compose.foundation.layout.height
13+
import androidx.compose.foundation.layout.navigationBarsPadding
14+
import androidx.compose.foundation.layout.padding
15+
import androidx.compose.foundation.lazy.LazyColumn
16+
import androidx.compose.foundation.lazy.rememberLazyListState
17+
import androidx.compose.foundation.shape.RoundedCornerShape
18+
import androidx.compose.material3.HorizontalDivider
19+
import androidx.compose.material3.Icon
20+
import androidx.compose.material3.Scaffold
21+
import androidx.compose.material3.Text
22+
import androidx.compose.runtime.Composable
23+
import androidx.compose.runtime.getValue
24+
import androidx.compose.runtime.mutableFloatStateOf
25+
import androidx.compose.runtime.remember
26+
import androidx.compose.runtime.setValue
27+
import androidx.compose.ui.Alignment
28+
import androidx.compose.ui.Modifier
29+
import androidx.compose.ui.draw.clip
30+
import androidx.compose.ui.draw.clipToBounds
31+
import androidx.compose.ui.geometry.Offset
32+
import androidx.compose.ui.graphics.Color
33+
import androidx.compose.ui.graphics.vector.ImageVector
34+
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
35+
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
36+
import androidx.compose.ui.input.nestedscroll.nestedScroll
37+
import androidx.compose.ui.layout.ContentScale
38+
import androidx.compose.ui.platform.LocalContext
39+
import androidx.compose.ui.platform.LocalDensity
40+
import androidx.compose.ui.res.stringResource
41+
import androidx.compose.ui.res.vectorResource
42+
import androidx.compose.ui.text.SpanStyle
43+
import androidx.compose.ui.text.buildAnnotatedString
44+
import androidx.compose.ui.text.withStyle
45+
import androidx.compose.ui.tooling.preview.Preview
46+
import androidx.compose.ui.unit.dp
47+
import coil3.compose.AsyncImage
48+
import com.yapp.ndgl.core.ui.R
49+
import com.yapp.ndgl.core.ui.designsystem.NDGLCTAButton
50+
import com.yapp.ndgl.core.ui.designsystem.NDGLCTAButtonAttr
51+
import com.yapp.ndgl.core.ui.designsystem.NDGLNavigationBar
52+
import com.yapp.ndgl.core.ui.designsystem.NDGLNavigationBarAttr
53+
import com.yapp.ndgl.core.ui.theme.NDGLTheme
54+
import com.yapp.ndgl.core.ui.util.launchBrowser
55+
import com.yapp.ndgl.feature.travel.addplace.component.AddPlaceInfoTab
56+
import com.yapp.ndgl.feature.travel.addplace.component.AddPlacePhotoTab
57+
import com.yapp.ndgl.feature.travel.addplace.component.AddPlaceTabRow
58+
import com.yapp.ndgl.feature.travel.model.PlaceDetailTab
59+
import com.yapp.ndgl.feature.travel.model.PlacePhoto
60+
import com.yapp.ndgl.feature.travel.model.PlaceType
61+
import com.yapp.ndgl.feature.travel.model.Price
62+
import com.yapp.ndgl.feature.travel.model.PriceRange
63+
64+
@Composable
65+
internal fun AddPlaceRoute(
66+
viewModel: AddPlaceViewModel,
67+
navigateBack: () -> Unit,
68+
) {
69+
val state by viewModel.collectAsState()
70+
val context = LocalContext.current
71+
72+
viewModel.collectSideEffect { sideEffect ->
73+
when (sideEffect) {
74+
is AddPlaceSideEffect.NavigateBack -> navigateBack()
75+
is AddPlaceSideEffect.NavigateToBrowser -> context.launchBrowser(sideEffect.url)
76+
}
77+
}
78+
79+
AddPlaceScreen(
80+
state = state,
81+
clickBack = { viewModel.onIntent(AddPlaceIntent.ClickBack) },
82+
selectTab = { viewModel.onIntent(AddPlaceIntent.SelectTab(it)) },
83+
clickAddress = { viewModel.onIntent(AddPlaceIntent.ClickAddress) },
84+
clickMenu = { viewModel.onIntent(AddPlaceIntent.ClickMenu) },
85+
clickAddItinerary = { viewModel.onIntent(AddPlaceIntent.ClickAddItinerary) },
86+
)
87+
}
88+
89+
@Composable
90+
private fun AddPlaceScreen(
91+
state: AddPlaceState,
92+
clickBack: () -> Unit = {},
93+
selectTab: (PlaceDetailTab) -> Unit = {},
94+
clickAddress: () -> Unit = {},
95+
clickMenu: () -> Unit = {},
96+
clickAddItinerary: () -> Unit = {},
97+
) {
98+
Scaffold(
99+
containerColor = NDGLTheme.colors.white,
100+
bottomBar = {
101+
Column(
102+
modifier = Modifier
103+
.fillMaxWidth()
104+
.background(NDGLTheme.colors.white)
105+
.navigationBarsPadding()
106+
.padding(horizontal = 24.dp, vertical = 16.dp),
107+
) {
108+
NDGLCTAButton(
109+
modifier = Modifier.fillMaxWidth(),
110+
type = NDGLCTAButtonAttr.Type.PRIMARY,
111+
size = NDGLCTAButtonAttr.Size.LARGE,
112+
status = NDGLCTAButtonAttr.Status.ACTIVE,
113+
label = stringResource(R.string.add_schedule),
114+
onClick = clickAddItinerary,
115+
)
116+
}
117+
},
118+
) { innerPadding ->
119+
AddPlaceContent(
120+
state = state,
121+
innerPadding = innerPadding,
122+
clickBack = clickBack,
123+
selectTab = selectTab,
124+
clickAddress = clickAddress,
125+
clickMenu = clickMenu,
126+
)
127+
}
128+
}
129+
130+
@Composable
131+
private fun AddPlaceContent(
132+
state: AddPlaceState,
133+
innerPadding: PaddingValues,
134+
clickBack: () -> Unit,
135+
selectTab: (PlaceDetailTab) -> Unit,
136+
clickAddress: () -> Unit,
137+
clickMenu: () -> Unit,
138+
) {
139+
val placeInfo = state.placeInfo
140+
val listState = rememberLazyListState()
141+
val density = LocalDensity.current
142+
val thumbnailHeight = 230.dp
143+
val navBarSectionHeight = 48.dp
144+
val thumbnailHeightPx = with(density) { thumbnailHeight.toPx() }
145+
val navBarSectionHeightPx = with(density) { navBarSectionHeight.toPx() }
146+
val maxCollapseHeightPx = navBarSectionHeightPx + thumbnailHeightPx
147+
148+
var collapseOffset by remember { mutableFloatStateOf(0f) }
149+
150+
val nestedScrollConnection = remember {
151+
object : NestedScrollConnection {
152+
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
153+
if (available.y < 0f) {
154+
val oldOffset = collapseOffset
155+
collapseOffset = (collapseOffset - available.y).coerceIn(0f, maxCollapseHeightPx)
156+
return Offset(0f, -(collapseOffset - oldOffset))
157+
}
158+
return Offset.Zero
159+
}
160+
161+
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
162+
if (available.y > 0f) {
163+
val oldOffset = collapseOffset
164+
collapseOffset = (collapseOffset - available.y).coerceIn(0f, maxCollapseHeightPx)
165+
return Offset(0f, oldOffset - collapseOffset)
166+
}
167+
return Offset.Zero
168+
}
169+
}
170+
}
171+
172+
val navBarProgress = (1f - collapseOffset / navBarSectionHeightPx).coerceIn(0f, 1f)
173+
val thumbnailProgress = (1f - (collapseOffset - navBarSectionHeightPx).coerceAtLeast(0f) / thumbnailHeightPx).coerceIn(0f, 1f)
174+
175+
Box(
176+
modifier = Modifier
177+
.fillMaxSize()
178+
.background(NDGLTheme.colors.white)
179+
.padding(innerPadding),
180+
) {
181+
Column(
182+
modifier = Modifier
183+
.fillMaxSize()
184+
.nestedScroll(nestedScrollConnection),
185+
) {
186+
if (navBarProgress > 0f) {
187+
Column(
188+
modifier = Modifier
189+
.fillMaxWidth()
190+
.height((navBarSectionHeight * navBarProgress).coerceAtLeast(0.dp))
191+
.clipToBounds(),
192+
) {
193+
NDGLNavigationBar(
194+
textAlignType = NDGLNavigationBarAttr.TextAlignType.CENTER,
195+
leadingIcon = R.drawable.ic_28_chevron_left,
196+
onLeadingIconClick = clickBack,
197+
)
198+
Spacer(Modifier.height(20.dp))
199+
}
200+
}
201+
202+
Column(
203+
Modifier
204+
.fillMaxWidth()
205+
.background(NDGLTheme.colors.white)
206+
.padding(horizontal = 24.dp)
207+
.padding(top = if (navBarProgress == 0f) 8.dp else 0.dp),
208+
verticalArrangement = Arrangement.spacedBy(8.dp),
209+
) {
210+
Text(placeInfo.name, color = NDGLTheme.colors.black800, style = NDGLTheme.typography.titleMdSemiBold)
211+
Row(
212+
modifier = Modifier.fillMaxWidth(),
213+
horizontalArrangement = Arrangement.spacedBy(2.dp),
214+
verticalAlignment = Alignment.CenterVertically,
215+
) {
216+
Icon(imageVector = ImageVector.vectorResource(placeInfo.placeType.iconRes), contentDescription = null, tint = Color.Unspecified)
217+
val placeTypeLabel = stringResource(placeInfo.placeType.labelRes)
218+
val reviewLabel = if (placeInfo.rating != null) {
219+
stringResource(R.string.place_detail_review_format, placeInfo.rating)
220+
} else {
221+
null
222+
}
223+
Text(
224+
text = buildAnnotatedString {
225+
withStyle(style = SpanStyle(color = NDGLTheme.colors.black500)) {
226+
append(placeTypeLabel)
227+
placeInfo.priceRange?.let { priceRange ->
228+
append("" + priceRange.formattedPriceRange)
229+
}
230+
if (reviewLabel != null) append("$reviewLabel")
231+
}
232+
if (placeInfo.userRatingCount != null) {
233+
withStyle(style = SpanStyle(color = NDGLTheme.colors.black300)) {
234+
append(" (${placeInfo.formattedRatingCount})")
235+
}
236+
}
237+
},
238+
style = NDGLTheme.typography.bodyMdMedium,
239+
)
240+
}
241+
}
242+
Spacer(
243+
Modifier
244+
.fillMaxWidth()
245+
.height(20.dp)
246+
.background(NDGLTheme.colors.white),
247+
)
248+
249+
if (thumbnailProgress > 0f) {
250+
AsyncImage(
251+
model = placeInfo.thumbnail,
252+
contentDescription = null,
253+
modifier = Modifier
254+
.fillMaxWidth()
255+
.height(thumbnailHeight * thumbnailProgress)
256+
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
257+
.background(Color.LightGray),
258+
contentScale = ContentScale.Crop,
259+
)
260+
}
261+
262+
Column(Modifier.background(NDGLTheme.colors.white)) {
263+
AddPlaceTabRow(
264+
selectedTab = state.selectedTab,
265+
onTabSelected = selectTab,
266+
)
267+
HorizontalDivider(thickness = 1.dp, color = NDGLTheme.colors.black200)
268+
}
269+
270+
LazyColumn(
271+
modifier = Modifier.weight(1f),
272+
state = listState,
273+
) {
274+
when (state.selectedTab) {
275+
PlaceDetailTab.INFO -> {
276+
item {
277+
Spacer(Modifier.height(24.dp))
278+
AddPlaceInfoTab(
279+
placeInfo = placeInfo,
280+
clickAddress = clickAddress,
281+
clickMenu = clickMenu,
282+
)
283+
}
284+
}
285+
286+
PlaceDetailTab.PHOTO -> {
287+
val (leftPhotos, rightPhotos) = state.photos.foldIndexed(
288+
initial = mutableListOf<PlacePhoto>() to mutableListOf<PlacePhoto>(),
289+
) { index, lists, photo ->
290+
if (index % 2 == 0) {
291+
lists.first.add(photo)
292+
} else {
293+
lists.second.add(photo)
294+
}
295+
lists
296+
}
297+
298+
item {
299+
Spacer(Modifier.height(20.dp))
300+
AddPlacePhotoTab(leftPhotos = leftPhotos, rightPhotos = rightPhotos)
301+
}
302+
}
303+
}
304+
305+
item { Spacer(Modifier.height(60.dp)) }
306+
}
307+
}
308+
}
309+
}
310+
311+
@Preview(showBackground = true)
312+
@Composable
313+
private fun AddPlaceScreenPreview() {
314+
NDGLTheme {
315+
AddPlaceScreen(
316+
state = AddPlaceState(
317+
placeInfo = AddPlaceInfo(
318+
id = "",
319+
name = "젤라테리아 파씨 (Gelateria Fassi)",
320+
placeType = PlaceType.RESTAURANT,
321+
address = "Via Principe Eugenio, 65, 00185 Roma RM, Italy",
322+
phoneNumber = "+39 06 446 4740",
323+
openingHours = "매일 12:00 ~ 24:00",
324+
websiteUrl = "https://www.gelateriafassi.com",
325+
rating = 4.7,
326+
userRatingCount = 12450,
327+
priceRange = PriceRange(
328+
startPrice = Price(currencyCode = "EUR", units = "5", symbol = ""),
329+
endPrice = Price(currencyCode = "EUR", units = "15", symbol = ""),
330+
),
331+
),
332+
),
333+
)
334+
}
335+
}

0 commit comments

Comments
 (0)