Skip to content

feat: 프로필 선택 BottomModal 컴포넌트 구현 - #49

Merged
codebidoof merged 3 commits into
developfrom
feat/#45-component-profile-modal
Jul 30, 2026
Merged

feat: 프로필 선택 BottomModal 컴포넌트 구현#49
codebidoof merged 3 commits into
developfrom
feat/#45-component-profile-modal

Conversation

@WAcAW9

@WAcAW9 WAcAW9 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

📄 작업 내용 요약

  • 프로필 선택 BottomModal 컴포넌트 구현
  • ConfirmBottomSheet 공통 컴포넌트 아래 2가지 수정
  1. 고정 높이보다 본문 콘텐츠가 크면 잘리는 이슈 해결
  2. title 2줄을 넘어갈 경우 상단이 시트 모서리에 붙어서 여백 확보

📎 Issue 번호


✅ 작업 목록

  • 기능 구현
  • 코드 리뷰 반영
  • 테스트 코드 작성
  • 문서 업데이트

📝 기타 참고사항

Screen_recording_20260729_091840.webm

Summary by CodeRabbit

  • 새 기능

    • 성별별 프로필 이미지 선택 UI를 추가했습니다.
    • 이미지를 가로로 탐색하고 선택할 수 있는 캐러셀과 페이지 인디케이터를 제공합니다.
    • 선택 가능한 이미지가 없을 때 안내 문구를 표시합니다.
    • 확인용 하단 시트의 높이를 선택적으로 설정할 수 있습니다.
  • 개선

    • 여러 줄 제목과 긴 콘텐츠가 잘리지 않도록 하단 시트 레이아웃을 개선했습니다.
    • 화면 하단 시스템 영역을 고려해 여백 처리를 보완했습니다.

WAcAW9 added 2 commits July 29, 2026 10:24
- 고정 높이보다 본문 콘텐츠가 크면 잘리는 이슈 해결
- title 2줄을 넘어갈 경우 상단이 시트 모서리에 붙어서 여백 확보
@WAcAW9 WAcAW9 linked an issue Jul 29, 2026 that may be closed by this pull request
1 task
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

프로필 이미지 선택 모델과 성별 토글, 이미지 캐러셀이 추가되었습니다. 선택 콘텐츠를 ConfirmBottomSheet에 연결했으며, 시트는 선택적 높이와 멀티라인 제목 및 내비게이션 여백을 지원합니다.

Changes

프로필 이미지 선택 기능

Layer / File(s) Summary
ConfirmBottomSheet 높이 및 헤더 레이아웃
core/designsystem/.../bottomsheet/ConfirmBottomSheet.kt
sheetHeight로 고정 높이 또는 콘텐츠 기반 높이를 선택하고, 멀티라인 제목과 시스템 내비게이션 여백을 처리합니다.
프로필 이미지 모델 및 선택 컴포넌트
core/designsystem/.../profileimage/ProfileImagePickerModels.kt, ProfileImageGenderToggle.kt, ProfileImageCarousel.kt, ProfileImagePickerPreviewData.kt
성별·이미지 옵션 모델, 성별 토글, 페이지 확정 콜백을 지원하는 이미지 캐러셀과 인디케이터를 추가합니다.
프로필 이미지 선택 콘텐츠 조합
core/designsystem/.../profileimage/ProfileImagePickerContent.kt
성별 토글과 캐러셀을 조합하고, 빈 이미지 목록과 이미지 목록 변경을 처리합니다.
프로필 이미지 BottomSheet 연결
core/designsystem/.../bottomsheet/ProfileImagePickerBottomSheet.kt
프로필 이미지 콘텐츠를 확인용 BottomSheet에 배치하고 저장·닫기 콜백을 연결합니다.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant ProfileImagePickerBottomSheet
  participant ConfirmBottomSheet
  participant ProfileImagePickerContent
  participant ProfileImageCarousel

  Caller->>ProfileImagePickerBottomSheet: 이미지 목록과 선택 상태 전달
  ProfileImagePickerBottomSheet->>ConfirmBottomSheet: 콘텐츠와 저장/닫기 콜백 전달
  ConfirmBottomSheet->>ProfileImagePickerContent: 본문 렌더링
  ProfileImagePickerContent->>ProfileImageCarousel: 이미지 목록과 선택 콜백 전달
  ProfileImageCarousel->>Caller: 선택된 이미지 전달
  ConfirmBottomSheet->>Caller: 저장 또는 닫기 이벤트 전달
