[FEAT/#1580] 솝레터 메인 화면 뷰 구현#1586
Conversation
- `ic_active_heart_24`, `ic_inactive_heart_24` 하트 아이콘 추가 - `ic_alert_32`, `ic_close_32`, `ic_download_32`, `ic_edit_32`, `ic_trash_32` 기능성 아이콘 추가 - `ic_sopletter_memo_cloud`, `point`, `sharp`, `smooth` 등 메모지 형태 벡터 리소스 추가 - `img_sopletter_empty` 빈 화면 이미지 추가
- `SopletterMainScreen` 및 `SopletterMemoCard` 등 메인 화면 구성 요소 추가 - `SopletterMainViewModel` 및 `SopletterMainUiState`를 통한 상태 관리 로직 추가 - 메모 카드 모델(`SopletterMemoUiModel`) 및 관련 상수(색상, 회전 각도) 정의 - 작성된 메모가 없을 경우를 위한 `EmptySopletterContent` 추가
- `ic_edit_28.xml` 아이콘 리소스 추가 - `SopletterMainScreen` 내 작성 버튼(`EditSopletterFloatingButton`) 구현 및 배치
- `SopletterMainScreen` 내부에 구현되어 있던 컴포넌트들을 개별 파일로 분리 (`EmptySopletterContent`, `EditSopletterFloatingActionButton`, `SopletterMainTopBar`, `SopletterMemoCard`). - 상단 바(`SopletterMainTopBar`)에 닫기, 다운로드, 신고하기 버튼 이벤트 핸들러 추가. - 플로팅 버튼(`EditSopletterFloatingActionButton`)에 클릭 이벤트 처리를 위한 `noRippleClickable` 적용. - 작성된 메모 유무에 따라 다운로드 버튼의 노출 여부를 제어하는 로직 추가.
- `SopletterMemoDetailDialog` 컴포넌트 및 관련 UI 상태(`SopletterMemoDetailDialogState`) 추가 - `SopletterMainUiState`에 선택된 메모 상세 상태(`selectedMemoDetail`) 필드 추가 - `SopletterMainViewModel` 내 메모 선택 및 해제 로직 구현 - `SopletterMemoCard` 클릭 이벤트 연결 및 메인 화면 다이얼로그 노출 로직 추가 - `SopletterMainPreviewParameterProvider`를 통한 미리보기 데이터 구조 개선 및 다이얼로그 프리뷰 추가
- 불필요한 중첩 Column을 제거하고 최상위 Column에 배경색과 패딩 설정을 통합하여 구조를 단순화했습니다. - 수정 및 삭제 아이콘의 고정 크기(`size(20.dp)`) 제약과 좋아요 아이콘의 크기(`size(16.dp)`) 제약을 제거했습니다. - 좋아요 섹션의 아이콘과 텍스트 사이 간격을 4.dp에서 2.dp로 조정했습니다.
- `SopletterMainViewModel`에 `onLikeClick` 메서드를 추가하여 좋아요 상태 및 카운트 토글 로직 구현 - `SopletterMainScreen`에서 상세 다이얼로그의 좋아요 클릭 이벤트를 ViewModel과 연결 - `SopletterMemoUiModel`에 서버 스펙 관련 작업 예정 주석 추가
- Replace `IconButton` with `Icon` using `noRippleClickable` modifier to remove the ripple effect. - Apply the change to close, download, and report buttons within `SopletterMainTopBar`.
- `SopletterMainScreen` 내 `onEditFABClick` 버튼의 하단 패딩을 66.dp에서 24.dp로 조정함.
sonms
left a comment
There was a problem hiding this comment.
와우 승준님의 고민이 돋보이는 pr이였습니다 와우띵! 역시 대승준!
| onDeleteClick = { }, | ||
| onDismissClick = { }, | ||
| ), | ||
| ) |
There was a problem hiding this comment.
mapper를 따로 분리하신 이유가 있으실까요
제 생각은 순수한 데이터 모델을 유지하고 관심사를 분리하여 나누신 것 같은데
맞을까요?
제 생각에는 이 매퍼 함수가 오직 SopletterMemoUiModel과만 밀접하게 연관되어 있어서 나중에 모델에 필드가 추가되거나 변경될 때 한눈에 파악할 수 있도록 같은 파일에 묶어 응집도를 높이는 게 유지보수 측면에서 더 편리할 것 같은데 승준님의 생각을 듣고 싶습니다!
There was a problem hiding this comment.
말씀해주신 방향에 공감해요! 처음에는 변환 로직을 분리하려는 의도로 mapper를 별도 파일로 두었는데 현재 mapper가 SopletterMemoUiModel 과 밀접하게 연관되어 있어 모델 필드 변경 시 같은 파일에서 함께 확인하는 편이 더 유지보수하기 좋을 것 같습니다.
해당 방식으로 수정해보겠습니다!
| @Stable | ||
| data class SopletterMemoDetailDialogState( | ||
| val memoId: Long, | ||
| val memoColor: SopletterMemoColor, | ||
| val writerName: String, | ||
| val isMine: Boolean, | ||
| val isLiked: Boolean, | ||
| val likeCount: Long, | ||
| val date: String, | ||
| val content: String, | ||
| val event: Event, | ||
| ) { | ||
| @Stable | ||
| data class Event( | ||
| val onLikeClick: () -> Unit, | ||
| val onEditClick: () -> Unit, | ||
| val onDeleteClick: () -> Unit, | ||
| val onDismissClick: () -> Unit, | ||
| ) |
There was a problem hiding this comment.
@stable은 람다를 data class 내부 프로퍼티로 들고 있으면 Compose 컴파일러가 stable, immutable 추론을 하지 못할 때가 많다고 알고 있어요
추가로 state와 event가 DialogState안에 혼재되어 데이터와 이벤트가 함께 묶여있어 Event 객체가 State가 변경될때마다 불필요하게 재구성될 수도 있을 것 같습니다
그래서 interface로 따로 이벤트는 변수에서 제거하고
data class SopletterMemoDetailDialogState(
val memoId: Long,
val memoColor: SopletterMemoColor,
val writerName: String,
val isMine: Boolean,
val isLiked: Boolean,
val likeCount: Long,
val date: String,
val content: String,
)
interface SopletterMemoDetailActions {
fun onLikeClick()
fun onEditClick()
fun onDeleteClick()
fun onDismissClick()
}
class SopletterMainViewModel : ViewModel(), SopletterMemoDetailActions {뷰모델에서 구현하는 방식이나
MVI 를 필요한 부분에 적용하여
// Event 정의
sealed interface SopletterMemoDetailEvent {
// 다이얼로그 관련 액션
data class MemoClick(val memoId: Long) : SopletterMainEvent // 어떤 메모를 눌렀는지 데이터 포함 가능
object LikeClick : SopletterMainEvent
object EditClick : SopletterMainEvent
object DeleteClick : SopletterMainEvent
object DialogDismiss : SopletterMainEvent
}
@Composable
fun SopletterMainScreen(
uiState: SopletterMainUiState,
onEvent: (SopletterMainEvent) -> Unit,
) {
if (uiState.selectedMemo != null) {
SopletterMemoDetailDialog(
state = uiState.selectedMemo,
// 하위 다이얼로그에도 동일한 단일 통로를 넘기거나, 필요한 이벤트만 맵핑해서 전달
onEvent = onEvent
)
}
}
// ViewModel
fun onEvent(event: SopletterMainEvent) {
when (event) {
is SopletterMainEvent.MemoClick -> handleMemoClick(event.memoId)
SopletterMainEvent.LikeClick -> handleLike()
SopletterMainEvent.EditClick -> navigateToEdit()
SopletterMainEvent.DeleteClick -> deleteMemo()
SopletterMainEvent.DialogDismiss -> clearSelectedMemo()
}방식을 생각했는데 승준님의 생각을 여쭤보고 싶습니다!
There was a problem hiding this comment.
좋은 의견과 예시까지 감사합니다!
초기에는 메인 화면과 다이얼로그의 상태/이벤트를 분리하고 싶어서 다이얼로그에 필요한 데이터와 액션을 하나의 모델로 묶는 방식으로 구성했습니다. 말씀주신 것처럼 DialogState 내부에 Event를 함께 두면 상태와 액션의 책임이 섞이고 상태 변경 시 copy 과정에서 리컴포지션의 우려도 있겠네요..!
제안 주신 내용 참고해서 다이얼로그는 별도 contract로 분리하고 State는 순수 데이터만 가지도록 두고 Actions는 별도로 전달하는 방향으로 수정해볼게요! 메인 화면은 기존 구조를 유지하되 다이얼로그 쪽만 분리하는 방식으로 반영하겠습니다.
| import org.sopt.official.feature.sopletter.main.model.SopletterMemoUiModel | ||
| import org.sopt.official.sopletter.R | ||
|
|
||
| class SopletterMainPreviewParameterProvider : PreviewParameterProvider<SopletterMainUiState> { |
- 수정 및 삭제 아이콘의 `tint`를 `Color.Unspecified`로 변경하여 원본 아이콘 색상을 유지하도록 수정했습니다. - 확인 버튼의 텍스트 색상을 `White`에서 `SoptTheme.colors.primary`로 변경했습니다.
| val content: String, | ||
| val event: Event, | ||
| ) { | ||
| @Stable |
- `SopletterMemoDetailDialogContract`를 신규 생성하여 State와 Actions(이벤트 핸들러)를 인터페이스로 분리 정의했습니다. - 기존 `SopletterMemoDetailDialogState` 클래스를 삭제하고 Contract 기반의 구조로 대체했습니다. - `SopletterMainViewModel`에서 `Actions` 인터페이스를 구현하여 이벤트 처리를 담당하도록 변경했습니다. - UI 모델 및 스크린 컴포넌트에서 상태 모델과 액션 파라미터를 분리하여 가독성을 높였습니다. - `SopletterMainUiState` 및 관련 모델에 `@Immutable` 어노테이션을 추가하여 Compose 안정성을 강화했습니다.
- `SopletterMemoMapper.kt`를 삭제하고 매핑 로직을 `SopletterMemoDetailDialogContract.kt` 내부로 이동. - `toDetailDialogState` 함수명을 `toMemoDetailDialogState`로 변경. - `SopletterMainScreen` 및 `SopletterMainPreviewParameterProvider`에서 변경된 함수명을 사용하도록 수정.
- `SopletterScaffold` 및 `SopletterSnackBar` 컴포넌트 추가 - `SopletterMainViewModel`에 `snackbarMessage` SharedFlow 추가 및 본인 글 좋아요 클릭 시 에러 메시지 노출 로직 구현 - `SopletterMainScreen`에 `SnackbarHostState` 적용 및 메시지 수집 로직 추가 - 스낵바용 경고 아이콘 리소스(`ic_alert_circle_20.xml`) 추가
- `SopletterMainViewModel` 내 리스트 새로고침을 위한 `refreshMemoList` 함수 추가 - `SopletterMainScreen`에 `PullRefreshIndicator` 및 `pullRefresh` modifier 적용 - 로딩 상태(`isLoading`)에 따른 새로고침 UI 연동
- `SopletterMemoDetailDialog.kt` 파일에서 사용되지 않는 `White` 색상 임포트 제거.
| interface SopletterMemoDetailDialogContract { | ||
| @Immutable | ||
| data class State( | ||
| val memoId: Long, | ||
| val memoColor: SopletterMemoColor, | ||
| val writerName: String, | ||
| val isMine: Boolean, | ||
| val isLiked: Boolean, | ||
| val likeCount: Long, | ||
| val date: String, | ||
| val content: String, | ||
| ) | ||
|
|
||
| interface Actions { | ||
| fun onLikeClick() | ||
| fun onEditClick() | ||
| fun onDeleteClick() | ||
| fun onDismissClick() | ||
| } | ||
| } |
| onDeleteClick = { /* TODO delete click */ }, | ||
| onDismissClick = viewModel::clearSelectedMemo, | ||
| ), | ||
| dialogActions = viewModel, |
There was a problem hiding this comment.
JW) 혹시 이 부분은 viewModel을 넘기시는 이유가 있을까요?
There was a problem hiding this comment.
ViewModel이 SopletterMemoDetailDialogContract.Actions의 구현체 역할을 하고 있어서 우선은 Actions 를 위한 별도 래핑 객체를 없이 사용하기 위해서 그대로 전달했습니다.
근데 이제 보니 현재처럼 바로 viewModel을 넘기면 의도가 충분히 드러나지 않을 수 있을거 같다는 생각이 드네요.
val dialogActions: SopletterMemoDetailDialogContract.Actions = viewModel 요런식으로
Actions 타입으로 한 번 명시해서 전달하는 방식이 더 나을거 같은데 괜찮으면 해당 방식으로 수정해볼게요 !
There was a problem hiding this comment.
좀 더 가독성이 올라갈 것 같아 좋은 방식 같습니당! 확인 감사합니다~~
- `viewModel`에서 `SopletterMemoDetailDialogContract.Actions` 타입의 `dialogActions` 변수를 별도로 추출하여 가독성 개선 - UI 컴포넌트에 `viewModel` 대신 추출된 `dialogActions`를 전달하도록 수정
|
Caution Review failedPull request was closed or merged during review Summary by CodeRabbit새로운 기능
워크스루Sopletter 메인 화면의 Jetpack Compose 기반 전체 구현입니다. 상태 모델부터 시작하여 뷰모델을 통한 상태 관리, Scaffold 구성, 메인 화면 라우팅, 메모 리스트 및 상세 다이얼로그 렌더링, 보조 컴포넌트 제공, 그리고 미리보기 및 드로어블 리소스를 포함합니다. 변경사항Sopletter 메인 화면 기능
시퀀스 다이어그램sequenceDiagram
participant User as 사용자
participant Route as MainRoute
participant ViewModel as ViewModel
participant Screen as MainScreen
participant Dialog as Dialog
User->>Route: 화면 진입
Route->>ViewModel: uiState 수집
Route->>Screen: 상태 전달
Screen->>Screen: Pull-to-refresh 상태 구성
alt 메모 리스트 있음
Screen->>Screen: LazyVerticalStaggeredGrid 렌더링
User->>Screen: 메모 카드 클릭
Screen->>ViewModel: onMemoClick 호출
ViewModel->>Route: selectedMemoDetail 갱신
Route->>Dialog: 다이얼로그 표시
User->>Dialog: 좋아요 클릭
Dialog->>ViewModel: onLikeClick 호출
ViewModel->>ViewModel: 좋아요 상태 갱신
else 메모 리스트 비어있음
Screen->>Screen: EmptySopletterContent 표시
end
User->>Screen: Pull-to-refresh
Screen->>ViewModel: refreshMemoList 호출
🎯 3 (중간) | ⏱️ ~25분
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Related issue 🛠
Work Description ✏️
Screenshot 📸
Uncompleted Tasks 😅
To Reviewers 📢
아직 서버 스펙이랑 불안정한 부분들이 몇가지 있어서 우선 UI 그린거 먼저 올립니다!추후에 서버 스펙이랑 미완성된 로직 추가할게요.
++ 스낵바, pull to refresh 추가했습니다. 스낵바 쪽은 write 에서도 쓰이는 것 같아서 래퍼 형태로 만들어보았는데 상위단의 공통 route 에서 처리하는 것도 괜찮을거 같은데 의견 부탁드립니다.