From 4f135ab563dc81f16ebdfd438dafb66a90d10f55 Mon Sep 17 00:00:00 2001 From: WAcAW9 Date: Wed, 29 Jul 2026 10:24:07 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20ConfirmBottomSheet=20=EC=88=98?= =?UTF-8?q?=EC=A0=95(#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 고정 높이보다 본문 콘텐츠가 크면 잘리는 이슈 해결 - title 2줄을 넘어갈 경우 상단이 시트 모서리에 붙어서 여백 확보 --- .../bottomsheet/ConfirmBottomSheet.kt | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/core/designsystem/src/main/java/kr/co/call/designsystem/component/bottomsheet/ConfirmBottomSheet.kt b/core/designsystem/src/main/java/kr/co/call/designsystem/component/bottomsheet/ConfirmBottomSheet.kt index 917d27e1..4ecbb37c 100644 --- a/core/designsystem/src/main/java/kr/co/call/designsystem/component/bottomsheet/ConfirmBottomSheet.kt +++ b/core/designsystem/src/main/java/kr/co/call/designsystem/component/bottomsheet/ConfirmBottomSheet.kt @@ -9,8 +9,10 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets 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.size +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton @@ -23,6 +25,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kr.co.call.designsystem.R import kr.co.call.designsystem.component.button.PrimaryButton @@ -47,14 +50,16 @@ import kr.co.call.designsystem.theme.CallTheme * } * ``` * - * 바텀시트의 높이는 공통 디자인 규격인 387dp로 고정됩니다. 호출부에서는 높이를 별도로 - * 지정하지 않고 본문 콘텐츠만 전달합니다. + * 바텀시트 기본 높이는 공통 규격 387dp입니다. + * 본문이 커서 고정 높이 안에 안 들어가면 [sheetHeight]를 null로 두어 + * 콘텐츠 높이만큼 늘어나게 할 수 있습니다 (확인 버튼이 잘리지 않음). * * @param title 바텀시트 상단에 표시할 제목입니다. * @param onConfirmClick 하단 확인 버튼을 눌렀을 때 호출됩니다. * @param onDismissRequest 닫기 버튼, 바깥 영역 또는 시스템 뒤로가기로 닫을 때 호출됩니다. * @param modifier `ModalBottomSheet` 자체에 적용할 Modifier입니다. * @param confirmText 확인 버튼에 표시할 문구입니다. + * @param sheetHeight 시트 전체 고정 높이. null이면 콘텐츠 wrap (기본 387dp). * @param content 제목과 확인 버튼 사이에 표시할 화면별 콘텐츠입니다. */ @OptIn(ExperimentalMaterial3Api::class) @@ -65,6 +70,7 @@ fun ConfirmBottomSheet( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, confirmText: String = "확인", + sheetHeight: Dp? = 387.dp, content: @Composable ColumnScope.() -> Unit, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -85,34 +91,56 @@ fun ConfirmBottomSheet( confirmText = confirmText, onConfirmClick = onConfirmClick, onDismissRequest = onDismissRequest, + sheetHeight = sheetHeight, content = content, ) } } @Composable -private fun ConfirmBottomSheetContent( +internal fun ConfirmBottomSheetContent( title: String, confirmText: String, onConfirmClick: () -> Unit, onDismissRequest: () -> Unit, + sheetHeight: Dp? = 387.dp, content: @Composable ColumnScope.() -> Unit, ) { + // 고정 높이보다 본문 + 버튼 합이 크면 하단(저장 버튼)이 잘림 이슈 대응-> null이면 wrap + val heightModifier = if (sheetHeight != null) { + Modifier.height(sheetHeight) + } else { + Modifier.wrapContentHeight() + } + Column( modifier = Modifier .fillMaxWidth() - .height(387.dp), + .then(heightModifier) + .navigationBarsPadding() + .padding(bottom = 16.dp), ) { + // 2줄 제목은 82dp 안에서 Bottom 정렬 시 상단이 시트 모서리에 붙음 -> 높이/상단 여백 확보 + val isMultiLineTitle = title.contains('\n') + val headerHeight = if (isMultiLineTitle) 104.dp else 82.dp + Box( modifier = Modifier .fillMaxWidth() - .height(82.dp), + .height(headerHeight), ) { Text( text = title, modifier = Modifier - .align(Alignment.BottomStart) - .padding(start = 16.dp, bottom = 24.dp), + .align( + if (isMultiLineTitle) Alignment.TopStart else Alignment.BottomStart, + ) + .padding( + start = 16.dp, + end = 56.dp, // 닫기 버튼과 겹치지 않도록 + top = if (isMultiLineTitle) 52.dp else 0.dp, + bottom = if (isMultiLineTitle) 0.dp else 24.dp, + ), style = CallTheme.typography.bodyLargeBold, color = CallTheme.colors.black, ) From adcc4196a21f815a04fe0902a9c16a83e11b407d Mon Sep 17 00:00:00 2001 From: WAcAW9 Date: Wed, 29 Jul 2026 10:31:59 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20ProfileImagePickerBottomSheet=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProfileImagePickerBottomSheet.kt | 168 ++++++++++++ .../profileimage/ProfileImageCarousel.kt | 248 ++++++++++++++++++ .../profileimage/ProfileImageGenderToggle.kt | 173 ++++++++++++ .../profileimage/ProfileImagePickerContent.kt | 127 +++++++++ .../profileimage/ProfileImagePickerModels.kt | 34 +++ .../ProfileImagePickerPreviewData.kt | 28 ++ 6 files changed, 778 insertions(+) create mode 100644 core/designsystem/src/main/java/kr/co/call/designsystem/component/bottomsheet/ProfileImagePickerBottomSheet.kt create mode 100644 core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageCarousel.kt create mode 100644 core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageGenderToggle.kt create mode 100644 core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImagePickerContent.kt create mode 100644 core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImagePickerModels.kt create mode 100644 core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImagePickerPreviewData.kt diff --git a/core/designsystem/src/main/java/kr/co/call/designsystem/component/bottomsheet/ProfileImagePickerBottomSheet.kt b/core/designsystem/src/main/java/kr/co/call/designsystem/component/bottomsheet/ProfileImagePickerBottomSheet.kt new file mode 100644 index 00000000..bf192e5d --- /dev/null +++ b/core/designsystem/src/main/java/kr/co/call/designsystem/component/bottomsheet/ProfileImagePickerBottomSheet.kt @@ -0,0 +1,168 @@ +package kr.co.call.designsystem.component.bottomsheet + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kr.co.call.designsystem.component.profileimage.ProfileImageGender +import kr.co.call.designsystem.component.profileimage.ProfileImageOption +import kr.co.call.designsystem.component.profileimage.ProfileImagePickerContent +import kr.co.call.designsystem.component.profileimage.ProfileImagePickerPreviewData +import kr.co.call.designsystem.theme.CallFromAiTheme +import kr.co.call.designsystem.theme.CallTheme + +/** + * 프로필 사진 선택 공통 바텀시트 + * + * 마이페이지·온보딩 등에서 동일 UI로 재사용합니다. + * [ConfirmBottomSheet] 셸 + [ProfileImagePickerContent](토글·캐러셀) 조합입니다. + * + * 모델·캐러셀·토글 구현은 + * `kr.co.call.designsystem.component.profileimage` 패키지에 있습니다. + * + * ## 책임 경계 + * | 이 컴포넌트 | 호출부 (feature / ViewModel) | + * |-------------|------------------------------| + * | 바텀시트 UI, 토글·캐러셀 표시 | 시트 열림 여부 | + * | 스와이프/탭에 따른 콜백 전달 | [images] 목록 (성별별 필터 포함) | + * | | [selectedGender], [selectedImageId] 보관 | + * | | 저장 API / 로컬 반영 | + * + * ## 호출 계약 + * 1. [images]는 **현재 성별에 해당하는 목록만** 넘깁니다. 성별 전환 시 목록을 갈아끼우세요. + * 2. [onGenderChange]에서 성별 state를 바꾼 뒤, 새 목록의 첫 항목 등으로 + * [selectedImageId]를 리셋하는 것을 권장합니다. + * 3. [onImageSelected]는 페이지 확정마다 호출됩니다. 저장 전 선택 반영에 사용합니다. + * 4. designsystem에 domain 모델·Repository를 넣지 마세요. [ProfileImageOption]으로 매핑합니다. + * + * - 사용 예시 + * ```kotlin + * import kr.co.call.designsystem.component.bottomsheet.ProfileImagePickerBottomSheet + * import kr.co.call.designsystem.component.profileimage.ProfileImageGender + * import kr.co.call.designsystem.component.profileimage.ProfileImageOption + * + * if (state.showImagePicker) { + * ProfileImagePickerBottomSheet( + * images = state.pickerImages, + * selectedGender = state.pickerGender, + * selectedImageId = state.selectedImageId, + * onGenderChange = { viewModel.onPickerGenderChange(it) }, + * onImageSelected = { viewModel.onPickerImageSelected(it) }, + * onSaveClick = { viewModel.onSaveProfileImage() }, + * onDismissRequest = { viewModel.dismissImagePicker() }, + * ) + * } + * ``` + * + * @param images 현재 성별 후보 목록 (빈 목록이면 empty 문구 표시) + * @param selectedGender 성별 토글 선택값 + * @param selectedImageId 현재 선택 이미지 ID. null이면 캐러셀 첫 페이지 + * @param onGenderChange 성별 변경. 목록 교체는 호출부에서 수행 + * @param onImageSelected 캐러셀 페이지 확정 시 + * @param onSaveClick 하단 저장 버튼 + * @param onDismissRequest 닫기 / 스크림 / 시스템 뒤로가기 + * @param title 시트 제목 (기본 2줄) + * @param confirmText 확인 버튼 문구 + */ +@Composable +fun ProfileImagePickerBottomSheet( + images: List, + selectedGender: ProfileImageGender, + selectedImageId: String?, + onGenderChange: (ProfileImageGender) -> Unit, + onImageSelected: (ProfileImageOption) -> Unit, + onSaveClick: () -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + title: String = "원하는 사진을\n선택해주세요", + confirmText: String = "저장하기", +) { + ConfirmBottomSheet( + title = title, + onConfirmClick = onSaveClick, + onDismissRequest = onDismissRequest, + modifier = modifier, + confirmText = confirmText, + sheetHeight = null, + ) { + ProfileImagePickerContent( + images = images, + selectedGender = selectedGender, + selectedImageId = selectedImageId, + onGenderChange = onGenderChange, + onImageSelected = onImageSelected, + ) + } +} + + +/** + * Preview + */ +@Preview( + showBackground = true +) +@Composable +private fun ProfileImagePickerModalBottomSheetPreview() { + CallFromAiTheme { + var selectedGender by remember { mutableStateOf(ProfileImageGender.FEMALE) } + var selectedImageId by remember { + mutableStateOf(ProfileImagePickerPreviewData.femaleImages.first().id) + } + val images = ProfileImagePickerPreviewData.imagesFor(selectedGender) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(800.dp) + .background(CallTheme.colors.gray800.copy(alpha = 0.35f)), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(800.dp) + .background(CallTheme.colors.subGray2), + ) + + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background( + color = CallTheme.colors.white, + shape = RoundedCornerShape(topStart = 40.dp, topEnd = 40.dp), + ), + ) { + ConfirmBottomSheetContent( + title = "원하는 사진을\n선택해주세요", + confirmText = "저장하기", + onConfirmClick = {}, + onDismissRequest = {}, + sheetHeight = null, + ) { + ProfileImagePickerContent( + images = images, + selectedGender = selectedGender, + selectedImageId = selectedImageId, + onGenderChange = { gender -> + selectedGender = gender + selectedImageId = + ProfileImagePickerPreviewData.imagesFor(gender).first().id + }, + onImageSelected = { selectedImageId = it.id }, + ) + } + } + } + } +} diff --git a/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageCarousel.kt b/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageCarousel.kt new file mode 100644 index 00000000..7a075b94 --- /dev/null +++ b/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageCarousel.kt @@ -0,0 +1,248 @@ +package kr.co.call.designsystem.component.profileimage + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import coil.compose.AsyncImage +import kotlin.math.absoluteValue +import kotlinx.coroutines.flow.distinctUntilChanged +import kr.co.call.designsystem.R +import kr.co.call.designsystem.theme.CallFromAiTheme +import kr.co.call.designsystem.theme.CallTheme + +/** + * 프로필 후보 이미지 가로 캐러셀 + 페이지 인디케이터 + * + * ## 상태 계약 + * - 인디케이터는 [androidx.compose.foundation.pager.PagerState.currentPage]에 직결되어 + * 부모 [selectedImageId] 갱신 전에도 스와이프에 반응합니다. + * - 페이지가 확정되면 [onImageSelected]를 호출합니다. 호출부가 [selectedImageId]를 + * 갱신해야 저장 시 최종 선택이 유지됩니다. + * - 외부에서 [selectedImageId]가 바뀌면(성별 전환 등) 해당 페이지로 스크롤합니다. + * - 목록 자체가 바뀌면 호출부에서 `key(images.map { it.id })` 등으로 이 컴포저블을 + * 재생성해 pager를 초기화하는 것을 권장합니다. + */ +@Composable +internal fun ProfileImageCarousel( + images: List, + selectedImageId: String?, + onImageSelected: (ProfileImageOption) -> Unit, + modifier: Modifier = Modifier, +) { + require(images.isNotEmpty()) { + "ProfileImageCarousel에는 비어 있지 않은 이미지 목록이 필요합니다. 빈 목록은 UI에서 처리하세요." + } + + val initialPage = remember(images) { + images.indexOfFirst { it.id == selectedImageId } + .takeIf { it >= 0 } + ?: 0 + } + val pagerState = rememberPagerState( + initialPage = initialPage, + pageCount = { images.size }, + ) + val defaultProfile = painterResource(id = R.drawable.img_profile_url_default) + + LaunchedEffect(selectedImageId, images) { + val target = images.indexOfFirst { it.id == selectedImageId } + if (target >= 0 && target != pagerState.currentPage && !pagerState.isScrollInProgress) { + pagerState.animateScrollToPage(target) + } + } + + LaunchedEffect(pagerState, images) { + snapshotFlow { pagerState.settledPage } + .distinctUntilChanged() + .collect { page -> + images.getOrNull(page)?.let(onImageSelected) + } + } + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .height(CenterImageHeight), + ) { + // 페이지 폭 = 중앙 카드 폭이 되도록 패딩 -> 현재 페이지가 화면 중앙 + val horizontalPadding = ((maxWidth - CenterImageWidth) / 2).coerceAtLeast(0.dp) + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = horizontalPadding), + pageSpacing = PageSpacing, + verticalAlignment = Alignment.CenterVertically, + ) { page -> + val pageOffset = ( + (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction + ).absoluteValue + val offsetFraction = pageOffset.coerceIn(0f, 1f) + + val scaleX = lerp( + start = SideScaleX, + stop = 1f, + fraction = 1f - offsetFraction, + ) + val scaleY = lerp( + start = SideScaleY, + stop = 1f, + fraction = 1f - offsetFraction, + ) + val dimAlpha = lerp( + start = SideDimAlpha, + stop = 0f, + fraction = 1f - offsetFraction, + ) + + val option = images[page] + // 슬롯은 fillMaxWidth, 카드만 Center -> 페이지 내 start 정렬로 인한 왼쪽 쏠림 방지 + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .graphicsLayer { + this.scaleX = scaleX + this.scaleY = scaleY + } + .size(width = CenterImageWidth, height = CenterImageHeight) + .clip(RoundedCornerShape(CardCornerRadius)) + .background(CallTheme.colors.gray100), + ) { + AsyncImage( + model = option.imageUrl.takeIf { it.isNotBlank() }, + contentDescription = "프로필 후보 사진", + modifier = Modifier.fillMaxSize(), + placeholder = defaultProfile, + error = defaultProfile, + fallback = defaultProfile, + contentScale = ContentScale.Crop, + ) + if (dimAlpha > 0f) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = dimAlpha)), + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(CarouselIndicatorGap)) + + ProfileImagePageIndicator( + pageCount = images.size, + currentPage = pagerState.currentPage, + ) + } +} + +// 하단 dot indicator +@Composable +private fun ProfileImagePageIndicator( + pageCount: Int, + currentPage: Int, + modifier: Modifier = Modifier, +) { + if (pageCount <= 0) return + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(pageCount) { index -> + val selected = index == currentPage + Box( + modifier = Modifier + .size(if (selected) 6.dp else 5.dp) + .clip(CircleShape) + .background( + if (selected) { + CallTheme.colors.mainVariant1 + } else { + CallTheme.colors.gray200 + }, + ), + ) + } + } +} + +/** 중앙(선택) 카드 사이즈 */ +internal val CenterImageWidth = 225.dp +internal val CenterImageHeight = 249.dp + +/** 양옆 카드 배율 */ +internal const val SideScaleX = 160f / 225f +internal const val SideScaleY = 179f / 249f + +/** 비선택 카드 알파값 */ +private const val SideDimAlpha = 0.4f + +private val CardCornerRadius = 20.dp +private val PageSpacing = (-24).dp +private val CarouselIndicatorGap = 17.dp + +/** + * Preview : 캐러셀 + 인디케이터 + */ +@Preview( + name = "Carousel_Interactive", + showBackground = true, +) +@Composable +private fun ProfileImageCarouselPreview() { + CallFromAiTheme { + val images = ProfileImagePickerPreviewData.femaleImages + var selectedImageId by remember { mutableStateOf(images.first().id) } + + ProfileImageCarousel( + images = images, + selectedImageId = selectedImageId, + onImageSelected = { selectedImageId = it.id }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + ) + } +} \ No newline at end of file diff --git a/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageGenderToggle.kt b/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageGenderToggle.kt new file mode 100644 index 00000000..d12a47d3 --- /dev/null +++ b/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageGenderToggle.kt @@ -0,0 +1,173 @@ +package kr.co.call.designsystem.component.profileimage + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import kr.co.call.designsystem.modifier.noRippleClickable +import kr.co.call.designsystem.theme.CallFromAiTheme +import kr.co.call.designsystem.theme.CallTheme + +/** + * 성별 슬라이드 세그먼트 컨트롤 + * + * ## 상태 + * - 클릭 즉시 내부 state를 갱신해 썸·텍스트 색이 바로 바뀝니다 (프리뷰·부모 지연에도 반응). + * - 동시에 [onGenderChange]를 호출합니다. **이미지 목록 교체는 호출부 책임**입니다. + * - 외부 [selectedGender]가 바뀌면 내부 state와 재동기화합니다. + * + */ +@Composable +internal fun ProfileImageGenderToggle( + selectedGender: ProfileImageGender, + onGenderChange: (ProfileImageGender) -> Unit, + modifier: Modifier = Modifier, +) { + var currentGender by remember { mutableStateOf(selectedGender) } + LaunchedEffect(selectedGender) { + currentGender = selectedGender + } + + val segmentWidth = 63.dp + val trackHeight = 28.dp + val thumbInset = 2.dp + val trackShape = RoundedCornerShape(30.dp) + val thumbShape = RoundedCornerShape(30.dp) + + val thumbOffset by animateDpAsState( + targetValue = when (currentGender) { + ProfileImageGender.MALE -> thumbInset + ProfileImageGender.FEMALE -> thumbInset + segmentWidth + }, + animationSpec = tween(durationMillis = 220), + label = "genderThumbOffset", + ) + + fun select(gender: ProfileImageGender) { + if (currentGender == gender) return + currentGender = gender + onGenderChange(gender) + } + + Box( + modifier = modifier + .height(trackHeight) + .width(segmentWidth * 2 + thumbInset * 2) + .background( + color = CallTheme.colors.gray100, + shape = trackShape, + ) + .clip(trackShape), + ) { + Box( + modifier = Modifier + .offset(x = thumbOffset) + .width(segmentWidth) + .fillMaxHeight() + .background( + color = CallTheme.colors.mainVariant2, + shape = thumbShape, + ) + .border( + width = 1.dp, + color = CallTheme.colors.mainVariant1, + shape = thumbShape, + ), + ) + + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = thumbInset) + .zIndex(1f), + verticalAlignment = Alignment.CenterVertically, + ) { + GenderSegmentLabel( + label = "남자", + selected = currentGender == ProfileImageGender.MALE, + onClick = { select(ProfileImageGender.MALE) }, + modifier = Modifier + .width(segmentWidth) + .fillMaxHeight(), + ) + GenderSegmentLabel( + label = "여자", + selected = currentGender == ProfileImageGender.FEMALE, + onClick = { select(ProfileImageGender.FEMALE) }, + modifier = Modifier + .width(segmentWidth) + .fillMaxHeight(), + ) + } + } +} + +@Composable +private fun GenderSegmentLabel( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val textColor by animateColorAsState( + targetValue = if (selected) { + CallTheme.colors.mainVariant1 + } else { + CallTheme.colors.gray800 + }, + animationSpec = tween(durationMillis = 220), + label = "genderSegmentText", + ) + + Box( + modifier = modifier.noRippleClickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = if(selected) CallTheme.typography.bodySmallBold else CallTheme.typography.bodySmall, + color = textColor, + ) + } +} + + +/** + * Preview : 성별 토글 + * */ +@Preview(showBackground = true) +@Composable +private fun ProfileImageGenderTogglePreview() { + CallFromAiTheme { + var gender by remember { mutableStateOf(ProfileImageGender.FEMALE) } + Box(modifier = Modifier.padding(16.dp)) { + ProfileImageGenderToggle( + selectedGender = gender, + onGenderChange = { gender = it }, + ) + } + } +} diff --git a/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImagePickerContent.kt b/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImagePickerContent.kt new file mode 100644 index 00000000..8ce61d5e --- /dev/null +++ b/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImagePickerContent.kt @@ -0,0 +1,127 @@ +package kr.co.call.designsystem.component.profileimage + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kr.co.call.designsystem.theme.CallFromAiTheme +import kr.co.call.designsystem.theme.CallTheme + +/** + * 프로필 사진 선택 본문 (성별 토글 + 캐러셀 / empty). + */ +@Composable +internal fun ProfileImagePickerContent( + images: List, + selectedGender: ProfileImageGender, + selectedImageId: String?, + onGenderChange: (ProfileImageGender) -> Unit, + onImageSelected: (ProfileImageOption) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ProfileImageGenderToggle( + selectedGender = selectedGender, + onGenderChange = onGenderChange, + ) + + Spacer(modifier = Modifier.height(ContentSectionGap)) + + if (images.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(CenterImageHeight), + contentAlignment = Alignment.Center, + ) { + Text( + text = "선택 가능한 사진이 없습니다", + style = CallTheme.typography.bodySmall, + color = CallTheme.colors.gray400, + ) + } + } else { + key(images.map { it.id }) { + ProfileImageCarousel( + images = images, + selectedImageId = selectedImageId, + onImageSelected = onImageSelected, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +private val ContentSectionGap = 17.dp + +/** + * Preview : 토글 + 캐러셀 본문 + */ +@Preview(showBackground = true,) +@Composable +private fun ProfileImagePickerContentGenderSwitchPreview() { + CallFromAiTheme { + var selectedGender by remember { mutableStateOf(ProfileImageGender.FEMALE) } + var selectedImageId by remember { + mutableStateOf(ProfileImagePickerPreviewData.femaleImages.first().id) + } + val images = ProfileImagePickerPreviewData.imagesFor(selectedGender) + + Box( + modifier = Modifier + .fillMaxWidth() + .background(CallTheme.colors.white) + .padding(vertical = 8.dp), + ) { + ProfileImagePickerContent( + images = images, + selectedGender = selectedGender, + selectedImageId = selectedImageId, + onGenderChange = { gender -> + selectedGender = gender + selectedImageId = ProfileImagePickerPreviewData.imagesFor(gender).first().id + }, + onImageSelected = { selectedImageId = it.id }, + ) + } + } +} + +@Preview( name = "빈 콘텐츠", showBackground = true) +@Composable +private fun ProfileImagePickerContentEmptyPreview() { + CallFromAiTheme { + Box( + modifier = Modifier + .fillMaxWidth() + .background(CallTheme.colors.white) + .padding(vertical = 8.dp), + ) { + ProfileImagePickerContent( + images = emptyList(), + selectedGender = ProfileImageGender.MALE, + selectedImageId = null, + onGenderChange = {}, + onImageSelected = {}, + ) + } + } +} \ No newline at end of file diff --git a/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImagePickerModels.kt b/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImagePickerModels.kt new file mode 100644 index 00000000..5a82a7a3 --- /dev/null +++ b/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImagePickerModels.kt @@ -0,0 +1,34 @@ +package kr.co.call.designsystem.component.profileimage + +/** + * 프로필 사진 후보 필터용 성별 + * + * designsystem 전용 값입니다. domain/서버 성별 enum을 직접 넣지 말고, + * feature 레이어에서 1:1 매핑해 전달하세요. + * + * ```kotlin + * // feature 예시 + * fun DomainGender.toPicker(): ProfileImageGender = when (this) { + * DomainGender.MALE -> ProfileImageGender.MALE + * DomainGender.FEMALE -> ProfileImageGender.FEMALE + * } + * ``` + */ +enum class ProfileImageGender { + MALE, + FEMALE, +} + +/** + * 프로필 사진 선택 목록의 한 항목입니다. + * + * 네트워크/DB 모델이 아니라 UI 전달용 DTO입니다. + * Repository 응답을 feature에서 이 타입으로 변환해 넘깁니다. + * + * @property id 저장/식별에 쓰는 고유 키 + * @property imageUrl 표시 URL (우선 api 연동 전 단게이므로, blank면 기본 프로필 표시로 나타냄) + */ +data class ProfileImageOption( + val id: String, + val imageUrl: String, +) diff --git a/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImagePickerPreviewData.kt b/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImagePickerPreviewData.kt new file mode 100644 index 00000000..c9bf3303 --- /dev/null +++ b/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImagePickerPreviewData.kt @@ -0,0 +1,28 @@ +package kr.co.call.designsystem.component.profileimage + +/** + * designsystem Preview 전용 샘플 데이터 + */ +internal object ProfileImagePickerPreviewData { + + val maleImages: List = listOf( + ProfileImageOption(id = "1", imageUrl = ""), + ProfileImageOption(id = "2", imageUrl = ""), + ProfileImageOption(id = "3", imageUrl = ""), + ProfileImageOption(id = "4", imageUrl = ""), + ProfileImageOption(id = "5", imageUrl = ""), + ) + + val femaleImages: List = listOf( + ProfileImageOption(id = "6", imageUrl = ""), + ProfileImageOption(id = "7", imageUrl = ""), + ProfileImageOption(id = "8", imageUrl = ""), + ProfileImageOption(id = "9", imageUrl = ""), + ProfileImageOption(id = "10", imageUrl = ""), + ) + + fun imagesFor(gender: ProfileImageGender): List = when (gender) { + ProfileImageGender.MALE -> maleImages + ProfileImageGender.FEMALE -> femaleImages + } +} From a4cac4732316287672122935158e9c2a44ca7661 Mon Sep 17 00:00:00 2001 From: WAcAW9 Date: Wed, 29 Jul 2026 10:55:27 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=ED=94=BC=EB=93=9C=EB=B0=B1=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20(#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConfirmBottomSheet 하한만 보장 - 네이밍 컨벤션에 맞춰 수정 --- .../bottomsheet/ConfirmBottomSheet.kt | 11 ++++++---- .../profileimage/ProfileImageCarousel.kt | 20 +++++++++---------- .../profileimage/ProfileImageGenderToggle.kt | 14 ++++++++----- 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/core/designsystem/src/main/java/kr/co/call/designsystem/component/bottomsheet/ConfirmBottomSheet.kt b/core/designsystem/src/main/java/kr/co/call/designsystem/component/bottomsheet/ConfirmBottomSheet.kt index 4ecbb37c..55f820d6 100644 --- a/core/designsystem/src/main/java/kr/co/call/designsystem/component/bottomsheet/ConfirmBottomSheet.kt +++ b/core/designsystem/src/main/java/kr/co/call/designsystem/component/bottomsheet/ConfirmBottomSheet.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -106,9 +107,11 @@ internal fun ConfirmBottomSheetContent( sheetHeight: Dp? = 387.dp, content: @Composable ColumnScope.() -> Unit, ) { - // 고정 높이보다 본문 + 버튼 합이 크면 하단(저장 버튼)이 잘림 이슈 대응-> null이면 wrap + // 고정 높이 모드: height() 후 인셋이면 콘텐츠 영역이 줄어 버튼이 잘릴 수 있음 + // 인셋/하단 패딩을 바깥에 두고, 본문은 heightIn(min)으로 하한만 맞춤 -> 전체 높이가 인셋만큼 늘어남 + // sheetHeight == null: 콘텐츠 wrap (프로필 피커 등) val heightModifier = if (sheetHeight != null) { - Modifier.height(sheetHeight) + Modifier.heightIn(min = sheetHeight) } else { Modifier.wrapContentHeight() } @@ -116,9 +119,9 @@ internal fun ConfirmBottomSheetContent( Column( modifier = Modifier .fillMaxWidth() - .then(heightModifier) .navigationBarsPadding() - .padding(bottom = 16.dp), + .padding(bottom = 16.dp) + .then(heightModifier), ) { // 2줄 제목은 82dp 안에서 Bottom 정렬 시 상단이 시트 모서리에 붙음 -> 높이/상단 여백 확보 val isMultiLineTitle = title.contains('\n') diff --git a/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageCarousel.kt b/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageCarousel.kt index 7a075b94..ad061fe5 100644 --- a/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageCarousel.kt +++ b/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageCarousel.kt @@ -115,17 +115,17 @@ internal fun ProfileImageCarousel( val offsetFraction = pageOffset.coerceIn(0f, 1f) val scaleX = lerp( - start = SideScaleX, + start = SIDE_SCALE_X, stop = 1f, fraction = 1f - offsetFraction, ) val scaleY = lerp( - start = SideScaleY, + start = SIDE_SCALE_Y, stop = 1f, fraction = 1f - offsetFraction, ) val dimAlpha = lerp( - start = SideDimAlpha, + start = SIDE_DIM_ALPHA, stop = 0f, fraction = 1f - offsetFraction, ) @@ -191,13 +191,13 @@ private fun ProfileImagePageIndicator( verticalAlignment = Alignment.CenterVertically, ) { repeat(pageCount) { index -> - val selected = index == currentPage + val isSelected = index == currentPage Box( modifier = Modifier - .size(if (selected) 6.dp else 5.dp) + .size(if (isSelected) 6.dp else 5.dp) .clip(CircleShape) .background( - if (selected) { + if (isSelected) { CallTheme.colors.mainVariant1 } else { CallTheme.colors.gray200 @@ -213,11 +213,11 @@ internal val CenterImageWidth = 225.dp internal val CenterImageHeight = 249.dp /** 양옆 카드 배율 */ -internal const val SideScaleX = 160f / 225f -internal const val SideScaleY = 179f / 249f +internal const val SIDE_SCALE_X = 160f / 225f +internal const val SIDE_SCALE_Y = 179f / 249f -/** 비선택 카드 알파값 */ -private const val SideDimAlpha = 0.4f +/** 비선택 카드 dim 알파 */ +private const val SIDE_DIM_ALPHA = 0.4f private val CardCornerRadius = 20.dp private val PageSpacing = (-24).dp diff --git a/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageGenderToggle.kt b/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageGenderToggle.kt index d12a47d3..667abdff 100644 --- a/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageGenderToggle.kt +++ b/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageGenderToggle.kt @@ -107,7 +107,7 @@ internal fun ProfileImageGenderToggle( ) { GenderSegmentLabel( label = "남자", - selected = currentGender == ProfileImageGender.MALE, + isSelected = currentGender == ProfileImageGender.MALE, onClick = { select(ProfileImageGender.MALE) }, modifier = Modifier .width(segmentWidth) @@ -115,7 +115,7 @@ internal fun ProfileImageGenderToggle( ) GenderSegmentLabel( label = "여자", - selected = currentGender == ProfileImageGender.FEMALE, + isSelected = currentGender == ProfileImageGender.FEMALE, onClick = { select(ProfileImageGender.FEMALE) }, modifier = Modifier .width(segmentWidth) @@ -128,12 +128,12 @@ internal fun ProfileImageGenderToggle( @Composable private fun GenderSegmentLabel( label: String, - selected: Boolean, + isSelected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) { val textColor by animateColorAsState( - targetValue = if (selected) { + targetValue = if (isSelected) { CallTheme.colors.mainVariant1 } else { CallTheme.colors.gray800 @@ -148,7 +148,11 @@ private fun GenderSegmentLabel( ) { Text( text = label, - style = if(selected) CallTheme.typography.bodySmallBold else CallTheme.typography.bodySmall, + style = if (isSelected) { + CallTheme.typography.bodySmallBold + } else { + CallTheme.typography.bodySmall + }, color = textColor, ) }