Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9d8b7fb
fix(setting): propagate repository cancellation (#1501)
OnlyReadOneBook Sep 20, 2026
57b3ad3
refactor(setting): 서비스 알림 저장 실패를 ErrorReporter로 기록 (#963)
OnlyReadOneBook Sep 23, 2026
d0b688d
fix(setting): 프로필 수정에서 갤러리 사진을 골라 저장한다 (#1438)
OnlyReadOneBook Sep 23, 2026
585b5c6
Merge branch 'refactor/1691-setting-save-usecases' into fix/1501-curr…
OnlyReadOneBook Sep 27, 2026
11de47a
test(setting): 취소 회귀를 MVI 프로필 계약에 맞춤 (#1501)
OnlyReadOneBook Sep 27, 2026
640e29d
Merge branch 'fix/1501-current-setting-cancellation' into refactor/96…
OnlyReadOneBook Sep 27, 2026
0f81836
fix(setting): 프로필 사진을 MVI Intent와 reducer로 연결 (#1438)
OnlyReadOneBook Sep 27, 2026
3245352
Merge branch 'refactor/1691-setting-save-usecases' into fix/1501-curr…
OnlyReadOneBook Sep 27, 2026
4d994f8
Merge branch 'fix/1501-current-setting-cancellation' into refactor/96…
OnlyReadOneBook Sep 27, 2026
c338889
Merge branch 'refactor/963-current-push-save-reporting' into fix/1438…
OnlyReadOneBook Sep 27, 2026
90c532f
Merge latest setting foundation after develop shortcut test adaptation
OnlyReadOneBook Sep 27, 2026
fb0f4a5
Merge latest setting foundation after develop shortcut test adaptation
OnlyReadOneBook Sep 27, 2026
0006235
Merge latest setting foundation after develop shortcut test adaptation
OnlyReadOneBook Sep 27, 2026
6207255
refactor(setting): 연결 계정·알림 설정의 이벤트 스트림을 MVI 상태 신호로 바꾸고 재유입을 막는다 (#1502)
OnlyReadOneBook Sep 28, 2026
e0af7b1
fix(setting): 알림 설정 화면이 멈추면 떠 있던 마케팅 동의 실패 안내도 거둔다 (#1502)
OnlyReadOneBook Sep 28, 2026
ebf4d53
Merge branch 'refactor/1691-setting-save-usecases' into fix/1501-curr…
OnlyReadOneBook Sep 30, 2026
62bbcdb
Merge branch 'fix/1501-current-setting-cancellation' into refactor/96…
OnlyReadOneBook Sep 30, 2026
9710ba7
Merge branch 'refactor/963-current-push-save-reporting' into fix/1438…
OnlyReadOneBook Sep 30, 2026
b8caa9d
Merge branch 'refactor/963-current-push-save-reporting' into refactor…
OnlyReadOneBook Sep 30, 2026
d138c4f
test(setting): cover profile save back and picker restoration (#1438)
OnlyReadOneBook Oct 1, 2026
c423286
Merge setting event MVI prerequisite for profile follow-ups (#1438)
OnlyReadOneBook Oct 1, 2026
dccfbde
chore(setting): propagate current setting parent (#1501)
OnlyReadOneBook Oct 1, 2026
6f17dd3
chore(setting): propagate current setting parent (#963)
OnlyReadOneBook Oct 1, 2026
8904e29
chore(setting): propagate current setting parent (#1502)
OnlyReadOneBook Oct 1, 2026
f2f0636
chore(setting): propagate current setting parent (#1438)
OnlyReadOneBook Oct 1, 2026
2c65cd4
test(setting): propagate save error viewport check (#1501)
OnlyReadOneBook Oct 1, 2026
5092cb2
test(setting): propagate save error viewport check (#963)
OnlyReadOneBook Oct 1, 2026
3d9ba81
test(setting): propagate save error viewport check (#1502)
OnlyReadOneBook Oct 1, 2026
b08306a
test(setting): propagate save error viewport check (#1438)
OnlyReadOneBook Oct 1, 2026
f7ba5b6
Merge remote-tracking branch 'origin/develop' into fix/1438-profile-p…
OnlyReadOneBook Oct 5, 2026
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
@@ -0,0 +1,120 @@
package com.afternote.afternote_fe

import androidx.activity.compose.setContent
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextReplacement
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.afternote.core.domain.repository.MyProfileRepository
import com.afternote.core.domain.testing.FakeUserRepository
import com.afternote.core.model.user.User
import com.afternote.core.ui.navigation.FeatureNavigationCallbacks
import com.afternote.core.ui.theme.AfternoteTheme
import com.afternote.feature.setting.presentation.navigation.SettingExternalActions
import com.afternote.feature.setting.presentation.navigation.SettingNavHost
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import javax.inject.Inject

/** #1438: 실제 SettingNavHost의 entry 이탈이 보류 중 저장을 취소하고 새 entry를 만드는지 검증한다. */
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class ProfileSaveBackAndroidTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)

@get:Rule(order = 1)
val composeRule = createAndroidComposeRule<MainActivity>()

@Inject
lateinit var profileRepository: MyProfileRepository

@Before
fun setUp() {
hiltRule.inject()
}

@Test
fun systemBackDuringPendingSaveCancelsRequestAndReentryLoadsServerProfile() {
// 기존 app Hilt fixture가 제공하는 동일 저장소의 응답만 보류한다. 프로덕션 nav/VM은 그대로다.
val repository = profileRepository as FakeUserRepository
val saved = User("서버 이름", "qa@example.test", "01000000000", null)
repository.profile = saved
val pendingSave = CompletableDeferred<User>()
val cancelled = CompletableDeferred<Unit>()
var exits = 0
repository.onUpdateMyProfile = { _, _, _ ->
try {
pendingSave.await()
} catch (error: CancellationException) {
cancelled.complete(Unit)
throw error
}
}
composeRule.activityRule.scenario.onActivity { activity ->
activity.setContent {
AfternoteTheme {
SettingNavHost(
navigationCallbacks = FeatureNavigationCallbacks { exits += 1 },
externalActions =
object : SettingExternalActions {
override fun onLogoutSuccess() = Unit

override fun onWithdrawSuccess() = Unit
},
)
}
}
}
waitForText("프로필 수정")
composeRule.onNodeWithText("프로필 수정").performClick()
waitForText("프로필 설정")
composeRule.onAllNodes(hasSetTextAction())[0].performTextReplacement("저장 중 임시 이름")
composeRule.onNodeWithText("수정하기").performScrollTo().performClick()
composeRule.waitUntil(10_000) { repository.profileUpdateCalls.size == 1 }
composeRule.onNodeWithText("수정하기").assertIsNotEnabled()
assertFalse(pendingSave.isCompleted)

// Activity의 실제 back dispatcher가 SettingNavHost/NavDisplay의 back 처리를 실행한다.
composeRule.activityRule.scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() }
waitForText("프로필 수정")
composeRule.waitUntil(10_000) { cancelled.isCompleted }
assertEquals(0, exits)
assertEquals(saved, repository.profile)

val loadsBeforeReentry = repository.profileCalls
composeRule.onNodeWithText("프로필 수정").performClick()
waitForText("서버 이름")
composeRule.waitUntil(10_000) { repository.profileCalls > loadsBeforeReentry }
composeRule.onNodeWithText("수정하기").performScrollTo().assertIsEnabled()

// 취소 뒤 늦은 성공도 현재 entry를 닫거나 서버 fake의 정본을 바꾸지 않는다.
pendingSave.complete(saved.copy(name = "저장 중 임시 이름"))
composeRule.waitForIdle()
composeRule.onNodeWithText("프로필 설정").assertIsDisplayed()
assertEquals(saved, repository.profile)
assertEquals(1, repository.profileUpdateCalls.size)
assertTrue(cancelled.isCompleted)
assertEquals(0, exits)
}

private fun waitForText(text: String) {
composeRule.waitUntil(10_000) { composeRule.onAllNodes(hasText(text)).fetchSemanticsNodes().isNotEmpty() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import com.afternote.core.ui.mvi.MviIntent
import com.afternote.core.ui.mvi.ReducerEvent

internal sealed interface ProfileEditIntent : MviIntent {
data class SelectPhoto(
val uri: String,
) : ProfileEditIntent

data class UpdateProfile(
val name: String,
val phone: String,
Expand All @@ -20,6 +24,11 @@ internal sealed interface ProfileEditReducerEvent : ReducerEvent {
val name: String,
val phone: String,
val email: String,
val profileImageUrl: String?,
) : ProfileEditReducerEvent

data class PhotoSelected(
val uri: String,
) : ProfileEditReducerEvent

data object LoadFailed : ProfileEditReducerEvent
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package com.afternote.feature.setting.presentation.profile

import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
Expand All @@ -17,20 +21,25 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Scaffold
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.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.afternote.core.ui.AfternoteTextField
import com.afternote.core.ui.ProfileImagePicker
import com.afternote.core.ui.button.AfternoteButton
import com.afternote.core.ui.button.AfternoteButtonType
import com.afternote.core.ui.mvi.ObserveSignal
import com.afternote.core.ui.theme.AfternoteDesign
import com.afternote.core.ui.topbar.DetailTopBar
import com.afternote.feature.setting.presentation.shared.component.ProfilePhotoWithAddBadge

@Composable
internal fun ProfileEditScreen(
Expand All @@ -40,6 +49,11 @@ internal fun ProfileEditScreen(
viewModel: ProfileEditViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val onPickProfileImage =
rememberProfileImagePicker(
canAcceptPhoto = (uiState as? ProfileEditUiState.Success)?.isUpdating == false,
onPhotoPicked = { viewModel.onIntent(ProfileEditIntent.SelectPhoto(it)) },
)

(uiState as? ProfileEditUiState.Success)?.pendingEvent?.let { pendingEvent ->
ObserveSignal(
Expand All @@ -57,6 +71,7 @@ internal fun ProfileEditScreen(

ProfileEditContent(
uiState = uiState,
onPickImageClick = onPickProfileImage,
onIntent = viewModel::onIntent,
onBackClick = onBackClick,
onWithdrawGuideClick = onWithdrawGuideClick,
Expand All @@ -67,6 +82,7 @@ internal fun ProfileEditScreen(
@Composable
private fun ProfileEditContent(
uiState: ProfileEditUiState,
onPickImageClick: () -> Unit,
onIntent: (ProfileEditIntent) -> Unit,
onBackClick: () -> Unit,
onWithdrawGuideClick: () -> Unit,
Expand Down Expand Up @@ -98,6 +114,7 @@ private fun ProfileEditContent(
is ProfileEditUiState.Success -> {
ProfileEditForm(
state = state,
onPickImageClick = onPickImageClick,
onUpdateClick = { name, phone -> onIntent(ProfileEditIntent.UpdateProfile(name, phone)) },
onWithdrawGuideClick = onWithdrawGuideClick,
modifier = Modifier.padding(innerPadding),
Expand All @@ -122,6 +139,7 @@ private fun ProfileEditContent(
@Composable
private fun ProfileEditForm(
state: ProfileEditUiState.Success,
onPickImageClick: () -> Unit,
onUpdateClick: (name: String, phone: String) -> Unit,
onWithdrawGuideClick: () -> Unit,
modifier: Modifier = Modifier,
Expand All @@ -141,7 +159,10 @@ private fun ProfileEditForm(
.padding(top = 50.dp),
contentAlignment = Alignment.Center,
) {
ProfilePhotoWithAddBadge()
ProfileImagePicker(
onPickClick = onPickImageClick,
displayImageUri = state.displayImageUri,
)
}
}
item {
Expand Down Expand Up @@ -232,3 +253,38 @@ private fun ProfileEditForm(
}
}
}

/**
* 갤러리 사진 선택기를 등록하고, 누르면 띄우는 함수를 돌려준다.
*
* 온보딩 프로필 화면과 같이 갤러리 전용이다(`PickVisualMedia.ImageOnly`). 취소 결과(`null`)는 선택
* 변경이 아니라서 기존 사진을 그대로 둔다(온보딩 #1113·#1115 와 같은 처리).
*
* 돌아온 사진은 [canAcceptPhoto] 가 참일 때까지 [rememberSaveable] 에 보관했다가 넘긴다. 갤러리에
* 다녀오는 사이 프로세스가 회수되면 결과가 새 ViewModel 의 프로필 조회보다 먼저 도착하고, 저장 중에
* 돌아온 결과는 ViewModel 이 받지 않기 때문이다. 바로 넘기면 두 경우 모두 고른 사진이 사라진다.
*/
@Composable
private fun rememberProfileImagePicker(
canAcceptPhoto: Boolean,
onPhotoPicked: (String) -> Unit,
): () -> Unit {
val queuedPhoto = rememberSaveable { mutableStateOf<String?>(null) }
val currentOnPhotoPicked by rememberUpdatedState(onPhotoPicked)

LaunchedEffect(queuedPhoto.value, canAcceptPhoto) {
val uri = queuedPhoto.value
if (uri != null && canAcceptPhoto) {
queuedPhoto.value = null
currentOnPhotoPicked(uri)
}
}

val launcher =
rememberLauncherForActivityResult(PickVisualMedia()) { uri: Uri? ->
uri?.let { queuedPhoto.value = it.toString() }
}
return remember(launcher) {
{ launcher.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,25 @@ import com.afternote.core.ui.mvi.UiState
internal sealed interface ProfileEditUiState : UiState {
data object Loading : ProfileEditUiState

/**
* @param profileImageUrl 서버가 내려준 표시용 프로필 사진 주소. 아직 사진이 없으면 널이다.
* @param selectedImageUri 이 화면에서 고르기만 하고 아직 저장하지 않은 사진의 content URI.
* 저장이 성공하기 전까지 서버 사진은 건드리지 않는다. 선택 취소나 저장 실패가 기존 사진을
* 지우지 않는 자리다(#1438).
*/
data class Success(
val name: String,
val phone: String,
val email: String,
val profileImageUrl: String? = null,
val selectedImageUri: String? = null,
val isUpdating: Boolean = false,
/** 수정을 마쳤다는 사실. 소비되는 [pendingEvent] 와 달리 되돌리지 않아, 화면이 닫히는 동안에도 재제출을 막는다. */
val isUpdated: Boolean = false,
val pendingEvent: ProfileEditEvent? = null,
) : ProfileEditUiState {
/** 아바타에 그릴 사진. 저장 전 선택이 서버 사진보다 앞선다. */
val displayImageUri: String? get() = selectedImageUri ?: profileImageUrl
val isUpdateLocked: Boolean get() = isUpdating || isUpdated
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,29 @@ package com.afternote.feature.setting.presentation.profile

import androidx.lifecycle.viewModelScope
import com.afternote.core.domain.repository.MyProfileRepository
import com.afternote.core.domain.repository.PhotoUploadRepository
import com.afternote.core.domain.result.runCatchingCancellable
import com.afternote.core.ui.mvi.MviViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject

private const val PROFILE_UPLOAD_DIRECTORY = "profiles"

@HiltViewModel
internal class ProfileEditViewModel
@Inject
constructor(
private val myProfileRepository: MyProfileRepository,
private val photoUploadRepository: PhotoUploadRepository,
) : MviViewModel<ProfileEditIntent, ProfileEditUiState, ProfileEditReducerEvent>(ProfileEditUiState.Loading) {
init {
loadProfile()
}

override fun onIntent(intent: ProfileEditIntent) {
when (intent) {
is ProfileEditIntent.SelectPhoto -> dispatch(ProfileEditReducerEvent.PhotoSelected(intent.uri))
is ProfileEditIntent.UpdateProfile -> updateProfile(intent.name, intent.phone)
is ProfileEditIntent.ConsumeEvent -> dispatch(ProfileEditReducerEvent.EventConsumed(intent.event))
}
Expand All @@ -31,7 +36,16 @@ internal class ProfileEditViewModel
): ProfileEditUiState =
when (event) {
is ProfileEditReducerEvent.Loaded -> {
ProfileEditUiState.Success(name = event.name, phone = event.phone, email = event.email)
ProfileEditUiState.Success(
name = event.name,
phone = event.phone,
email = event.email,
profileImageUrl = event.profileImageUrl,
)
}

is ProfileEditReducerEvent.PhotoSelected -> {
state.updateSuccess { if (it.isUpdating) it else it.copy(selectedImageUri = event.uri) }
}

ProfileEditReducerEvent.LoadFailed -> {
Expand Down Expand Up @@ -66,6 +80,7 @@ internal class ProfileEditViewModel
name = user.name,
phone = user.phone.orEmpty(),
email = user.email,
profileImageUrl = user.profileImageUrl,
),
)
}.onFailure {
Expand All @@ -83,10 +98,15 @@ internal class ProfileEditViewModel
dispatch(ProfileEditReducerEvent.Updating)
viewModelScope.launch {
runCatchingCancellable {
val uploadedKey =
current.selectedImageUri?.let { uri ->
photoUploadRepository.upload(uri, PROFILE_UPLOAD_DIRECTORY).getOrThrow().fileKey
}
myProfileRepository.updateMyProfile(
name = name.takeIf { it.isNotBlank() },
phone = phone.takeIf { it.isNotBlank() },
profileImageUrl = null,
// 서버는 표시용 URL 대신 업로드 키를 받아 승격한다. 널이면 기존 사진을 유지한다.
profileImageUrl = uploadedKey,
)
}.onSuccess {
dispatch(ProfileEditReducerEvent.UpdateSucceeded)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,14 @@ import androidx.compose.ui.unit.dp
import com.afternote.feature.setting.presentation.R

/**
* 프로필 사진 자리 + 우하단 「추가」 배지. 표시 전용이며 아직 picker 가 붙어 있지 않다.
* 프로필 사진 자리 + 우하단 「추가」 배지. 표시 전용이며 picker 가 붙어 있지 않다.
*
* 프로필 이미지 크기 134dp 의 단일 출처다(#1782). setting 의 프로필 편집·수신인 등록 두 화면이
* 같은 블록을 복제하고 있어 여기로 모았다.
* 남은 소비처는 수신인 등록 화면 하나다. 프로필 편집 화면은 사진 선택을 붙이면서(#1438) core:ui 의
* [ProfileImagePicker][com.afternote.core.ui.ProfileImagePicker] 로 옮겼다. 시안의 프로필 노드가
* core 자산(`core_ui_ic_profile_placeholder`, 48dp 배지)과 같다는 #2003 의 판정을 따랐다.
*
* core:ui 의 [ProfileImagePicker][com.afternote.core.ui.ProfileImagePicker] 로 흡수하지 않은
* 이유는 자산과 배지 기하가 달라서다 — 이쪽은 `ic_default_profile`(60dp 뷰포트) 과
* 48dp 칸 안에 72dp 로 넘쳐 그려지는 `ic_plus` 를 쓰고, core 쪽은
* `core_ui_ic_profile_placeholder` 와 48dp `PlusBadgeButton` 을 쓴다. 바꾸면 픽셀이 움직인다.
* 수렴은 시안 대조가 선행돼야 하므로 #2003 으로 분리했다.
* 이쪽은 `ic_default_profile`(60dp 뷰포트)과 48dp 칸 안에 72dp 로 넘쳐 그려지는 `ic_plus` 를 쓴다.
* 수신인 등록 화면의 수렴은 #2003 에 남아 있다.
*/
@Composable
fun ProfilePhotoWithAddBadge(modifier: Modifier = Modifier) {
Expand Down
Loading
Loading