Loading

Possibly related PRs

Suggested labels: 🧩 Component, ✨ Feature

Suggested reviewers: codebidoof

🚥 Pre-merge checks | ✅ 5 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
모듈 의존성 방향 검증 ⚠️ Warning core/domain/src/main/java/kr/co/call/domain/repository/ChatRepository.kt imports androidx.paging.PagingData, so core/domain is not pure Kotlin. Remove AndroidX from domain interfaces; move PagingData usage to data/feature layers or wrap it in a domain-agnostic type.
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed 직접 이슈 #45의 프로필 선택 BottomModal 구현 요구를 새 ProfileImagePickerBottomSheet로 충족합니다.
Out of Scope Changes check ✅ Passed ConfirmBottomSheet 수정은 새 프로필 선택 BottomModal 구현과 공통 높이 문제 해결에 필요한 범위로 보입니다.
Ui 변경 시 스크린샷 첨부 확인 ✅ Passed 변경 파일은 designsystem 컴포넌트뿐이라 화면 파일 기준에 해당하지 않으며, PR 설명에 .webm 화면 녹화 링크도 포함돼 있습니다.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Conventional Commits 형식을 따르고 있으며 프로필 선택 BottomModal 구현이라는 변경 내용을 잘 요약합니다.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/#45-component-profile-modal

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

Gemini AI 코드리뷰

안녕하세요, 시니어 Android 개발자로서 해당 PR에 대한 코드리뷰를 진행하겠습니다. 전반적으로 코드 품질, 구조, Jetpack Compose 모범 사례 준수 측면에서 매우 인상적인 PR입니다. 특히 상태 호이스팅, 책임 경계 명확화, 그리고 상세한 문서화는 훌륭합니다. 몇 가지 개선점을 제안드립니다.


core/designsystem/src/main/java/kr/co/call/designsystem/component/bottomsheet/ConfirmBottomSheet.kt

개선점:

  1. 하드코딩된 문자열 외부화:
    • confirmText: String = "확인"의 기본값 "확인"stringResource를 사용하여 외부화하는 것이 좋습니다. 다국어 지원 및 유지보수성을 향상시킵니다.
      // 변경 전:
      // confirmText: String = "확인",
      
      // 변경 후:
      confirmText: String = stringResource(id = R.string.common_confirm), // 예시

긍정적인 평가:

  • 높이 유연성 (sheetHeight): 바텀시트의 높이를 고정(387.dp)하거나 콘텐츠에 따라 wrapContentHeight()를 사용하도록 (null) 유연하게 조절할 수 있도록 개선한 점은 매우 좋습니다. 다양한 시나리오에 대응할 수 있게 되었습니다.
  • navigationBarsPadding 적용: 하단 내비게이션 바 영역에 UI가 가려지지 않도록 navigationBarsPadding()을 추가한 것은 훌륭한 사용자 경험(UX) 개선 및 디자인 시스템 고려 사항입니다.
  • 다중 라인 제목 처리: 제목이 두 줄 이상일 경우 높이와 패딩, 정렬을 동적으로 조절하여 디자인 일관성을 유지하고 닫기 버튼과의 겹침을 방지한 디테일은 매우 인상적입니다.
  • 내부 컴포저블 접근 제한: ConfirmBottomSheetContentinternal로 변경하여 내부 구현 디테일을 외부로 노출하지 않은 점도 좋습니다.

