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 @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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,
),
Comment on lines +127 to +146

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

isMultiLineTitle 판별이 명시적 \n에만 의존합니다.

title.contains('\n')으로만 멀티라인 여부를 판단하므로, 개행 문자 없이 화면 폭 때문에 자동으로 두 줄이 되는 제목에서는 headerHeight=82.dp, BottomStart 정렬, bottom=24.dp 패딩이 그대로 적용됩니다. 이는 이 PR이 고치려는 문제(첫 줄이 시트 상단 모서리에 붙거나 잘림)를 그대로 재현할 수 있습니다.

현재 이 컴포넌트를 사용하는 곳들이 모두 의도적으로 \n을 명시("원하는 사진을\n선택해주세요")하고 있어 실사용상 문제가 드러나지 않았을 뿐, 공용 디자인시스템 컴포넌트로서는 "제목에 두 줄 이상이면 반드시 \n을 넣어야 한다"는 계약을 KDoc에 명시하거나, onTextLayout으로 실제 줄 수를 측정하는 방식으로 보강하는 것을 권장합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/designsystem/src/main/java/kr/co/call/designsystem/component/bottomsheet/ConfirmBottomSheet.kt`
around lines 124 - 143, Update the title layout logic around isMultiLineTitle so
titles that wrap naturally due to available width receive the same multi-line
height, alignment, and padding as explicitly newline-separated titles. Prefer
using Text’s onTextLayout to derive whether the rendered line count exceeds one,
while preserving the existing single-line behavior and avoiding reliance on
title.contains('\n') alone.

style = CallTheme.typography.bodyLargeBold,
color = CallTheme.colors.black,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +24 to +77

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

와 kdoc 맛있게 쓰네..

fun ProfileImagePickerBottomSheet(
images: List<ProfileImageOption>,
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 },
)
}
}
}
}
}
Loading