From b181459b1705f3d0a8421766fb946d3e9532e24c Mon Sep 17 00:00:00 2001 From: 1hyok Date: Thu, 19 Feb 2026 02:19:16 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(afternote):=20=EC=95=A0=ED=94=84?= =?UTF-8?q?=ED=84=B0=EB=85=B8=ED=8A=B8=20=EB=8F=99=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EB=A1=9C=EB=94=A9=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=94=94=EB=B2=84=EA=B9=85=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `FuneralVideoUpload` 컴포저블에 동영상 URL, 썸네일 URL, 동영상 존재 여부, 비트맵 상태 등의 변화를 추적하는 `LaunchedEffect` 로그를 추가하여 컴포넌트의 상태 변화를 명확히 파악할 수 있도록 했습니다. - `SideEffect`를 사용하여 각 분기(원격 URL 썸네일, 로컬 비트맵 썸네일, 썸네일 없음)가 실행될 때마다 로그를 남겨, 어떤 조건으로 썸네일이 렌더링되는지 실시간으로 확인할 수 있게 했습니다. - Coil 이미지 로딩 라이브러리의 `onLoading`, `onSuccess`, `onError` 콜백에 로그를 추가하여 원격 썸네일 이미지의 로딩 시작, 성공, 실패 과정을 추적할 수 있도록 개선했습니다. - 썸네일 로딩 실패 시 표시되는 플레이스홀더 이미지를 기존 `ic_add_circle`에서 `img_placeholder_1`로 변경했습니다. --- .../edit/upload/FuneralVideoUpload.kt | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/upload/FuneralVideoUpload.kt b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/upload/FuneralVideoUpload.kt index d5448c3d..e757d373 100644 --- a/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/upload/FuneralVideoUpload.kt +++ b/app/src/main/java/com/kuit/afternote/feature/afternote/presentation/component/edit/upload/FuneralVideoUpload.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -41,6 +42,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.net.toUri import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter import coil3.network.NetworkHeaders import coil3.network.httpHeaders import coil3.request.ImageRequest @@ -82,6 +84,15 @@ fun FuneralVideoUpload( val context = LocalContext.current var thumbnailBitmap by remember(videoUrl) { mutableStateOf(null) } + LaunchedEffect(videoUrl, thumbnailUrl, hasVideo, thumbnailBitmap) { + Log.d( + TAG, + "FuneralVideoUpload active: videoUrl=${videoUrl?.take(50) ?: "null"}, " + + "thumbnailUrl=${thumbnailUrl?.take(80) ?: "null"}, hasVideo=$hasVideo, " + + "hasBitmap=${thumbnailBitmap != null}" + ) + } + LaunchedEffect(videoUrl) { if (videoUrl.isNullOrBlank()) { thumbnailBitmap = null @@ -175,6 +186,9 @@ fun FuneralVideoUpload( ) { when { !thumbnailUrl.isNullOrBlank() -> { + SideEffect { + Log.d(TAG, "Branch=URL, thumbnailUrl=$thumbnailUrl") + } AsyncImage( model = ImageRequest.Builder(context) @@ -188,17 +202,26 @@ fun FuneralVideoUpload( contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, - error = painterResource(R.drawable.ic_add_circle), - onError = { state -> + error = painterResource(R.drawable.img_placeholder_1), + onLoading = { + Log.d(TAG, "Coil loading started: url=$thumbnailUrl") + }, + onSuccess = { + Log.d(TAG, "Coil load success: url=$thumbnailUrl") + }, + onError = { state: AsyncImagePainter.State.Error -> Log.e( TAG, - "Thumbnail load failed: url=$thumbnailUrl", + "Coil load failed: url=$thumbnailUrl", state.result.throwable ) } ) } thumbnailBitmap != null -> { + SideEffect { + Log.d(TAG, "Branch=LocalBitmap, videoUrl=$videoUrl") + } Image( bitmap = thumbnailBitmap!!, contentDescription = null, @@ -206,6 +229,15 @@ fun FuneralVideoUpload( contentScale = ContentScale.Crop ) } + else -> { + SideEffect { + Log.w( + TAG, + "Branch=NoThumbnail, videoUrl=$videoUrl, " + + "thumbnailUrl=$thumbnailUrl, thumbnailBitmap=null" + ) + } + } } Image( painter = painterResource(R.drawable.ic_playback), From 1df218f65ffbad13cbea46feb3c1cce55d6d7855 Mon Sep 17 00:00:00 2001 From: 1hyok Date: Thu, 19 Feb 2026 02:47:35 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor(home,=20record):=20=ED=99=88/?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=ED=99=94=EB=A9=B4=20TopBar=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=ED=94=84=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=ED=99=98=EA=B2=BD=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `HomeScreen`과 `RecordMainScreen`의 `TopBar`를 `Column` 내부에서 `Scaffold`의 `topBar` 슬롯으로 이동시켜 레이아웃 구조를 개선했습니다. - `HomeScreen`의 `@Preview`가 Hilt ViewModel에 의존하지 않도록 `ProfileViewModel`과 `MindRecordViewModel`의 Contract(인터페이스)를 정의하고, 프리뷰용 Fake ViewModel 구현체를 추가했습니다. - 설정 아이콘 리소스를 `png` 형식에서 벡터 드로어블(`xml`)로 교체했습니다. - `MindRecordViewModel`에서 날짜 파싱 실패 시 `getOrElse` 대신 `getOrNull`을 사용하도록 코드를 개선했습니다. --- .../presentation/screen/RecordMainScreen.kt | 11 +-- .../viewmodel/MindRecordViewModel.kt | 40 ++++++--- .../home/presentation/screen/HomeScreen.kt | 82 ++++++++++++++---- app/src/main/res/drawable/setting.png | Bin 698 -> 0 bytes app/src/main/res/drawable/setting.xml | 24 +++++ 5 files changed, 122 insertions(+), 35 deletions(-) delete mode 100644 app/src/main/res/drawable/setting.png create mode 100644 app/src/main/res/drawable/setting.xml diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordMainScreen.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordMainScreen.kt index 0a647e3e..c43e8695 100644 --- a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordMainScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/screen/RecordMainScreen.kt @@ -5,13 +5,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape @@ -59,6 +56,9 @@ fun RecordMainScreen( Scaffold( modifier = modifier.fillMaxSize(), containerColor = Gray1, + topBar = { + TopBar(title = "나의 모든 기록") + }, bottomBar = { BottomNavigationBar( selectedItem = selectedBottomNavItem, @@ -103,12 +103,7 @@ fun RecordMainScreen( modifier = Modifier .fillMaxWidth() .padding(paddingValues) - .windowInsetsPadding(WindowInsets.statusBars) // 상태바 만큼 패딩을 줘서 겹치지 않도록 ) { - // 샹단 제목 - TopBar( - title = "나의 모든 기록" - ) // 리스트 val allItems = listOf( "데일리 질문답변" to "매일 다른 질문들에 나를 남겨보세요", diff --git a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/viewmodel/MindRecordViewModel.kt b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/viewmodel/MindRecordViewModel.kt index 0bd6d6a9..89473cfa 100644 --- a/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/viewmodel/MindRecordViewModel.kt +++ b/app/src/main/java/com/kuit/afternote/feature/dailyrecord/presentation/viewmodel/MindRecordViewModel.kt @@ -5,16 +5,26 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.kuit.afternote.feature.afternote.domain.model.AfternoteItem import com.kuit.afternote.feature.afternote.domain.usecase.GetAfternotesUseCase -import com.kuit.afternote.feature.dailyrecord.data.dto.Emotion import com.kuit.afternote.feature.dailyrecord.data.dto.EmotionResponse import com.kuit.afternote.feature.dailyrecord.data.dto.PostMindRecordRequest -import com.kuit.afternote.feature.dailyrecord.domain.usecase.* +import com.kuit.afternote.feature.dailyrecord.domain.usecase.CreateMindRecordUseCase +import com.kuit.afternote.feature.dailyrecord.domain.usecase.DeleteMindRecordUseCase +import com.kuit.afternote.feature.dailyrecord.domain.usecase.EditMindRecordUseCase +import com.kuit.afternote.feature.dailyrecord.domain.usecase.GetDailyQuestionUseCase +import com.kuit.afternote.feature.dailyrecord.domain.usecase.GetEmotionsUseCase +import com.kuit.afternote.feature.dailyrecord.domain.usecase.GetMindRecordUseCase +import com.kuit.afternote.feature.dailyrecord.domain.usecase.GetMindRecordsUseCase import com.kuit.afternote.feature.dailyrecord.presentation.uimodel.MindRecordUiModel import com.kuit.afternote.feature.dailyrecord.presentation.uimodel.MindRecordUiState import com.kuit.afternote.feature.home.presentation.component.CalendarDay import com.kuit.afternote.feature.home.presentation.component.CalendarDayStyle import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import retrofit2.HttpException import java.time.DayOfWeek @@ -48,6 +58,14 @@ data class EditRecordParams( val isDraft: Boolean ) +/** + * Contract for HomeScreen so a fake can be used in Previews (no Hilt). + */ +interface MindRecordHomeContract { + val calendarDays: StateFlow> + fun loadRecordsForDiaryList() +} + @HiltViewModel class MindRecordViewModel @Inject constructor( private val getMindRecordsUseCase: GetMindRecordsUseCase, @@ -58,7 +76,7 @@ class MindRecordViewModel @Inject constructor( private val editMindRecordUseCase: EditMindRecordUseCase, private val getAfternotesUseCase: GetAfternotesUseCase, private val getEmotionsUseCase: GetEmotionsUseCase -) : ViewModel() { +) : ViewModel(), MindRecordHomeContract { // --- State 정의 --- private val _records = MutableStateFlow>(emptyList()) @@ -124,7 +142,7 @@ class MindRecordViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WeeklySummaryUiState()) // 기존 캘린더 데이 (메인 화면용 유지) - val calendarDays: StateFlow> = _records + override val calendarDays: StateFlow> = _records .map { recordList -> val recordedDates = recordList.map { it.originalDate }.toSet() val today = LocalDate.now() @@ -215,7 +233,7 @@ class MindRecordViewModel @Inject constructor( MindRecordUiModel( id = summary.recordId, title = summary.title ?: "", - formattedDate = runCatching { formatDate(summary.date) }.getOrElse { summary.date }, + formattedDate = runCatching { formatDate(summary.date) }.getOrNull() ?: summary.date, draftLabel = if (summary.isDraft) "임시저장" else "완료", content = summary.content, originalDate = summary.date, @@ -239,7 +257,7 @@ class MindRecordViewModel @Inject constructor( } } - fun loadRecordsForDiaryList() { + override fun loadRecordsForDiaryList() { viewModelScope.launch { try { val diaryResult = getMindRecordsUseCase("DIARY", "LIST", null, null) @@ -251,7 +269,7 @@ class MindRecordViewModel @Inject constructor( MindRecordUiModel( id = summary.recordId, title = summary.title ?: "", - formattedDate = runCatching { formatDate(summary.date) }.getOrElse { summary.date }, + formattedDate = runCatching { formatDate(summary.date) }.getOrNull() ?: summary.date, draftLabel = if (summary.isDraft) "임시저장" else "완료", content = summary.content, originalDate = summary.date, @@ -358,7 +376,7 @@ class MindRecordViewModel @Inject constructor( _selectedRecord.value = MindRecordUiModel( id = detail.recordId, title = detail.title, - formattedDate = runCatching { formatDate(detail.date) }.getOrElse { detail.date }, + formattedDate = runCatching { formatDate(detail.date) }.getOrNull() ?: detail.date, draftLabel = if (detail.isDraft) "임시저장" else "완료", content = detail.content, type = detail.type, @@ -396,9 +414,7 @@ fun AfternoteItem.toMindRecordUiModel(): MindRecordUiModel { val parsedDate = runCatching { java.time.LocalDate.parse(this.date, inputFormatter) - }.getOrElse { - java.time.LocalDate.now() // 파싱 실패 시 오늘 날짜로 폴백 (시스템 안정성) - } + }.getOrNull() ?: java.time.LocalDate.now() // 파싱 실패 시 오늘 날짜로 폴백 (시스템 안정성) val originalDateStr = parsedDate.format(isoFormatter) // "2026-02-06" val formattedDateStr = "${parsedDate.monthValue}월 ${parsedDate.dayOfMonth}일" diff --git a/app/src/main/java/com/kuit/afternote/feature/home/presentation/screen/HomeScreen.kt b/app/src/main/java/com/kuit/afternote/feature/home/presentation/screen/HomeScreen.kt index e20b35d3..7e12f14b 100644 --- a/app/src/main/java/com/kuit/afternote/feature/home/presentation/screen/HomeScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/home/presentation/screen/HomeScreen.kt @@ -28,6 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.kuit.afternote.R import com.kuit.afternote.core.ui.component.navigation.BottomNavItem import com.kuit.afternote.core.ui.component.navigation.BottomNavigationBar +import com.kuit.afternote.feature.dailyrecord.presentation.viewmodel.MindRecordHomeContract import com.kuit.afternote.feature.dailyrecord.presentation.viewmodel.MindRecordViewModel import com.kuit.afternote.feature.home.presentation.component.CalendarDay import com.kuit.afternote.feature.home.presentation.component.CalendarDayStyle @@ -36,12 +37,16 @@ import com.kuit.afternote.feature.home.presentation.component.HomeHeader import com.kuit.afternote.feature.home.presentation.component.HomeInfoCard import com.kuit.afternote.feature.home.presentation.component.RecipientBadge import com.kuit.afternote.feature.home.presentation.component.WeeklyCalendarStrip +import com.kuit.afternote.feature.user.presentation.uimodel.ProfileUiState +import com.kuit.afternote.feature.user.presentation.viewmodel.ProfileEditViewModelContract import com.kuit.afternote.feature.user.presentation.viewmodel.ProfileViewModel import com.kuit.afternote.ui.theme.AfternoteTheme import com.kuit.afternote.ui.theme.Gray1 import com.kuit.afternote.ui.theme.Gray5 import com.kuit.afternote.ui.theme.Gray9 import com.kuit.afternote.ui.theme.Sansneo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.Locale @@ -69,8 +74,8 @@ interface HomeScreenEvent { fun HomeScreen( modifier: Modifier = Modifier, event: HomeScreenEvent = EmptyHomeScreenEvent, - profileViewModel: ProfileViewModel = hiltViewModel(), - recordViewModel: MindRecordViewModel = hiltViewModel() + profileViewModel: ProfileEditViewModelContract = hiltViewModel(), + recordViewModel: MindRecordHomeContract = hiltViewModel() ) { var content by remember { mutableStateOf(HomeScreenContent()) } val uiState = profileViewModel.uiState.collectAsStateWithLifecycle() @@ -91,6 +96,12 @@ fun HomeScreen( Scaffold( modifier = modifier.fillMaxSize(), containerColor = Gray1, + topBar = { + HomeHeader( + onProfileClick = event::onProfileClick, + onSettingsClick = event::onSettingsClick + ) + }, bottomBar = { BottomNavigationBar( selectedItem = BottomNavItem.HOME, @@ -104,12 +115,6 @@ fun HomeScreen( .padding(paddingValues) .verticalScroll(rememberScrollState()) ) { - // Header - HomeHeader( - onProfileClick = event::onProfileClick, - onSettingsClick = event::onSettingsClick - ) - // Greeting section GreetingSection(userName = uiState.value.name) @@ -229,12 +234,56 @@ private fun SectionTitle( } private object EmptyHomeScreenEvent : HomeScreenEvent { - override fun onBottomNavTabSelected(item: BottomNavItem) = Unit - override fun onProfileClick() = Unit - override fun onSettingsClick() = Unit - override fun onDailyQuestionCtaClick() = Unit - override fun onTimeLetterClick() = Unit - override fun onAfterNoteClick() = Unit + override fun onBottomNavTabSelected(item: BottomNavItem) { + // No-op: default event when no callback is provided. + } + override fun onProfileClick() { + // No-op: default event when no callback is provided. + } + override fun onSettingsClick() { + // No-op: default event when no callback is provided. + } + override fun onDailyQuestionCtaClick() { + // No-op: default event when no callback is provided. + } + override fun onTimeLetterClick() { + // No-op: default event when no callback is provided. + } + override fun onAfterNoteClick() { + // No-op: default event when no callback is provided. + } +} + +/** Fake ProfileEditViewModelContract for Preview (no Hilt). */ +private class FakeProfileEditViewModel : ProfileEditViewModelContract { + private val _uiState = MutableStateFlow(ProfileUiState(name = "박서연")) + override val uiState: StateFlow = _uiState + override fun loadProfile() { + // No-op: Fake for Preview only; no API call. + } + override fun updateProfile( + name: String?, + phone: String?, + profileImageUrl: String?, + pickedProfileImageUri: String? + ) { + // No-op: Fake for Preview only; no state update. + } + override fun setSelectedProfileImageUri(uri: android.net.Uri?) { + // No-op: Fake for Preview only. + } + override fun clearUpdateSuccess() { + // No-op: Fake for Preview only. + } +} + +/** Fake MindRecordHomeContract for Preview (no Hilt). */ +private class FakeMindRecordHomeViewModel : MindRecordHomeContract { + override val calendarDays: StateFlow> = + MutableStateFlow(defaultCalendarDays()) + override fun loadRecordsForDiaryList() { + // No-op: Fake for Preview only; no API call. + } } internal fun defaultCalendarDays(): List = listOf( @@ -251,6 +300,9 @@ internal fun defaultCalendarDays(): List = listOf( @Composable private fun HomeScreenPreview() { AfternoteTheme { - HomeScreen() + HomeScreen( + profileViewModel = remember { FakeProfileEditViewModel() }, + recordViewModel = remember { FakeMindRecordHomeViewModel() } + ) } } diff --git a/app/src/main/res/drawable/setting.png b/app/src/main/res/drawable/setting.png deleted file mode 100644 index 6ab1715bc6b034ac3863ec285eb373742160ae71..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 698 zcmV;r0!96aP)Beg0U%V25DsP<&3vn=qbTF zC~kqKIQH;44+G!0egK6$OA;9of+zCz8UIi&mj_VP*JFhJ5bGKWmo(ziQYw{bfTj24`9W3gLH++yREa|}X0+XI zKe4UC=z}D=TKPgavj(9mv@R`(Z0$vJVqmQX23XqX6dxI}{uu@vlGI}4-`}}S6h&Wo z0Ndw6p)keyM=}61ueyDN?612&Xj8N!Q^On=yH zoJOW4o=!xwkc?2TIe39nj#`#-o6TmVlQ_jCnI`(aWC9w6P6qlCbak>%yr{4ySnHAz zkefs9{lu_;(1(lokc@!Gd=x7%(ml(-_g%g3$d!Qn@;D0rEpokY)gXJi!w!RYW54j; zaMtNLu6jBktZcSe)UazVjh3yy09ZpH)5%R~LL^r*~fidO#(bOLjxZ zPS9?k@V@ZcbqmNeRmLhEC_jkc5|~>_eps4}=J*%x67D4h^rvwN+Uk`Bt + + + + + + From c5b901dcaf238054aacaa0a8d57241c77e6cbd27 Mon Sep 17 00:00:00 2001 From: 1hyok Date: Thu, 19 Feb 2026 02:54:44 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(res):=20=ED=99=88=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EC=83=81=EB=8B=A8=20=EB=A1=9C=EA=B3=A0=20=EA=B5=90=EC=B2=B4?= =?UTF-8?q?=20=EB=B0=8F=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 홈 화면의 상단 로고를 기존 `logo_blue` 이미지에서 새로운 벡터 드로어블인 `ic_home_topbar_logo`로 교체했습니다. - 로고 교체에 따라 더 이상 사용되지 않는 `onProfileClick` 파라미터를 `HomeHeader` 컴포저블과 관련 호출부에서 제거했습니다. --- .../home/presentation/component/HomeHeader.kt | 3 +- .../home/presentation/screen/HomeScreen.kt | 1 - .../main/res/drawable/ic_home_topbar_logo.xml | 39 +++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 app/src/main/res/drawable/ic_home_topbar_logo.xml diff --git a/app/src/main/java/com/kuit/afternote/feature/home/presentation/component/HomeHeader.kt b/app/src/main/java/com/kuit/afternote/feature/home/presentation/component/HomeHeader.kt index 0cb26d4e..c40af064 100644 --- a/app/src/main/java/com/kuit/afternote/feature/home/presentation/component/HomeHeader.kt +++ b/app/src/main/java/com/kuit/afternote/feature/home/presentation/component/HomeHeader.kt @@ -25,7 +25,6 @@ import com.kuit.afternote.ui.theme.Gray9 @Composable fun HomeHeader( modifier: Modifier = Modifier, - onProfileClick: () -> Unit = {}, onSettingsClick: () -> Unit = {} ) { Row( @@ -37,7 +36,7 @@ fun HomeHeader( verticalAlignment = Alignment.CenterVertically ) { Image( - painter = painterResource(R.drawable.logo_blue), + painter = painterResource(R.drawable.ic_home_topbar_logo), contentDescription = "AFTERNOTE", modifier = Modifier.height(16.dp) ) diff --git a/app/src/main/java/com/kuit/afternote/feature/home/presentation/screen/HomeScreen.kt b/app/src/main/java/com/kuit/afternote/feature/home/presentation/screen/HomeScreen.kt index 7e12f14b..30f02996 100644 --- a/app/src/main/java/com/kuit/afternote/feature/home/presentation/screen/HomeScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/home/presentation/screen/HomeScreen.kt @@ -98,7 +98,6 @@ fun HomeScreen( containerColor = Gray1, topBar = { HomeHeader( - onProfileClick = event::onProfileClick, onSettingsClick = event::onSettingsClick ) }, diff --git a/app/src/main/res/drawable/ic_home_topbar_logo.xml b/app/src/main/res/drawable/ic_home_topbar_logo.xml new file mode 100644 index 00000000..4db54688 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_topbar_logo.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + From 426f0390e001981c53ab72adf9056702bc16ba76 Mon Sep 17 00:00:00 2001 From: 1hyok Date: Thu, 19 Feb 2026 02:57:22 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor(home):=20=ED=99=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20UI=20=EB=B0=8F=20=EC=BD=94=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `HomeScreen`의 인사말 상단에 `24.dp` 높이의 `Spacer`를 추가하여 상단 여백을 조정했습니다. - 가독성 향상을 위해 `FakeHomeEvent`와 `FakeMindRecordHomeViewModel` 내 메서드 사이에 공백 라인을 추가했습니다. --- .../feature/home/presentation/screen/HomeScreen.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/src/main/java/com/kuit/afternote/feature/home/presentation/screen/HomeScreen.kt b/app/src/main/java/com/kuit/afternote/feature/home/presentation/screen/HomeScreen.kt index 30f02996..7c58c2d6 100644 --- a/app/src/main/java/com/kuit/afternote/feature/home/presentation/screen/HomeScreen.kt +++ b/app/src/main/java/com/kuit/afternote/feature/home/presentation/screen/HomeScreen.kt @@ -196,6 +196,7 @@ private fun GreetingSection( modifier = Modifier.padding(horizontal = 20.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { + Spacer(Modifier.height(24.dp)) Text( text = stringResource(R.string.home_greeting, userName), fontFamily = Sansneo, @@ -236,18 +237,23 @@ private object EmptyHomeScreenEvent : HomeScreenEvent { override fun onBottomNavTabSelected(item: BottomNavItem) { // No-op: default event when no callback is provided. } + override fun onProfileClick() { // No-op: default event when no callback is provided. } + override fun onSettingsClick() { // No-op: default event when no callback is provided. } + override fun onDailyQuestionCtaClick() { // No-op: default event when no callback is provided. } + override fun onTimeLetterClick() { // No-op: default event when no callback is provided. } + override fun onAfterNoteClick() { // No-op: default event when no callback is provided. } @@ -260,6 +266,7 @@ private class FakeProfileEditViewModel : ProfileEditViewModelContract { override fun loadProfile() { // No-op: Fake for Preview only; no API call. } + override fun updateProfile( name: String?, phone: String?, @@ -268,9 +275,11 @@ private class FakeProfileEditViewModel : ProfileEditViewModelContract { ) { // No-op: Fake for Preview only; no state update. } + override fun setSelectedProfileImageUri(uri: android.net.Uri?) { // No-op: Fake for Preview only. } + override fun clearUpdateSuccess() { // No-op: Fake for Preview only. } @@ -280,6 +289,7 @@ private class FakeProfileEditViewModel : ProfileEditViewModelContract { private class FakeMindRecordHomeViewModel : MindRecordHomeContract { override val calendarDays: StateFlow> = MutableStateFlow(defaultCalendarDays()) + override fun loadRecordsForDiaryList() { // No-op: Fake for Preview only; no API call. } From b44852f738241ad8733c1b9b3b419d3c2732262c Mon Sep 17 00:00:00 2001 From: 1hyok Date: Thu, 19 Feb 2026 03:21:33 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix(auth):=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EB=A1=9C=EC=A7=81=EC=9D=98=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=84=B1=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `AuthInterceptor`에서 여러 API 요청이 동시에 401 오류를 반환했을 때, 토큰 재발급 API(`auth/reissue`)가 여러 번 호출되는 경쟁 상태(race condition) 문제를 해결했습니다. - `synchronized` 블록과 double-check locking 패턴을 적용하여, 여러 스레드가 동시에 토큰 재발급을 시도하더라도 단 하나의 스레드만 실제 재발급을 실행하도록 보장합니다. - 재발급이 진행되는 동안 대기하던 다른 스레드들은, 재발급이 완료된 후 새로 저장된 액세스 토큰을 사용하여 원래의 요청을 재시도합니다. - 동기화를 위해 프로세스 전역에서 사용될 `refreshTokenLock` 객체를 `companion object` 내에 추가했습니다. --- .../afternote/data/remote/AuthInterceptor.kt | 101 +++++++++++------- 1 file changed, 61 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/kuit/afternote/data/remote/AuthInterceptor.kt b/app/src/main/java/com/kuit/afternote/data/remote/AuthInterceptor.kt index 4e29748c..c682578b 100644 --- a/app/src/main/java/com/kuit/afternote/data/remote/AuthInterceptor.kt +++ b/app/src/main/java/com/kuit/afternote/data/remote/AuthInterceptor.kt @@ -32,6 +32,12 @@ class AuthInterceptor private const val BASE_URL = "https://afternote.kro.kr/" private const val REISSUE_ENDPOINT = "auth/reissue" + /** + * 토큰 재발급 동기화 락. + * companion object에 두어 프로세스 전역에서 하나의 refresh만 실행되도록 보장. + */ + private val refreshTokenLock = Any() + /** * 인증이 필요 없는 경로 목록. */ @@ -107,10 +113,10 @@ class AuthInterceptor val response = proceedAndLog(chain, authenticatedRequest) - // 401 응답 시 토큰 재발급 시도 + // 401 응답 시 토큰 재발급 시도 (synchronized double-check locking) if (response.code == 401) { Log.w(TAG, "Auth: 401 Unauthorized - Attempting token refresh") - return handleTokenRefresh(chain, originalRequest, response) + return handleTokenRefresh(chain, originalRequest, response, accessToken) } return response @@ -118,59 +124,74 @@ class AuthInterceptor /** * 401 응답 시 토큰 재발급을 시도하고 요청을 재시도합니다. + * + * synchronized + double-check locking으로 동시 401에서 reissue API가 + * 한 번만 호출되도록 보장합니다. 나머지 스레드는 lock을 기다린 후 + * 이미 갱신된 토큰으로 바로 재시도합니다. + * + * @param failedAccessToken 401을 받은 시점의 accessToken (double-check에 사용) */ private fun handleTokenRefresh( chain: Interceptor.Chain, originalRequest: Request, - originalResponse: Response + originalResponse: Response, + failedAccessToken: String, ): Response { - val refreshToken = runBlocking { tokenManager.getRefreshToken() } + // refreshTokenLock으로 동기화 — 동시에 하나의 스레드만 refresh 수행 + synchronized(refreshTokenLock) { + // Double-check: lock을 기다리는 동안 다른 스레드가 이미 갱신했는지 확인 + val currentAccessToken = runBlocking { tokenManager.getAccessToken() } + if (currentAccessToken != failedAccessToken && !currentAccessToken.isNullOrEmpty()) { + // 다른 스레드가 이미 토큰을 갱신함 → refresh 없이 새 토큰으로 재시도 + Log.d(TAG, "TokenRefresh: Already refreshed by another thread, retrying") + originalResponse.close() + val newRequest = originalRequest + .newBuilder() + .header(AUTHORIZATION_HEADER, "$BEARER_PREFIX$currentAccessToken") + .build() + return proceedAndLog(chain, newRequest) + } - if (refreshToken.isNullOrEmpty()) { - Log.e(TAG, "TokenRefresh: No refresh token available") - // 토큰이 전혀 없는 상태에서는 추가 조치를 하지 않고 401을 그대로 반환한다. - // (UI 레이어에서 isLoggedInFlow를 보고 로그인 화면으로 유도) - return originalResponse - } + // 이 스레드가 최초 진입 → 실제 refresh 수행 + val refreshToken = runBlocking { tokenManager.getRefreshToken() } + + if (refreshToken.isNullOrEmpty()) { + Log.e(TAG, "TokenRefresh: No refresh token available") + return originalResponse + } - Log.d(TAG, "TokenRefresh: Attempting with refreshToken=${refreshToken.take(n = 20)}...") + Log.d(TAG, "TokenRefresh: Attempting with refreshToken=${refreshToken.take(n = 20)}...") - return try { - val newTokens = refreshAccessToken(refreshToken) + return try { + val newTokens = refreshAccessToken(refreshToken) - if (newTokens != null) { - Log.d(TAG, "TokenRefresh: SUCCESS - New tokens received") + if (newTokens != null) { + Log.d(TAG, "TokenRefresh: SUCCESS - New tokens received") - // 새 토큰 저장 - runBlocking { - tokenManager.updateTokens( - accessToken = newTokens.accessToken ?: "", - refreshToken = newTokens.refreshToken ?: refreshToken - ) - } + runBlocking { + tokenManager.updateTokens( + accessToken = newTokens.accessToken ?: "", + refreshToken = newTokens.refreshToken ?: refreshToken + ) + } - // 원래 응답 닫기 - originalResponse.close() + originalResponse.close() - // 새 토큰으로 원래 요청 재시도 - val newRequest = originalRequest - .newBuilder() - .header(AUTHORIZATION_HEADER, "$BEARER_PREFIX${newTokens.accessToken}") - .build() + val newRequest = originalRequest + .newBuilder() + .header(AUTHORIZATION_HEADER, "$BEARER_PREFIX${newTokens.accessToken}") + .build() - Log.d(TAG, "TokenRefresh: Retrying original request with new token") - proceedAndLog(chain, newRequest) - } else { - Log.e(TAG, "TokenRefresh: FAILED - keeping existing tokens") - // 재발급 실패 시 기존 토큰을 삭제하지 않고 401을 그대로 반환한다. - // 이렇게 해야 사용자가 이미 로그인했다고 생각하는 상태에서 - // 토큰이 갑자기 null로 사라지는 문제를 방지할 수 있다. + Log.d(TAG, "TokenRefresh: Retrying original request with new token") + proceedAndLog(chain, newRequest) + } else { + Log.e(TAG, "TokenRefresh: FAILED - keeping existing tokens") + originalResponse + } + } catch (e: Exception) { + Log.e(TAG, "TokenRefresh: Exception - ${e.message}", e) originalResponse } - } catch (e: Exception) { - Log.e(TAG, "TokenRefresh: Exception - ${e.message}", e) - // 예외 발생 시에도 토큰을 강제로 삭제하지 않고 401 응답을 그대로 반환한다. - originalResponse } }