core/designsystem/src/main/java/kr/co/call/designsystem/component/bottomsheet/ProfileImagePickerBottomSheet.kt (신규 파일)

개선점:

  1. 하드코딩된 문자열 외부화:
    • title: String = "원하는 사진을\n선택해주세요"confirmText: String = "저장하기"stringResource를 사용하여 외부화하는 것이 좋습니다.
      // 변경 전:
      // title: String = "원하는 사진을\n선택해주세요",
      // confirmText: String = "저장하기",
      
      // 변경 후:
      title: String = stringResource(id = R.string.profile_image_picker_title),
      confirmText: String = stringResource(id = R.string.common_save),
  2. 프리뷰 백그라운드 레이어 단순화:
    • ProfileImagePickerModalBottomSheetPreview에서 Box(modifier = Modifier.fillMaxWidth().height(800.dp).background(CallTheme.colors.subGray2)) 이 Scrim 역할을 하는 gray800 백그라운드 Box 안에 또 있습니다. subGray2 배경이 불필요하거나, 의도한 바가 있다면 명확하게 주석으로 남기는 것을 권장합니다. 보통 스크린 전체 배경은 CallFromAiThemeSurface나 Preview 자체의 showBackground = true에서 처리합니다.

긍정적인 평가:

  • 명확한 책임 경계 및 호출 계약: 컴포넌트의 책임과 호출부(feature/ViewModel)의 책임을 상세하게 문서화하고, 호출 계약까지 명시한 것은 다른 개발자가 이 컴포넌트를 사용하고 유지보수하는 데 엄청난 도움이 됩니다. 매우 훌륭합니다!
  • 완벽한 상태 호이스팅: 필요한 모든 상태(images, selectedGender, selectedImageId)와 이벤트를 상위 컴포넌트에서 주입받고 콜백으로 처리하여 이 컴포저블을 완전한 stateless 컴포넌트로 만든 점은 Jetpack Compose의 핵심 원칙을 잘 준수하고 있습니다.
  • ConfirmBottomSheet 재활용: 기존 ConfirmBottomSheetsheetHeight = null과 함께 효과적으로 재활용한 점은 디자인 시스템 컴포넌트 간의 재사용성을 높이는 좋은 사례입니다.

core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageCarousel.kt (신규 파일)

개선점:

  1. 하드코딩된 문자열 외부화:
    • contentDescription = "프로필 후보 사진"stringResource를 사용하여 외부화하는 것이 좋습니다.
      // 변경 전:
      // contentDescription = "프로필 후보 사진",
      
      // 변경 후:
      contentDescription = stringResource(id = R.string.profile_candidate_image_description),

긍정적인 평가:

  • PagerStateLaunchedEffect의 적절한 활용: rememberPagerStateLaunchedEffect를 사용하여 외부 selectedImageId와 내부 PagerState를 동기화하고, snapshotFlow().distinctUntilChanged().collect를 통해 페이지 변경 이벤트를 정확하게 감지하여 onImageSelected 콜백을 호출하는 방식은 Compose 및 코루틴 사용의 모범 사례를 잘 따르고 있습니다. !pagerState.isScrollInProgress 체크는 사용자 스크롤과의 충돌을 방지하는 좋은 디테일입니다.
  • BoxWithConstraints를 이용한 동적 패딩: BoxWithConstraints를 활용하여 중앙 이미지가 항상 화면 중앙에 오도록 동적으로 패딩을 계산한 점은 캐러셀 구현의 높은 완성도를 보여줍니다.
  • graphicsLayer를 이용한 애니메이션 성능: graphicsLayer를 사용하여 스케일 애니메이션을 적용함으로써 리컴포지션 없이 GPU에서 처리되도록 하여 성능을 최적화한 점이 좋습니다.
  • Coil 이미지 로딩의 견고함: AsyncImagemodel에서 takeIf { it.isNotBlank() }를 사용하고, placeholder, error, fallback을 지정하여 이미지 로딩 오류 및 빈 URL에 대한 처리를 견고하게 한 점은 훌륭합니다.
  • require를 통한 사전 조건 검사: images 목록이 비어있지 않아야 함을 require로 명시하여 컴포넌트의 사용 계약을 명확히 한 점이 좋습니다. (비어있는 경우에 대한 처리는 상위 컴포넌트 ProfileImagePickerContent에서 수행하는 분리가 잘 되어 있습니다.)
  • 명확한 상수 정의: 캐러셀의 다양한 크기, 비율, 간격 등을 상수로 정의하여 가독성과 유지보수성을 높였습니다.

