diff --git a/app/src/androidTest/java/com/example/slowclock/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/slowclock/ExampleInstrumentedTest.kt index 67e70b9c..a103c0c3 100644 --- a/app/src/androidTest/java/com/example/slowclock/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/example/slowclock/ExampleInstrumentedTest.kt @@ -17,6 +17,6 @@ class ExampleInstrumentedTest { fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.example.slowclock", appContext.packageName) + assertEquals("com.ilhyok.slowclock", appContext.packageName) } } diff --git a/app/src/androidTest/java/com/example/slowclock/UiRecoveryTest.kt b/app/src/androidTest/java/com/example/slowclock/UiRecoveryTest.kt new file mode 100644 index 00000000..756c21ce --- /dev/null +++ b/app/src/androidTest/java/com/example/slowclock/UiRecoveryTest.kt @@ -0,0 +1,88 @@ +package com.example.slowclock + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.assertHasNoClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.unit.Density +import com.example.slowclock.data.model.Schedule +import com.example.slowclock.ui.common.components.ErrorCard +import com.example.slowclock.ui.common.components.ScheduleRow +import com.example.slowclock.ui.main.components.ScheduleDetailDialog +import com.example.slowclock.ui.theme.SlowClockTheme +import com.example.slowclock.util.AppError +import com.google.firebase.Timestamp +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import java.util.Date + +class UiRecoveryTest { + @get:Rule + val compose = createComposeRule() + + @Test + fun nonRetryableErrorCanBeDismissed() { + var dismisses = 0 + compose.setContent { + SlowClockTheme(darkTheme = false) { + ErrorCard(error = AppError.PermissionError, canRetry = false, onDismiss = { dismisses++ }) + } + } + compose.onNodeWithText("다시 시도").assertDoesNotExist() + compose.onNodeWithText("닫기").assertIsEnabled().performClick() + compose.runOnIdle { assertEquals(1, dismisses) } + } + + @Test + fun nonClickableRowIsNotDisabledAndItsCompletionActionWorks() { + var toggles = 0 + compose.setContent { + SlowClockTheme(darkTheme = false) { + ScheduleRow(title = "혈압약 먹기", time = Date(0), completed = false, onToggleComplete = { toggles++ }) + } + } + compose.onNodeWithText("혈압약 먹기").assertIsEnabled().assertHasNoClickAction() + compose.onNodeWithContentDescription("완료로 표시").assertIsEnabled().performClick() + compose.runOnIdle { assertEquals(1, toggles) } + } + + @Test + fun longDetailAtLargeFontCanScrollToActions() { + var deletes = 0 + var dismisses = 0 + compose.setContent { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 2f)) { + SlowClockTheme(darkTheme = false) { + ScheduleDetailDialog( + schedule = + Schedule( + id = "detail", + title = "혈압약 먹기", + description = List(30) { "식사 후 물과 함께 복용해주세요." }.joinToString("\n"), + startTime = Timestamp(Date(0)), + ), + onDismiss = { dismisses++ }, + onDelete = { deletes++ }, + ) + } + } + } + compose + .onNodeWithText("삭제") + .performScrollTo() + .assertIsDisplayed() + .performClick() + compose.runOnIdle { + assertEquals(1, deletes) + assertEquals(1, dismisses) + } + } +} diff --git a/app/src/screenshotTest/java/com/example/slowclock/RecommendationLargeTextScreenshotTest.kt b/app/src/screenshotTest/java/com/example/slowclock/RecommendationLargeTextScreenshotTest.kt new file mode 100644 index 00000000..8a6f0cee --- /dev/null +++ b/app/src/screenshotTest/java/com/example/slowclock/RecommendationLargeTextScreenshotTest.kt @@ -0,0 +1,20 @@ +package com.example.slowclock + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.android.tools.screenshot.PreviewTest +import com.example.slowclock.ui.addschedule.components.RecommendationPlaceholder +import com.example.slowclock.ui.theme.SlowClockTheme + +@PreviewTest +@Preview(name = "추천 카드 큰 글자 320", widthDp = 320, heightDp = 480, fontScale = 2f) +@Preview(name = "추천 카드 큰 글자 360", widthDp = 360, heightDp = 480, fontScale = 2f) +@Composable +internal fun RecommendationLargeTextScreenshot() { + SlowClockTheme(darkTheme = false) { + Column { + RecommendationPlaceholder(onNavigateToRecommendation = {}) + } + } +} diff --git "a/app/src/screenshotTestDebug/reference/com/example/slowclock/RecommendationLargeTextScreenshotTestKt/RecommendationLargeTextScreenshot_\354\266\224\354\262\234 \354\271\264\353\223\234 \355\201\260 \352\270\200\354\236\220 320_dfadd674_0.png" "b/app/src/screenshotTestDebug/reference/com/example/slowclock/RecommendationLargeTextScreenshotTestKt/RecommendationLargeTextScreenshot_\354\266\224\354\262\234 \354\271\264\353\223\234 \355\201\260 \352\270\200\354\236\220 320_dfadd674_0.png" new file mode 100644 index 00000000..7454a16a Binary files /dev/null and "b/app/src/screenshotTestDebug/reference/com/example/slowclock/RecommendationLargeTextScreenshotTestKt/RecommendationLargeTextScreenshot_\354\266\224\354\262\234 \354\271\264\353\223\234 \355\201\260 \352\270\200\354\236\220 320_dfadd674_0.png" differ diff --git "a/app/src/screenshotTestDebug/reference/com/example/slowclock/RecommendationLargeTextScreenshotTestKt/RecommendationLargeTextScreenshot_\354\266\224\354\262\234 \354\271\264\353\223\234 \355\201\260 \352\270\200\354\236\220 360_a59ce89d_0.png" "b/app/src/screenshotTestDebug/reference/com/example/slowclock/RecommendationLargeTextScreenshotTestKt/RecommendationLargeTextScreenshot_\354\266\224\354\262\234 \354\271\264\353\223\234 \355\201\260 \352\270\200\354\236\220 360_a59ce89d_0.png" new file mode 100644 index 00000000..f56d883f Binary files /dev/null and "b/app/src/screenshotTestDebug/reference/com/example/slowclock/RecommendationLargeTextScreenshotTestKt/RecommendationLargeTextScreenshot_\354\266\224\354\262\234 \354\271\264\353\223\234 \355\201\260 \352\270\200\354\236\220 360_a59ce89d_0.png" differ diff --git a/core/common/src/main/java/com/example/slowclock/util/ErrorType.kt b/core/common/src/main/java/com/example/slowclock/util/ErrorType.kt index e81fcda3..044d1526 100644 --- a/core/common/src/main/java/com/example/slowclock/util/ErrorType.kt +++ b/core/common/src/main/java/com/example/slowclock/util/ErrorType.kt @@ -41,5 +41,5 @@ fun Throwable.toAppError(): AppError = message?.contains("not found", ignoreCase = true) == true -> AppError.NotFoundError message?.contains("authentication", ignoreCase = true) == true -> AppError.AuthError message?.contains("storage", ignoreCase = true) == true -> AppError.StorageFullError - else -> AppError.GeneralError(message) + else -> AppError.GeneralError() } diff --git a/core/common/src/test/java/com/example/slowclock/util/ErrorTypeTest.kt b/core/common/src/test/java/com/example/slowclock/util/ErrorTypeTest.kt new file mode 100644 index 00000000..4128446f --- /dev/null +++ b/core/common/src/test/java/com/example/slowclock/util/ErrorTypeTest.kt @@ -0,0 +1,19 @@ +package com.example.slowclock.util + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ErrorTypeTest { + @Test + fun `분류하지 못한 예외는 내부 메시지 대신 기본 안내를 낸다`() { + assertEquals(AppError.GeneralError().message, IllegalStateException("INTERNAL transport closed").toAppError().message) + assertEquals(AppError.GeneralError().message, RuntimeException().toAppError().message) + } + + @Test + fun `알려진 오류와 앱이 작성한 안내는 유지한다`() { + assertEquals(AppError.NetworkError, RuntimeException("NETWORK unavailable").toAppError()) + assertEquals(AppError.TimeoutError, RuntimeException("timeout").toAppError()) + assertEquals("일정 제목을 입력해주세요", AppError.GeneralError("일정 제목을 입력해주세요").message) + } +} diff --git a/core/ui/src/main/java/com/example/slowclock/ui/common/components/BottomNavigationBar.kt b/core/ui/src/main/java/com/example/slowclock/ui/common/components/BottomNavigationBar.kt index b9bb6e87..7e839482 100644 --- a/core/ui/src/main/java/com/example/slowclock/ui/common/components/BottomNavigationBar.kt +++ b/core/ui/src/main/java/com/example/slowclock/ui/common/components/BottomNavigationBar.kt @@ -46,12 +46,12 @@ fun BottomNavigationBar( indicatorColor = MaterialTheme.colorScheme.primaryContainer, ), icon = { Icon(item.icon, contentDescription = null) }, - // 글꼴을 키우면 두 줄로 흘러 옆 항목과 겹친다. 한 줄로 고정한다. + // 큰 글자에서도 「시간표」의 끝 글자가 잘리지 않도록 줄바꿈을 허용한다. label = { Text( text = item.label, style = MaterialTheme.typography.labelLarge, - maxLines = 1, + maxLines = 2, ) }, ) diff --git a/core/ui/src/main/java/com/example/slowclock/ui/common/components/ErrorCard.kt b/core/ui/src/main/java/com/example/slowclock/ui/common/components/ErrorCard.kt index 9002df1c..cde0afe5 100644 --- a/core/ui/src/main/java/com/example/slowclock/ui/common/components/ErrorCard.kt +++ b/core/ui/src/main/java/com/example/slowclock/ui/common/components/ErrorCard.kt @@ -2,6 +2,7 @@ package com.example.slowclock.ui.common.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.outlined.Block @@ -32,6 +34,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.example.slowclock.util.AppError @@ -107,33 +110,39 @@ fun ErrorCard( textAlign = TextAlign.Center, ) - if (canRetry && onRetry != null) { + if (onDismiss != null || (canRetry && onRetry != null)) { Spacer(modifier = Modifier.height(20.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + val actionMinWidth = 144.dp * LocalDensity.current.fontScale + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { if (onDismiss != null) { OutlinedButton( onClick = onDismiss, shape = MaterialTheme.shapes.small, - modifier = Modifier.weight(1f).heightIn(min = 56.dp), + modifier = Modifier.widthIn(min = actionMinWidth).weight(1f).heightIn(min = 56.dp), ) { Text(text = "닫기", style = MaterialTheme.typography.labelLarge) } } - Button( - onClick = onRetry, - shape = MaterialTheme.shapes.small, - modifier = Modifier.weight(1f).heightIn(min = 56.dp), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - ) { - Icon(Icons.Default.Refresh, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = "다시 시도", style = MaterialTheme.typography.labelLarge) + if (canRetry && onRetry != null) { + Button( + onClick = onRetry, + shape = MaterialTheme.shapes.small, + modifier = Modifier.widthIn(min = actionMinWidth).weight(1f).heightIn(min = 56.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Icon(Icons.Default.Refresh, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = "다시 시도", style = MaterialTheme.typography.labelLarge) + } } } } diff --git a/core/ui/src/main/java/com/example/slowclock/ui/common/components/ScheduleRow.kt b/core/ui/src/main/java/com/example/slowclock/ui/common/components/ScheduleRow.kt index 47fc5fe1..018e8992 100644 --- a/core/ui/src/main/java/com/example/slowclock/ui/common/components/ScheduleRow.kt +++ b/core/ui/src/main/java/com/example/slowclock/ui/common/components/ScheduleRow.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle @@ -65,18 +66,10 @@ fun ScheduleRow( MaterialTheme.colorScheme.onSurface } - Surface( - onClick = onClick ?: {}, - enabled = onClick != null, + ScheduleRowSurface( + onClick = onClick, + completed = completed, modifier = modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - color = - if (completed) { - MaterialTheme.colorScheme.surfaceContainerLow - } else { - MaterialTheme.colorScheme.surface - }, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), ) { Row( modifier = @@ -86,7 +79,7 @@ fun ScheduleRow( .padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { - Column(modifier = Modifier.width(64.dp)) { + Column(modifier = Modifier.widthIn(min = 64.dp)) { Text( text = meridiem, style = MaterialTheme.typography.labelSmall, @@ -94,6 +87,7 @@ fun ScheduleRow( ) Text( text = clock, + maxLines = 1, style = MaterialTheme.typography.titleMedium, color = titleColor, ) @@ -154,3 +148,26 @@ fun ScheduleRow( } } } + +@Composable +private fun ScheduleRowSurface( + onClick: (() -> Unit)?, + completed: Boolean, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val color = if (completed) MaterialTheme.colorScheme.surfaceContainerLow else MaterialTheme.colorScheme.surface + val border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + if (onClick == null) { + Surface(modifier = modifier, shape = MaterialTheme.shapes.medium, color = color, border = border, content = content) + } else { + Surface( + onClick = onClick, + modifier = modifier, + shape = MaterialTheme.shapes.medium, + color = color, + border = border, + content = content, + ) + } +} diff --git a/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/RecommendationPlaceholder.kt b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/RecommendationPlaceholder.kt index 3c010f38..3da91ea5 100644 --- a/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/RecommendationPlaceholder.kt +++ b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/RecommendationPlaceholder.kt @@ -30,13 +30,13 @@ fun RecommendationPlaceholder( modifier = Modifier .fillMaxWidth() - .height(140.dp) + .heightIn(min = 140.dp) .padding(24.dp), contentAlignment = Alignment.Center, ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = "🔮 일정 추천해드릴까요?", + text = "일정 추천해드릴까요?", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onTertiaryContainer, ) diff --git a/feature/main/src/main/java/com/example/slowclock/ui/common/ExternalActivityLauncher.kt b/feature/main/src/main/java/com/example/slowclock/ui/common/ExternalActivityLauncher.kt new file mode 100644 index 00000000..5fd7174e --- /dev/null +++ b/feature/main/src/main/java/com/example/slowclock/ui/common/ExternalActivityLauncher.kt @@ -0,0 +1,20 @@ +package com.example.slowclock.ui.common + +import android.content.ActivityNotFoundException +import android.util.Log + +/** 시스템 설정이나 브라우저가 없거나 정책으로 막힌 기기에서도 현재 화면을 유지한다. */ +internal fun launchExternalActivity( + open: () -> Unit, + fallback: (() -> Unit)? = null, +): Boolean { + try { + open() + return true + } catch (e: ActivityNotFoundException) { + Log.w("ExternalActivity", "외부 화면을 열지 못했다", e) + } catch (e: SecurityException) { + Log.w("ExternalActivity", "외부 화면이 허용되지 않았다", e) + } + return fallback?.let { launchExternalActivity(it) } ?: false +} diff --git a/feature/main/src/main/java/com/example/slowclock/ui/common/ScheduleLoadingIndicator.kt b/feature/main/src/main/java/com/example/slowclock/ui/common/ScheduleLoadingIndicator.kt new file mode 100644 index 00000000..3c0f110a --- /dev/null +++ b/feature/main/src/main/java/com/example/slowclock/ui/common/ScheduleLoadingIndicator.kt @@ -0,0 +1,28 @@ +package com.example.slowclock.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +internal fun ScheduleLoadingIndicator( + modifier: Modifier = Modifier, + message: String = "일정을 불러오고 있습니다", +) { + Row( + modifier = modifier.padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + Text(text = message, style = MaterialTheme.typography.bodyLarge) + } +} 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 8ab55dac..82fbd3b1 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 @@ -19,6 +19,7 @@ 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.ScheduleLoadingIndicator import com.example.slowclock.ui.common.components.DayProgress import com.example.slowclock.ui.common.components.EmptyState import com.example.slowclock.ui.common.components.ErrorCard @@ -77,7 +78,11 @@ internal fun DoneContent( ) } + if (state.isLoading) { + ScheduleLoadingIndicator() + } if (completed.isEmpty() && remaining.isEmpty()) { + if (state.isLoading || state.error != null) return@Column EmptyState( icon = Icons.Outlined.TaskAlt, title = "오늘 일정이 없습니다", 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 ad14311d..acaf6795 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 @@ -44,8 +44,19 @@ sealed interface MainIntent : MviIntent { data object DismissExactAlarmNotice : MainIntent data object ConsumeExactAlarmSettingsRequest : MainIntent + + data object ExactAlarmSettingsUnavailable : MainIntent + + data object ConsumeUserMessage : MainIntent } +/** 동일한 일정을 다시 삭제하더라도 늦은 이전 응답과 구분한다. */ +data class DeleteOperation( + val schedule: Schedule, + val userId: String, + val requestId: String, +) + /** 메인 화면의 단일 UI 상태. 오늘 일정과 공유 일정, 다이얼로그 상태를 담는다. */ data class MainUiState( val todaySchedules: List = emptyList(), @@ -60,6 +71,9 @@ data class MainUiState( val selectedScheduleForDetail: Schedule? = null, /** null 이 아니면 삭제 확인 다이얼로그가 떠 있다. */ val scheduleToDelete: Schedule? = null, + val pendingDelete: DeleteOperation? = null, + /** 목록 스냅샷이 와도 사용자가 재시도하거나 닫을 때까지 실패 작업을 보존한다. */ + val failedDelete: DeleteOperation? = null, val currentUserId: String = "", /** 로그인 여부를 아직 모르는 동안에는 로그인 안내를 띄우지 않는다. */ val isSignedInKnown: Boolean = false, @@ -67,6 +81,7 @@ data class MainUiState( val showExactAlarmNotice: Boolean = false, /** null 이 아니면 화면이 시스템 설정을 한 번 연다. */ val openExactAlarmSettings: Unit? = null, + val userMessage: String? = null, ) : UiState /** 메인 화면의 상태가 겪은 것. 화면은 만들지 않고 ViewModel 만 dispatch 한다. */ @@ -113,10 +128,17 @@ sealed interface MainReducerEvent : ReducerEvent { data object DeleteDismissed : MainReducerEvent - data object Deleting : MainReducerEvent + data class Deleting( + val operation: DeleteOperation, + ) : MainReducerEvent + + data class DeleteFailed( + val operation: DeleteOperation, + val error: AppError, + ) : MainReducerEvent data class Deleted( - val scheduleId: String, + val operation: DeleteOperation, val nowMillis: Long, ) : MainReducerEvent @@ -141,4 +163,8 @@ sealed interface MainReducerEvent : ReducerEvent { data object ExactAlarmSettingsRequested : MainReducerEvent data object ExactAlarmSettingsRequestConsumed : MainReducerEvent + + data object ExactAlarmSettingsFailed : MainReducerEvent + + data object UserMessageConsumed : MainReducerEvent } 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 c5b556e2..88c1eab3 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 @@ -1,6 +1,7 @@ package com.example.slowclock.ui.main import android.content.Intent +import android.provider.Settings import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues @@ -19,6 +20,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -26,17 +29,21 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.core.net.toUri 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.ScheduleLoadingIndicator import com.example.slowclock.ui.common.components.ErrorCard import com.example.slowclock.ui.common.components.SignInPrompt import com.example.slowclock.ui.common.components.rememberDayText import com.example.slowclock.ui.common.dialog.DeleteConfirmDialog +import com.example.slowclock.ui.common.launchExternalActivity import com.example.slowclock.ui.main.components.DayHeader import com.example.slowclock.ui.main.components.EmptyStateCard import com.example.slowclock.ui.main.components.NowCard @@ -44,6 +51,7 @@ import com.example.slowclock.ui.main.components.ScheduleDetailDialog import com.example.slowclock.ui.main.components.SharedRemindersSection import com.example.slowclock.ui.main.components.TodayScheduleSection import com.example.slowclock.ui.mvi.ObserveSignal +import kotlinx.coroutines.launch import java.util.Date /** 메인 화면(stateful). 네비게이션 콜백만 받고 나머지는 [MainIntent] 로 보낸다. */ @@ -59,6 +67,13 @@ fun MainScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + ObserveSignal( + signal = state.userMessage, + consumed = MainIntent.ConsumeUserMessage, + onIntent = viewModel::onIntent, + ) { message -> scope.launch { snackbarHostState.showSnackbar(message) } } // 앱을 켜 둔 채 자정을 넘기면 구독이 어제 회차를 보고 있다(#171). LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.onIntent(MainIntent.ScreenResumed) } ObserveSignal( @@ -66,7 +81,16 @@ fun MainScreen( consumed = MainIntent.ConsumeExactAlarmSettingsRequest, onIntent = viewModel::onIntent, ) { - context.startActivity(Intent(android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)) + val opened = + launchExternalActivity( + open = { + context.startActivity(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM, "package:${context.packageName}".toUri())) + }, + fallback = { + context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, "package:${context.packageName}".toUri())) + }, + ) + if (!opened) viewModel.onIntent(MainIntent.ExactAlarmSettingsUnavailable) } MainContent( state = state, @@ -76,6 +100,7 @@ fun MainScreen( onNavigateToProfile = onNavigateToProfile, onNavigateToSettings = onNavigateToSettings, onSignIn = onSignIn, + snackbarHost = { SnackbarHost(snackbarHostState) }, modifier = modifier, ) } @@ -100,6 +125,7 @@ internal fun MainContent( // 날짜 머리글의 기준이 되는 날. 미리보기와 스크린샷 테스트는 고정된 날을 넣어, 그린 날에 // 따라 그림이 달라지지 않게 한다. 시계를 읽으면 baseline 이 하루 만에 어긋난다(#143). today: Date = Date(), + snackbarHost: @Composable () -> Unit = {}, ) { val isSignedOut = state.isSignedInKnown && state.currentUserId.isBlank() val dateText = rememberDayText(today) @@ -142,6 +168,7 @@ internal fun MainContent( Scaffold( modifier = modifier, + snackbarHost = snackbarHost, containerColor = MaterialTheme.colorScheme.background, topBar = { TopAppBar( @@ -244,7 +271,15 @@ internal fun MainContent( } } - if (state.todaySchedules.isEmpty() && !state.isLoading) { + if (!state.isSignedInKnown || state.isLoading) { + item { ScheduleLoadingIndicator() } + } + if (state.pendingDelete != null) { + item { ScheduleLoadingIndicator(message = "일정을 삭제하고 있습니다") } + } + if (state.isSignedInKnown && state.todaySchedules.isEmpty() && !state.isLoading && state.error == null && + state.pendingDelete == null + ) { item { EmptyStateCard() } } 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 b21eb0cc..98f247d1 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 @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import java.util.Calendar import java.util.Date +import java.util.UUID import javax.inject.Inject /** @@ -42,6 +43,7 @@ class MainViewModel private val alarmScheduler: AlarmScheduler, ) : MviViewModel(MainUiState()) { private var scheduleJob: Job? = null + private var deleteJob: Job? = null /** 지금 구독이 보고 있는 날. 날이 바뀌면 다시 건다(#171). */ private var subscribedDay: String = "" @@ -69,9 +71,17 @@ class MainViewModel } MainIntent.Retry -> { + if (!currentState.canRetry) return + val failedDelete = currentState.failedDelete dispatch(MainReducerEvent.ErrorConsumed) - observeTodaySchedules() - authRepository.currentUid?.let(::syncAlarms) + if (failedDelete != null) { + if (failedDelete.userId == currentState.currentUserId) { + deleteSchedule(failedDelete.schedule) + } + } else { + observeTodaySchedules() + authRepository.currentUid?.let(::syncAlarms) + } } is MainIntent.ToggleComplete -> { @@ -89,6 +99,7 @@ class MainViewModel } is MainIntent.RequestDelete -> { + if (currentState.pendingDelete != null) return currentState.todaySchedules .find { it.id == intent.scheduleId } ?.let { dispatch(MainReducerEvent.DeleteRequested(it)) } @@ -99,7 +110,7 @@ class MainViewModel } MainIntent.ConfirmDelete -> { - deleteSchedule() + currentState.scheduleToDelete?.let(::deleteSchedule) } is MainIntent.ToggleSharedReminderComplete -> { @@ -123,6 +134,14 @@ class MainViewModel MainIntent.ConsumeExactAlarmSettingsRequest -> { dispatch(MainReducerEvent.ExactAlarmSettingsRequestConsumed) } + + MainIntent.ExactAlarmSettingsUnavailable -> { + dispatch(MainReducerEvent.ExactAlarmSettingsFailed) + } + + MainIntent.ConsumeUserMessage -> { + dispatch(MainReducerEvent.UserMessageConsumed) + } } } @@ -132,7 +151,17 @@ class MainViewModel ): MainUiState = when (event) { is MainReducerEvent.UserResolved -> { - state.copy(currentUserId = event.userId, isSignedInKnown = true) + if (state.currentUserId != event.userId) { + // 직접 계정 전환에도 이전 계정의 삭제·재시도와 목록을 넘기지 않는다. + MainUiState( + currentUserId = event.userId, + isSignedInKnown = true, + showExactAlarmNotice = state.showExactAlarmNotice, + openExactAlarmSettings = state.openExactAlarmSettings, + ) + } else { + state.copy(isSignedInKnown = true) + } } MainReducerEvent.SignedOut -> { @@ -150,19 +179,29 @@ class MainViewModel canRetry = false, selectedScheduleForDetail = null, scheduleToDelete = null, + pendingDelete = null, + failedDelete = null, ) } MainReducerEvent.Loading -> { - state.copy(isLoading = true, error = null, canRetry = false) + state.copy( + isLoading = true, + error = if (state.failedDelete != null) state.error else null, + canRetry = state.failedDelete != null, + ) } is MainReducerEvent.SchedulesLoaded -> { - state.withSchedules(event.schedules, event.nowMillis).copy(isLoading = false, error = null) + state.withSchedules(event.schedules, event.nowMillis).copy( + isLoading = false, + error = if (state.failedDelete != null) state.error else null, + canRetry = state.failedDelete != null, + ) } is MainReducerEvent.LoadFailed -> { - state.copy(isLoading = false, error = event.error, canRetry = event.canRetry) + state.copy(isLoading = false, error = event.error, canRetry = event.canRetry, failedDelete = null) } is MainReducerEvent.CompletionToggled -> { @@ -188,14 +227,32 @@ class MainViewModel state.copy(scheduleToDelete = null) } - MainReducerEvent.Deleting -> { - state.copy(isLoading = true, error = null, scheduleToDelete = null) + is MainReducerEvent.Deleting -> { + state.copy( + error = null, + canRetry = false, + scheduleToDelete = null, + pendingDelete = event.operation, + failedDelete = null, + ) + } + + is MainReducerEvent.DeleteFailed -> { + if (state.pendingDelete == event.operation) { + state.copy(pendingDelete = null, failedDelete = event.operation, error = event.error, canRetry = true) + } else { + state + } } is MainReducerEvent.Deleted -> { - state - .withSchedules(state.todaySchedules.filter { it.id != event.scheduleId }, event.nowMillis) - .copy(isLoading = false) + if (state.pendingDelete == event.operation) { + state + .withSchedules(state.todaySchedules.filter { it.id != event.operation.schedule.id }, event.nowMillis) + .copy(pendingDelete = null) + } else { + state + } } is MainReducerEvent.SharedRemindersLoaded -> { @@ -216,7 +273,7 @@ class MainViewModel } MainReducerEvent.ErrorConsumed -> { - state.copy(error = null, canRetry = false) + state.copy(error = null, canRetry = false, failedDelete = null) } MainReducerEvent.ExactAlarmNoticeShown -> { @@ -234,6 +291,16 @@ class MainViewModel MainReducerEvent.ExactAlarmSettingsRequestConsumed -> { state.copy(openExactAlarmSettings = null) } + + MainReducerEvent.ExactAlarmSettingsFailed -> { + state.copy( + userMessage = "설정 화면을 열 수 없습니다. 기기 설정에서 느린 시계의 알람 권한을 확인해주세요", + ) + } + + MainReducerEvent.UserMessageConsumed -> { + state.copy(userMessage = null) + } } private fun MainUiState.withSchedules( @@ -262,6 +329,8 @@ class MainViewModel alarmSyncJob?.cancel() alarmSyncJob = null alarmSyncedFor = null + deleteJob?.cancel() + deleteJob = null dispatch(MainReducerEvent.UserResolved(uid.orEmpty())) if (uid != null) { observeTodaySchedules() @@ -392,22 +461,28 @@ class MainViewModel } } - private fun deleteSchedule() { - val schedule = currentState.scheduleToDelete ?: return - dispatch(MainReducerEvent.Deleting) - viewModelScope.launch { - when (val result = scheduleRepository.deleteSchedule(schedule.id)) { - is ScheduleRepository.ScheduleResult.Success -> { - runCatching { alarmScheduler.cancel(schedule) }.onFailure { Log.e(TAG, "알람 취소 실패", it) } - dispatch(MainReducerEvent.Deleted(schedule.id, System.currentTimeMillis())) - } + private fun deleteSchedule(schedule: Schedule) { + val uid = currentState.currentUserId + if (uid.isBlank() || authRepository.currentUid != uid || currentState.pendingDelete != null) return + val operation = DeleteOperation(schedule, uid, UUID.randomUUID().toString()) + dispatch(MainReducerEvent.Deleting(operation)) + deleteJob = + viewModelScope.launch { + val result = scheduleRepository.deleteSchedule(schedule.id) + currentCoroutineContext().ensureActive() + if (currentState.pendingDelete != operation || authRepository.currentUid != uid) return@launch + when (result) { + is ScheduleRepository.ScheduleResult.Success -> { + runCatching { alarmScheduler.cancel(schedule) }.onFailure { Log.e(TAG, "알람 취소 실패", it) } + dispatch(MainReducerEvent.Deleted(operation, System.currentTimeMillis())) + } - is ScheduleRepository.ScheduleResult.Error -> { - Log.e(TAG, "일정 삭제 실패: ${result.error.message}") - dispatch(MainReducerEvent.LoadFailed(result.error, canRetry = true)) + is ScheduleRepository.ScheduleResult.Error -> { + Log.e(TAG, "일정 삭제 실패: ${result.error.message}") + dispatch(MainReducerEvent.DeleteFailed(operation, result.error)) + } } } - } } private fun toggleSharedReminderComplete(scheduleId: String) { diff --git a/feature/main/src/main/java/com/example/slowclock/ui/main/components/ScheduleDetailDialog.kt b/feature/main/src/main/java/com/example/slowclock/ui/main/components/ScheduleDetailDialog.kt index 477224b1..0e58cec3 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/main/components/ScheduleDetailDialog.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/main/components/ScheduleDetailDialog.kt @@ -1,8 +1,10 @@ // app/src/main/java/com/example/slowclock/ui/main/components/ScheduleDetailDialog.kt package com.example.slowclock.ui.main.components +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -10,6 +12,9 @@ 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.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Notes import androidx.compose.material.icons.filled.AccessTime @@ -28,6 +33,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.example.slowclock.data.model.Schedule @@ -40,230 +46,245 @@ fun ScheduleDetailDialog( onDismiss: () -> Unit, onEdit: () -> Unit = {}, // 새로 추가 onDelete: () -> Unit = {}, // 새로 추가 +) { + Dialog(onDismissRequest = onDismiss) { + ScheduleDetailContent(schedule = schedule, onDismiss = onDismiss, onEdit = onEdit, onDelete = onDelete) + } +} + +/** Dialog 또는 프리뷰의 유한한 높이 안에서 모든 내용과 액션에 스크롤로 닿는다. */ +@Composable +internal fun ScheduleDetailContent( + schedule: Schedule, + onDismiss: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, + scrollState: ScrollState = rememberScrollState(), ) { val timeFormat = SimpleDateFormat("a h:mm", Locale.KOREAN) val dateFormat = SimpleDateFormat("M월 d일", Locale.KOREAN) + val actionMinWidth = 96.dp * LocalDensity.current.fontScale - Dialog(onDismissRequest = onDismiss) { - Card( - modifier = - Modifier - .fillMaxWidth() - .padding(20.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - shape = MaterialTheme.shapes.large, - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + Card( + modifier = + modifier + .fillMaxWidth() + .padding(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + shape = MaterialTheme.shapes.large, + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column( + modifier = Modifier.verticalScroll(scrollState).padding(28.dp), ) { - Column( - modifier = Modifier.padding(28.dp), + // 헤더 (제목 + 닫기 버튼) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - // 헤더 (제목 + 닫기 버튼) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + Text( + text = "일정 상세", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + ) + IconButton(onClick = onDismiss) { + Icon( + Icons.Default.Close, + contentDescription = "닫기", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + // 제목 (크게) + Text( + text = schedule.title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // 시간 정보 + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.AccessTime, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { Text( - text = "일정 상세", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.primary, + text = "${dateFormat.format(schedule.startTime.toDate())} ${ + timeFormat.format( + schedule.startTime.toDate(), + ) + }", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, ) - IconButton(onClick = onDismiss) { - Icon( - Icons.Default.Close, - contentDescription = "닫기", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(24.dp), + schedule.endTime?.let { endTime -> + Text( + text = "~ ${timeFormat.format(endTime.toDate())}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } + } + // 설명 (있을 때만) + if (schedule.description.isNotBlank()) { Spacer(modifier = Modifier.height(20.dp)) - - // 제목 (크게) - Text( - text = schedule.title, - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - ) - - Spacer(modifier = Modifier.height(24.dp)) - - // 시간 정보 Row( - verticalAlignment = Alignment.CenterVertically, + verticalAlignment = Alignment.Top, ) { Icon( - Icons.Default.AccessTime, + Icons.AutoMirrored.Filled.Notes, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, + tint = MaterialTheme.colorScheme.secondary, modifier = Modifier.size(24.dp), ) Spacer(modifier = Modifier.width(12.dp)) Column { Text( - text = "${dateFormat.format(schedule.startTime.toDate())} ${ - timeFormat.format( - schedule.startTime.toDate(), - ) - }", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - schedule.endTime?.let { endTime -> - Text( - text = "~ ${timeFormat.format(endTime.toDate())}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - - // 설명 (있을 때만) - if (schedule.description.isNotBlank()) { - Spacer(modifier = Modifier.height(20.dp)) - Row( - verticalAlignment = Alignment.Top, - ) { - Icon( - Icons.AutoMirrored.Filled.Notes, - contentDescription = null, - tint = MaterialTheme.colorScheme.secondary, - modifier = Modifier.size(24.dp), + text = "상세 내용", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Spacer(modifier = Modifier.width(12.dp)) - Column { - Text( - text = "상세 내용", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = schedule.description, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - lineHeight = MaterialTheme.typography.bodyLarge.lineHeight, - ) - } - } - } - - // 반복 일정 정보 (있을 때만) - if (schedule.recurring) { - Spacer(modifier = Modifier.height(20.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Default.Loop, - contentDescription = null, - tint = MaterialTheme.colorScheme.tertiary, - modifier = Modifier.size(24.dp), - ) - Spacer(modifier = Modifier.width(12.dp)) + Spacer(modifier = Modifier.height(8.dp)) Text( - text = - when (schedule.recurringType) { - "daily" -> "매일 반복" - "weekly" -> "매주 반복" - "monthly" -> "매월 반복" - else -> "반복 일정" - }, + text = schedule.description, style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.tertiary, + color = MaterialTheme.colorScheme.onSurface, + lineHeight = MaterialTheme.typography.bodyLarge.lineHeight, ) } } + } - // 완료 상태 + // 반복 일정 정보 (있을 때만) + if (schedule.recurring) { Spacer(modifier = Modifier.height(20.dp)) Row( verticalAlignment = Alignment.CenterVertically, ) { Icon( - Icons.Default.CheckCircle, + Icons.Default.Loop, contentDescription = null, - tint = - if (schedule.completed) { - MaterialTheme.colorScheme.secondary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + tint = MaterialTheme.colorScheme.tertiary, modifier = Modifier.size(24.dp), ) Spacer(modifier = Modifier.width(12.dp)) Text( - text = if (schedule.completed) "✅ 완료됨" else "⏳ 미완료", - style = MaterialTheme.typography.bodyLarge, - color = - if (schedule.completed) { - MaterialTheme.colorScheme.secondary - } else { - MaterialTheme.colorScheme.onSurfaceVariant + text = + when (schedule.recurringType) { + "daily" -> "매일 반복" + "weekly" -> "매주 반복" + "monthly" -> "매월 반복" + else -> "반복 일정" }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.tertiary, ) } + } - Spacer(modifier = Modifier.height(28.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - // 수정 버튼 - Button( - onClick = { - onEdit() - onDismiss() + // 완료 상태 + Spacer(modifier = Modifier.height(20.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = + if (schedule.completed) { + MaterialTheme.colorScheme.tertiary + } else { + MaterialTheme.colorScheme.onSurfaceVariant }, - modifier = Modifier.weight(1f), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text( - "수정", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onPrimary, - ) - } - - // 삭제 버튼 - Button( - onClick = { - onDelete() - onDismiss() + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = if (schedule.completed) "완료됨" else "미완료", + style = MaterialTheme.typography.bodyLarge, + color = + if (schedule.completed) { + MaterialTheme.colorScheme.tertiary + } else { + MaterialTheme.colorScheme.onSurfaceVariant }, - modifier = Modifier.weight(1f), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - ), - ) { - Text( - "삭제", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onError, - ) - } - } + ) + } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(28.dp)) - // 닫기 버튼 - TextButton( - onClick = onDismiss, - modifier = Modifier.fillMaxWidth(), + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // 수정 버튼 + Button( + onClick = { + onEdit() + onDismiss() + }, + modifier = Modifier.widthIn(min = actionMinWidth).weight(1f), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), ) { Text( - text = "닫기", + "수정", style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, // 색상 명시 + color = MaterialTheme.colorScheme.onPrimary, ) } + + // 삭제 버튼 + Button( + onClick = { + onDelete() + onDismiss() + }, + modifier = Modifier.widthIn(min = actionMinWidth).weight(1f), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text( + "삭제", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onError, + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // 닫기 버튼 + TextButton( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "닫기", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, // 색상 명시 + ) } } } diff --git a/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsContract.kt b/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsContract.kt index e438579f..3c1dee62 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsContract.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsContract.kt @@ -4,6 +4,7 @@ import com.example.slowclock.data.model.ThemeMode import com.example.slowclock.ui.mvi.MviIntent import com.example.slowclock.ui.mvi.ReducerEvent import com.example.slowclock.ui.mvi.UiState +import com.example.slowclock.util.AppError /** 정보 화면에서 사용자가 하려는 것. */ sealed interface SettingsIntent : MviIntent { @@ -18,6 +19,14 @@ sealed interface SettingsIntent : MviIntent { data object OpenFullScreenAlarmSettings : SettingsIntent data object ConsumeFullScreenAlarmSettingsRequest : SettingsIntent + + data object OpenMedicalNews : SettingsIntent + + data object ConsumeMedicalNewsRequest : SettingsIntent + + data object MedicalNewsUnavailable : SettingsIntent + + data object ConsumeError : SettingsIntent } /** 정보 화면의 단일 UI 상태. */ @@ -27,6 +36,8 @@ data class SettingsUiState( val showFullScreenAlarmNotice: Boolean = false, /** null 이 아니면 화면이 시스템 설정을 한 번 연다. */ val openFullScreenAlarmSettings: Unit? = null, + val openMedicalNews: Unit? = null, + val error: AppError? = null, ) : UiState /** 정보 화면의 상태가 겪은 것. */ @@ -42,4 +53,12 @@ sealed interface SettingsReducerEvent : ReducerEvent { data object FullScreenAlarmSettingsRequested : SettingsReducerEvent data object FullScreenAlarmSettingsRequestConsumed : SettingsReducerEvent + + data object MedicalNewsRequested : SettingsReducerEvent + + data object MedicalNewsRequestConsumed : SettingsReducerEvent + + data object MedicalNewsFailed : SettingsReducerEvent + + data object ErrorConsumed : SettingsReducerEvent } diff --git a/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsScreen.kt b/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsScreen.kt index dd5983fe..b01cad14 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsScreen.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsScreen.kt @@ -43,7 +43,9 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.slowclock.data.model.ThemeMode +import com.example.slowclock.ui.common.components.ErrorCard import com.example.slowclock.ui.common.components.ScreenHeader +import com.example.slowclock.ui.common.launchExternalActivity import com.example.slowclock.ui.mvi.ObserveSignal /** 메디컬타임즈 의료 뉴스 목록. 기사는 앱이 읽거나 저장하지 않고 브라우저로만 연다. */ @@ -69,6 +71,15 @@ fun SettingsScreen( ) { context.openFullScreenAlarmSettings() } + ObserveSignal( + signal = state.openMedicalNews, + consumed = SettingsIntent.ConsumeMedicalNewsRequest, + onIntent = viewModel::onIntent, + ) { + if (!launchExternalActivity(open = { context.startActivity(Intent(Intent.ACTION_VIEW, MEDICAL_NEWS_URL.toUri())) })) { + viewModel.onIntent(SettingsIntent.MedicalNewsUnavailable) + } + } SettingsContent(state = state, onIntent = viewModel::onIntent, modifier = modifier) } @@ -116,7 +127,10 @@ internal fun SettingsContent( FullScreenAlarmCard(onOpenSettings = { onIntent(SettingsIntent.OpenFullScreenAlarmSettings) }) } ThemeModeCard(selected = state.themeMode, onSelect = { onIntent(SettingsIntent.SelectThemeMode(it)) }) - MedicalNewsCard() + state.error?.let { error -> + ErrorCard(error = error, canRetry = false, onDismiss = { onIntent(SettingsIntent.ConsumeError) }) + } + MedicalNewsCard(onOpen = { onIntent(SettingsIntent.OpenMedicalNews) }) } } @@ -240,15 +254,15 @@ private fun FullScreenAlarmCard( } @Composable -private fun MedicalNewsCard(modifier: Modifier = Modifier) { - val context = LocalContext.current +private fun MedicalNewsCard( + onOpen: () -> Unit, + modifier: Modifier = Modifier, +) { Card( modifier = modifier .fillMaxWidth() - .clickable { - context.startActivity(Intent(Intent.ACTION_VIEW, MEDICAL_NEWS_URL.toUri())) - }, + .clickable(onClick = onOpen), shape = MaterialTheme.shapes.medium, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), diff --git a/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsViewModel.kt b/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsViewModel.kt index a6e777cb..f8a1d858 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsViewModel.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/settings/SettingsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.viewModelScope import com.example.slowclock.core.alarm.AlarmScheduler import com.example.slowclock.data.remote.repository.SettingsRepository import com.example.slowclock.ui.mvi.MviViewModel +import com.example.slowclock.util.AppError import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -45,6 +46,22 @@ class SettingsViewModel SettingsIntent.ConsumeFullScreenAlarmSettingsRequest -> { dispatch(SettingsReducerEvent.FullScreenAlarmSettingsRequestConsumed) } + + SettingsIntent.OpenMedicalNews -> { + dispatch(SettingsReducerEvent.MedicalNewsRequested) + } + + SettingsIntent.ConsumeMedicalNewsRequest -> { + dispatch(SettingsReducerEvent.MedicalNewsRequestConsumed) + } + + SettingsIntent.MedicalNewsUnavailable -> { + dispatch(SettingsReducerEvent.MedicalNewsFailed) + } + + SettingsIntent.ConsumeError -> { + dispatch(SettingsReducerEvent.ErrorConsumed) + } } } @@ -78,5 +95,23 @@ class SettingsViewModel SettingsReducerEvent.FullScreenAlarmSettingsRequestConsumed -> { state.copy(openFullScreenAlarmSettings = null) } + + SettingsReducerEvent.MedicalNewsRequested -> { + state.copy(openMedicalNews = Unit, error = null) + } + + SettingsReducerEvent.MedicalNewsRequestConsumed -> { + state.copy(openMedicalNews = null) + } + + SettingsReducerEvent.MedicalNewsFailed -> { + state.copy( + error = AppError.GeneralError("브라우저를 열 수 없습니다. 기기에 브라우저가 설치되어 있고 사용 가능한지 확인해주세요"), + ) + } + + SettingsReducerEvent.ErrorConsumed -> { + state.copy(error = null) + } } } diff --git a/feature/main/src/main/java/com/example/slowclock/ui/timeline/DaySwipeGesture.kt b/feature/main/src/main/java/com/example/slowclock/ui/timeline/DaySwipeGesture.kt new file mode 100644 index 00000000..c0e0cbc9 --- /dev/null +++ b/feature/main/src/main/java/com/example/slowclock/ui/timeline/DaySwipeGesture.kt @@ -0,0 +1,27 @@ +package com.example.slowclock.ui.timeline + +/** 한 번의 드래그에서 이동량을 누적하고 날짜 이동은 한 번만 낸다. */ +internal class DaySwipeGesture( + private val threshold: Float, +) { + private var distance = 0f + private var handled = false + + fun reset() { + distance = 0f + handled = false + } + + fun dragBy(delta: Float): TimelineIntent? { + if (handled) return null + distance += delta + val intent = + when { + distance > threshold -> TimelineIntent.PreviousDay + distance < -threshold -> TimelineIntent.NextDay + else -> return null + } + handled = true + return intent + } +} diff --git a/feature/main/src/main/java/com/example/slowclock/ui/timeline/TimelineScreen.kt b/feature/main/src/main/java/com/example/slowclock/ui/timeline/TimelineScreen.kt index 2427e3b8..1639bfb0 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/timeline/TimelineScreen.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/timeline/TimelineScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -32,6 +33,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.slowclock.ui.common.ScheduleLoadingIndicator import com.example.slowclock.ui.common.components.EmptyState import com.example.slowclock.ui.common.components.ErrorCard import com.example.slowclock.ui.common.components.ScreenHeader @@ -63,7 +65,7 @@ internal fun TimelineContent( modifier: Modifier = Modifier, ) { val context = LocalContext.current - var hasSwiped by remember { mutableStateOf(false) } + val currentOnIntent by rememberUpdatedState(onIntent) val selectedDate = state.selectedDate val dayText = rememberDayText(selectedDate.time) @@ -73,18 +75,13 @@ internal fun TimelineContent( .fillMaxSize() .padding(horizontal = 20.dp) .pointerInput(Unit) { + val gesture = DaySwipeGesture(SWIPE_THRESHOLD_PX) detectHorizontalDragGestures( - onDragStart = { hasSwiped = false }, + onDragStart = { gesture.reset() }, + onDragEnd = { gesture.reset() }, + onDragCancel = { gesture.reset() }, ) { _, dragAmount -> - if (!hasSwiped) { - if (dragAmount > SWIPE_THRESHOLD_PX) { - onIntent(TimelineIntent.PreviousDay) - hasSwiped = true - } else if (dragAmount < -SWIPE_THRESHOLD_PX) { - onIntent(TimelineIntent.NextDay) - hasSwiped = true - } - } + gesture.dragBy(dragAmount)?.let(currentOnIntent) } }, ) { @@ -106,7 +103,11 @@ internal fun TimelineContent( ) } + if (state.isLoading) { + ScheduleLoadingIndicator() + } if (state.schedules.isEmpty()) { + if (state.isLoading || state.error != null) return@Column EmptyState( icon = Icons.Outlined.Schedule, title = "이 날은 일정이 없습니다", diff --git a/feature/main/src/screenshotTest/java/com/example/slowclock/ui/LargeTextScreenshotTest.kt b/feature/main/src/screenshotTest/java/com/example/slowclock/ui/LargeTextScreenshotTest.kt new file mode 100644 index 00000000..8d7d0eb3 --- /dev/null +++ b/feature/main/src/screenshotTest/java/com/example/slowclock/ui/LargeTextScreenshotTest.kt @@ -0,0 +1,34 @@ +package com.example.slowclock.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.android.tools.screenshot.PreviewTest +import com.example.slowclock.ui.common.components.BottomNavigationBar +import com.example.slowclock.ui.common.components.ScheduleRow +import com.example.slowclock.ui.theme.SlowClockTheme +import java.util.Calendar + +@PreviewTest +@Preview(name = "일정 줄 큰 글자 320", widthDp = 320, heightDp = 240, fontScale = 2f) +@Preview(name = "일정 줄 큰 글자 360", widthDp = 360, heightDp = 240, fontScale = 2f) +@Composable +internal fun ScheduleRowLargeTextScreenshot() { + SlowClockTheme(darkTheme = false) { + ScheduleRow( + time = Calendar.getInstance().apply { set(2026, Calendar.SEPTEMBER, 6, 11, 59, 0) }.time, + title = "혈압약 먹기", + completed = false, + onToggleComplete = {}, + ) + } +} + +@PreviewTest +@Preview(name = "하단 탭 큰 글자 320", widthDp = 320, heightDp = 160, fontScale = 2f) +@Preview(name = "하단 탭 큰 글자 360", widthDp = 360, heightDp = 160, fontScale = 2f) +@Composable +internal fun BottomNavigationLargeTextScreenshot() { + SlowClockTheme(darkTheme = false) { + BottomNavigationBar(currentRoute = "timeline", onNavigate = {}) + } +} diff --git a/feature/main/src/screenshotTest/java/com/example/slowclock/ui/RecoveryScreenshotTest.kt b/feature/main/src/screenshotTest/java/com/example/slowclock/ui/RecoveryScreenshotTest.kt new file mode 100644 index 00000000..0ca2f0c7 --- /dev/null +++ b/feature/main/src/screenshotTest/java/com/example/slowclock/ui/RecoveryScreenshotTest.kt @@ -0,0 +1,140 @@ +package com.example.slowclock.ui + +import androidx.compose.foundation.ScrollState +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.android.tools.screenshot.PreviewTest +import com.example.slowclock.data.model.Schedule +import com.example.slowclock.ui.common.components.ErrorCard +import com.example.slowclock.ui.done.DoneContent +import com.example.slowclock.ui.done.DoneUiState +import com.example.slowclock.ui.main.MainContent +import com.example.slowclock.ui.main.MainUiState +import com.example.slowclock.ui.main.components.ScheduleDetailContent +import com.example.slowclock.ui.theme.SlowClockTheme +import com.example.slowclock.ui.timeline.TimelineContent +import com.example.slowclock.ui.timeline.TimelineUiState +import com.example.slowclock.util.AppError +import com.google.firebase.Timestamp +import java.util.Calendar + +private val recoveryDate = + Calendar.getInstance().apply { + set(2026, Calendar.SEPTEMBER, 6, 11, 59, 0) + set(Calendar.MILLISECOND, 0) + } + +@PreviewTest +@Preview(name = "메인 로딩", widthDp = 320, heightDp = 600) +@Composable +internal fun MainLoadingScreenshot() = MainRecoveryPreview(MainUiState(isLoading = true)) + +@PreviewTest +@Preview(name = "메인 조회 오류", widthDp = 320, heightDp = 600) +@Composable +internal fun MainErrorScreenshot() = MainRecoveryPreview(MainUiState(error = AppError.NetworkError, canRetry = true)) + +@Composable +private fun MainRecoveryPreview(state: MainUiState) { + SlowClockTheme(darkTheme = false) { + MainContent( + state = state.copy(currentUserId = "uid-1", isSignedInKnown = true), + onIntent = {}, + onAddSchedule = {}, + onEditSchedule = {}, + onNavigateToProfile = {}, + onNavigateToSettings = {}, + onSignIn = {}, + today = recoveryDate.time, + ) + } +} + +@PreviewTest +@Preview(name = "완료 로딩", widthDp = 320, heightDp = 420) +@Composable +internal fun DoneLoadingScreenshot() { + SlowClockTheme(darkTheme = false) { + DoneContent(state = DoneUiState(isLoading = true), onIntent = {}, today = recoveryDate.time) + } +} + +@PreviewTest +@Preview(name = "완료 조회 오류", widthDp = 320, heightDp = 600) +@Composable +internal fun DoneErrorScreenshot() { + SlowClockTheme(darkTheme = false) { + DoneContent(state = DoneUiState(error = AppError.NetworkError), onIntent = {}, today = recoveryDate.time) + } +} + +@PreviewTest +@Preview(name = "시간표 로딩", widthDp = 360, heightDp = 420) +@Composable +internal fun TimelineLoadingScreenshot() { + SlowClockTheme(darkTheme = false) { + TimelineContent(state = TimelineUiState(selectedDate = recoveryDate, isLoading = true), onIntent = {}) + } +} + +@PreviewTest +@Preview(name = "시간표 조회 오류", widthDp = 360, heightDp = 600) +@Composable +internal fun TimelineErrorScreenshot() { + SlowClockTheme(darkTheme = false) { + TimelineContent(state = TimelineUiState(selectedDate = recoveryDate, error = AppError.NetworkError), onIntent = {}) + } +} + +@PreviewTest +@Preview(name = "재시도 없는 오류 닫기", widthDp = 320, heightDp = 400, fontScale = 2f) +@Composable +internal fun DismissErrorLargeTextScreenshot() { + SlowClockTheme(darkTheme = false) { + ErrorCard(error = AppError.PermissionError, canRetry = false, onDismiss = {}) + } +} + +private val longDetail = + Schedule( + id = "detail", + title = "혈압약 먹기", + description = List(20) { "식사 후 물과 함께 복용해주세요." }.joinToString("\n"), + startTime = Timestamp(recoveryDate.time), + completed = true, + ) + +@PreviewTest +@Preview(name = "오류 재시도 큰 글자", widthDp = 320, heightDp = 600, fontScale = 2f) +@Composable +internal fun RetryErrorLargeTextScreenshot() { + SlowClockTheme(darkTheme = false) { + ErrorCard(error = AppError.NetworkError, canRetry = true, onRetry = {}, onDismiss = {}) + } +} + +@PreviewTest +@Preview(name = "상세 큰 글자 상단 320", widthDp = 320, heightDp = 640, fontScale = 2f) +@Preview(name = "상세 큰 글자 상단 360", widthDp = 360, heightDp = 640, fontScale = 2f) +@Composable +internal fun DetailTopLargeTextScreenshot() { + SlowClockTheme(darkTheme = false) { + ScheduleDetailContent(schedule = longDetail, onDismiss = {}, onEdit = {}, onDelete = {}) + } +} + +@PreviewTest +@Preview(name = "상세 큰 글자 하단 320", widthDp = 320, heightDp = 640, fontScale = 2f) +@Preview(name = "상세 큰 글자 하단 360", widthDp = 360, heightDp = 640, fontScale = 2f) +@Composable +internal fun DetailBottomLargeTextScreenshot() { + SlowClockTheme(darkTheme = false) { + ScheduleDetailContent( + schedule = longDetail, + onDismiss = {}, + onEdit = {}, + onDelete = {}, + scrollState = ScrollState(Int.MAX_VALUE), + ) + } +} diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/LargeTextScreenshotTestKt/BottomNavigationLargeTextScreenshot_\355\225\230\353\213\250 \355\203\255 \355\201\260 \352\270\200\354\236\220 320_03a6813e_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/LargeTextScreenshotTestKt/BottomNavigationLargeTextScreenshot_\355\225\230\353\213\250 \355\203\255 \355\201\260 \352\270\200\354\236\220 320_03a6813e_0.png" new file mode 100644 index 00000000..1d409c51 Binary files /dev/null and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/LargeTextScreenshotTestKt/BottomNavigationLargeTextScreenshot_\355\225\230\353\213\250 \355\203\255 \355\201\260 \352\270\200\354\236\220 320_03a6813e_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/LargeTextScreenshotTestKt/BottomNavigationLargeTextScreenshot_\355\225\230\353\213\250 \355\203\255 \355\201\260 \352\270\200\354\236\220 360_729bee77_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/LargeTextScreenshotTestKt/BottomNavigationLargeTextScreenshot_\355\225\230\353\213\250 \355\203\255 \355\201\260 \352\270\200\354\236\220 360_729bee77_0.png" new file mode 100644 index 00000000..028fca28 Binary files /dev/null and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/LargeTextScreenshotTestKt/BottomNavigationLargeTextScreenshot_\355\225\230\353\213\250 \355\203\255 \355\201\260 \352\270\200\354\236\220 360_729bee77_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/LargeTextScreenshotTestKt/ScheduleRowLargeTextScreenshot_\354\235\274\354\240\225 \354\244\204 \355\201\260 \352\270\200\354\236\220 320_8b0ca01c_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/LargeTextScreenshotTestKt/ScheduleRowLargeTextScreenshot_\354\235\274\354\240\225 \354\244\204 \355\201\260 \352\270\200\354\236\220 320_8b0ca01c_0.png" new file mode 100644 index 00000000..dd5c2a80 Binary files /dev/null and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/LargeTextScreenshotTestKt/ScheduleRowLargeTextScreenshot_\354\235\274\354\240\225 \354\244\204 \355\201\260 \352\270\200\354\236\220 320_8b0ca01c_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/LargeTextScreenshotTestKt/ScheduleRowLargeTextScreenshot_\354\235\274\354\240\225 \354\244\204 \355\201\260 \352\270\200\354\236\220 360_e3a223a5_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/LargeTextScreenshotTestKt/ScheduleRowLargeTextScreenshot_\354\235\274\354\240\225 \354\244\204 \355\201\260 \352\270\200\354\236\220 360_e3a223a5_0.png" new file mode 100644 index 00000000..f2b65da5 Binary files /dev/null and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/LargeTextScreenshotTestKt/ScheduleRowLargeTextScreenshot_\354\235\274\354\240\225 \354\244\204 \355\201\260 \352\270\200\354\236\220 360_e3a223a5_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/DetailBottomLargeTextScreenshot_\354\203\201\354\204\270 \355\201\260 \352\270\200\354\236\220 \355\225\230\353\213\250 320_c20cf7f1_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/DetailBottomLargeTextScreenshot_\354\203\201\354\204\270 \355\201\260 \352\270\200\354\236\220 \355\225\230\353\213\250 320_c20cf7f1_0.png" new file mode 100644 index 00000000..1985499d Binary files /dev/null and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/DetailBottomLargeTextScreenshot_\354\203\201\354\204\270 \355\201\260 \352\270\200\354\236\220 \355\225\230\353\213\250 320_c20cf7f1_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/DetailBottomLargeTextScreenshot_\354\203\201\354\204\270 \355\201\260 \352\270\200\354\236\220 \355\225\230\353\213\250 360_50c4da35_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/DetailBottomLargeTextScreenshot_\354\203\201\354\204\270 \355\201\260 \352\270\200\354\236\220 \355\225\230\353\213\250 360_50c4da35_0.png" new file mode 100644 index 00000000..1b907b01 Binary files /dev/null and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/DetailBottomLargeTextScreenshot_\354\203\201\354\204\270 \355\201\260 \352\270\200\354\236\220 \355\225\230\353\213\250 360_50c4da35_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/DetailTopLargeTextScreenshot_\354\203\201\354\204\270 \355\201\260 \352\270\200\354\236\220 \354\203\201\353\213\250 320_3ccd4f46_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/DetailTopLargeTextScreenshot_\354\203\201\354\204\270 \355\201\260 \352\270\200\354\236\220 \354\203\201\353\213\250 320_3ccd4f46_0.png" new file mode 100644 index 00000000..98b1ef53 Binary files /dev/null and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/DetailTopLargeTextScreenshot_\354\203\201\354\204\270 \355\201\260 \352\270\200\354\236\220 \354\203\201\353\213\250 320_3ccd4f46_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/DetailTopLargeTextScreenshot_\354\203\201\354\204\270 \355\201\260 \352\270\200\354\236\220 \354\203\201\353\213\250 360_8f635f36_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/DetailTopLargeTextScreenshot_\354\203\201\354\204\270 \355\201\260 \352\270\200\354\236\220 \354\203\201\353\213\250 360_8f635f36_0.png" new file mode 100644 index 00000000..93b6e5e5 Binary files /dev/null and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/DetailTopLargeTextScreenshot_\354\203\201\354\204\270 \355\201\260 \352\270\200\354\236\220 \354\203\201\353\213\250 360_8f635f36_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/DismissErrorLargeTextScreenshot_\354\236\254\354\213\234\353\217\204 \354\227\206\353\212\224 \354\230\244\353\245\230 \353\213\253\352\270\260_4350eab9_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/DismissErrorLargeTextScreenshot_\354\236\254\354\213\234\353\217\204 \354\227\206\353\212\224 \354\230\244\353\245\230 \353\213\253\352\270\260_4350eab9_0.png" new file mode 100644 index 00000000..a43cd9e2 Binary files /dev/null and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/DismissErrorLargeTextScreenshot_\354\236\254\354\213\234\353\217\204 \354\227\206\353\212\224 \354\230\244\353\245\230 \353\213\253\352\270\260_4350eab9_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/DoneErrorScreenshot_\354\231\204\353\243\214 \354\241\260\355\232\214 \354\230\244\353\245\230_f24b0917_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/DoneErrorScreenshot_\354\231\204\353\243\214 \354\241\260\355\232\214 \354\230\244\353\245\230_f24b0917_0.png" new file mode 100644 index 00000000..dde756e2 Binary files /dev/null and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/DoneErrorScreenshot_\354\231\204\353\243\214 \354\241\260\355\232\214 \354\230\244\353\245\230_f24b0917_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/DoneLoadingScreenshot_\354\231\204\353\243\214 \353\241\234\353\224\251_adc5e26a_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/DoneLoadingScreenshot_\354\231\204\353\243\214 \353\241\234\353\224\251_adc5e26a_0.png" new file mode 100644 index 00000000..caec8b35 Binary files /dev/null and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/DoneLoadingScreenshot_\354\231\204\353\243\214 \353\241\234\353\224\251_adc5e26a_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/MainErrorScreenshot_\353\251\224\354\235\270 \354\241\260\355\232\214 \354\230\244\353\245\230_b1bbe94e_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/MainErrorScreenshot_\353\251\224\354\235\270 \354\241\260\355\232\214 \354\230\244\353\245\230_b1bbe94e_0.png" new file mode 100644 index 00000000..4604014b Binary files /dev/null and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/MainErrorScreenshot_\353\251\224\354\235\270 \354\241\260\355\232\214 \354\230\244\353\245\230_b1bbe94e_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/MainLoadingScreenshot_\353\251\224\354\235\270 \353\241\234\353\224\251_bff103fd_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/MainLoadingScreenshot_\353\251\224\354\235\270 \353\241\234\353\224\251_bff103fd_0.png" new file mode 100644 index 00000000..ac70a2fc Binary files /dev/null and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/MainLoadingScreenshot_\353\251\224\354\235\270 \353\241\234\353\224\251_bff103fd_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/RetryErrorLargeTextScreenshot_\354\230\244\353\245\230 \354\236\254\354\213\234\353\217\204 \355\201\260 \352\270\200\354\236\220_d33ab22f_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/RetryErrorLargeTextScreenshot_\354\230\244\353\245\230 \354\236\254\354\213\234\353\217\204 \355\201\260 \352\270\200\354\236\220_d33ab22f_0.png" new file mode 100644 index 00000000..e03893cb Binary files /dev/null and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/RetryErrorLargeTextScreenshot_\354\230\244\353\245\230 \354\236\254\354\213\234\353\217\204 \355\201\260 \352\270\200\354\236\220_d33ab22f_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/TimelineErrorScreenshot_\354\213\234\352\260\204\355\221\234 \354\241\260\355\232\214 \354\230\244\353\245\230_2c012743_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/TimelineErrorScreenshot_\354\213\234\352\260\204\355\221\234 \354\241\260\355\232\214 \354\230\244\353\245\230_2c012743_0.png" new file mode 100644 index 00000000..8655b21f Binary files /dev/null and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/TimelineErrorScreenshot_\354\213\234\352\260\204\355\221\234 \354\241\260\355\232\214 \354\230\244\353\245\230_2c012743_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/TimelineLoadingScreenshot_\354\213\234\352\260\204\355\221\234 \353\241\234\353\224\251_1cba0baf_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/TimelineLoadingScreenshot_\354\213\234\352\260\204\355\221\234 \353\241\234\353\224\251_1cba0baf_0.png" new file mode 100644 index 00000000..d0f603cc Binary files /dev/null and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/RecoveryScreenshotTestKt/TimelineLoadingScreenshot_\354\213\234\352\260\204\355\221\234 \353\241\234\353\224\251_1cba0baf_0.png" differ diff --git a/feature/main/src/test/java/com/example/slowclock/ui/common/ExternalActivityLauncherTest.kt b/feature/main/src/test/java/com/example/slowclock/ui/common/ExternalActivityLauncherTest.kt new file mode 100644 index 00000000..a228c272 --- /dev/null +++ b/feature/main/src/test/java/com/example/slowclock/ui/common/ExternalActivityLauncherTest.kt @@ -0,0 +1,30 @@ +package com.example.slowclock.ui.common + +import android.content.ActivityNotFoundException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ExternalActivityLauncherTest { + @Test + fun `기본 화면이 열리면 대체 화면은 열지 않는다`() { + var calls = 0 + assertTrue(launchExternalActivity(open = { calls++ }, fallback = { error("unexpected fallback") })) + assertEquals(1, calls) + } + + @Test + fun `화면이 없으면 대체 설정을 연다`() { + var calls = 0 + assertTrue(launchExternalActivity(open = { throw ActivityNotFoundException() }, fallback = { calls++ })) + assertEquals(1, calls) + } + + @Test + fun `모든 화면이 없거나 정책으로 막혀도 실패를 반환한다`() { + assertFalse(launchExternalActivity(open = { throw ActivityNotFoundException() })) + assertFalse(launchExternalActivity(open = { throw SecurityException() })) + assertFalse(launchExternalActivity(open = { throw ActivityNotFoundException() }, fallback = { throw SecurityException() })) + } +} 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 fccb726a..04cb2a98 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 @@ -72,4 +72,17 @@ class DoneViewModelTest { viewModel.onIntent(DoneIntent.ConsumeError) assertNull(viewModel.uiState.value.error) } + + @Test + fun `반복 일정 완료와 취소는 표시된 발생일을 전달한다`() = + runTest { + todaySchedules.value = listOf(Schedule(id = "a", title = "산책", occurrenceDate = "2026-09-06")) + coEvery { scheduleRepository.markScheduleAsCompleted("a", any(), "2026-09-06") } returns + ScheduleRepository.ScheduleResult.Success(Unit) + val viewModel = DoneViewModel(scheduleRepository) + viewModel.onIntent(DoneIntent.ToggleComplete("a")) + viewModel.onIntent(DoneIntent.ToggleComplete("a")) + coVerify(exactly = 1) { scheduleRepository.markScheduleAsCompleted("a", true, "2026-09-06") } + coVerify(exactly = 1) { scheduleRepository.markScheduleAsCompleted("a", false, "2026-09-06") } + } } 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 427cf7ab..ae9b8084 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 @@ -432,4 +432,147 @@ class MainViewModelTest { verify(exactly = 0) { scheduleRepository.observeSchedulesForDate(any(), any()) } } + + @Test + fun `삭제 실패는 목록 갱신 뒤에도 같은 삭제를 재시도한다`() = + runTest { + todaySchedules.value = listOf(soon) + coEvery { scheduleRepository.deleteSchedule("s1") } returnsMany + listOf( + ScheduleRepository.ScheduleResult.Error(AppError.NetworkError), + ScheduleRepository.ScheduleResult.Success(Unit), + ) + val viewModel = createViewModel() + viewModel.onIntent(MainIntent.RequestDelete("s1")) + viewModel.onIntent(MainIntent.ConfirmDelete) + assertEquals( + "s1", + viewModel.uiState.value.failedDelete + ?.schedule + ?.id, + ) + verify(exactly = 0) { alarmScheduler.cancel(any()) } + + todaySchedules.value = listOf(soon, done) + assertEquals(AppError.NetworkError, viewModel.uiState.value.error) + assertTrue(viewModel.uiState.value.canRetry) + viewModel.onIntent(MainIntent.Retry) + + coVerify(exactly = 2) { scheduleRepository.deleteSchedule("s1") } + verify(exactly = 1) { alarmScheduler.cancel(soon) } + assertNull(viewModel.uiState.value.failedDelete) + assertNull(viewModel.uiState.value.pendingDelete) + assertEquals( + listOf("s2"), + viewModel.uiState.value.todaySchedules + .map { it.id }, + ) + } + + @Test + fun `삭제 중 목록 응답과 중복 확인이 와도 요청은 한 번이다`() = + runTest { + todaySchedules.value = listOf(soon) + val result = CompletableDeferred>() + coEvery { scheduleRepository.deleteSchedule("s1") } coAnswers { result.await() } + val viewModel = createViewModel() + viewModel.onIntent(MainIntent.RequestDelete("s1")) + viewModel.onIntent(MainIntent.ConfirmDelete) + todaySchedules.value = listOf(soon, done) + viewModel.onIntent(MainIntent.RequestDelete("s1")) + viewModel.onIntent(MainIntent.RequestDelete("s2")) + viewModel.onIntent(MainIntent.ConfirmDelete) + assertNotNull(viewModel.uiState.value.pendingDelete) + assertNull(viewModel.uiState.value.scheduleToDelete) + coVerify(exactly = 1) { scheduleRepository.deleteSchedule("s1") } + coVerify(exactly = 0) { scheduleRepository.deleteSchedule("s2") } + result.complete(ScheduleRepository.ScheduleResult.Success(Unit)) + assertNull(viewModel.uiState.value.pendingDelete) + assertNull(viewModel.uiState.value.scheduleToDelete) + } + + @Test + fun `삭제 실패를 닫으면 늦은 Retry 가 삭제를 다시 하지 않는다`() = + runTest { + todaySchedules.value = listOf(soon) + coEvery { scheduleRepository.deleteSchedule("s1") } returns ScheduleRepository.ScheduleResult.Error(AppError.NetworkError) + val viewModel = createViewModel() + viewModel.onIntent(MainIntent.RequestDelete("s1")) + viewModel.onIntent(MainIntent.ConfirmDelete) + viewModel.onIntent(MainIntent.ConsumeError) + viewModel.onIntent(MainIntent.Retry) + assertNull(viewModel.uiState.value.failedDelete) + coVerify(exactly = 1) { scheduleRepository.deleteSchedule("s1") } + } + + @Test + fun `계정이 바뀐 뒤 이전 삭제 응답은 새 목록과 알람을 건드리지 않는다`() = + runTest { + val uid = MutableStateFlow("uid-1") + every { authRepository.observeCurrentUid() } returns uid + every { authRepository.currentUid } answers { uid.value } + todaySchedules.value = listOf(soon) + val result = CompletableDeferred>() + coEvery { scheduleRepository.deleteSchedule("s1") } coAnswers { withContext(NonCancellable) { result.await() } } + val viewModel = createViewModel() + viewModel.onIntent(MainIntent.RequestDelete("s1")) + viewModel.onIntent(MainIntent.ConfirmDelete) + + uid.value = "uid-2" + val other = soon.copy(title = "새 계정 일정", userId = "uid-2") + todaySchedules.value = listOf(other) + result.complete(ScheduleRepository.ScheduleResult.Success(Unit)) + + assertEquals(listOf(other), viewModel.uiState.value.todaySchedules) + assertNull(viewModel.uiState.value.pendingDelete) + assertNull(viewModel.uiState.value.failedDelete) + assertNull(viewModel.uiState.value.error) + verify(exactly = 0) { alarmScheduler.cancel(any()) } + } + + @Test + fun `삭제 실패 뒤 계정 전환은 재시도 대상을 지운다`() = + runTest { + val uid = MutableStateFlow("uid-1") + every { authRepository.observeCurrentUid() } returns uid + every { authRepository.currentUid } answers { uid.value } + todaySchedules.value = listOf(soon) + coEvery { scheduleRepository.deleteSchedule("s1") } returns ScheduleRepository.ScheduleResult.Error(AppError.NetworkError) + val viewModel = createViewModel() + viewModel.onIntent(MainIntent.RequestDelete("s1")) + viewModel.onIntent(MainIntent.ConfirmDelete) + uid.value = "uid-2" + viewModel.onIntent(MainIntent.Retry) + assertNull(viewModel.uiState.value.failedDelete) + assertFalse(viewModel.uiState.value.canRetry) + coVerify(exactly = 1) { scheduleRepository.deleteSchedule("s1") } + } + + @Test + fun `반복 일정 완료와 취소는 표시된 발생일을 전달한다`() = + runTest { + val occurrence = soon.copy(occurrenceDate = "2026-09-06") + todaySchedules.value = listOf(occurrence) + coEvery { scheduleRepository.markScheduleAsCompleted("s1", any(), "2026-09-06") } returns + ScheduleRepository.ScheduleResult.Success(Unit) + val viewModel = createViewModel() + viewModel.onIntent(MainIntent.ToggleComplete("s1")) + viewModel.onIntent(MainIntent.ToggleComplete("s1")) + coVerify(exactly = 1) { scheduleRepository.markScheduleAsCompleted("s1", true, "2026-09-06") } + coVerify(exactly = 1) { scheduleRepository.markScheduleAsCompleted("s1", false, "2026-09-06") } + } + + @Test + fun `알람 설정을 열지 못하면 소비할 수 있는 안내를 낸다`() = + runTest { + val viewModel = createViewModel() + viewModel.onIntent(MainIntent.ExactAlarmSettingsUnavailable) + assertTrue( + viewModel.uiState.value.userMessage + .orEmpty() + .contains("설정 화면을 열 수 없습니다"), + ) + viewModel.onIntent(MainIntent.ConsumeUserMessage) + assertNull(viewModel.uiState.value.userMessage) + } } diff --git a/feature/main/src/test/java/com/example/slowclock/ui/settings/SettingsViewModelTest.kt b/feature/main/src/test/java/com/example/slowclock/ui/settings/SettingsViewModelTest.kt index 43666a6d..d7828dde 100644 --- a/feature/main/src/test/java/com/example/slowclock/ui/settings/SettingsViewModelTest.kt +++ b/feature/main/src/test/java/com/example/slowclock/ui/settings/SettingsViewModelTest.kt @@ -125,4 +125,23 @@ class SettingsViewModelTest { viewModel.onIntent(SettingsIntent.ConsumeFullScreenAlarmSettingsRequest) assertNull(viewModel.uiState.value.openFullScreenAlarmSettings) } + + @Test + fun `의료 링크 요청을 소비하고 열기 실패 안내를 닫는다`() = + runTest { + val viewModel = createViewModel() + viewModel.onIntent(SettingsIntent.OpenMedicalNews) + assertNotNull(viewModel.uiState.value.openMedicalNews) + viewModel.onIntent(SettingsIntent.ConsumeMedicalNewsRequest) + assertNull(viewModel.uiState.value.openMedicalNews) + viewModel.onIntent(SettingsIntent.MedicalNewsUnavailable) + assertTrue( + viewModel.uiState.value.error + ?.message + .orEmpty() + .contains("브라우저를 열 수 없습니다"), + ) + viewModel.onIntent(SettingsIntent.ConsumeError) + assertNull(viewModel.uiState.value.error) + } } diff --git a/feature/main/src/test/java/com/example/slowclock/ui/timeline/DaySwipeGestureTest.kt b/feature/main/src/test/java/com/example/slowclock/ui/timeline/DaySwipeGestureTest.kt new file mode 100644 index 00000000..b3b74f0e --- /dev/null +++ b/feature/main/src/test/java/com/example/slowclock/ui/timeline/DaySwipeGestureTest.kt @@ -0,0 +1,31 @@ +package com.example.slowclock.ui.timeline + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DaySwipeGestureTest { + @Test + fun `작은 이동을 누적해 양쪽 날짜로 한 번만 이동한다`() { + for ((delta, expected) in listOf(20f to TimelineIntent.PreviousDay, -20f to TimelineIntent.NextDay)) { + val gesture = DaySwipeGesture(100f) + repeat(5) { assertNull(gesture.dragBy(delta)) } + assertEquals(expected, gesture.dragBy(delta)) + assertNull(gesture.dragBy(delta * 20)) + assertNull(gesture.dragBy(-delta * 20)) + } + } + + @Test + fun `왕복 이동은 순이동으로 판단하고 새 제스처는 초기화한다`() { + val gesture = DaySwipeGesture(100f) + assertNull(gesture.dragBy(80f)) + assertNull(gesture.dragBy(-80f)) + assertNull(gesture.dragBy(50f)) + gesture.reset() + assertNull(gesture.dragBy(60f)) + assertEquals(TimelineIntent.PreviousDay, gesture.dragBy(60f)) + gesture.reset() + assertEquals(TimelineIntent.NextDay, gesture.dragBy(-120f)) + } +} 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 a8dfbe63..685b37d1 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 @@ -53,12 +53,22 @@ class TimelineViewModelTest { runTest { val viewModel = TimelineViewModel(scheduleRepository) + val requested = mutableListOf() + every { scheduleRepository.observeSchedulesForDate(capture(requested), any()) } returns + flowOf(listOf(Schedule(id = "a", title = "a"))) viewModel.onIntent(TimelineIntent.SelectDate(2026, Calendar.SEPTEMBER, 20)) val selected = viewModel.uiState.value.selectedDate assertEquals(2026, selected.get(Calendar.YEAR)) assertEquals(Calendar.SEPTEMBER, selected.get(Calendar.MONTH)) assertEquals(20, selected.get(Calendar.DAY_OF_MONTH)) + assertEquals(2026, requested.single().get(Calendar.YEAR)) + assertEquals(Calendar.SEPTEMBER, requested.single().get(Calendar.MONTH)) + assertEquals(20, requested.single().get(Calendar.DAY_OF_MONTH)) + assertEquals(0, requested.single().get(Calendar.HOUR_OF_DAY)) + assertEquals(0, requested.single().get(Calendar.MINUTE)) + assertEquals(0, requested.single().get(Calendar.SECOND)) + assertEquals(0, requested.single().get(Calendar.MILLISECOND)) assertEquals( listOf("a"), viewModel.uiState.value.schedules @@ -72,11 +82,16 @@ class TimelineViewModelTest { runTest { val viewModel = TimelineViewModel(scheduleRepository) val start = viewModel.uiState.value.selectedDate + val tomorrow = (start.clone() as Calendar).apply { add(Calendar.DAY_OF_MONTH, 1) } + val requested = mutableListOf() + every { scheduleRepository.observeSchedulesForDate(capture(requested), any()) } returns flowOf(emptyList()) viewModel.onIntent(TimelineIntent.NextDay) - assertEquals(start.timeInMillis + 24 * 60 * 60 * 1000L, viewModel.uiState.value.selectedDate.timeInMillis) + assertEquals(tomorrow.timeInMillis, viewModel.uiState.value.selectedDate.timeInMillis) + assertEquals(tomorrow.timeInMillis, requested.last().timeInMillis) viewModel.onIntent(TimelineIntent.PreviousDay) assertEquals(start.timeInMillis, viewModel.uiState.value.selectedDate.timeInMillis) + assertEquals(start.timeInMillis, requested.last().timeInMillis) } }