diff --git a/core/design-system/src/main/java/team/aliens/dms/android/core/designsystem/Picker.kt b/core/design-system/src/main/java/team/aliens/dms/android/core/designsystem/Picker.kt new file mode 100644 index 000000000..41550041e --- /dev/null +++ b/core/design-system/src/main/java/team/aliens/dms/android/core/designsystem/Picker.kt @@ -0,0 +1,115 @@ +package team.aliens.dms.android.core.designsystem + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun Picker( + items: List, + state: PickerState = rememberPickerState(), + modifier: Modifier = Modifier, + startIndex: Int = 0, + visibleItemsCount: Int = 3, + textModifier: Modifier = Modifier, + textStyle: TextStyle = LocalTextStyle.current, +) { + val visibleItemsMiddle = visibleItemsCount / 2 + val listScrollCount = Integer.MAX_VALUE + val listScrollMiddle = listScrollCount / 2 + val listStartIndex = listScrollMiddle - listScrollMiddle % items.size - visibleItemsMiddle + startIndex + + fun getItem(index: Int) = items[index % items.size] + + val listState = rememberLazyListState(initialFirstVisibleItemIndex = listStartIndex) + val flingBehavior = rememberSnapFlingBehavior(lazyListState = listState) + + val itemHeightPixels = remember { mutableIntStateOf(0) } + val itemHeightDp = pixelsToDp(itemHeightPixels.value) + + val fadingEdgeGradient = remember { + Brush.verticalGradient( + 0f to Color.Transparent, + 0.5f to Color.Black, + 1f to Color.Transparent + ) + } + + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex } + .map { index -> getItem(index + visibleItemsMiddle) } + .distinctUntilChanged() + .collect { item -> state.selectedItem = item } + } + + Box(modifier = modifier) { + LazyColumn( + state = listState, + flingBehavior = flingBehavior, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .height(itemHeightDp * visibleItemsCount) + .fadingEdge(fadingEdgeGradient) + ) { + items(listScrollCount) { index -> + Text( + text = getItem(index), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = textStyle, + modifier = Modifier + .onSizeChanged { size -> itemHeightPixels.value = size.height } + .then(textModifier) + ) + } + } + } +} + +private fun Modifier.fadingEdge(brush: Brush) = this + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + .drawWithContent { + drawContent() + drawRect(brush = brush, blendMode = BlendMode.DstIn) + } + +@Composable +private fun pixelsToDp(pixels: Int) = with(LocalDensity.current) { pixels.toDp() } + +@Composable +fun rememberPickerState() = remember { PickerState() } + +class PickerState { + var selectedItem by mutableStateOf("") +} diff --git a/feature/src/main/java/team/aliens/dms/android/feature/outing/OutingApplicationScreen.kt b/feature/src/main/java/team/aliens/dms/android/feature/outing/OutingApplicationScreen.kt index ef3ae8acb..1e494522f 100644 --- a/feature/src/main/java/team/aliens/dms/android/feature/outing/OutingApplicationScreen.kt +++ b/feature/src/main/java/team/aliens/dms/android/feature/outing/OutingApplicationScreen.kt @@ -27,9 +27,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ModalBottomSheetDefaults import androidx.compose.material3.Text -import androidx.compose.material3.TimePicker import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -42,30 +40,31 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage import com.ramcosta.composedestinations.annotation.Destination import kotlinx.coroutines.launch import org.threeten.bp.DayOfWeek -import org.threeten.bp.LocalDateTime import org.threeten.bp.LocalTime -import team.aliens.dms.android.core.designsystem.AlertDialog import team.aliens.dms.android.core.designsystem.Button import team.aliens.dms.android.core.designsystem.DmsTheme import team.aliens.dms.android.core.designsystem.DmsTopAppBar import team.aliens.dms.android.core.designsystem.LocalToast import team.aliens.dms.android.core.designsystem.ModalBottomSheet +import team.aliens.dms.android.core.designsystem.Picker +import team.aliens.dms.android.core.designsystem.PickerState import team.aliens.dms.android.core.designsystem.Scaffold import team.aliens.dms.android.core.designsystem.ShadowDefaults -import team.aliens.dms.android.core.designsystem.TextButton import team.aliens.dms.android.core.designsystem.TextField import team.aliens.dms.android.core.designsystem.VerticallyFadedLazyColumn +import team.aliens.dms.android.core.designsystem.rememberPickerState import team.aliens.dms.android.core.ui.DefaultHorizontalSpace import team.aliens.dms.android.core.ui.DefaultVerticalSpace import team.aliens.dms.android.core.ui.PaddingDefaults @@ -95,62 +94,70 @@ fun OutingApplicationScreen( val scope = rememberCoroutineScope() val context = LocalContext.current val toast = LocalToast.current - val lifeCycleOwner = LocalLifecycleOwner.current - val startTimePickerState = rememberTimePickerState() - val (shouldShowStartTimePicker, onChangeShouldShowStartTimePicker) = remember { + val timeSheetState = rememberModalBottomSheetState() + val (shouldShowTimePicker, onChangeShouldShowTimePicker) = remember { mutableStateOf(false) } - if (shouldShowStartTimePicker) { - AlertDialog( - confirmButton = { - TextButton( + val startHourValuesPickerState = rememberPickerState() + val startMinuteValuesPickerState = rememberPickerState() + val endHourValuesPickerState = rememberPickerState() + val endMinuteValuesPickerState = rememberPickerState() + + if (shouldShowTimePicker) { + ModalBottomSheet( + sheetState = timeSheetState, + onDismissRequest = { onChangeShouldShowTimePicker(false) }, + properties = ModalBottomSheetDefaults.properties( + shouldDismissOnBackPress = false, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 320.dp), + ) { + TimePickerSpinner( + startHourValuesPickerState = startHourValuesPickerState, + startMinuteValuesPickerState = startMinuteValuesPickerState, + endHourValuesPickerState = endHourValuesPickerState, + endMinuteValuesPickerState = endMinuteValuesPickerState, + startTime = uiState.selectedOutingStartTime, + endTime = uiState.selectedOutingEndTime, + ) + Spacer(modifier = Modifier.height(20.dp)) + Button( + modifier = Modifier + .fillMaxWidth() + .horizontalPadding() + .bottomPadding(), onClick = { viewModel.postIntent( OutingIntent.UpdateOutingStartTime( value = LocalTime.of( - startTimePickerState.hour, - startTimePickerState.minute, - ), + startHourValuesPickerState.selectedItem.toInt(), + startMinuteValuesPickerState.selectedItem.toInt(), + ) ) ) - onChangeShouldShowStartTimePicker(false) - }, - ) { - Text(text = stringResource(id = R.string.accept)) - } - }, - onDismissRequest = { onChangeShouldShowStartTimePicker(false) }, - text = { TimePicker(state = startTimePickerState) }, - ) - } - - val endTimePickerState = rememberTimePickerState() - val (shouldShowEndTimePicker, onChangeShouldShowEndTimePicker) = remember { - mutableStateOf(false) - } - if (shouldShowEndTimePicker) { - AlertDialog( - confirmButton = { - TextButton( - onClick = { viewModel.postIntent( OutingIntent.UpdateOutingEndTime( value = LocalTime.of( - endTimePickerState.hour, - endTimePickerState.minute, + endHourValuesPickerState.selectedItem.toInt(), + endMinuteValuesPickerState.selectedItem.toInt(), ) ) ) - onChangeShouldShowEndTimePicker(false) + scope.launch { + timeSheetState.hide() + onChangeShouldShowTimePicker(false) + } }, ) { - Text(text = stringResource(id = R.string.close)) + Text(text = stringResource(id = R.string.accept)) } - }, - onDismissRequest = { onChangeShouldShowEndTimePicker(false) }, - text = { TimePicker(state = endTimePickerState) }, - ) + } + } } val (shouldShowCompanionListDialog, onShouldShowCompanionListDialogChange) = remember { @@ -337,78 +344,43 @@ fun OutingApplicationScreen( ) } Spacer(modifier = Modifier.height(DefaultVerticalSpace)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(DefaultHorizontalSpace), + OutingInput( + modifier = Modifier + .fillMaxWidth() + .startPadding() + .endPadding(), + text = { Text(text = stringResource(id = R.string.outing_start_time)) }, + indicator = { OutingInputDefaults.Indicator() }, ) { - OutingInput( - modifier = Modifier - .weight(1f) - .startPadding(), - text = { Text(text = stringResource(id = R.string.outing_start_time)) }, - indicator = { OutingInputDefaults.Indicator() }, - ) { - val time = remember(uiState.selectedOutingStartTime) { - uiState.selectedOutingStartTime - } - TextField( - trailingIcon = { - IconButton( - onClick = { onChangeShouldShowStartTimePicker(true) }, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_next), - contentDescription = stringResource( - id = R.string.outing_application_select_outing_time, - ), - tint = DmsTheme.colorScheme.icon, - ) - } - }, - value = stringResource( - id = R.string.outing_format_time_h_m, - time.hour, - time.minute, - ), - onValueChange = {}, - readOnly = true, - ) + val startTime = remember(uiState.selectedOutingStartTime) { + uiState.selectedOutingStartTime } - OutingInput( - modifier = Modifier - .weight(1f) - .endPadding(), - text = { Text(text = stringResource(id = R.string.outing_end_time)) }, - indicator = { OutingInputDefaults.Indicator() }, - ) { - val time = remember(uiState.selectedOutingEndTime) { - uiState.selectedOutingEndTime - } - TextField( - trailingIcon = { - IconButton( - onClick = { onChangeShouldShowEndTimePicker(true) }, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_next), - contentDescription = stringResource( - id = R.string.outing_application_select_outing_time, - ), - tint = DmsTheme.colorScheme.icon, - ) - } - }, - value = stringResource( - id = R.string.outing_format_time_h_m, - time.hour, - time.minute, - ), - onValueChange = {}, - readOnly = true, - ) + val endTime = remember(uiState.selectedOutingEndTime) { + uiState.selectedOutingEndTime } + TextField( + trailingIcon = { + IconButton( + onClick = { onChangeShouldShowTimePicker(true) }, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_next), + contentDescription = stringResource(id = R.string.outing_application_select_outing_time), + tint = DmsTheme.colorScheme.icon, + ) + } + }, + value = stringResource( + id = R.string.outing_format_time, + startTime.hour, + startTime.minute, + endTime.hour, + endTime.minute, + ), + onValueChange = { }, + readOnly = true, + ) } - Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(DefaultHorizontalSpace), @@ -655,14 +627,96 @@ private fun OutingInput( } } +@Composable +private fun TimePickerSpinner( + modifier: Modifier = Modifier, + startHourValuesPickerState: PickerState, + startMinuteValuesPickerState: PickerState, + endHourValuesPickerState: PickerState, + endMinuteValuesPickerState: PickerState, + startTime: LocalTime, + endTime: LocalTime, +) { + val startHourValues = remember { (0..23).map { it.toString() } } + val startMinuteValues = remember { (0..5).map { (it * 10).toString() } } + val endHourValues = remember { (0..23).map { it.toString() } } + val endMinuteValues = remember { (0..5).map { (it * 10).toString() } } + + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Picker( + modifier = Modifier.weight(1f), + state = startHourValuesPickerState, + items = startHourValues, + visibleItemsCount = 5, + startIndex = startTime.hour, + textStyle = DmsTheme.typography.body1, + textModifier = Modifier.padding(vertical = 10.dp), + ) + Text( + text = ":", + fontSize = 30.sp, + modifier = Modifier + .weight(1f), + textAlign = TextAlign.Center, + style = DmsTheme.typography.body1, + ) + Picker( + modifier = Modifier.weight(1f), + state = startMinuteValuesPickerState, + items = startMinuteValues, + visibleItemsCount = 5, + startIndex = startTime.minute / 10, + textStyle = DmsTheme.typography.body1, + textModifier = Modifier.padding(vertical = 10.dp), + ) + Text( + modifier = Modifier + .weight(2f), + text = "~", + textAlign = TextAlign.Center, + ) + Picker( + modifier = Modifier.weight(1f), + state = endHourValuesPickerState, + items = endHourValues, + visibleItemsCount = 5, + startIndex = endTime.hour, + textStyle = DmsTheme.typography.body1, + textModifier = Modifier.padding(vertical = 10.dp), + ) + Text( + modifier = Modifier + .weight(1f), + text = ":", + fontSize = 30.sp, + textAlign = TextAlign.Center, + ) + Picker( + modifier = Modifier.weight(1f), + state = endMinuteValuesPickerState, + items = endMinuteValues, + visibleItemsCount = 5, + startIndex = endTime.minute / 10, + textStyle = DmsTheme.typography.body1, + textModifier = Modifier.padding(vertical = 10.dp), + ) + } +} + object OutingInputDefaults { - val IndicatorSize = DpSize( + private val IndicatorSize = DpSize( width = 4.dp, height = 4.dp, ) - val IndicatorColor: Color + private val IndicatorColor: Color @Composable get() = DmsTheme.colorScheme.primary @Composable diff --git a/feature/src/main/res/values/strings.xml b/feature/src/main/res/values/strings.xml index c3b6a9ad7..09d3016ff 100644 --- a/feature/src/main/res/values/strings.xml +++ b/feature/src/main/res/values/strings.xml @@ -316,7 +316,7 @@ 작성한 정보가 파기됩니다. 계속하시겠습니까? 외출 신청 정보를 조회할 수 없습니다 %s ~ %s - %02d:%02d + %02d:%02d ~ %02d:%02d 외출 유형이 존재하지 않습니다 선택 외출 유형