core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageGenderToggle.kt (신규 파일)

개선점:

  1. 하드코딩된 문자열 외부화:
    • label = "남자", label = "여자"stringResource를 사용하여 외부화하는 것이 좋습니다.
      // 변경 전:
      // label = "남자",
      // label = "여자",
      
      // 변경 후:
      label = stringResource(id = R.string.gender_male),
      label = stringResource(id = R.string.gender_female),
  2. 애니메이션 Duration 상수화:
    • animationSpec = tween(durationMillis = 220) 에서 220은 매직 넘버입니다. 디자인 시스템 내에 애니메이션 관련 상수를 정의하고 사용하는 것이 좋습니다 (예: CallTheme.animation.duration.short).
      // 예시:
      object DesignSystemAnimationConstants {
          const val ShortDurationMillis = 220
          // ...
      }
      
      // 사용:
      animationSpec = tween(durationMillis = DesignSystemAnimationConstants.ShortDurationMillis),

긍정적인 평가:

  • 상태 동기화 패턴: 내부 currentGender 상태와 LaunchedEffect(selectedGender)를 이용한 외부 상태 동기화는 토글과 같은 인터랙티브 컴포넌트에서 흔히 사용되는 견고한 패턴입니다. 즉각적인 UI 피드백과 외부 상태와의 일관성을 동시에 잡을 수 있습니다.
  • animateDpAsState, animateColorAsState: 애니메이션을 매끄럽게 구현하기 위해 Compose의 애니메이션 API를 잘 활용했습니다.
  • noRippleClickable 사용: 기본 리플 효과를 원치 않을 때 noRippleClickable 커스텀 모디파이어를 사용하여 클릭 영역을 지정한 점이 좋습니다.
  • zIndex 활용: 슬라이딩 썸(thumb)과 텍스트 라벨이 적절하게 겹치도록 zIndex를 사용한 점도 구현 디테일을 잘 처리한 부분입니다.

core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImagePickerContent.kt (신규 파일)

개선점:

  1. 하드코딩된 문자열 외부화:
    • text = "선택 가능한 사진이 없습니다"stringResource를 사용하여 외부화하는 것이 좋습니다.
      // 변경 전:
      // text = "선택 가능한 사진이 없습니다",
      
      // 변경 후:
      text = stringResource(id = R.string.profile_no_images_available),

긍정적인 평가:

  • key(images.map { it.id })의 적절한 사용: images 목록이 변경될 때 (예: 성별 전환 시) ProfileImageCarousel이 완전히 재구성되도록 key를 사용한 점은 매우 중요하고 올바른 판단입니다. 이는 PagerState가 이전 목록의 상태를 유지하여 발생할 수 있는 잠재적인 버그를 방지합니다.
  • Empty State 처리: images.isEmpty()일 때 "선택 가능한 사진이 없습니다" 메시지를 표시하여 사용자에게 명확한 피드백을 제공하는 점이 좋습니다.
  • 모듈화 및 조합: ProfileImageGenderToggleProfileImageCarousel을 조합하여 하나의 기능 단위(Profile Image Picker Content)를 구성하는 방식으로 모듈화를 잘 수행했습니다.

core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImagePickerModels.kt (신규 파일)

