Skip to content

Commit 822f9b3

Browse files
committed
[NDGL-53] feature: TimelineContent 추가
1 parent 0a9a545 commit 822f9b3

2 files changed

Lines changed: 350 additions & 2 deletions

File tree

feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailViewModel.kt

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,13 +307,34 @@ class TravelDetailViewModel @AssistedInject constructor(
307307
reduce {
308308
copy(
309309
startTime = duration,
310-
endTime = duration + 15.hours,
310+
endTime = duration + 15.hours, // FIXME
311311
)
312312
}
313313
}
314314

315315
private fun reorderPlaces(fromIndex: Int, toIndex: Int) {
316-
// TODO
316+
reduce {
317+
val updatedItineraries = tempItineraries.map { itinerary ->
318+
val mutablePlaces = itinerary.places.toMutableList()
319+
320+
if (fromIndex in mutablePlaces.indices && toIndex in mutablePlaces.indices) {
321+
val movedItem = mutablePlaces.removeAt(fromIndex)
322+
mutablePlaces.add(toIndex, movedItem)
323+
324+
val reorderedPlaces = mutablePlaces.mapIndexed { index, place ->
325+
place.copy(sequence = index + 1)
326+
}
327+
328+
itinerary.copy(
329+
places = reorderedPlaces,
330+
)
331+
} else {
332+
itinerary
333+
}
334+
}
335+
336+
copy(tempItineraries = updatedItineraries)
337+
}
317338
}
318339

