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 @@ -10,17 +10,20 @@ 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.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
Expand Down Expand Up @@ -50,6 +53,10 @@ fun ReceiverSelectBottomSheet(
onToggle: (Long) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
/** 조회 실패 문구. null 이면 «아직 등록 안 함» 이다 — 둘을 같은 빈 화면으로 보이지 않게 한다 (#1019). */
loadError: String? = null,
onRetry: (() -> Unit)? = null,
isLoading: Boolean = false,
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
Expand All @@ -69,6 +76,9 @@ fun ReceiverSelectBottomSheet(
ReceiverSelectContent(
receivers = receivers,
selectedReceiverIds = selectedReceiverIds,
loadError = loadError,
onRetry = onRetry,
isLoading = isLoading,
onToggle = onToggle,
onConfirm = onDismiss,
modifier = modifier,
Expand All @@ -83,6 +93,9 @@ private fun ReceiverSelectContent(
onToggle: (Long) -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
loadError: String? = null,
onRetry: (() -> Unit)? = null,
isLoading: Boolean = false,
) {
Column(
modifier =
Expand All @@ -105,18 +118,43 @@ private fun ReceiverSelectContent(
}

if (receivers.isEmpty()) {
Box(
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 40.dp),
contentAlignment = Alignment.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.mindrecord_write_receiver_empty),
style = AfternoteDesign.typography.captionLargeR,
color = AfternoteDesign.colors.gray6,
)
// 조회 중이 빠지면 재시도를 누른 순간부터 응답이 올 때까지 이 자리가 «등록 안 함»
// 을 고른다 — 목록은 아직 비었고 오류만 지워진 창이다. 이 PR 이 없애려던 혼동이
// 사용자 손으로 되돌아오므로, 세 상태를 모두 가른다 (#1019 리뷰).
if (isLoading) {
CircularProgressIndicator(

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.

[nit] 이 모듈의 로딩 표기는 지금까지 전부 shimmerLoadingPlaceholder(core:ui) 였습니다 — TodayQuestionCard:159 · RecordCategoryCard:145 · MemoryDetailOverlay:83. CircularProgressIndicator 는 이 시트가 처음입니다.

다만 여기서는 스피너가 더 맞다고 봅니다. shimmer 는 «들어올 콘텐츠의 자리를 미리 아는» 경우에 그 자리를 흐리게 그리는 장치인데, 이 시트는 목록이 몇 줄이 될지 모르는 빈 중앙이라 흉내 낼 골격이 없습니다.

기록 삼아 남깁니다 — 이 모듈에 «자리를 모르는 로딩» 이 하나 더 생기면 그때는 둘 중 하나를 공용으로 올리는 편이 낫겠습니다.

modifier = Modifier.size(24.dp),
color = AfternoteDesign.colors.gray6,
strokeWidth = 2.dp,
)
} else {
// 조회 실패와 «아직 등록 안 함» 은 같은 빈 목록이지만 사용자가 할 일이 다르다 —
// 실패는 다시 시도, 미등록은 등록이다. 문구로 갈라 준다 (#1019).
Text(
text = loadError ?: stringResource(R.string.mindrecord_write_receiver_empty),
style = AfternoteDesign.typography.captionLargeR,
color = AfternoteDesign.colors.gray6,
)
if (loadError != null && onRetry != null) {
// 이 모듈의 재시도는 MindRecordErrorBox 가 TextButton 으로 그린다.
// 평문 Text + clickable 은 모양이 갈릴 뿐 아니라 터치 타깃이 글자
// 높이(≈18dp)에 그쳐 접근성 최소치에 못 미친다 (#1019 리뷰).
TextButton(onClick = onRetry) {
Text(
text = stringResource(R.string.mindrecord_error_retry),
style = AfternoteDesign.typography.bodySmallB,
color = AfternoteDesign.colors.gray9,
)
}
}
}
}
} else {
LazyColumn {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ private fun ErrorBox(
// 종전에는 콜백을 받아 두고 쓰지 않아, 화면에 재시도 수단이 없었다 (#614).
TextButton(onClick = onRetry) {
Text(
text = stringResource(R.string.mindrecord_receiver_retry),
text = stringResource(R.string.mindrecord_error_retry),
style = AfternoteDesign.typography.bodySmallB,
color = AfternoteDesign.colors.gray9,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,10 @@ fun DiaryWriteScreen(
ReceiverSelectBottomSheet(
receivers = uiState.receivers,
selectedReceiverIds = uiState.selectedReceiverIds,
// 실패를 빈 목록으로 흡수하지 않는다 — 사용자가 «등록 안 함» 으로 오해한다 (#1019).
loadError = uiState.receiverLoadError?.asString(),
isLoading = uiState.isReceiverLoading,
onRetry = viewModel::loadReceivers,
onToggle = viewModel::onReceiverToggled,
onDismiss = { showReceiverSheet = false },
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ class DailyQuestionWriteViewModel
* 키 형태로 바꾼다 ([toWireContent]). 미리보기는 전체 URL 이라야 뜬다.
*/
suspend fun uploadImage(uriString: String): String? {
// 실패 문구의 수명은 «다음 업로드 시작까지» 다 — 화면에 걷는 수단이 따로 없고,
// 걷는 함수만 두면 호출부 0건인 죽은 코드가 된다 (#1019 리뷰 지적).
_uiState.update { it.copy(isUploadingImage = true, imageUploadError = null) }
return photoUploadRepository
.upload(uriString = uriString, directory = MIND_RECORD_UPLOAD_DIRECTORY)
Expand All @@ -214,10 +216,6 @@ class DailyQuestionWriteViewModel
}.getOrNull()
}

fun consumeImageUploadError() {
_uiState.update { it.copy(imageUploadError = null) }
}

/**
* 저장(`isDraft=false`) / 임시저장(`isDraft=true`).
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,21 @@ data class DiaryWriteUiState(
val mood: TodayMood? = null,
val date: LocalDate = LocalDate.now(),
val imageUrl: String? = null,
/** `GET /users/receivers` 로 불러온 내 수신인 목록. 로드 실패 시 빈 목록 (작성은 가능). */
/** `GET /users/receivers` 로 불러온 내 수신인 목록. */
val receivers: List<Receiver> = emptyList(),
/**
* 수신인 조회 실패 안내. **빈 목록과 구분한다** — 조용히 흡수하면 사용자는 시트를 열어
* 빈 목록만 보고 «등록된 수신인이 없다» 로 읽는다 (#1019). 작성 자체는 계속 가능하다.
*/
val receiverLoadError: UiText? = null,
/**
* 수신인 목록을 조회하는 중.
*
* 이게 없으면 «못 불러옴» 과 «아직 등록 안 함» 을 갈라 놓고도, 재시도를 누른 순간부터
* 응답이 올 때까지 `receivers` 는 빈 목록이고 오류만 지워져 시트가 다시 «등록 안 함» 을
* 고른다 — 이 PR 이 없애려던 그 혼동이 사용자 손으로 되돌아온다 (#1019 리뷰).
*/
val isReceiverLoading: Boolean = false,
/** 수신자 선택 바텀시트에서 고른 수신자 ID. 등록 payload 의 `receiverIds` 로 전송. */
val selectedReceiverIds: Set<Long> = emptySet(),
/** 임시저장 이어쓰기 — draft 프리필 로딩 중 여부. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import com.afternote.core.common.result.runCatchingCancellable
import com.afternote.core.domain.repository.PhotoUploadRepository
import com.afternote.core.domain.repository.UserRepository
import com.afternote.core.ui.UiText
Expand Down Expand Up @@ -109,6 +110,8 @@ class DiaryWriteViewModel
* 제출 직전에 키로 바꾼다 ([toWireContent]). 미리보기는 전체 URL 이라야 뜬다 (#1016).
*/
suspend fun uploadImage(uriString: String): String? {
// 실패 문구의 수명은 «다음 업로드 시작까지» 다 — 화면에 걷는 수단이 따로 없고,
// 걷는 함수만 두면 호출부 0건인 죽은 코드가 된다 (#1019 리뷰 지적).
_uiState.update { it.copy(isUploadingImage = true, imageUploadError = null) }
return photoUploadRepository
.upload(uriString = uriString, directory = MIND_RECORD_UPLOAD_DIRECTORY)
Expand All @@ -130,10 +133,6 @@ class DiaryWriteViewModel
}.getOrNull()
}

fun consumeImageUploadError() {
_uiState.update { it.copy(imageUploadError = null) }
}

fun submit(isDraft: Boolean = false) {
val state = _uiState.value
if (!state.canSubmit) return
Expand Down Expand Up @@ -192,12 +191,29 @@ class DiaryWriteViewModel
_uiState.update { it.copy(submitState = SubmitState.Idle) }
}

// 실패해도 수신자 행이 "수신자 설정하기" 로 남을 뿐 작성 자체는 가능 — 에러는 조용히 무시.
private fun loadReceivers() {
/**
* 수신인 목록 조회. **실패를 빈 목록으로 흡수하지 않는다** (#1019).
*
* 흡수하면 화면이 «아직 등록 안 함» 과 «못 불러옴» 을 구분하지 못해, 사용자는 시트를
* 열어 빈 목록만 본다. 수신자 없이도 작성은 되므로 화면을 막지는 않는다.
*/
fun loadReceivers() {
viewModelScope.launch {
runCatching { userRepository.getReceivers() }
_uiState.update { it.copy(isReceiverLoading = true, receiverLoadError = null) }
runCatchingCancellable { userRepository.getReceivers() }
.onSuccess { receivers ->
_uiState.update { it.copy(receivers = receivers) }
_uiState.update {
it.copy(receivers = receivers, isReceiverLoading = false, receiverLoadError = null)
}
}.onFailure {
// 서버·예외 원문을 화면 문구로 쓰지 않는다 — 오프라인이면 «Unable to
// resolve host …» 가 그대로 노출된다. 도메인 문구로 고정한다 (#614·#1019).
_uiState.update {
it.copy(
isReceiverLoading = false,
receiverLoadError = UiText.Resource(R.string.mindrecord_error_receiver_load_failed),
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,9 @@
<!-- 수신자 열람 실패 (#614) — 서버 원문 대신 도메인 문구 -->
<string name="mindrecord_receiver_delivery_not_ready">아직 열람할 수 있는 기록이 없어요.\n작성자가 전달 시점을 정하면 이곳에서 볼 수 있어요.</string>
<string name="mindrecord_receiver_load_failed">마음의 기록을 불러오지 못했어요.</string>
<string name="mindrecord_receiver_retry">다시 시도</string>
<string name="mindrecord_receiver_title">마음의 기록</string>
<!-- 카테고리 카드 기록 수를 아직 모를 때. en dash — 수신자 홈·주간 요약과 같은 표기 (#700·#952·#562). -->
<string name="mindrecord_home_category_count_unavailable">–</string>
<!-- 수신인 조회 실패 — «등록된 수신인 없음» 과 구분해야 한다 (#1019). -->
<string name="mindrecord_error_receiver_load_failed">수신인 목록을 불러오지 못했어요.</string>
</resources>
Loading
Loading