긍정적인 평가:

  • 명확한 도메인-UI 매핑 지침: ProfileImageGender enum과 ProfileImageOption data class에 designsystem 전용 모델임을 명시하고, feature 레이어에서 도메인 모델과의 1:1 매핑을 권장하는 상세한 주석은 매우 훌륭합니다. 이는 아키텍처 원칙 준수와 레이어 간의 의존성 관리 측면에서 탁월한 가이드라인입니다.
  • data classval 사용: ProfileImageOption의 모든 프로퍼티를 val로 선언하여 불변성을 확보한 점은 좋은 Kotlin 코딩 스타일입니다.

core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImagePickerPreviewData.kt (신규 파일)

긍정적인 평가:

  • 프리뷰 데이터 분리: 프리뷰 전용 샘플 데이터를 별도의 파일로 분리하여 관리하는 것은 테스트 및 프리뷰 코드의 가독성과 독립성을 높이는 좋은 습관입니다.
  • imageUrl 빈 스트링 처리: imageUrl = ""로 테스트 데이터를 제공하고, 실제 AsyncImage에서 takeIf { it.isNotBlank() }로 처리하는 방식은 엣지 케이스 테스트에 유용합니다.

총평:

이 PR은 전반적으로 매우 높은 수준의 코드 품질과 디자인 시스템 설계 원칙을 보여줍니다. Jetpack Compose의 상태 관리, 리컴포지션 최적화, 애니메이션 처리, UI 상태 모델링에 대한 깊은 이해가 엿보입니다. 위에 언급된 몇 가지 개선점(주로 하드코딩된 문자열 외부화 및 애니메이션 상수화)을 적용하면 더욱 완벽한 PR이 될 것입니다.

훌륭한 작업입니다! 👍

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with 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.

Inline comments:
In
`@core/designsystem/src/main/java/kr/co/call/designsystem/component/bottomsheet/ConfirmBottomSheet.kt`:
- Around line 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.
- Around line 110-121: Update the height handling in ConfirmBottomSheet’s
heightModifier so fixed sheetHeight uses Modifier.heightIn(min = sheetHeight)
instead of a fixed height, allowing navigation bar and bottom padding insets to
expand the overall sheet while preserving the minimum height. Keep
wrapContentHeight() for the null sheetHeight case.

In
`@core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageCarousel.kt`:
- Around line 215-220: Rename the compile-time constants SideScaleX, SideScaleY,
and SideDimAlpha to UPPER_SNAKE_CASE names, then update every reference in
ProfileImageCarousel, including the scaleX, scaleY, and dimAlpha calculations,
while preserving their existing values and behavior.

In
`@core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageGenderToggle.kt`:
- Around line 128-155: Rename the Boolean selected parameter in
GenderSegmentLabel to isSelected and update its conditional usages plus the
named arguments at both call sites in ProfileImageGenderToggle.kt (lines 128-155
and 110,118). Also rename the local selected value in ProfileImagePageIndicator
to isSelected and update its usages in ProfileImageCarousel.kt (lines 181-209).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2eaacee1-5e6a-40dd-803c-11b19c753f5d

📥 Commits

Reviewing files that changed from the base of the PR and between 8cccb68 and adcc419.

📒 Files selected for processing (7)
  • core/designsystem/src/main/java/kr/co/call/designsystem/component/bottomsheet/ConfirmBottomSheet.kt
  • core/designsystem/src/main/java/kr/co/call/designsystem/component/bottomsheet/ProfileImagePickerBottomSheet.kt
  • core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageCarousel.kt
  • core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageGenderToggle.kt
  • core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImagePickerContent.kt
  • core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImagePickerModels.kt
  • core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImagePickerPreviewData.kt

Comment on lines +124 to +143
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,
),

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.

- ConfirmBottomSheet 하한만 보장
- 네이밍 컨벤션에 맞춰 수정
@github-actions

Copy link
Copy Markdown

Gemini AI 코드리뷰

안녕하세요! PR 잘 검토했습니다.

