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..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,8 +9,11 @@ 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 +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton @@ -23,6 +26,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 +51,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 +71,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 +92,58 @@ 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, ) { + // 고정 높이 모드: height() 후 인셋이면 콘텐츠 영역이 줄어 버튼이 잘릴 수 있음 + // 인셋/하단 패딩을 바깥에 두고, 본문은 heightIn(min)으로 하한만 맞춤 -> 전체 높이가 인셋만큼 늘어남 + // sheetHeight == null: 콘텐츠 wrap (프로필 피커 등) + val heightModifier = if (sheetHeight != null) { + Modifier.heightIn(min = sheetHeight) + } else { + Modifier.wrapContentHeight() + } + Column( modifier = Modifier .fillMaxWidth() - .height(387.dp), + .navigationBarsPadding() + .padding(bottom = 16.dp) + .then(heightModifier), ) { + // 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, ) 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..ad061fe5 --- /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 = SIDE_SCALE_X, + stop = 1f, + fraction = 1f - offsetFraction, + ) + val scaleY = lerp( + start = SIDE_SCALE_Y, + stop = 1f, + fraction = 1f - offsetFraction, + ) + val dimAlpha = lerp( + start = SIDE_DIM_ALPHA, + 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 isSelected = index == currentPage + Box( + modifier = Modifier + .size(if (isSelected) 6.dp else 5.dp) + .clip(CircleShape) + .background( + if (isSelected) { + CallTheme.colors.mainVariant1 + } else { + CallTheme.colors.gray200 + }, + ), + ) + } + } +} + +/** 중앙(선택) 카드 사이즈 */ +internal val CenterImageWidth = 225.dp +internal val CenterImageHeight = 249.dp + +/** 양옆 카드 배율 */ +internal const val SIDE_SCALE_X = 160f / 225f +internal const val SIDE_SCALE_Y = 179f / 249f + +/** 비선택 카드 dim 알파 */ +private const val SIDE_DIM_ALPHA = 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..667abdff --- /dev/null +++ b/core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageGenderToggle.kt @@ -0,0 +1,177 @@ +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 = "남자", + isSelected = currentGender == ProfileImageGender.MALE, + onClick = { select(ProfileImageGender.MALE) }, + modifier = Modifier + .width(segmentWidth) + .fillMaxHeight(), + ) + GenderSegmentLabel( + label = "여자", + isSelected = currentGender == ProfileImageGender.FEMALE, + onClick = { select(ProfileImageGender.FEMALE) }, + modifier = Modifier + .width(segmentWidth) + .fillMaxHeight(), + ) + } + } +} + +@Composable +private fun GenderSegmentLabel( + label: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val textColor by animateColorAsState( + targetValue = if (isSelected) { + 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 (isSelected) { + 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 + } +}