Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<String> = emptyList(),
place2List: List<String> = 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<List<String>>(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) }

Expand All @@ -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
}
}
Expand All @@ -129,6 +155,13 @@ fun RegisterTestInfoBottomSheet(
Column(
modifier = Modifier
.wrapContentHeight()
.pointerInput(Unit) {
detectVerticalDragGestures(
onDragEnd = {},
onDragCancel = {},
onVerticalDrag = { _, _ -> /* 아무것도 안 함 */ }
)
}
.fillMaxWidth()
) {
Spacer(Modifier.heightForScreenPercentage(35.dp))
Expand Down Expand Up @@ -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))
Expand All @@ -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))
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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
}
}
Expand Down Expand Up @@ -418,7 +451,7 @@ fun RegisterTestInfoBottomSheet(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.noRippleClickable {
onDismiss()
onConfirmWithNoData()
}
)

Expand All @@ -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
Expand Down Expand Up @@ -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 = {}
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 } // 스크롤이 멈췄을 때
Expand Down Expand Up @@ -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,
Expand All @@ -219,6 +240,7 @@ fun TimePickerColumn(
modifier = Modifier
.heightForScreenPercentage(40.dp)
.wrapContentHeight()
.alpha(if (isVisible) 1f else 0f)
)
}

Expand All @@ -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 } // 스크롤이 멈췄을 때
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -342,9 +376,7 @@ fun TimePickerPreview() {
CustomTimePicker(
initialHour = 12,
initialMinute = 0,
onTimeSelected = { hour, minute ->
println("Selected time: $hour:$minute")
}
onTimeSelected = { hour, minute -> }
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.sopt.certi.core.util.region

data class RegionData(
val city: String,
val districts: List<String>
)
Original file line number Diff line number Diff line change
@@ -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<RegionData> {
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<String> {
return parseRegionData(context).map { it.city }
}

fun getDistricts(context: Context, city: String): List<String> {
return parseRegionData(context)
.find { it.city == city }
?.districts
?: emptyList()
}
}
Loading