이번 PR은 프로필 이미지 피커 바텀시트와 관련 컴포넌트들을 디자인 시스템 레이어에 추가하는 작업이네요. 전반적으로 코드가 매우 깔끔하고, Jetpack Compose의 모범 사례와 디자인 시스템 원칙을 훌륭하게 준수하고 있어 인상 깊습니다. 특히 자세한 KDoc과 책임 경계 설명은 다른 개발자들이 컴포넌트를 이해하고 사용하기 쉽게 할 것입니다.

몇 가지 코멘트와 개선 제안 사항을 드립니다.


🟢 core/designsystem/src/main/java/kr/co/call/designsystem/component/bottomsheet/ConfirmBottomSheet.kt

  • 개선 사항 (Good):
    • sheetHeight: Dp? = 387.dp 파라미터 추가를 통해 바텀시트 높이 유연성을 확보한 점이 좋습니다. wrapContentHeightheightIn을 적절히 사용하여 콘텐츠 길이에 따라 높이를 조절할 수 있도록 한 점도 훌륭합니다.
    • navigationBarsPadding()padding(bottom = 16.dp)heightModifier 전에 적용하여 시스템 UI 인셋과 하단 여백이 콘텐츠를 침범하지 않도록 한 순서도 올바른 구현입니다.
    • isMultiLineTitle을 체크하여 제목이 2줄일 경우 headerHeightpadding을 조절하는 세심한 UI 고려가 돋보입니다. end = 56.dp로 닫기 버튼과의 겹침을 방지한 것도 좋습니다.
    • ConfirmBottomSheetContentinternal로 변경하여 외부에서 직접 호출하는 것을 방지하고, ConfirmBottomSheet 컴포넌트의 내부 구현으로 명확히 한 점도 디자인 시스템 설계 관점에서 바람직합니다.

🟢 core/designsystem/src/main/java/kr/co/call/designsystem/component/bottomsheet/ProfileImagePickerBottomSheet.kt

  • 개선 사항 (Good):
    • 기존 ConfirmBottomSheet를 재사용하여 바텀시트 셸을 구현한 점이 좋습니다. sheetHeight = null을 통해 콘텐츠 높이에 맞게 바텀시트가 확장되도록 한 것도 의도에 맞게 잘 사용했습니다.
    • KDoc이 매우 상세하고 명확합니다. "책임 경계" 및 "호출 계약" 섹션을 통해 이 컴포넌트의 역할과 사용법을 명확히 제시한 점은 다른 개발자들이 실수 없이 사용할 수 있도록 돕는 훌륭한 문서화입니다.
    • 디자인 시스템 레이어에서 Domain 모델을 직접 사용하지 않고 ProfileImageOptionProfileImageGender와 같은 UI 전용 DTO를 사용하도록 명시하고 가이드한 점은 아키텍처 원칙을 잘 지킨 모범 사례입니다.
    • Preview 코드도 ConfirmBottomSheetContent를 직접 사용하여 바텀시트의 내부 콘텐츠를 독립적으로 미리 볼 수 있도록 구성한 점이 효율적입니다.

