diff --git a/core/src/main/java/com/sopt/core/designsystem/component/checkbox/NoostakCheckbox.kt b/core/src/main/java/com/sopt/core/designsystem/component/checkbox/NoostakCheckbox.kt new file mode 100644 index 00000000..e42707a0 --- /dev/null +++ b/core/src/main/java/com/sopt/core/designsystem/component/checkbox/NoostakCheckbox.kt @@ -0,0 +1,74 @@ +package com.sopt.core.designsystem.component.checkbox + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sopt.core.designsystem.theme.NoostakAndroidTheme +import com.sopt.core.designsystem.theme.NoostakTheme +import com.sopt.core.extension.noRippleClickable + +@Composable +fun NoostakCheckbox( + modifier: Modifier = Modifier, + text: String = "", + isChecked: Boolean = false, + onCheckedChange: (Boolean) -> Unit = {}, + textStyle: TextStyle = NoostakTheme.typography.b4SemiBold, + borderColor: Color = NoostakTheme.colors.gray500, + backgroundColorChecked: Color = NoostakTheme.colors.gray50, + backgroundColorUnchecked: Color = NoostakTheme.colors.white, + paddingValues: PaddingValues = PaddingValues(horizontal = 12.dp, vertical = 15.dp) +) { + Row( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .border( + width = 0.5.dp, + color = borderColor, + shape = RoundedCornerShape(10.dp) + ) + .background( + color = if (isChecked) backgroundColorChecked else backgroundColorUnchecked, + shape = RoundedCornerShape(10.dp) + ) + .noRippleClickable { onCheckedChange(!isChecked) } + .padding(paddingValues), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = text, + modifier = Modifier.weight(1f), + style = textStyle, + color = NoostakTheme.colors.gray900 + ) + CircularCheckbox( + isChecked = isChecked, + onCheckedChange = { onCheckedChange(it) } + ) + } +} + +@Preview(showBackground = true) +@Composable +fun NoostakCheckboxPreview() { + NoostakAndroidTheme { + NoostakCheckbox( + isChecked = true, + text = "체크박스블라블라" + ) + } +} diff --git a/core/src/main/java/com/sopt/core/designsystem/component/timetable/NoostakEditableTimeTable.kt b/core/src/main/java/com/sopt/core/designsystem/component/timetable/NoostakEditableTimeTable.kt index 8de5556a..5423421b 100644 --- a/core/src/main/java/com/sopt/core/designsystem/component/timetable/NoostakEditableTimeTable.kt +++ b/core/src/main/java/com/sopt/core/designsystem/component/timetable/NoostakEditableTimeTable.kt @@ -35,6 +35,7 @@ import com.sopt.domain.entity.TimeEntity fun NoostakEditableTimeTable( availablePeriods: List, modifier: Modifier = Modifier, + isChecked: Boolean = false, onSelectedTimesChanged: (List) -> Unit ) { val days = availablePeriods.size @@ -43,7 +44,7 @@ fun NoostakEditableTimeTable( availablePeriods.first().startTime, availablePeriods.first().endTime ) - val selectedCells = remember { mutableStateListOf>() } + val selectedCells = remember(key1 = isChecked) { mutableStateListOf>() } LazyColumn( modifier = modifier diff --git a/presentation/src/main/java/com/sopt/presentation/appointment/appointmentCheck/AppointmentCheckRoute.kt b/presentation/src/main/java/com/sopt/presentation/appointment/appointmentCheck/AppointmentCheckRoute.kt index c2b5faed..f98495e4 100644 --- a/presentation/src/main/java/com/sopt/presentation/appointment/appointmentCheck/AppointmentCheckRoute.kt +++ b/presentation/src/main/java/com/sopt/presentation/appointment/appointmentCheck/AppointmentCheckRoute.kt @@ -5,8 +5,11 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding @@ -34,6 +37,7 @@ import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sopt.core.designsystem.component.button.NoostakBottomButton +import com.sopt.core.designsystem.component.checkbox.NoostakCheckbox import com.sopt.core.designsystem.component.dialog.NoostakDialog import com.sopt.core.designsystem.component.snackbar.NoostakSnackBar import com.sopt.core.designsystem.component.snackbar.SNACK_BAR_DURATION @@ -62,6 +66,7 @@ fun AppointmentCheckRoute( val context = LocalContext.current val showErrorDialog by appointmentCheckViewModel.showErrorDialog.collectAsStateWithLifecycle() var selectedData by remember { mutableStateOf(emptyList()) } + var isChecked by remember { mutableStateOf(false) } val rememberedAvailablePeriods = remember { availablePeriods } val snackBarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() @@ -99,7 +104,7 @@ fun AppointmentCheckRoute( ) is AppointmentCheckSideEffect.ShowSnackBar -> onShowFailureSnackBar( - context.getString(sideEffect.message) + context.getString(sideEffect.message, duration / 60) ) } } @@ -109,16 +114,31 @@ fun AppointmentCheckRoute( groupId = groupId, appointmentName = appointmentName, availablePeriods = rememberedAvailablePeriods, - onSelectedDataChange = { selectedData = it }, + onSelectedDataChange = { + selectedData = it + if (it.isNotEmpty()) isChecked = false + }, + duration = duration, + isChecked = isChecked, + onCheckedChange = { + isChecked = it + if (isChecked) selectedData = emptyList() + }, onBackButtonClick = appointmentCheckViewModel::navigateToGroupDetail, onConfirmButtonClick = { - // TODO: selectedData가 duration 이하인지 체크하는 로직 추가 - appointmentCheckViewModel.postTimeTable( - groupId, - appointmentId, - appointmentName, - selectedData - ) + if (appointmentCheckViewModel.isSelectedDataValid( + duration / 60, + selectedData, + isChecked + ) + ) { + appointmentCheckViewModel.postTimeTable( + groupId, + appointmentId, + appointmentName, + selectedData + ) + } }, snackBarHostState = snackBarHostState, snackBarVisible = snackBarVisible @@ -149,6 +169,9 @@ fun AppointmentCheckScreen( appointmentName: String, availablePeriods: List, onSelectedDataChange: (List) -> Unit = {}, + duration: Long, + isChecked: Boolean = false, + onCheckedChange: (Boolean) -> Unit = {}, onBackButtonClick: (Long) -> Unit, onConfirmButtonClick: () -> Unit, snackBarHostState: SnackbarHostState, @@ -199,15 +222,27 @@ fun AppointmentCheckScreen( ) { Text( modifier = Modifier - .padding(top = 11.dp, start = 6.dp, bottom = 16.dp), - text = stringResource(R.string.title_appointment_check), + .padding(vertical = 12.dp), + text = stringResource(R.string.title_appointment_check, duration / 60), color = NoostakTheme.colors.black, style = NoostakTheme.typography.h4Bold, textAlign = TextAlign.Start ) + NoostakCheckbox( + text = stringResource(R.string.cb_appointment_check_impossible), + isChecked = isChecked, + onCheckedChange = { + onCheckedChange(it) + }, + textStyle = NoostakTheme.typography.b5Regular, + borderColor = NoostakTheme.colors.gray200, + paddingValues = PaddingValues(horizontal = 12.dp, vertical = 14.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) NoostakEditableTimeTable( availablePeriods = availablePeriods, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + isChecked = isChecked ) { onSelectedDataChange(it) Timber.d("selectedData: $it") @@ -251,6 +286,7 @@ fun PreviewAppointmentConfirmScreen() { endTime = "2024-09-07T18:00:00" ) ), + duration = 360, onBackButtonClick = {}, onConfirmButtonClick = {}, snackBarHostState = SnackbarHostState(), diff --git a/presentation/src/main/java/com/sopt/presentation/appointment/appointmentCheck/AppointmentCheckViewModel.kt b/presentation/src/main/java/com/sopt/presentation/appointment/appointmentCheck/AppointmentCheckViewModel.kt index 034bd5b0..aae37fb0 100644 --- a/presentation/src/main/java/com/sopt/presentation/appointment/appointmentCheck/AppointmentCheckViewModel.kt +++ b/presentation/src/main/java/com/sopt/presentation/appointment/appointmentCheck/AppointmentCheckViewModel.kt @@ -6,6 +6,7 @@ import com.sopt.core.type.DialogType import com.sopt.core.util.BaseViewModel import com.sopt.domain.entity.TimeEntity import com.sopt.domain.repository.AppointmentConfirmRepository +import com.sopt.presentation.R import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -13,6 +14,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.io.IOException +import java.time.Duration +import java.time.LocalDateTime import javax.inject.Inject @HiltViewModel @@ -71,6 +74,46 @@ class AppointmentCheckViewModel @Inject constructor( } } + fun isSelectedDataValid(duration: Long, selectedDate: List, isChecked: Boolean): Boolean { + if (isChecked) { + return true + } + + if (selectedDate.isEmpty()) { + emitSideEffect(AppointmentCheckSideEffect.ShowSnackBar(R.string.sb_appointment_check_invalid)) + return false + } + + val sorted = selectedDate.sortedBy { LocalDateTime.parse(it.startTime) } + var currentStart = LocalDateTime.parse(sorted.first().startTime) + var currentEnd = LocalDateTime.parse(sorted.first().endTime) + + for (i in 1 until sorted.size) { + val nextStart = LocalDateTime.parse(sorted[i].startTime) + val nextEnd = LocalDateTime.parse(sorted[i].endTime) + + if (nextStart == currentEnd) { + currentEnd = nextEnd + } else { + // 블록이 끊기면 검사 + val blockDuration = Duration.between(currentStart, currentEnd).toHours() + if (blockDuration < duration) { + emitSideEffect(AppointmentCheckSideEffect.ShowSnackBar(R.string.sb_appointment_check_invalid)) + return false + } + currentStart = nextStart + currentEnd = nextEnd + } + } + val finalBlockDuration = Duration.between(currentStart, currentEnd).toHours() + if (finalBlockDuration < duration) { + emitSideEffect(AppointmentCheckSideEffect.ShowSnackBar(R.string.sb_appointment_check_invalid)) + return false + } + + return true + } + fun showErrorDialog(show: Boolean, dialogType: DialogType) { _showErrorDialog.update { it.copy(first = show, second = dialogType) } } @@ -79,16 +122,6 @@ class AppointmentCheckViewModel @Inject constructor( emitSideEffect(AppointmentCheckSideEffect.NavigateUp) } - fun navigateToAppointment(groupId: Long, appointmentId: Long, appointmentName: String) { - emitSideEffect( - AppointmentCheckSideEffect.NavigateToAppointment( - groupId, - appointmentId, - appointmentName - ) - ) - } - fun navigateToGroupDetail(groupId: Long) { emitSideEffect(AppointmentCheckSideEffect.NavigateToGroupDetail(groupId)) } diff --git a/presentation/src/main/java/com/sopt/presentation/appointmentCreate/appointmentCreateTimePicker/AppointmentCreateTimePickerRoute.kt b/presentation/src/main/java/com/sopt/presentation/appointmentCreate/appointmentCreateTimePicker/AppointmentCreateTimePickerRoute.kt index a88de1db..d63a98c8 100644 --- a/presentation/src/main/java/com/sopt/presentation/appointmentCreate/appointmentCreateTimePicker/AppointmentCreateTimePickerRoute.kt +++ b/presentation/src/main/java/com/sopt/presentation/appointmentCreate/appointmentCreateTimePicker/AppointmentCreateTimePickerRoute.kt @@ -4,8 +4,6 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -17,11 +15,9 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -41,7 +37,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.sopt.core.designsystem.component.button.NoostakBottomButton -import com.sopt.core.designsystem.component.checkbox.CircularCheckbox +import com.sopt.core.designsystem.component.checkbox.NoostakCheckbox import com.sopt.core.designsystem.component.progressbar.NoostakProgressBar import com.sopt.core.designsystem.component.snackbar.NoostakSnackBar import com.sopt.core.designsystem.component.snackbar.SNACK_BAR_DURATION @@ -51,7 +47,6 @@ import com.sopt.core.designsystem.component.timepicker.NoostakTimePicker import com.sopt.core.designsystem.component.topappbar.NoostakTopAppBar import com.sopt.core.designsystem.theme.NoostakAndroidTheme import com.sopt.core.designsystem.theme.NoostakTheme -import com.sopt.core.extension.noRippleClickable import com.sopt.presentation.R import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -190,45 +185,19 @@ fun AppointmentCreateTimePickerScreen( Spacer(modifier = Modifier.height(18.dp)) NoostakProgressBar(progressBar = listOf(false, false, true)) NoostakHeaderText(text = stringResource(R.string.text_calendar_appointment_time_choose)) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 18.dp) - .height(54.dp) - .border( - width = 0.5.dp, - color = NoostakTheme.colors.gray500, - shape = RoundedCornerShape(10.dp) - ) - .background( - color = if (isChecked) NoostakTheme.colors.gray50 else NoostakTheme.colors.white, - shape = RoundedCornerShape(10.dp) - ) - .noRippleClickable { - isChecked = !isChecked - showPicker = !isChecked - } - .padding(horizontal = 12.dp, vertical = 15.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.text_calendar_appointment_time_select), - modifier = Modifier.weight(1f), - style = NoostakTheme.typography.b4SemiBold, - color = NoostakTheme.colors.gray900 - ) - CircularCheckbox( - isChecked = isChecked, - onCheckedChange = { - isChecked = it - showPicker = !it - if (isChecked) { - selectedStartHour = null - selectedEndHour = null - } + NoostakCheckbox( + modifier = Modifier.padding(top = 18.dp), + text = stringResource(R.string.text_calendar_appointment_time_select), + isChecked = isChecked, + onCheckedChange = { + isChecked = it + showPicker = !isChecked + if (isChecked) { + selectedStartHour = null + selectedEndHour = null } - ) - } + } + ) Row( modifier = Modifier .fillMaxWidth() diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 38c6e1f1..ec38df6d 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -89,7 +89,7 @@ 가능한 친구 %1$s 불가능한 친구 %1$s 약속 확정하기 - 가능한 시간을\n모두 선택해주세요 + %1$d시간 이상\n가능한 시간대를 모두 선택해주세요 확인 시간 입력에 실패했어요 추천 시간 @@ -168,6 +168,10 @@ %1$s ~ %2$s ", " + + %1$d시간 이상 연속으로 선택해주세요 + 연속 가능한 시간이 없어요 + %1$s ~ %2$s