From fc0bf1f0ce62c795ee32577609845aec39e8a48c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 6 Sep 2026 14:32:26 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=A7=84=EB=8B=A8=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=9E=90=EB=A6=AC=EB=A7=88=EB=8B=A4=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B4=EA=B2=8C=20=EB=82=A8=EA=B8=B0=EA=B3=A0=20=EC=9E=90?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20=EB=84=98=EA=B2=A8=EB=8F=84=20=EC=98=A4?= =?UTF-8?q?=EB=8A=98=20=ED=9A=8C=EC=B0=A8=EB=A5=BC=20=EB=B3=B8=EB=8B=A4=20?= =?UTF-8?q?(170,=20171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../remote/repository/ScheduleRepository.kt | 17 +++++++++-- .../data/remote/repository/UserRepository.kt | 20 +++---------- .../example/slowclock/ui/done/DoneContract.kt | 3 ++ .../example/slowclock/ui/done/DoneScreen.kt | 4 +++ .../slowclock/ui/done/DoneViewModel.kt | 27 +++++++++++++++++- .../example/slowclock/ui/main/MainContract.kt | 3 ++ .../example/slowclock/ui/main/MainScreen.kt | 4 +++ .../slowclock/ui/main/MainViewModel.kt | 28 ++++++++++++++++++- .../slowclock/ui/done/DoneViewModelTest.kt | 2 +- .../slowclock/ui/main/MainViewModelTest.kt | 28 +++++++++++++++++-- .../ui/timeline/TimelineViewModelTest.kt | 6 ++-- 11 files changed, 114 insertions(+), 28 deletions(-) diff --git a/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt b/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt index 02ec7a6e..85900f18 100644 --- a/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt +++ b/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt @@ -399,10 +399,20 @@ class ScheduleRepository } } - // 특정 날짜의 일정 실시간 구독. 로그인 전이면 빈 목록을 한 번 내고 끝난다. - fun observeSchedulesForDate(calendar: Calendar): Flow> { + /** + * 특정 날짜의 일정 실시간 구독. 로그인 전이면 빈 목록을 한 번 내고 끝난다. + * + * [today] 가 true 면 「오늘」 을 구독하는 것이고, 회차를 펼칠 날짜를 스냅샷이 올 때마다 + * 다시 읽는다. 붙잡아 두면 자정을 넘긴 뒤에도 어제 회차로 펼쳐지고, 그 회차 식별자가 + * 완료 기록의 열쇠라 잘못된 날짜가 서버에 남는다(#171). 날짜를 사용자가 고르는 화면은 + * false 로 두어 고른 날이 흔들리지 않게 한다. + */ + fun observeSchedulesForDate( + calendar: Calendar, + today: Boolean = false, + ): Flow> { val uid = auth.currentUser?.uid ?: return flowOf(emptyList()) - val dayMillis = calendar.timeInMillis + val fixedDayMillis = calendar.timeInMillis return callbackFlow { val listener = schedulesCollection @@ -412,6 +422,7 @@ class ScheduleRepository close(error) return@addSnapshotListener } + val dayMillis = if (today) System.currentTimeMillis() else fixedDayMillis val schedules = snapshot ?.documents diff --git a/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt b/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt index 4aaeb52d..61ce4ed3 100644 --- a/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt +++ b/core/data/src/main/java/com/example/slowclock/data/remote/repository/UserRepository.kt @@ -65,10 +65,7 @@ class UserRepository publicProfilesCollection.document(userId).delete().await() true } catch (e: Exception) { - // 공유 코드가 없으면 그 뒤에 만든 일정이 sharedCode 없이 저장되고, 보안 규칙의 - // 공유 읽기 조건이 sharedCode != "" 라 가족이 어떤 코드로도 읽지 못한다. - // 조용히 넘기면 사용자도 보호자도 무엇이 잘못됐는지 알 방법이 없다(#134). - Log.e(TAG, "공유 코드 보장 실패", e) + Log.e(TAG, "사용자 문서 삭제 실패", e) false } @@ -164,10 +161,7 @@ class UserRepository usersCollection.document(uid).update("fcmToken", token).await() true } catch (e: Exception) { - // 공유 코드가 없으면 그 뒤에 만든 일정이 sharedCode 없이 저장되고, 보안 규칙의 - // 공유 읽기 조건이 sharedCode != "" 라 가족이 어떤 코드로도 읽지 못한다. - // 조용히 넘기면 사용자도 보호자도 무엇이 잘못됐는지 알 방법이 없다(#134). - Log.e(TAG, "공유 코드 보장 실패", e) + Log.e(TAG, "FCM 토큰 저장 실패", e) false } } @@ -180,10 +174,7 @@ class UserRepository shareCodeWatcherTokens(shareCode).document(uid).set(mapOf("fcmToken" to token)).await() true } catch (e: Exception) { - // 공유 코드가 없으면 그 뒤에 만든 일정이 sharedCode 없이 저장되고, 보안 규칙의 - // 공유 읽기 조건이 sharedCode != "" 라 가족이 어떤 코드로도 읽지 못한다. - // 조용히 넘기면 사용자도 보호자도 무엇이 잘못됐는지 알 방법이 없다(#134). - Log.e(TAG, "공유 코드 보장 실패", e) + Log.e(TAG, "공유 코드 감시자 등록 실패", e) false } } @@ -194,10 +185,7 @@ class UserRepository shareCodeWatcherTokens(shareCode).document(uid).delete().await() true } catch (e: Exception) { - // 공유 코드가 없으면 그 뒤에 만든 일정이 sharedCode 없이 저장되고, 보안 규칙의 - // 공유 읽기 조건이 sharedCode != "" 라 가족이 어떤 코드로도 읽지 못한다. - // 조용히 넘기면 사용자도 보호자도 무엇이 잘못됐는지 알 방법이 없다(#134). - Log.e(TAG, "공유 코드 보장 실패", e) + Log.e(TAG, "공유 코드 감시자 해제 실패", e) false } } diff --git a/feature/main/src/main/java/com/example/slowclock/ui/done/DoneContract.kt b/feature/main/src/main/java/com/example/slowclock/ui/done/DoneContract.kt index cfe3658a..af394dff 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/done/DoneContract.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/done/DoneContract.kt @@ -13,6 +13,9 @@ sealed interface DoneIntent : MviIntent { data object Retry : DoneIntent + /** 화면이 다시 보인다. 날이 바뀌었으면 오늘 회차로 다시 구독한다(#171). */ + data object ScreenResumed : DoneIntent + data object ConsumeError : DoneIntent } diff --git a/feature/main/src/main/java/com/example/slowclock/ui/done/DoneScreen.kt b/feature/main/src/main/java/com/example/slowclock/ui/done/DoneScreen.kt index a8903525..8ab55dac 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/done/DoneScreen.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/done/DoneScreen.kt @@ -16,6 +16,8 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.slowclock.ui.common.components.DayProgress import com.example.slowclock.ui.common.components.EmptyState @@ -32,6 +34,8 @@ fun DoneScreen( viewModel: DoneViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + // 앱을 켜 둔 채 자정을 넘기면 구독이 어제 회차를 보고 있다(#171). + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.onIntent(DoneIntent.ScreenResumed) } DoneContent(state = state, onIntent = viewModel::onIntent, modifier = modifier) } diff --git a/feature/main/src/main/java/com/example/slowclock/ui/done/DoneViewModel.kt b/feature/main/src/main/java/com/example/slowclock/ui/done/DoneViewModel.kt index 43d29099..b1d88522 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/done/DoneViewModel.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/done/DoneViewModel.kt @@ -21,6 +21,9 @@ class DoneViewModel ) : MviViewModel(DoneUiState()) { private var scheduleJob: Job? = null + /** 지금 구독이 보고 있는 날. 날이 바뀌면 다시 건다(#171). */ + private var subscribedDay: String = "" + init { observeTodaySchedules() } @@ -39,6 +42,10 @@ class DoneViewModel DoneIntent.ConsumeError -> { dispatch(DoneReducerEvent.ErrorConsumed) } + + DoneIntent.ScreenResumed -> { + resubscribeIfDayChanged() + } } } @@ -72,11 +79,12 @@ class DoneViewModel private fun observeTodaySchedules() { scheduleJob?.cancel() + subscribedDay = todayKey() scheduleJob = viewModelScope.launch { dispatch(DoneReducerEvent.Loading) scheduleRepository - .observeSchedulesForDate(Calendar.getInstance()) + .observeSchedulesForDate(Calendar.getInstance(), today = true) .catch { e -> Log.e(TAG, "일정 구독 실패", e) dispatch(DoneReducerEvent.Failed(e.toAppError())) @@ -84,6 +92,23 @@ class DoneViewModel } } + /** + * 날이 바뀌었으면 오늘 회차로 다시 구독한다. + * + * 앱을 켜 둔 채 자정을 넘기면 구독은 어제 회차를 보고 있다. 그 회차 식별자가 완료 기록의 + * 열쇠라, 그대로 두면 어제 날짜가 서버에 남는다(#171). + */ + private fun resubscribeIfDayChanged() { + if (subscribedDay.isNotEmpty() && subscribedDay != todayKey()) { + observeTodaySchedules() + } + } + + private fun todayKey(): String = + Calendar.getInstance().let { + "%04d-%03d".format(it.get(Calendar.YEAR), it.get(Calendar.DAY_OF_YEAR)) + } + private fun toggleComplete(scheduleId: String) { val schedule = currentState.schedules.find { it.id == scheduleId } ?: return dispatch(DoneReducerEvent.Toggled(scheduleId)) diff --git a/feature/main/src/main/java/com/example/slowclock/ui/main/MainContract.kt b/feature/main/src/main/java/com/example/slowclock/ui/main/MainContract.kt index 45371451..ad14311d 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/main/MainContract.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/main/MainContract.kt @@ -10,6 +10,9 @@ import com.example.slowclock.util.AppError sealed interface MainIntent : MviIntent { data object Retry : MainIntent + /** 화면이 다시 보인다. 날이 바뀌었으면 오늘 회차로 다시 구독한다(#171). */ + data object ScreenResumed : MainIntent + data class ToggleComplete( val scheduleId: String, ) : MainIntent diff --git a/feature/main/src/main/java/com/example/slowclock/ui/main/MainScreen.kt b/feature/main/src/main/java/com/example/slowclock/ui/main/MainScreen.kt index 8557fc4c..c5b556e2 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/main/MainScreen.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/main/MainScreen.kt @@ -30,6 +30,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.slowclock.ui.common.components.ErrorCard import com.example.slowclock.ui.common.components.SignInPrompt @@ -57,6 +59,8 @@ fun MainScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current + // 앱을 켜 둔 채 자정을 넘기면 구독이 어제 회차를 보고 있다(#171). + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.onIntent(MainIntent.ScreenResumed) } ObserveSignal( signal = state.openExactAlarmSettings, consumed = MainIntent.ConsumeExactAlarmSettingsRequest, diff --git a/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt b/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt index a65a1ca0..1df9988a 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt @@ -40,6 +40,9 @@ class MainViewModel ) : MviViewModel(MainUiState()) { private var scheduleJob: Job? = null + /** 지금 구독이 보고 있는 날. 날이 바뀌면 다시 건다(#171). */ + private var subscribedDay: String = "" + init { observeSignedInUser() // 정확한 알람 권한이 없으면 첫 진입에 한 번만 이유를 설명한다. 설정으로 보내는 건 @@ -53,6 +56,10 @@ class MainViewModel override fun onIntent(intent: MainIntent) { when (intent) { + MainIntent.ScreenResumed -> { + resubscribeIfDayChanged() + } + MainIntent.Retry -> { dispatch(MainReducerEvent.ErrorConsumed) observeTodaySchedules() @@ -255,11 +262,12 @@ class MainViewModel private fun observeTodaySchedules() { scheduleJob?.cancel() + subscribedDay = todayKey() scheduleJob = viewModelScope.launch { dispatch(MainReducerEvent.Loading) scheduleRepository - .observeSchedulesForDate(Calendar.getInstance()) + .observeSchedulesForDate(Calendar.getInstance(), today = true) .catch { e -> Log.e(TAG, "일정 구독 실패", e) dispatch(MainReducerEvent.LoadFailed(e.toAppError(), canRetry = true)) @@ -277,6 +285,7 @@ class MainViewModel * 공유 코드 값이 실제로 바뀌기 전까지 새 구독을 걸지 않기 때문이다. 그리고 로그인 * 전에는 보안 규칙이 읽기를 막으므로 리스너가 그 자리에서 닫힌다(#134 · #137). */ + private fun observeSharedReminders() { viewModelScope.launch { authRepository @@ -302,6 +311,23 @@ class MainViewModel } } + /** + * 날이 바뀌었으면 오늘 회차로 다시 구독한다. + * + * 앱을 켜 둔 채 자정을 넘기면 구독은 어제 회차를 보고 있다. 그 회차 식별자가 완료 기록의 + * 열쇠라, 그대로 두면 어제 날짜가 서버에 남는다(#171). + */ + private fun resubscribeIfDayChanged() { + if (subscribedDay.isNotEmpty() && subscribedDay != todayKey()) { + observeTodaySchedules() + } + } + + private fun todayKey(): String = + Calendar.getInstance().let { + "%04d-%03d".format(it.get(Calendar.YEAR), it.get(Calendar.DAY_OF_YEAR)) + } + private fun toggleComplete(scheduleId: String) { val schedule = currentState.todaySchedules.find { it.id == scheduleId } ?: return dispatch(MainReducerEvent.CompletionToggled(scheduleId, System.currentTimeMillis())) diff --git a/feature/main/src/test/java/com/example/slowclock/ui/done/DoneViewModelTest.kt b/feature/main/src/test/java/com/example/slowclock/ui/done/DoneViewModelTest.kt index 427c1f2a..fccb726a 100644 --- a/feature/main/src/test/java/com/example/slowclock/ui/done/DoneViewModelTest.kt +++ b/feature/main/src/test/java/com/example/slowclock/ui/done/DoneViewModelTest.kt @@ -28,7 +28,7 @@ class DoneViewModelTest { @Before fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) - every { scheduleRepository.observeSchedulesForDate(any()) } returns todaySchedules + every { scheduleRepository.observeSchedulesForDate(any(), any()) } returns todaySchedules } @After diff --git a/feature/main/src/test/java/com/example/slowclock/ui/main/MainViewModelTest.kt b/feature/main/src/test/java/com/example/slowclock/ui/main/MainViewModelTest.kt index 780ed7b0..5acc12d9 100644 --- a/feature/main/src/test/java/com/example/slowclock/ui/main/MainViewModelTest.kt +++ b/feature/main/src/test/java/com/example/slowclock/ui/main/MainViewModelTest.kt @@ -8,6 +8,7 @@ import com.example.slowclock.data.remote.repository.SettingsRepository import com.example.slowclock.data.remote.repository.UserRepository import com.example.slowclock.util.AppError import com.google.firebase.Timestamp +import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -52,7 +53,7 @@ class MainViewModelTest { Dispatchers.setMain(UnconfinedTestDispatcher()) every { authRepository.currentUid } returns "uid-1" every { authRepository.observeCurrentUid() } returns flowOf("uid-1") - every { scheduleRepository.observeSchedulesForDate(any()) } returns todaySchedules + every { scheduleRepository.observeSchedulesForDate(any(), any()) } returns todaySchedules every { settingsRepository.observeShareCode() } returns shareCode every { scheduleRepository.observeSchedulesBySharedCode(any()) } returns flowOf(emptyList()) coEvery { userRepository.getUserNames(any()) } returns emptyMap() @@ -157,12 +158,12 @@ class MainViewModelTest { @Test fun `일정 구독이 실패하면 재시도 가능한 오류를 두고 Retry 가 다시 구독한다`() = runTest { - every { scheduleRepository.observeSchedulesForDate(any()) } returns flow { throw IllegalStateException("network down") } + every { scheduleRepository.observeSchedulesForDate(any(), any()) } returns flow { throw IllegalStateException("network down") } val viewModel = createViewModel() assertEquals(AppError.NetworkError, viewModel.uiState.value.error) assertTrue(viewModel.uiState.value.canRetry) - every { scheduleRepository.observeSchedulesForDate(any()) } returns flowOf(listOf(soon)) + every { scheduleRepository.observeSchedulesForDate(any(), any()) } returns flowOf(listOf(soon)) viewModel.onIntent(MainIntent.Retry) assertNull(viewModel.uiState.value.error) @@ -313,4 +314,25 @@ class MainViewModelTest { verify(exactly = 0) { scheduleRepository.observeSchedulesBySharedCode(any()) } } + + @Test + fun `오늘 일정은 날짜를 다시 읽는 구독으로 건다`() = + runTest { + // 붙잡아 두면 자정을 넘긴 뒤에도 어제 회차로 펼쳐지고, 그 회차 식별자가 완료 기록의 + // 열쇠라 어제 날짜가 서버에 남는다(#171). + createViewModel() + + verify { scheduleRepository.observeSchedulesForDate(any(), today = true) } + } + + @Test + fun `같은 날 화면이 다시 보이면 구독을 다시 걸지 않는다`() = + runTest { + val viewModel = createViewModel() + clearMocks(scheduleRepository, answers = false, recordedCalls = true) + + viewModel.onIntent(MainIntent.ScreenResumed) + + verify(exactly = 0) { scheduleRepository.observeSchedulesForDate(any(), any()) } + } } diff --git a/feature/main/src/test/java/com/example/slowclock/ui/timeline/TimelineViewModelTest.kt b/feature/main/src/test/java/com/example/slowclock/ui/timeline/TimelineViewModelTest.kt index 9932e442..a8dfbe63 100644 --- a/feature/main/src/test/java/com/example/slowclock/ui/timeline/TimelineViewModelTest.kt +++ b/feature/main/src/test/java/com/example/slowclock/ui/timeline/TimelineViewModelTest.kt @@ -26,7 +26,7 @@ class TimelineViewModelTest { @Before fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) - every { scheduleRepository.observeSchedulesForDate(any()) } returns flowOf(listOf(Schedule(id = "a", title = "a"))) + every { scheduleRepository.observeSchedulesForDate(any(), any()) } returns flowOf(listOf(Schedule(id = "a", title = "a"))) } @After @@ -38,7 +38,7 @@ class TimelineViewModelTest { fun `처음에는 오늘 자정을 기준으로 구독한다`() = runTest { val requested = slot() - every { scheduleRepository.observeSchedulesForDate(capture(requested)) } returns flowOf(emptyList()) + every { scheduleRepository.observeSchedulesForDate(capture(requested), any()) } returns flowOf(emptyList()) val state = TimelineViewModel(scheduleRepository).uiState.value @@ -64,7 +64,7 @@ class TimelineViewModelTest { viewModel.uiState.value.schedules .map { it.id }, ) - verify(exactly = 2) { scheduleRepository.observeSchedulesForDate(any()) } + verify(exactly = 2) { scheduleRepository.observeSchedulesForDate(any(), any()) } } @Test