🟢 core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageCarousel.kt

  • 개선 사항 (Good):
    • require(images.isNotEmpty())를 통해 필수 데이터 존재 여부를 명확히 한 점이 좋습니다.
    • rememberPagerState를 사용하고 initialPage 로직을 selectedImageId에 따라 설정한 점은 외부 상태와 내부 Pager 상태를 잘 연결한 것입니다.
    • LaunchedEffect(selectedImageId, images)를 사용하여 외부 selectedImageId 변경 시 Pager를 스크롤하도록 동기화한 로직이 올바릅니다. 특히 !pagerState.isScrollInProgress를 통해 사용자 스크롤과의 충돌을 방지한 점이 좋습니다.
    • LaunchedEffect(pagerState, images)에서 snapshotFlow { pagerState.settledPage }.distinctUntilChanged().collect { ... } 패턴을 사용하여 Pager의 페이지 변경 이벤트를 정확하고 효율적으로 처리하고 onImageSelected를 호출한 점은 Compose Pager 상태 관리의 모범 사례입니다.
    • BoxWithConstraints를 활용하여 중앙 정렬 캐러셀의 패딩을 동적으로 계산하고, graphicsLayerlerp 함수를 사용하여 중앙/측면 카드의 크기 및 투명도를 조절하는 시각적 효과가 매우 훌륭하게 구현되었습니다.
    • AsyncImage 사용 시 model = option.imageUrl.takeIf { it.isNotBlank() }placeholder, error, fallbackdefaultProfile로 통일하여 이미지 로딩 실패/없음 상황을 일관성 있게 처리한 점이 좋습니다.
    • 모든 magic number들을 상수로 선언하여 가독성과 유지보수성을 높인 점은 디자인 시스템 컴포넌트로서 매우 바람직합니다.
    • internal 가시성으로 컴포넌트의 사용 범위를 제한한 점도 좋습니다.

🟢 core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImageGenderToggle.kt

  • 개선 사항 (Good):

    • currentGenderselectedGender를 분리하고 LaunchedEffect(selectedGender)를 사용하여 외부 상태와 내부 애니메이션 상태를 동기화한 구현이 올바릅니다.
    • animateDpAsStateanimateColorAsState를 사용하여 부드러운 토글 애니메이션을 구현한 점이 좋습니다.
    • noRippleClickable 모디파이어를 사용하여 불필요한 리플 효과를 제거한 것은 좋은 UX 디테일입니다.
    • GenderSegmentLabel 컴포저블을 분리하여 재사용성을 높이고 코드 구조를 깔끔하게 유지했습니다.
    • internal 가시성도 적절합니다.
  • 개선 제안 (Suggestion):

    • label = "남자", label = "여자"와 같은 사용자에게 직접 노출되는 문자열은 하드코딩 대신 string resources (예: R.string.gender_male, R.string.gender_female)로 분리하는 것을 권장합니다. 이는 다국어 지원 및 유지보수에 중요합니다.

🟢 core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImagePickerContent.kt

  • 개선 사항 (Good):

    • ProfileImageGenderToggleProfileImageCarousel을 조합하여 하나의 완성된 프로필 이미지 피커 본문 컴포넌트를 구성한 점이 좋습니다.
    • if (images.isEmpty()) 블록을 통해 이미지가 없을 경우 사용자에게 "선택 가능한 사진이 없습니다" 메시지를 보여주는 예외 처리도 잘 되어 있습니다.
    • key(images.map { it.id }) { ProfileImageCarousel(...) } 사용은 매우 훌륭합니다! images 목록이 변경될 때 ProfileImageCarousel의 내부 PagerState가 올바르게 재설정되도록 하여 버그를 방지하는 중요한 패턴입니다.
    • internal 가시성도 적절합니다.
  • 개선 제안 (Suggestion):

    • text = "선택 가능한 사진이 없습니다" 문자열 또한 사용자에게 노출되는 문구이므로 string resources (예: R.string.no_images_available)로 분리하는 것을 권장합니다.

🟢 core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImagePickerModels.kt

  • 개선 사항 (Good):
    • ProfileImageGender enum을 designsystem 전용 값으로 정의하고 domain/서버 enum과의 매핑을 명시한 점이 아키텍처 관점에서 매우 훌륭합니다.
    • ProfileImageOption data class 역시 UI 전달용 DTO로 정의하고, 네트워크/DB 모델과 분리한 점이 좋습니다.
    • imageUrl: String으로 null-safety를 확보하고, 빈 문자열인 경우에 대한 처리(AsyncImage에서 takeIf { it.isNotBlank() }를 사용)를 염두에 둔 점도 좋습니다.
    • data class에 불필요한 var 없이 모두 val로 선언하여 불변성을 지킨 점도 좋습니다.

