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 @@ -399,10 +399,20 @@ class ScheduleRepository
}
}

// 특정 날짜의 일정 실시간 구독. 로그인 전이면 빈 목록을 한 번 내고 끝난다.
fun observeSchedulesForDate(calendar: Calendar): Flow<List<Schedule>> {
/**
* 특정 날짜의 일정 실시간 구독. 로그인 전이면 빈 목록을 한 번 내고 끝난다.
*
* [today] 가 true 면 「오늘」 을 구독하는 것이고, 회차를 펼칠 날짜를 스냅샷이 올 때마다
* 다시 읽는다. 붙잡아 두면 자정을 넘긴 뒤에도 어제 회차로 펼쳐지고, 그 회차 식별자가
* 완료 기록의 열쇠라 잘못된 날짜가 서버에 남는다(#171). 날짜를 사용자가 고르는 화면은
* false 로 두어 고른 날이 흔들리지 않게 한다.
*/
fun observeSchedulesForDate(
calendar: Calendar,
today: Boolean = false,
): Flow<List<Schedule>> {
val uid = auth.currentUser?.uid ?: return flowOf(emptyList())
val dayMillis = calendar.timeInMillis
val fixedDayMillis = calendar.timeInMillis
return callbackFlow {
val listener =
schedulesCollection
Expand All @@ -412,6 +422,7 @@ class ScheduleRepository
close(error)
return@addSnapshotListener
}
val dayMillis = if (today) System.currentTimeMillis() else fixedDayMillis
val schedules =
snapshot
?.documents
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ sealed interface DoneIntent : MviIntent {

data object Retry : DoneIntent

/** 화면이 다시 보인다. 날이 바뀌었으면 오늘 회차로 다시 구독한다(#171). */
data object ScreenResumed : DoneIntent

data object ConsumeError : DoneIntent
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ class DoneViewModel
) : MviViewModel<DoneIntent, DoneUiState, DoneReducerEvent>(DoneUiState()) {
private var scheduleJob: Job? = null

/** 지금 구독이 보고 있는 날. 날이 바뀌면 다시 건다(#171). */
private var subscribedDay: String = ""

init {
observeTodaySchedules()
}
Expand All @@ -39,6 +42,10 @@ class DoneViewModel
DoneIntent.ConsumeError -> {
dispatch(DoneReducerEvent.ErrorConsumed)
}

DoneIntent.ScreenResumed -> {
resubscribeIfDayChanged()
}
}
}

Expand Down Expand Up @@ -72,18 +79,36 @@ 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()))
}.collect { dispatch(DoneReducerEvent.Loaded(it)) }
}
}

/**
* 날이 바뀌었으면 오늘 회차로 다시 구독한다.
*
* 앱을 켜 둔 채 자정을 넘기면 구독은 어제 회차를 보고 있다. 그 회차 식별자가 완료 기록의
* 열쇠라, 그대로 두면 어제 날짜가 서버에 남는다(#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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ class MainViewModel
) : MviViewModel<MainIntent, MainUiState, MainReducerEvent>(MainUiState()) {
private var scheduleJob: Job? = null

/** 지금 구독이 보고 있는 날. 날이 바뀌면 다시 건다(#171). */
private var subscribedDay: String = ""

init {
observeSignedInUser()
// 정확한 알람 권한이 없으면 첫 진입에 한 번만 이유를 설명한다. 설정으로 보내는 건
Expand All @@ -53,6 +56,10 @@ class MainViewModel

override fun onIntent(intent: MainIntent) {
when (intent) {
MainIntent.ScreenResumed -> {
resubscribeIfDayChanged()
}

MainIntent.Retry -> {
dispatch(MainReducerEvent.ErrorConsumed)
observeTodaySchedules()
Expand Down Expand Up @@ -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))
Expand All @@ -277,6 +285,7 @@ class MainViewModel
* 공유 코드 값이 실제로 바뀌기 전까지 새 구독을 걸지 않기 때문이다. 그리고 로그인
* 전에는 보안 규칙이 읽기를 막으므로 리스너가 그 자리에서 닫힌다(#134 · #137).
*/

private fun observeSharedReminders() {
viewModelScope.launch {
authRepository
Expand All @@ -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()))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,7 +38,7 @@ class TimelineViewModelTest {
fun `처음에는 오늘 자정을 기준으로 구독한다`() =
runTest {
val requested = slot<Calendar>()
every { scheduleRepository.observeSchedulesForDate(capture(requested)) } returns flowOf(emptyList())
every { scheduleRepository.observeSchedulesForDate(capture(requested), any()) } returns flowOf(emptyList())

val state = TimelineViewModel(scheduleRepository).uiState.value

Expand All @@ -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
Expand Down
Loading