319340
private fun confirmEditMode() {
Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
package com.yapp.ndgl.feature.travel.traveldetail.component
2+
3+
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.clickable
5+
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
6+
import androidx.compose.foundation.layout.Arrangement
7+
import androidx.compose.foundation.layout.Box
8+
import androidx.compose.foundation.layout.Column
9+
import androidx.compose.foundation.layout.Row
10+
import androidx.compose.foundation.layout.Spacer
11+
import androidx.compose.foundation.layout.fillMaxWidth
12+
import androidx.compose.foundation.layout.height
13+
import androidx.compose.foundation.layout.padding
14+
import androidx.compose.foundation.layout.size
15+
import androidx.compose.foundation.layout.wrapContentHeight
16+
import androidx.compose.foundation.lazy.LazyColumn
17+
import androidx.compose.foundation.lazy.rememberLazyListState
18+
import androidx.compose.foundation.shape.RoundedCornerShape
19+
import androidx.compose.material3.Icon
20+
import androidx.compose.material3.Text
21+
import androidx.compose.runtime.Composable
22+
import androidx.compose.runtime.LaunchedEffect
23+
import androidx.compose.runtime.derivedStateOf
24+
import androidx.compose.runtime.getValue
25+
import androidx.compose.runtime.mutableIntStateOf
26+
import androidx.compose.runtime.mutableStateOf
27+
import androidx.compose.runtime.remember
28+
import androidx.compose.runtime.setValue
29+
import androidx.compose.ui.Alignment
30+
import androidx.compose.ui.Modifier
31+
import androidx.compose.ui.draw.clip
32+
import androidx.compose.ui.geometry.Offset
33+
import androidx.compose.ui.graphics.graphicsLayer
34+
import androidx.compose.ui.graphics.vector.ImageVector
35+
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
36+
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
37+
import androidx.compose.ui.input.nestedscroll.nestedScroll
38+
import androidx.compose.ui.res.stringResource
39+
import androidx.compose.ui.res.vectorResource
40+
import androidx.compose.ui.tooling.preview.Preview
41+
import androidx.compose.ui.unit.Dp
42+
import androidx.compose.ui.unit.dp
43+
import androidx.compose.ui.unit.sp
44+
import com.yapp.ndgl.core.ui.R
45+
import com.yapp.ndgl.core.ui.designsystem.NDGLCTAButton
46+
import com.yapp.ndgl.core.ui.designsystem.NDGLCTAButtonAttr
47+
import com.yapp.ndgl.core.ui.theme.NDGLTheme
48+
import com.yapp.ndgl.core.util.toTimeString
49+
import kotlin.math.abs
50+
import kotlin.time.Duration
51+
import kotlin.time.Duration.Companion.hours
52+
import kotlin.time.Duration.Companion.minutes
53+
54+
@Composable
55+
internal fun TimelineContent(
56+
startTime: Duration,
57+
endTime: Duration,
58+
onDismissRequest: () -> Unit,
59+
onConfirm: (Duration) -> Unit,
60+
) {
61+
var isSettingStartTime by remember { mutableStateOf(false) }
62+
var selectedStartTime by remember { mutableStateOf(startTime) }
63+
var selectedEndTime by remember { mutableStateOf(endTime) }
64+
65+
Column(
66+
modifier = Modifier
67+
.fillMaxWidth()
68+
.padding(24.dp),
69+
) {
70+
Row(
71+
modifier = Modifier.fillMaxWidth(),
72+
verticalAlignment = Alignment.CenterVertically,
73+
) {
74+
Text(
75+
text = stringResource(R.string.timeline_auto_setting_title),
76+
color = NDGLTheme.colors.black900,
77+
style = NDGLTheme.typography.titleMdSemiBold,
78+
)
79+
Spacer(Modifier.weight(1f))
80+
Box(
81+
modifier = Modifier
82+
.size(32.dp)
83+
.clip(RoundedCornerShape(8.dp))
84+
.background(NDGLTheme.colors.black50)
85+
.clickable { onDismissRequest() }
86+
.padding(8.dp),
87+
) {
88+
Icon(
89+
imageVector = ImageVector.vectorResource(R.drawable.ic_24_close),
90+
contentDescription = stringResource(R.string.close),
91+
tint = NDGLTheme.colors.black900,
92+
)
93+
}
94+
}
95+
Spacer(Modifier.height(32.dp))
96+
97+
if (isSettingStartTime) {
98+
val hourItems = remember { (0..23).toList() }
99+
val minuteItems = remember { (0..55 step 5).toList() }
100+
101+
var selectedHour by remember { mutableIntStateOf(hourItems[0]) }
102+
var selectedMinute by remember { mutableIntStateOf(minuteItems[0]) }
103+
104+
Box(
105+
modifier = Modifier
106+
.fillMaxWidth()
107+
.wrapContentHeight(),
108+
) {
109+
Box(
110+
modifier = Modifier
111+
.fillMaxWidth()
112+
.height(40.dp)
113+
.align(Alignment.Center)
114+
.clip(RoundedCornerShape(8.dp))
115+
.background(NDGLTheme.colors.black50),
116+
)
117+
118+
Row(
119+
modifier = Modifier
120+
.fillMaxWidth()
121+
.padding(horizontal = 16.dp),
122+
horizontalArrangement = Arrangement.spacedBy(48.dp, Alignment.CenterHorizontally),
123+
verticalAlignment = Alignment.CenterVertically,
124+
) {
125+
WheelPicker(
126+
items = hourItems,
127+
onItemSelected = { selectedHour = it },
128+
initialIndex = 12,
129+
)
130+
WheelPicker(
131+
items = minuteItems,
132+
onItemSelected = { selectedMinute = it },
133+
initialIndex = 0,
134+
padEnabled = true,
135+
)
136+
}
137+
}
138+
139+
Spacer(Modifier.height(32.dp))
140+
NDGLCTAButton(
141+
modifier = Modifier.fillMaxWidth(),
142+
type = NDGLCTAButtonAttr.Type.PRIMARY,
143+
size = NDGLCTAButtonAttr.Size.LARGE,
144+
status = NDGLCTAButtonAttr.Status.ACTIVE,
145+
label = stringResource(R.string.timeline_save_time),
146+
onClick = {
147+
val totalMinutes = (selectedHour * 60) + selectedMinute
148+
selectedStartTime = totalMinutes.minutes
149+
selectedEndTime = selectedStartTime + 15.hours
150+
isSettingStartTime = false
151+
},
152+
)
153+
} else {
154+
val isEndTimeExceeds24Hours = selectedEndTime.inWholeHours >= 24
155+
val isTimeSet = selectedStartTime.inWholeHours > 0 && selectedEndTime.inWholeHours > 0
156+
157+
Text(
158+
text = stringResource(R.string.timeline_start_time),
159+
color = NDGLTheme.colors.black700,
160+
style = NDGLTheme.typography.bodyMdMedium,
161+
)
162+
Spacer(Modifier.height(4.dp))
163+
Row(
164+
modifier = Modifier
165+
.fillMaxWidth()
166+
.clip(RoundedCornerShape(8.dp))
167+
.background(NDGLTheme.colors.black50)
168+
.clickable { isSettingStartTime = true }
169+
.padding(horizontal = 24.dp, vertical = 17.5.dp),
170+
verticalAlignment = Alignment.CenterVertically,
171+
) {
172+
Text(
173+
text = selectedStartTime.toTimeString(),
174+
color = NDGLTheme.colors.black400,
175+
style = NDGLTheme.typography.bodyMdMedium,
176+
)
177+
Spacer(Modifier.weight(1f))
178+
Icon(
179+
imageVector = ImageVector.vectorResource(R.drawable.ic_24_chevron_right),
180+
contentDescription = null,
181+
tint = NDGLTheme.colors.black400,
182+
)
183+
}
184+
Spacer(Modifier.height(20.dp))
185+
Text(
186+
text = stringResource(R.string.timeline_end_time),
187+
color = NDGLTheme.colors.black700,
188+
style = NDGLTheme.typography.bodyMdMedium,
189+
)
190+
Spacer(Modifier.height(4.dp))
191+
Row(
192+
modifier = Modifier
193+
.fillMaxWidth()
194+
.clip(RoundedCornerShape(8.dp))
195+
.background(NDGLTheme.colors.black50)
196+
.padding(horizontal = 24.dp, vertical = 17.5.dp),
197+
verticalAlignment = Alignment.CenterVertically,
198+
) {
199+
Text(
200+
text = selectedEndTime.toTimeString(),
201+
color = NDGLTheme.colors.black400,
202+
style = NDGLTheme.typography.bodyMdMedium,
203+
)
204+
}
205+
206+
if (isEndTimeExceeds24Hours) {
207+
Spacer(Modifier.height(4.dp))
208+
Text(
209+
text = stringResource(R.string.timeline_time_exceeds_warning),
210+
color = NDGLTheme.colors.red500,
211+
style = NDGLTheme.typography.bodySmMedium,
212+
)
213+
}
214+
Spacer(Modifier.height(32.dp))
215+
NDGLCTAButton(
216+
modifier = Modifier.fillMaxWidth(),
217+
type = NDGLCTAButtonAttr.Type.PRIMARY,
218+
size = NDGLCTAButtonAttr.Size.LARGE,
219+
status = if (isTimeSet && !isEndTimeExceeds24Hours) {
220+
NDGLCTAButtonAttr.Status.ACTIVE
221+
} else {
222+
NDGLCTAButtonAttr.Status.DISABLED
223+
},
224+
label = stringResource(R.string.timeline_set_time),
225+
onClick = {
226+
onConfirm(selectedStartTime)
227+
},
228+
)
229+
}
230+
}
231+
}
232+
233+
@Composable
234+
private fun <T> WheelPicker(
235+
modifier: Modifier = Modifier,
236+
items: List<T>,
237+
onItemSelected: (T) -> Unit,
238+
initialIndex: Int = 0,
239+
itemHeight: Dp = 40.dp,
240+
visibleItemCount: Int = 5,
241+
padEnabled: Boolean = false,
242+
) {
243+
val totalItemsCount = Int.MAX_VALUE
244+
val startIndex = (totalItemsCount / 2) - (totalItemsCount / 2 % items.size) + initialIndex
245+
246+
val listState = rememberLazyListState(initialFirstVisibleItemIndex = startIndex)
247+
val snapFlingBehavior = rememberSnapFlingBehavior(lazyListState = listState)
248+
val currentIndex by remember {
249+
derivedStateOf {
250+
listState.firstVisibleItemIndex + (visibleItemCount / 2)
251+
}
252+
}
253+
254+
val nestedScrollConnection = remember {
255+
object : NestedScrollConnection {
256+
override fun onPostScroll(
257+
consumed: Offset,
258+
available: Offset,
259+
source: NestedScrollSource,
260+
): Offset {
261+
return available
262+
}
263+
}
264+
}
265+
266+
LaunchedEffect(currentIndex) {
267+
onItemSelected(items[currentIndex % items.size])
268+
}
269+
270+
Box(
271+
modifier = modifier
272+
.height(itemHeight * visibleItemCount)
273+
.nestedScroll(nestedScrollConnection),
274+
contentAlignment = Alignment.Center,
275+
) {
276+
LazyColumn(
277+
state = listState,
278+
flingBehavior = snapFlingBehavior,
279+
horizontalAlignment = Alignment.CenterHorizontally,
280+
) {
281+
items(totalItemsCount) { index ->
282+
val actualIndex = index % items.size
283+
val item = items[actualIndex]
284+
val distance = abs(index - currentIndex)
285+
286+
val scale = when (distance) {
287+
0 -> 1f
288+
1 -> 0.85f
289+
else -> 0.60f
290+
}
291+
val alpha = when (distance) {
292+
0 -> 1f
293+
else -> 0.35f
294+
}
295+
296+
Box(
297+
modifier = Modifier
298+
.height(itemHeight),
299+
contentAlignment = Alignment.Center,
300+
) {
301+
Text(
302+
text = if (padEnabled) item.toString().padStart(2, '0') else item.toString(),
303+
style = NDGLTheme.typography.bodyMdRegular.copy(fontSize = 22.sp),
304+
color = NDGLTheme.colors.black900.copy(alpha = alpha),
305+
modifier = Modifier.graphicsLayer {
306+
scaleX = scale
307+
scaleY = scale
308+
},
309+
)
310+
}
311+
}
312+
}
313+
}
314+
}
315+
316+
@Preview(showBackground = true)
317+
@Composable
318+
private fun TimelineContentPreview() {
319+
NDGLTheme {
320+
TimelineContent(
321+
startTime = 9.hours,
322+
endTime = 25.hours,
323+
onDismissRequest = {},
324+
onConfirm = {},
325+
)
326+
}
327+
}

0 commit comments

Comments
 (0)