🟢 core/designsystem/src/main/java/kr/co/call/designsystem/component/profileimage/ProfileImagePickerPreviewData.kt

  • 개선 사항 (Good):
    • internal object로 Preview 전용 샘플 데이터를 제공한 점이 좋습니다. 각 컴포넌트의 Preview에서 잘 활용되고 있습니다.

총평 및 추가 코멘트:

  • Kotlin 코드 리뷰:

    • Coroutine Dispatcher 명시, viewModelScope/lifecycleScope 오남용: 해당 PR에 ViewModel/Repository 코드가 없어 직접적인 리뷰는 어렵습니다.
    • Flow/StateFlow collect 시점 (repeatOnLifecycle): 해당 PR에 Flow/StateFlow collect 로직이 없어 직접적인 리뷰는 어렵습니다.
    • null 안전성 (!! 사용 지양): !! 사용은 발견되지 않았으며, imageUrl.takeIf { it.isNotBlank() }처럼 안전하게 처리하고 있습니다. 훌륭합니다.
    • data class에 불필요한 var 사용 여부: 모두 val로 선언되어 있습니다. 훌륭합니다.
    • Hilt DI 시 생성자 주입 원칙 준수 여부: 해당 PR에 Hilt 관련 코드가 없어 직접적인 리뷰는 어렵습니다.
    • 하드코딩된 문자열/매직 넘버: 대부분의 매직 넘버는 상수로 분리되었고, UI 관련 문자열 ("남자", "여자", "선택 가능한 사진이 없습니다")만 string resources로 분리할 필요가 있습니다.
  • Jetpack Compose 코드 리뷰:

    • Composable 함수의 불필요한 recomposition 유발 여부: remember, key, LaunchedEffect 등을 적절히 사용하여 recomposition을 효율적으로 관리하고 있습니다. 매우 훌륭합니다.
    • State hoisting 원칙 준수: 각 컴포넌트가 자신의 책임 범위 내에서 상태를 관리하고, 외부에서 제어되어야 하는 상태는 호이스팅하여 부모로부터 전달받는 패턴을 잘 따르고 있습니다.
    • side effect (LaunchedEffect, DisposableEffect) 사용의 적절성: LaunchedEffect가 외부 상태 동기화 및 PagerState 관리에 적절하게 사용되었습니다.
    • UI 상태를 data class로 표현했는지: ProfileImageOption, ProfileImageGender 등 UI 전용 모델을 명확히 정의하여 사용하고 있습니다. 훌륭합니다.
    • 로딩/에러 상태를 Boolean 대신 LoadStatus로 관리하는지: 해당 PR에서 직접적인 로딩/에러 상태 관리는 없지만, images.isEmpty()를 통해 데이터 부재 상황을 명확히 처리하고 있습니다.
  • Repository/DataSource, ViewModel, FCM/SSE 관련 레이어:

    • 해당 PR에는 디자인 시스템 컴포넌트만 포함되어 있어, 이 부분에 대한 리뷰는 어렵습니다.

최종 액션 아이템:

  1. ProfileImageGenderToggle.kt: "남자", "여자" 문자열을 string resources로 분리해 주세요.
  2. ProfileImagePickerContent.kt: "선택 가능한 사진이 없습니다" 문자열을 string resources로 분리해 주세요.

이 두 가지 사항 외에는 특별히 수정할 부분이 없을 정도로 완성도 높은 PR입니다. 다음 PR도 기대하겠습니다!

@WAcAW9 WAcAW9 self-assigned this Jul 29, 2026

@codebidoof codebidoof left a comment

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.

고생했엉 어프루브 ><

Comment on lines +24 to +77
/**
* 프로필 사진 선택 공통 바텀시트
*
* 마이페이지·온보딩 등에서 동일 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

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 맛있게 쓰네..

@codebidoof
codebidoof merged commit e336d40 into develop Jul 30, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] 프로필 선택 BottomModal 컴포넌트 구현

2 participants