diff --git a/app/src/main/java/org/sopt/certi/core/component/bottomsheet/RegisterTestInfoBottomSheet.kt b/app/src/main/java/org/sopt/certi/core/component/bottomsheet/RegisterTestInfoBottomSheet.kt index 25bccdcf..0f5e6d37 100644 --- a/app/src/main/java/org/sopt/certi/core/component/bottomsheet/RegisterTestInfoBottomSheet.kt +++ b/app/src/main/java/org/sopt/certi/core/component/bottomsheet/RegisterTestInfoBottomSheet.kt @@ -2,6 +2,7 @@ package org.sopt.certi.core.component.bottomsheet import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -34,7 +35,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource @@ -46,9 +49,11 @@ import org.sopt.certi.R import org.sopt.certi.core.component.button.CertiBasicButton import org.sopt.certi.core.component.calendar.DatePickerCalendar import org.sopt.certi.core.component.timepicker.CustomTimePicker +import org.sopt.certi.core.util.dateString import org.sopt.certi.core.util.dropShadow import org.sopt.certi.core.util.heightForScreenPercentage import org.sopt.certi.core.util.noRippleClickable +import org.sopt.certi.core.util.region.RegionJsonParser import org.sopt.certi.core.util.roundedBackgroundWithBorder import org.sopt.certi.core.util.screenHeightDp import org.sopt.certi.core.util.screenWidthDp @@ -63,22 +68,43 @@ fun RegisterTestInfoBottomSheet( sheetState: SheetState, forModify: Boolean, certTitle: String, - onConfirm: () -> Unit, + onConfirm: (city: String, state: String, timeDate: String) -> Unit, + onConfirmWithNoData: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, - place1List: List = emptyList(), - place2List: List = emptyList(), certificationData: CertificationData? = null ) { val density = LocalDensity.current val scope = rememberCoroutineScope() + val context = LocalContext.current + val cityList = remember { RegionJsonParser.getCities(context) } + // Data var dateText by remember { mutableStateOf("") } - var placeTextP1 by remember { mutableStateOf("") } - var placeTextP2 by remember { mutableStateOf("") } + var cityText by remember { mutableStateOf("") } + var districtText by remember { mutableStateOf("") } var timeData by remember { mutableStateOf(Pair(-1, -1)) } + // District list based on selected city + var districtList by remember { mutableStateOf>(emptyList()) } + + // Update district list when city is selected + LaunchedEffect(cityText) { + districtList = if (cityText.isNotEmpty()) { + RegionJsonParser.getDistricts(context, cityText) + } else { + emptyList() + } + // Reset district selection when city changes + if (districtText.isNotEmpty() && cityText.isNotEmpty()) { + val isValidDistrict = districtList.contains(districtText) + if (!isValidDistrict) { + districtText = "" + } + } + } + // Date var showCalendar by remember { mutableStateOf(false) } @@ -90,21 +116,21 @@ fun RegisterTestInfoBottomSheet( var buttonEnable by remember { mutableStateOf(false) } - LaunchedEffect(dateText, placeTextP1, placeTextP2, timeData) { + LaunchedEffect(dateText, cityText, districtText, timeData) { buttonEnable = dateText.isNotEmpty() && - placeTextP1.isNotEmpty() && - placeTextP2.isNotEmpty() && + cityText.isNotEmpty() && + districtText.isNotEmpty() && timeData.first != -1 && timeData.second != -1 } LaunchedEffect(forModify) { if (forModify && certificationData != null) { - // TODO data 형식에 맞게 여기서 삽입 + // TODO data 형식에 맞게 여기서 삽입 @이지현 - dateText = certificationData.testDate - placeTextP1 = certificationData.city - placeTextP2 = certificationData.state +// dateText = certificationData.testDate +// cityText = certificationData.city +// districtText = certificationData.state // timeData = certificationData.testTime } } @@ -129,6 +155,13 @@ fun RegisterTestInfoBottomSheet( Column( modifier = Modifier .wrapContentHeight() + .pointerInput(Unit) { + detectVerticalDragGestures( + onDragEnd = {}, + onDragCancel = {}, + onVerticalDrag = { _, _ -> /* 아무것도 안 함 */ } + ) + } .fillMaxWidth() ) { Spacer(Modifier.heightForScreenPercentage(35.dp)) @@ -270,9 +303,9 @@ fun RegisterTestInfoBottomSheet( verticalAlignment = Alignment.CenterVertically ) { Text( - text = placeTextP1.ifEmpty { stringResource(R.string.test_info_bottomsheet_place_p1_hint) }, + text = cityText.ifEmpty { stringResource(R.string.test_info_bottomsheet_place_p1_hint) }, style = CertiTheme.typography.caption.semibold_12, - color = if (placeTextP1.isEmpty()) CertiTheme.colors.gray300 else CertiTheme.colors.black + color = if (cityText.isEmpty()) CertiTheme.colors.gray300 else CertiTheme.colors.black ) Spacer(Modifier.weight(1f)) @@ -295,16 +328,16 @@ fun RegisterTestInfoBottomSheet( .clip(RoundedCornerShape(4.dp)) .padding(horizontal = screenWidthDp(12.dp)) .noRippleClickable { - if (placeTextP1.isNotEmpty()) { + if (cityText.isNotEmpty()) { showPlaceP2List = true } }, verticalAlignment = Alignment.CenterVertically ) { Text( - text = placeTextP2.ifEmpty { stringResource(R.string.test_info_bottomsheet_place_p2_hint) }, + text = districtText.ifEmpty { stringResource(R.string.test_info_bottomsheet_place_p2_hint) }, style = CertiTheme.typography.caption.semibold_12, - color = if (placeTextP2.isEmpty()) CertiTheme.colors.gray300 else CertiTheme.colors.black + color = if (districtText.isEmpty()) CertiTheme.colors.gray300 else CertiTheme.colors.black ) Spacer(Modifier.weight(1f)) @@ -336,13 +369,13 @@ fun RegisterTestInfoBottomSheet( ) .background(CertiTheme.colors.white) ) { - itemsIndexed(place1List) { index, placeName -> + itemsIndexed(cityList) { index, placeName -> PlaceItem( placeName = placeName, index = index, - listSize = place1List.size + listSize = cityList.size ) { - placeTextP1 = placeName + cityText = placeName showPlaceP1List = false } } @@ -364,13 +397,13 @@ fun RegisterTestInfoBottomSheet( ) .background(CertiTheme.colors.white) ) { - itemsIndexed(place2List) { index, placeName -> + itemsIndexed(districtList) { index, placeName -> PlaceItem( placeName = placeName, index = index, - listSize = place2List.size + listSize = districtList.size ) { - placeTextP2 = placeName + districtText = placeName showPlaceP2List = false } } @@ -418,7 +451,7 @@ fun RegisterTestInfoBottomSheet( modifier = Modifier .align(Alignment.CenterHorizontally) .noRippleClickable { - onDismiss() + onConfirmWithNoData() } ) @@ -440,7 +473,13 @@ fun RegisterTestInfoBottomSheet( enabled = buttonEnable, onClick = { scope.launch { sheetState.hide() }.invokeOnCompletion { - if (!sheetState.isVisible) onConfirm() + if (!sheetState.isVisible) { + onConfirm( + cityText, + districtText, + "$dateText ${timeData.first.dateString()}:${timeData.second.dateString()}:00" + ) + } } }, modifier = Modifier @@ -499,18 +538,13 @@ fun RegisterTestInfoBottomSheetPreview() { skipPartiallyExpanded = true ) - // FIXME Sample 서버데이터 - val place1List = listOf("서울", "경기", "부산", "인천", "충남", "충북", "강원", "경북") - val place2List = listOf("서울", "경기", "부산", "인천", "충남", "충북", "강원", "경북") - RegisterTestInfoBottomSheet( sheetState = sheetState, certTitle = "자격증 이름", - place1List = place1List, - place2List = place2List, forModify = false, certificationData = null, onDismiss = {}, - onConfirm = {} + onConfirm = { _, _, _ -> }, + onConfirmWithNoData = {} ) } diff --git a/app/src/main/java/org/sopt/certi/core/component/timepicker/TimePicker.kt b/app/src/main/java/org/sopt/certi/core/component/timepicker/TimePicker.kt index 94ffa491..cc142d81 100644 --- a/app/src/main/java/org/sopt/certi/core/component/timepicker/TimePicker.kt +++ b/app/src/main/java/org/sopt/certi/core/component/timepicker/TimePicker.kt @@ -25,6 +25,7 @@ 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.alpha import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -37,7 +38,7 @@ import org.sopt.certi.core.util.widthForScreenPercentage import org.sopt.certi.ui.theme.CertiTheme enum class TimePeriodType() { - AM, PM + AM, PM, NONE } @Composable @@ -147,7 +148,18 @@ fun TimePickerColumn( val flingBehavior = rememberSnapFlingBehavior(lazyListState = listState) val coroutineScope = rememberCoroutineScope() - // 스크롤이 멈췄을 때 중앙 아이템 감지 + // 현재 중앙에 있는 아이템 인덱스를 실시간으로 추적 + var currentCenterIndex by remember { mutableIntStateOf(listState.firstVisibleItemIndex) } + + // 스크롤 중에도 중앙 아이템 인덱스 업데이트 + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex } + .collect { index -> + currentCenterIndex = index + } + } + + // 스크롤이 멈췄을 때 선택 확정 LaunchedEffect(listState) { snapshotFlow { listState.isScrollInProgress } .filter { !it } // 스크롤이 멈췄을 때 @@ -210,7 +222,16 @@ fun TimePickerColumn( items(items.size) { index -> val item = items[index] - val isSelected = item == selectedItem + val isSelected = index == currentCenterIndex + val isVisible = when (items.size) { + 13 -> { + item != "13" + } + 61 -> { + item != "61" + } + else -> true + } Text( text = item, @@ -219,6 +240,7 @@ fun TimePickerColumn( modifier = Modifier .heightForScreenPercentage(40.dp) .wrapContentHeight() + .alpha(if (isVisible) 1f else 0f) ) } @@ -243,7 +265,18 @@ fun TimePeriodPickerColumn( val flingBehavior = rememberSnapFlingBehavior(lazyListState = listState) val coroutineScope = rememberCoroutineScope() - // 스크롤이 멈췄을 때 중앙 아이템 감지 + // 현재 중앙에 있는 아이템 인덱스를 실시간으로 추적 + var currentCenterIndex by remember { mutableIntStateOf(listState.firstVisibleItemIndex) } + + // 스크롤 중에도 중앙 아이템 인덱스 업데이트 + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex } + .collect { index -> + currentCenterIndex = index + } + } + + // 스크롤이 멈췄을 때 선택 확정 LaunchedEffect(listState) { snapshotFlow { listState.isScrollInProgress } .filter { !it } // 스크롤이 멈췄을 때 @@ -305,10 +338,11 @@ fun TimePeriodPickerColumn( items(items.size) { index -> val item = items[index] - val isSelected = item == selectedItem + val isSelected = index == currentCenterIndex val itemTitle = when (item) { TimePeriodType.AM -> stringResource(R.string.test_info_bottomsheet_time_morning) TimePeriodType.PM -> stringResource(R.string.test_info_bottomsheet_time_afternoon) + TimePeriodType.NONE -> "" } Text( @@ -342,9 +376,7 @@ fun TimePickerPreview() { CustomTimePicker( initialHour = 12, initialMinute = 0, - onTimeSelected = { hour, minute -> - println("Selected time: $hour:$minute") - } + onTimeSelected = { hour, minute -> } ) } } diff --git a/app/src/main/java/org/sopt/certi/core/util/region/RegionData.kt b/app/src/main/java/org/sopt/certi/core/util/region/RegionData.kt new file mode 100644 index 00000000..4f382121 --- /dev/null +++ b/app/src/main/java/org/sopt/certi/core/util/region/RegionData.kt @@ -0,0 +1,6 @@ +package org.sopt.certi.core.util.region + +data class RegionData( + val city: String, + val districts: List +) diff --git a/app/src/main/java/org/sopt/certi/core/util/region/RegionJsonParser.kt b/app/src/main/java/org/sopt/certi/core/util/region/RegionJsonParser.kt new file mode 100644 index 00000000..ed8bf106 --- /dev/null +++ b/app/src/main/java/org/sopt/certi/core/util/region/RegionJsonParser.kt @@ -0,0 +1,38 @@ +package org.sopt.certi.core.util.region + +import android.content.Context +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.sopt.certi.R + +object RegionJsonParser { + private val json = Json { ignoreUnknownKeys = true } + + fun parseRegionData(context: Context): List { + val jsonString = context.resources.openRawResource(R.raw.region) + .bufferedReader() + .use { it.readText() } + + val regionMap = json.parseToJsonElement(jsonString).jsonObject + + return regionMap.map { (city, districts) -> + RegionData( + city = city, + districts = districts.jsonArray.map { it.jsonPrimitive.content } + ) + } + } + + fun getCities(context: Context): List { + return parseRegionData(context).map { it.city } + } + + fun getDistricts(context: Context, city: String): List { + return parseRegionData(context) + .find { it.city == city } + ?.districts + ?: emptyList() + } +} diff --git a/app/src/main/java/org/sopt/certi/data/mapper/todomain/cert/CertInfoResponseDtoMapper.kt b/app/src/main/java/org/sopt/certi/data/mapper/todomain/cert/CertInfoResponseDtoMapper.kt index c316cc22..2cb6cc7b 100644 --- a/app/src/main/java/org/sopt/certi/data/mapper/todomain/cert/CertInfoResponseDtoMapper.kt +++ b/app/src/main/java/org/sopt/certi/data/mapper/todomain/cert/CertInfoResponseDtoMapper.kt @@ -2,6 +2,7 @@ package org.sopt.certi.data.mapper.todomain.cert import org.sopt.certi.data.remote.dto.response.GetCertInfoResponseDto import org.sopt.certi.domain.model.certification.CertificationData +import org.sopt.certi.domain.type.CertStateType fun GetCertInfoResponseDto.toDomain() = CertificationData( certificationId = certificationId, @@ -14,5 +15,7 @@ fun GetCertInfoResponseDto.toDomain() = CertificationData( description = description, applicationMethod = applicationMethod, applicationUrl = applicationUrl, - expirationPeriod = expirationPeriod + expirationPeriod = expirationPeriod, + testDateInformation = testDateInformation, + certState = CertStateType.valueOf(certState) ) diff --git a/app/src/main/java/org/sopt/certi/data/remote/datasource/PreCertRemoteDataSource.kt b/app/src/main/java/org/sopt/certi/data/remote/datasource/PreCertRemoteDataSource.kt index c8b914c8..5c3731a8 100644 --- a/app/src/main/java/org/sopt/certi/data/remote/datasource/PreCertRemoteDataSource.kt +++ b/app/src/main/java/org/sopt/certi/data/remote/datasource/PreCertRemoteDataSource.kt @@ -3,5 +3,5 @@ package org.sopt.certi.data.remote.datasource import org.sopt.certi.data.remote.dto.base.NullableApiResponse interface PreCertRemoteDataSource { - suspend fun acquireExpectCert(certificationId: Long): NullableApiResponse + suspend fun acquireExpectCert(certificationId: Long, city: String? = null, state: String? = null, testDate: String? = null): NullableApiResponse } diff --git a/app/src/main/java/org/sopt/certi/data/remote/datasourceimpl/PreCertRemoteDataSourceImpl.kt b/app/src/main/java/org/sopt/certi/data/remote/datasourceimpl/PreCertRemoteDataSourceImpl.kt index 2a031091..1a4be6b9 100644 --- a/app/src/main/java/org/sopt/certi/data/remote/datasourceimpl/PreCertRemoteDataSourceImpl.kt +++ b/app/src/main/java/org/sopt/certi/data/remote/datasourceimpl/PreCertRemoteDataSourceImpl.kt @@ -2,12 +2,20 @@ package org.sopt.certi.data.remote.datasourceimpl import org.sopt.certi.data.remote.datasource.PreCertRemoteDataSource import org.sopt.certi.data.remote.dto.base.NullableApiResponse +import org.sopt.certi.data.remote.dto.request.AddPreCertificationRequestDto import org.sopt.certi.data.remote.service.PreCertService import javax.inject.Inject class PreCertRemoteDataSourceImpl @Inject constructor( private val preCertService: PreCertService ) : PreCertRemoteDataSource { - override suspend fun acquireExpectCert(certificationId: Long): NullableApiResponse = - preCertService.acquireExpectCert(certificationId) + override suspend fun acquireExpectCert( + certificationId: Long, + city: String?, + state: String?, + testDate: String? + ): NullableApiResponse = + preCertService.acquireExpectCert( + AddPreCertificationRequestDto(certificationId, city, state, testDate) + ) } diff --git a/app/src/main/java/org/sopt/certi/data/remote/dto/request/AddPreCertificationRequestDto.kt b/app/src/main/java/org/sopt/certi/data/remote/dto/request/AddPreCertificationRequestDto.kt new file mode 100644 index 00000000..a8fb541e --- /dev/null +++ b/app/src/main/java/org/sopt/certi/data/remote/dto/request/AddPreCertificationRequestDto.kt @@ -0,0 +1,16 @@ +package org.sopt.certi.data.remote.dto.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AddPreCertificationRequestDto( + @SerialName("certificationId") + val certificationId: Long, + @SerialName("city") + val city: String?, + @SerialName("state") + val state: String?, + @SerialName("testDate") + val testDate: String? +) diff --git a/app/src/main/java/org/sopt/certi/data/remote/dto/response/GetCertInfoResponseDto.kt b/app/src/main/java/org/sopt/certi/data/remote/dto/response/GetCertInfoResponseDto.kt index ca2c97f8..90d0c7fd 100644 --- a/app/src/main/java/org/sopt/certi/data/remote/dto/response/GetCertInfoResponseDto.kt +++ b/app/src/main/java/org/sopt/certi/data/remote/dto/response/GetCertInfoResponseDto.kt @@ -28,5 +28,7 @@ data class GetCertInfoResponseDto( @SerialName("expirationPeriod") val expirationPeriod: String, @SerialName("testDateInformation") - val testDateInformation: String + val testDateInformation: String, + @SerialName("certState") + val certState: String ) diff --git a/app/src/main/java/org/sopt/certi/data/remote/service/PreCertService.kt b/app/src/main/java/org/sopt/certi/data/remote/service/PreCertService.kt index 9065451a..3df193e4 100644 --- a/app/src/main/java/org/sopt/certi/data/remote/service/PreCertService.kt +++ b/app/src/main/java/org/sopt/certi/data/remote/service/PreCertService.kt @@ -1,10 +1,11 @@ package org.sopt.certi.data.remote.service import org.sopt.certi.data.remote.dto.base.NullableApiResponse +import org.sopt.certi.data.remote.dto.request.AddPreCertificationRequestDto +import retrofit2.http.Body import retrofit2.http.POST -import retrofit2.http.Path interface PreCertService { - @POST("api/v1/home/pre-certification/{certificationId}") - suspend fun acquireExpectCert(@Path("certificationId") certificationId: Long): NullableApiResponse + @POST("api/v1/home/pre-certification") + suspend fun acquireExpectCert(@Body request: AddPreCertificationRequestDto): NullableApiResponse } diff --git a/app/src/main/java/org/sopt/certi/data/repositoryimpl/PreCertRepositoryImpl.kt b/app/src/main/java/org/sopt/certi/data/repositoryimpl/PreCertRepositoryImpl.kt index bc704fd1..069b0da9 100644 --- a/app/src/main/java/org/sopt/certi/data/repositoryimpl/PreCertRepositoryImpl.kt +++ b/app/src/main/java/org/sopt/certi/data/repositoryimpl/PreCertRepositoryImpl.kt @@ -9,9 +9,18 @@ import javax.inject.Inject class PreCertRepositoryImpl @Inject constructor( private val preCertRemoteDataSource: PreCertRemoteDataSource ) : PreCertRepository { - override suspend fun acquireExpectCert(certificationId: Long): Result = safeApiCall { - preCertRemoteDataSource.acquireExpectCert(certificationId) - .handleNullableApiResponse() + override suspend fun acquireExpectCert( + certificationId: Long, + city: String?, + state: String?, + testDate: String? + ): Result = safeApiCall { + preCertRemoteDataSource.acquireExpectCert( + certificationId, + city, + state, + testDate + ).handleNullableApiResponse() .getOrThrow() == true } } diff --git a/app/src/main/java/org/sopt/certi/domain/model/certification/CertificationData.kt b/app/src/main/java/org/sopt/certi/domain/model/certification/CertificationData.kt index 0bf6b7c6..7424e8d7 100644 --- a/app/src/main/java/org/sopt/certi/domain/model/certification/CertificationData.kt +++ b/app/src/main/java/org/sopt/certi/domain/model/certification/CertificationData.kt @@ -1,5 +1,6 @@ package org.sopt.certi.domain.model.certification +import org.sopt.certi.domain.type.CertStateType import java.time.LocalDate data class CertificationListData( @@ -27,11 +28,13 @@ data class CertificationData( val cardBackImageUrl: String = "", val createdAt: LocalDate = LocalDate.now(), val testDate: String = "", + val testDateInformation: String = "", val expirationPeriod: String = "", val acquisitionDate: String = "", val grade: String = "", val city: String = "", val state: String = "", val isAcquired: Boolean = false, // 취득 여부 - val testTime: String = "" // 시험시간 + val testTime: String = "", // 시험시간 + val certState: CertStateType = CertStateType.NORMAL ) diff --git a/app/src/main/java/org/sopt/certi/domain/repository/PreCertRepository.kt b/app/src/main/java/org/sopt/certi/domain/repository/PreCertRepository.kt index 90dfb664..818c6fea 100644 --- a/app/src/main/java/org/sopt/certi/domain/repository/PreCertRepository.kt +++ b/app/src/main/java/org/sopt/certi/domain/repository/PreCertRepository.kt @@ -1,5 +1,5 @@ package org.sopt.certi.domain.repository interface PreCertRepository { - suspend fun acquireExpectCert(certificationId: Long): Result + suspend fun acquireExpectCert(certificationId: Long, city: String?, state: String?, testDate: String?): Result } diff --git a/app/src/main/java/org/sopt/certi/domain/type/CertStateType.kt b/app/src/main/java/org/sopt/certi/domain/type/CertStateType.kt new file mode 100644 index 00000000..c62f0e5a --- /dev/null +++ b/app/src/main/java/org/sopt/certi/domain/type/CertStateType.kt @@ -0,0 +1,7 @@ +package org.sopt.certi.domain.type + +enum class CertStateType { + NORMAL, // 아무것도 아님 + ANTICIPATED, // 취득 예정 + ACQUISITION // 취득 완료 +} diff --git a/app/src/main/java/org/sopt/certi/domain/usecase/precert/AcquireExpectCertUseCase.kt b/app/src/main/java/org/sopt/certi/domain/usecase/precert/AcquireExpectCertUseCase.kt index 988f0a0c..4d42a14a 100644 --- a/app/src/main/java/org/sopt/certi/domain/usecase/precert/AcquireExpectCertUseCase.kt +++ b/app/src/main/java/org/sopt/certi/domain/usecase/precert/AcquireExpectCertUseCase.kt @@ -6,7 +6,12 @@ import javax.inject.Inject class AcquireExpectCertUseCase @Inject constructor( private val preCertRepository: PreCertRepository ) { - suspend operator fun invoke(certificationId: Long): Result { - return preCertRepository.acquireExpectCert(certificationId) + suspend operator fun invoke( + certificationId: Long, + city: String? = null, + state: String? = null, + testDate: String? = null + ): Result { + return preCertRepository.acquireExpectCert(certificationId, city, state, testDate) } } diff --git a/app/src/main/java/org/sopt/certi/presentation/ui/certdetail/CertDetailScreen.kt b/app/src/main/java/org/sopt/certi/presentation/ui/certdetail/CertDetailScreen.kt index c9f46c96..321f78f9 100644 --- a/app/src/main/java/org/sopt/certi/presentation/ui/certdetail/CertDetailScreen.kt +++ b/app/src/main/java/org/sopt/certi/presentation/ui/certdetail/CertDetailScreen.kt @@ -3,10 +3,10 @@ package org.sopt.certi.presentation.ui.certdetail import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -20,12 +20,14 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.sopt.certi.R +import org.sopt.certi.core.component.bottomsheet.RegisterTestInfoBottomSheet import org.sopt.certi.core.component.dialog.CertAcquiredDialog import org.sopt.certi.core.component.toast.ShowToastRoute import org.sopt.certi.core.component.webview.CertWebView import org.sopt.certi.core.state.UiState import org.sopt.certi.core.util.screenWidthDp import org.sopt.certi.domain.model.certification.CertificationData +import org.sopt.certi.domain.type.CertStateType import org.sopt.certi.presentation.model.ToastConfig import org.sopt.certi.presentation.ui.certdetail.component.tab.CertDetailTab import org.sopt.certi.presentation.ui.certdetail.component.tab.DetailTabType @@ -34,6 +36,7 @@ import org.sopt.certi.presentation.ui.certdetail.screen.CertDetailInfoScreen import org.sopt.certi.presentation.ui.certdetail.sideeffect.DetailSideEffect import org.sopt.certi.ui.theme.CERTITheme +@OptIn(ExperimentalMaterial3Api::class) @Composable fun CertDetailRoute( padding: PaddingValues, @@ -46,18 +49,31 @@ fun CertDetailRoute( var showAcquireExpectSuccessToast by remember { mutableStateOf(false) } var showAcquireExpectFailToast by remember { mutableStateOf(false) } var showAcquiredFailToast by remember { mutableStateOf(false) } + var showRegisterTestInfoBottomSheet by remember { mutableStateOf(false) } + var acquireSuccess by remember { mutableStateOf(false) } + var acquireExpectSuccess by remember { mutableStateOf(false) } val uiState by viewModel.detailUiState.collectAsStateWithLifecycle() + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + LaunchedEffect(Unit) { viewModel.getCertDetailInfo(certId) viewModel.sideEffect.collect { when (it) { - DetailSideEffect.ShowAcquiredSuccessDialog -> showAcquiredDialog = true + DetailSideEffect.ShowAcquiredSuccessDialog -> { + showAcquiredDialog = true + acquireSuccess = true + } DetailSideEffect.ShowAcquiredFailToast -> showAcquiredFailToast = true + DetailSideEffect.ShowAcquireExpectSuccessToast -> { + showAcquireExpectSuccessToast = true + acquireExpectSuccess = true + } DetailSideEffect.ShowAcquireExpectFailToast -> showAcquireExpectFailToast = true - DetailSideEffect.ShowAcquireExpectSuccessToast -> showAcquireExpectSuccessToast = true } } } @@ -69,11 +85,19 @@ fun CertDetailRoute( CertDetailScreen( certData = certData, modifier = Modifier.padding(padding), + acquireSuccess = acquireSuccess, + acquireExpectSuccess = acquireExpectSuccess, showWebView = { showWebView = true }, - acquireExpectCert = { - viewModel.acquireExpectCert(certId) + showRegisterTestInfoBottomSheet = { + showRegisterTestInfoBottomSheet = true + }, + showAcquireExpectFailToast = { + showAcquireExpectFailToast = true + }, + showAcquiredFailToast = { + showAcquiredFailToast = true }, acquiredCert = { viewModel.acquiredCert(certId) @@ -126,6 +150,25 @@ fun CertDetailRoute( ) ) } + + if (showRegisterTestInfoBottomSheet) { + RegisterTestInfoBottomSheet( + sheetState = sheetState, + forModify = false, + certTitle = certData.certificationName, + onConfirm = { city, state, timeDate -> + viewModel.acquireExpectCert(certId, city, state, timeDate) + showRegisterTestInfoBottomSheet = false + }, + onConfirmWithNoData = { + viewModel.acquireExpectCert(certId) + showRegisterTestInfoBottomSheet = false + }, + onDismiss = { + showRegisterTestInfoBottomSheet = false + } + ) + } } else -> {} } @@ -135,18 +178,33 @@ fun CertDetailRoute( fun CertDetailScreen( certData: CertificationData, modifier: Modifier = Modifier, + acquireSuccess: Boolean = false, + acquireExpectSuccess: Boolean = false, showWebView: () -> Unit = {}, - acquireExpectCert: () -> Unit = {}, + showRegisterTestInfoBottomSheet: () -> Unit = {}, + showAcquireExpectFailToast: () -> Unit = {}, + showAcquiredFailToast: () -> Unit = {}, acquiredCert: () -> Unit = {} ) { var selectedTab by remember { mutableStateOf(DetailTabType.Info) } + var certState by remember { mutableStateOf(certData.certState) } + + LaunchedEffect(acquireExpectSuccess) { + if (acquireExpectSuccess) { + certState = CertStateType.ACQUISITION + } + } + + LaunchedEffect(acquireSuccess) { + if (acquireSuccess) { + certState = CertStateType.ANTICIPATED + } + } Column( modifier = modifier .fillMaxSize() .statusBarsPadding() - .navigationBarsPadding() - .imePadding() ) { CertDetailTab( modifier = Modifier.padding(horizontal = screenWidthDp(20.dp)), @@ -163,10 +221,24 @@ fun CertDetailScreen( showWebView() }, acquireExpectCert = { - acquireExpectCert() + when (certState) { + CertStateType.ANTICIPATED -> { + showAcquireExpectFailToast() + } + CertStateType.ACQUISITION -> { + showAcquiredFailToast() + } + else -> { + showRegisterTestInfoBottomSheet() + } + } }, acquiredCert = { - acquiredCert() + if (certState == CertStateType.ACQUISITION) { + showAcquiredFailToast() + } else { + acquiredCert() + } } ) } diff --git a/app/src/main/java/org/sopt/certi/presentation/ui/certdetail/CertDetailViewModel.kt b/app/src/main/java/org/sopt/certi/presentation/ui/certdetail/CertDetailViewModel.kt index dcc11e80..ee26591a 100644 --- a/app/src/main/java/org/sopt/certi/presentation/ui/certdetail/CertDetailViewModel.kt +++ b/app/src/main/java/org/sopt/certi/presentation/ui/certdetail/CertDetailViewModel.kt @@ -1,6 +1,5 @@ package org.sopt.certi.presentation.ui.certdetail -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -55,13 +54,12 @@ class CertDetailViewModel @Inject constructor( }, onFailure = { _certDetailInfo.emit(UiState.Failure(it.message.toString())) - Log.d("Logd", it.message.toString()) } ) } - fun acquireExpectCert(certId: Long) = viewModelScope.launch { - acquireExpectCertUseCase.invoke(certId).fold( + fun acquireExpectCert(certId: Long, city: String? = null, state: String? = null, timeDate: String? = null) = viewModelScope.launch { + acquireExpectCertUseCase.invoke(certId, city, state, timeDate).fold( onSuccess = { if (it) { _sideEffect.send(DetailSideEffect.ShowAcquireExpectSuccessToast) diff --git a/app/src/main/java/org/sopt/certi/presentation/ui/certdetail/screen/CertDetailCommentScreen.kt b/app/src/main/java/org/sopt/certi/presentation/ui/certdetail/screen/CertDetailCommentScreen.kt index 340abfb3..e394a1ea 100644 --- a/app/src/main/java/org/sopt/certi/presentation/ui/certdetail/screen/CertDetailCommentScreen.kt +++ b/app/src/main/java/org/sopt/certi/presentation/ui/certdetail/screen/CertDetailCommentScreen.kt @@ -1,14 +1,22 @@ package org.sopt.certi.presentation.ui.certdetail.screen +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed @@ -29,6 +37,7 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -125,6 +134,7 @@ fun CertDetailCommentRoute() { CertDetailCommentScreen(commentData = dummyCommentData, myUserId = 0) } +@OptIn(ExperimentalLayoutApi::class) @Composable fun CertDetailCommentScreen( commentData: CommentData, @@ -138,6 +148,19 @@ fun CertDetailCommentScreen( var commentText by remember { mutableStateOf("") } + val imeVisible = if (LocalInspectionMode.current) false else WindowInsets.isImeVisible + val navigationBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val keyboardHeight = WindowInsets.ime.asPaddingValues().calculateBottomPadding() + + val textFieldOffset by animateDpAsState( + targetValue = if (imeVisible) { + keyboardHeight - navigationBarHeight + } else { + 0.dp + }, + label = "BottomPadding" + ) + Column( modifier = Modifier .fillMaxSize() @@ -205,6 +228,7 @@ fun CertDetailCommentScreen( Box( modifier = Modifier .fillMaxWidth() + .offset(y = -textFieldOffset) .drawBehind { // 위쪽 그림자만 그리기 val shadowHeight = 4.dp.toPx() @@ -223,6 +247,7 @@ fun CertDetailCommentScreen( size = Size(size.width, shadowHeight) ) } + .background(CertiTheme.colors.white) .padding(vertical = screenHeightDp(20.dp), horizontal = screenWidthDp(20.dp)) ) { Row( diff --git a/app/src/main/java/org/sopt/certi/presentation/ui/certdetail/screen/CertDetailInfoScreen.kt b/app/src/main/java/org/sopt/certi/presentation/ui/certdetail/screen/CertDetailInfoScreen.kt index 1a93509f..dd0475cb 100644 --- a/app/src/main/java/org/sopt/certi/presentation/ui/certdetail/screen/CertDetailInfoScreen.kt +++ b/app/src/main/java/org/sopt/certi/presentation/ui/certdetail/screen/CertDetailInfoScreen.kt @@ -186,7 +186,7 @@ fun CertDetailInfoScreen( Spacer(Modifier.widthForScreenPercentage(6.dp)) Text( - text = certData.testDate, + text = certData.testDateInformation, style = CertiTheme.typography.body.regular_16, color = CertiTheme.colors.gray600 ) diff --git a/app/src/main/java/org/sopt/certi/presentation/ui/mycertification/MyCertScreen.kt b/app/src/main/java/org/sopt/certi/presentation/ui/mycertification/MyCertScreen.kt index 73847ba7..33b78013 100644 --- a/app/src/main/java/org/sopt/certi/presentation/ui/mycertification/MyCertScreen.kt +++ b/app/src/main/java/org/sopt/certi/presentation/ui/mycertification/MyCertScreen.kt @@ -69,10 +69,12 @@ fun MyCertRoute( RegisterTestInfoBottomSheet( sheetState = sheetState, certTitle = data.certificationName, - place1List = emptyList(), - place2List = emptyList(), forModify = true, - onConfirm = viewModel::editItem, + onConfirm = { city, state, timeDate -> + viewModel.editItem() + }, + onConfirmWithNoData = { + }, onDismiss = viewModel::closeEditSheet, certificationData = data ) diff --git a/app/src/main/res/raw/region.json b/app/src/main/res/raw/region.json new file mode 100644 index 00000000..46c9f41d --- /dev/null +++ b/app/src/main/res/raw/region.json @@ -0,0 +1,258 @@ +{ + "서울특별시": [ + "종로구", + "중구", + "용산구", + "성동구", + "광진구", + "동대문구", + "중랑구", + "성북구", + "강북구", + "도봉구", + "노원구", + "은평구", + "서대문구", + "마포구", + "양천구", + "강서구", + "구로구", + "금천구", + "영등포구", + "동작구", + "관악구", + "서초구", + "강남구", + "송파구", + "강동구" + ], + + "부산광역시": [ + "중구", + "서구", + "동구", + "영도구", + "부산진구", + "동래구", + "남구", + "북구", + "해운대구", + "사하구", + "금정구", + "강서구", + "연제구", + "수영구", + "사상구", + "기장군" + ], + + "대구광역시": [ + "중구", + "동구", + "서구", + "남구", + "북구", + "수성구", + "달서구", + "달성군" + ], + + "인천광역시": [ + "중구", + "동구", + "미추홀구", + "연수구", + "남동구", + "부평구", + "계양구", + "서구", + "강화군", + "옹진군" + ], + + "광주광역시": ["동구", "서구", "남구", "북구", "광산구"], + + "대전광역시": ["동구", "중구", "서구", "유성구", "대덕구"], + + "울산광역시": ["중구", "남구", "동구", "북구", "울주군"], + + "세종특별자치시": ["세종시"], + + "경기도": [ + "수원시", + "성남시", + "의정부시", + "안양시", + "부천시", + "광명시", + "평택시", + "동두천시", + "안산시", + "고양시", + "과천시", + "구리시", + "남양주시", + "오산시", + "시흥시", + "군포시", + "의왕시", + "하남시", + "용인시", + "파주시", + "이천시", + "안성시", + "김포시", + "화성시", + "광주시", + "양주시", + "포천시", + "여주시", + "연천군", + "가평군", + "양평군" + ], + + "강원특별자치도": [ + "춘천시", + "원주시", + "강릉시", + "동해시", + "태백시", + "속초시", + "삼척시", + "홍천군", + "횡성군", + "영월군", + "평창군", + "정선군", + "철원군", + "화천군", + "양구군", + "인제군", + "고성군", + "양양군" + ], + + "충청북도": [ + "청주시", + "충주시", + "제천시", + "보은군", + "옥천군", + "영동군", + "증평군", + "진천군", + "괴산군", + "음성군", + "단양군" + ], + + "충청남도": [ + "천안시", + "공주시", + "보령시", + "아산시", + "서산시", + "논산시", + "계룡시", + "당진시", + "금산군", + "부여군", + "서천군", + "청양군", + "홍성군", + "예산군", + "태안군" + ], + + "전라북도": [ + "전주시", + "군산시", + "익산시", + "정읍시", + "남원시", + "김제시", + "완주군", + "진안군", + "무주군", + "장수군", + "임실군", + "순창군", + "고창군", + "부안군" + ], + + "전라남도": [ + "목포시", + "여수시", + "순천시", + "나주시", + "광양시", + "담양군", + "곡성군", + "구례군", + "고흥군", + "보성군", + "화순군", + "장흥군", + "강진군", + "해남군", + "영암군", + "무안군", + "함평군", + "영광군", + "장성군", + "완도군", + "진도군", + "신안군" + ], + + "경상북도": [ + "포항시", + "경주시", + "김천시", + "안동시", + "구미시", + "영주시", + "영천시", + "상주시", + "문경시", + "경산시", + "군위군", + "의성군", + "청송군", + "영양군", + "영덕군", + "청도군", + "고령군", + "성주군", + "칠곡군", + "예천군", + "봉화군", + "울진군", + "울릉군" + ], + + "경상남도": [ + "창원시", + "진주시", + "통영시", + "사천시", + "김해시", + "밀양시", + "거제시", + "양산시", + "의령군", + "함안군", + "창녕군", + "고성군", + "남해군", + "하동군", + "산청군", + "함양군", + "거창군", + "합천군" + ], + + "제주특별자치도": ["제주시", "서귀포시"] +}