diff --git a/app/src/androidTest/java/com/example/slowclock/ThemeAndAlarmAccessibilityTest.kt b/app/src/androidTest/java/com/example/slowclock/ThemeAndAlarmAccessibilityTest.kt new file mode 100644 index 00000000..5668c15e --- /dev/null +++ b/app/src/androidTest/java/com/example/slowclock/ThemeAndAlarmAccessibilityTest.kt @@ -0,0 +1,101 @@ +package com.example.slowclock + +import android.app.UiModeManager +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.graphics.Bitmap +import android.widget.TextView +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.scrollTo +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.platform.app.InstrumentationRegistry +import com.example.slowclock.data.model.ThemeMode +import com.example.slowclock.data.remote.repository.SettingsRepository +import com.example.slowclock.ui.alarm.AlarmFullScreenActivity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import com.example.slowclock.core.alarm.R as AlarmR + +class ThemeAndAlarmAccessibilityTest { + @Test + fun applicationModeOverridesSystemAndCanReturnToSystem() { + val context = ApplicationProvider.getApplicationContext() + val settings = SettingsRepository(context) + val previous = settings.getThemeMode() + val systemNight = context.getSystemService(UiModeManager::class.java).nightMode + try { + settings.setThemeMode(ThemeMode.DARK) + awaitNightMode(context, Configuration.UI_MODE_NIGHT_YES) + assertEquals(0xFF14120F.toInt(), context.getColor(R.color.window_background)) + settings.setThemeMode(ThemeMode.LIGHT) + awaitNightMode(context, Configuration.UI_MODE_NIGHT_NO) + assertEquals(0xFFFAF6F0.toInt(), context.getColor(R.color.window_background)) + settings.setThemeMode(ThemeMode.SYSTEM) + if (systemNight == UiModeManager.MODE_NIGHT_YES || systemNight == UiModeManager.MODE_NIGHT_NO) { + awaitNightMode( + context, + if (systemNight == UiModeManager.MODE_NIGHT_YES) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO, + ) + } + } finally { + settings.setThemeMode(previous) + } + } + + // 글자 배율은 기기 전역 설정이라 계측 테스트에서 바꾸지 않는다. 큰 글자에서의 알람 화면 + // 확인은 실기기 몫으로 남긴다(#220). 여기서는 기본 배율의 표기와 도달 가능성만 본다. + @Test + fun alarmClockReadsTwelveHourTimeAndActionsStayReachable() { + val context = ApplicationProvider.getApplicationContext() + val intent = + Intent(context, AlarmFullScreenActivity::class.java) + .putExtra("title", "혈압약 먹기") + .putExtra("desc", "식사 후 물과 함께 복용해 주세요.") + ActivityScenario.launch(intent).use { scenario -> + onView(withId(AlarmR.id.currentTimeText)).check(matches(isDisplayed())) + scenario.onActivity { activity -> + val clock = activity.findViewById(AlarmR.id.currentTimeText) + assertTrue(clock.text.toString().matches(Regex("(오전|오후) [0-9]{1,2}:[0-9]{2}"))) + val layout = clock.layout + for (line in 0 until layout.lineCount) { + assertEquals(0, layout.getEllipsisCount(line)) + assertTrue(layout.getLineRight(line) <= clock.width - clock.totalPaddingLeft - clock.totalPaddingRight) + } + } + capture(context, "alarm-clock.png") + onView(withId(AlarmR.id.dismissButton)).perform(scrollTo()).check(matches(isDisplayed())) + capture(context, "alarm-actions.png") + } + } + + private fun awaitNightMode( + context: Context, + expected: Int, + ) { + val deadline = System.currentTimeMillis() + 5_000 + while ((context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) != expected && + System.currentTimeMillis() < deadline + ) { + Thread.sleep(50) + } + assertEquals(expected, context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) + } + + private fun capture( + context: Context, + name: String, + ) { + // 창 진입 애니메이션의 중간 프레임을 증빙으로 저장하지 않는다. + Thread.sleep(500) + val bitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot() + File(context.cacheDir, name).outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + bitmap.recycle() + } +} diff --git a/app/src/androidTest/java/com/example/slowclock/WindowAccessibilityTest.kt b/app/src/androidTest/java/com/example/slowclock/WindowAccessibilityTest.kt new file mode 100644 index 00000000..ba2f7441 --- /dev/null +++ b/app/src/androidTest/java/com/example/slowclock/WindowAccessibilityTest.kt @@ -0,0 +1,87 @@ +package com.example.slowclock + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.doesNotExist +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withClassName +import androidx.test.espresso.matcher.ViewMatchers.withId +import com.example.slowclock.ui.recommendation.RecommendationScreen +import com.example.slowclock.ui.theme.SlowClockTheme +import com.example.slowclock.ui.timeline.TimelineDatePickerDialog +import org.hamcrest.Matchers.endsWith +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import java.util.Calendar + +class WindowAccessibilityTest { + @get:Rule + val compose = createComposeRule() + + @Test + fun healthNoticeDoesNotPreventScrollingToStudentRecommendations() { + compose.setContent { + SlowClockTheme { + RecommendationScreen(onSelectRecommendation = {}, onNavigateBack = {}) + } + } + compose.onNodeWithText("학생").performClick() + // LazyColumn 은 보이지 않는 줄을 아직 만들지 않는다. performScrollTo 는 이미 만들어진 + // 줄만 찾으므로 화면이 짧거나 글자 배율이 높으면 노드를 못 찾고 실패한다. 목록을 + // 스크롤해 가며 찾는 performScrollToNode 를 쓴다. + compose.onNode(hasScrollAction()).performScrollToNode(hasText("자습")) + compose.onNodeWithText("자습").assertIsDisplayed() + } + + @Test + fun calendarIsRemovedWithItsScreenWithoutCallingDisposedCallbacks() { + var mounted by mutableStateOf(true) + var dismisses = 0 + compose.setContent { + SlowClockTheme { + if (mounted) { + TimelineDatePickerDialog(Calendar.getInstance(), { _, _, _ -> }, { dismisses++ }) + } + } + } + onView(withClassName(endsWith("DatePicker"))).check(matches(isDisplayed())) + compose.runOnIdle { mounted = false } + compose.waitForIdle() + onView(withClassName(endsWith("DatePicker"))).check(doesNotExist()) + compose.runOnIdle { assertEquals(0, dismisses) } + } + + @Test + fun calendarUsesCurrentCallbackWithoutOpeningAnotherWindow() { + var generation by mutableIntStateOf(0) + var selectedGeneration = -1 + var dismisses = 0 + compose.setContent { + val current = generation + TimelineDatePickerDialog( + Calendar.getInstance(), + { _, _, _ -> selectedGeneration = current }, + { dismisses++ }, + ) + } + compose.runOnIdle { generation = 1 } + onView(withId(android.R.id.button1)).perform(click()) + compose.runOnIdle { + assertEquals(1, selectedGeneration) + assertEquals(1, dismisses) + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 363cab3b..afd44c0d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -68,6 +68,7 @@ + + + #14120F + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 00000000..ce40abc4 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..65e15b65 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + + #FAF6F0 + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index e9f12e38..050cb99e 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,8 @@ - - + diff --git a/app/src/screenshotTest/java/com/example/slowclock/RecommendationLargeTextScreenshotTest.kt b/app/src/screenshotTest/java/com/example/slowclock/RecommendationLargeTextScreenshotTest.kt index 8a6f0cee..cb992daa 100644 --- a/app/src/screenshotTest/java/com/example/slowclock/RecommendationLargeTextScreenshotTest.kt +++ b/app/src/screenshotTest/java/com/example/slowclock/RecommendationLargeTextScreenshotTest.kt @@ -5,6 +5,7 @@ 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.recommendation.RecommendationScreen import com.example.slowclock.ui.theme.SlowClockTheme @PreviewTest @@ -18,3 +19,13 @@ internal fun RecommendationLargeTextScreenshot() { } } } + +@PreviewTest +@Preview(name = "추천 안내 360", widthDp = 360, heightDp = 800) +@Preview(name = "추천 안내 큰 글자 320", widthDp = 320, heightDp = 1000, fontScale = 2f) +@Composable +internal fun RecommendationHealthNoticeScreenshot() { + SlowClockTheme(darkTheme = false) { + RecommendationScreen(onSelectRecommendation = {}, onNavigateBack = {}) + } +} diff --git a/app/src/screenshotTest/java/com/example/slowclock/ScheduleInputScreenshotTest.kt b/app/src/screenshotTest/java/com/example/slowclock/ScheduleInputScreenshotTest.kt new file mode 100644 index 00000000..63cc58d2 --- /dev/null +++ b/app/src/screenshotTest/java/com/example/slowclock/ScheduleInputScreenshotTest.kt @@ -0,0 +1,45 @@ +package com.example.slowclock + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.android.tools.screenshot.PreviewTest +import com.example.slowclock.ui.addschedule.ScheduleTimeInput +import com.example.slowclock.ui.addschedule.components.TimePickerSection +import com.example.slowclock.ui.addschedule.components.TitleInputSection +import com.example.slowclock.ui.theme.SlowClockTheme + +@PreviewTest +@Preview(name = "입력 안내 큰 글자 밝게", widthDp = 320, heightDp = 1600, fontScale = 2f) +@Composable +internal fun ScheduleInputLightScreenshot() = ScheduleInputPreview(dark = false) + +@PreviewTest +@Preview(name = "입력 안내 큰 글자 어둡게", widthDp = 320, heightDp = 1600, fontScale = 2f) +@Composable +internal fun ScheduleInputDarkScreenshot() = ScheduleInputPreview(dark = true) + +@Composable +private fun ScheduleInputPreview(dark: Boolean) { + SlowClockTheme(darkTheme = dark) { + Surface(color = MaterialTheme.colorScheme.background) { + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp)) { + TitleInputSection(title = "", description = "", onTitleChange = {}, onDescriptionChange = {}) + TimePickerSection( + startTime = ScheduleTimeInput("14", "30"), + endTime = ScheduleTimeInput("25", "00"), + onTimeSelect = {}, + onEndTimeSelect = {}, + ) + } + } + } +} diff --git "a/app/src/screenshotTestDebug/reference/com/example/slowclock/RecommendationLargeTextScreenshotTestKt/RecommendationHealthNoticeScreenshot_\354\266\224\354\262\234 \354\225\210\353\202\264 360_8e1b16b0_0.png" "b/app/src/screenshotTestDebug/reference/com/example/slowclock/RecommendationLargeTextScreenshotTestKt/RecommendationHealthNoticeScreenshot_\354\266\224\354\262\234 \354\225\210\353\202\264 360_8e1b16b0_0.png" new file mode 100644 index 00000000..4b2bac49 Binary files /dev/null and "b/app/src/screenshotTestDebug/reference/com/example/slowclock/RecommendationLargeTextScreenshotTestKt/RecommendationHealthNoticeScreenshot_\354\266\224\354\262\234 \354\225\210\353\202\264 360_8e1b16b0_0.png" differ diff --git "a/app/src/screenshotTestDebug/reference/com/example/slowclock/RecommendationLargeTextScreenshotTestKt/RecommendationHealthNoticeScreenshot_\354\266\224\354\262\234 \354\225\210\353\202\264 \355\201\260 \352\270\200\354\236\220 320_0d6f6c2c_0.png" "b/app/src/screenshotTestDebug/reference/com/example/slowclock/RecommendationLargeTextScreenshotTestKt/RecommendationHealthNoticeScreenshot_\354\266\224\354\262\234 \354\225\210\353\202\264 \355\201\260 \352\270\200\354\236\220 320_0d6f6c2c_0.png" new file mode 100644 index 00000000..dcb51d33 Binary files /dev/null and "b/app/src/screenshotTestDebug/reference/com/example/slowclock/RecommendationLargeTextScreenshotTestKt/RecommendationHealthNoticeScreenshot_\354\266\224\354\262\234 \354\225\210\353\202\264 \355\201\260 \352\270\200\354\236\220 320_0d6f6c2c_0.png" differ diff --git "a/app/src/screenshotTestDebug/reference/com/example/slowclock/ScheduleInputScreenshotTestKt/ScheduleInputDarkScreenshot_\354\236\205\353\240\245 \354\225\210\353\202\264 \355\201\260 \352\270\200\354\236\220 \354\226\264\353\221\241\352\262\214_6d7d7fe0_0.png" "b/app/src/screenshotTestDebug/reference/com/example/slowclock/ScheduleInputScreenshotTestKt/ScheduleInputDarkScreenshot_\354\236\205\353\240\245 \354\225\210\353\202\264 \355\201\260 \352\270\200\354\236\220 \354\226\264\353\221\241\352\262\214_6d7d7fe0_0.png" new file mode 100644 index 00000000..b86b55e4 Binary files /dev/null and "b/app/src/screenshotTestDebug/reference/com/example/slowclock/ScheduleInputScreenshotTestKt/ScheduleInputDarkScreenshot_\354\236\205\353\240\245 \354\225\210\353\202\264 \355\201\260 \352\270\200\354\236\220 \354\226\264\353\221\241\352\262\214_6d7d7fe0_0.png" differ diff --git "a/app/src/screenshotTestDebug/reference/com/example/slowclock/ScheduleInputScreenshotTestKt/ScheduleInputLightScreenshot_\354\236\205\353\240\245 \354\225\210\353\202\264 \355\201\260 \352\270\200\354\236\220 \353\260\235\352\262\214_e4b84571_0.png" "b/app/src/screenshotTestDebug/reference/com/example/slowclock/ScheduleInputScreenshotTestKt/ScheduleInputLightScreenshot_\354\236\205\353\240\245 \354\225\210\353\202\264 \355\201\260 \352\270\200\354\236\220 \353\260\235\352\262\214_e4b84571_0.png" new file mode 100644 index 00000000..5f636fb9 Binary files /dev/null and "b/app/src/screenshotTestDebug/reference/com/example/slowclock/ScheduleInputScreenshotTestKt/ScheduleInputLightScreenshot_\354\236\205\353\240\245 \354\225\210\353\202\264 \355\201\260 \352\270\200\354\236\220 \353\260\235\352\262\214_e4b84571_0.png" differ diff --git a/core/alarm/src/main/java/com/example/slowclock/ui/alarm/AlarmFullScreenActivity.kt b/core/alarm/src/main/java/com/example/slowclock/ui/alarm/AlarmFullScreenActivity.kt index 56216c74..af3faa70 100644 --- a/core/alarm/src/main/java/com/example/slowclock/ui/alarm/AlarmFullScreenActivity.kt +++ b/core/alarm/src/main/java/com/example/slowclock/ui/alarm/AlarmFullScreenActivity.kt @@ -108,12 +108,13 @@ class AlarmFullScreenActivity : Activity() { } private fun updateCurrentTime(timeTextView: TextView) { + val formatter = SimpleDateFormat("a h:mm", Locale.KOREAN) timeRunnable = object : Runnable { override fun run() { - val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) - timeTextView.text = sdf.format(Date()) - timeHandler.postDelayed(this, 1000) + val now = System.currentTimeMillis() + timeTextView.text = formatter.format(Date(now)) + timeHandler.postDelayed(this, 60_000L - now % 60_000L) } } timeHandler.post(timeRunnable) @@ -150,7 +151,7 @@ class AlarmFullScreenActivity : Activity() { startService(AlarmTriggerService.dismissIntent(this, ringingRequestCode(), intent.getStringExtra(AlarmNotifications.EXTRA_TOKEN))) } - /** 화면이 안 보이면 시계를 멈춘다. 보이지 않는 화면을 1초마다 다시 그릴 이유가 없다(#131). */ + /** 화면이 안 보이면 시계를 멈춘다. 보이지 않는 화면을 다시 그릴 이유가 없다(#131). */ override fun onStop() { super.onStop() timeHandler.removeCallbacks(timeRunnable) diff --git a/core/alarm/src/main/res/layout/activity_alarm_fullscreen.xml b/core/alarm/src/main/res/layout/activity_alarm_fullscreen.xml index 92a8f317..b4ce9ebe 100644 --- a/core/alarm/src/main/res/layout/activity_alarm_fullscreen.xml +++ b/core/alarm/src/main/res/layout/activity_alarm_fullscreen.xml @@ -6,6 +6,7 @@ #107 에서 화면 밝기 선택에 같은 함정이 있었고 같은 방법으로 고쳤다(#132). --> diff --git a/core/alarm/src/main/res/values/themes.xml b/core/alarm/src/main/res/values/themes.xml new file mode 100644 index 00000000..dfc26799 --- /dev/null +++ b/core/alarm/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/core/data/src/main/java/com/example/slowclock/data/remote/repository/SettingsRepository.kt b/core/data/src/main/java/com/example/slowclock/data/remote/repository/SettingsRepository.kt index d17a9c2c..34e2c8db 100644 --- a/core/data/src/main/java/com/example/slowclock/data/remote/repository/SettingsRepository.kt +++ b/core/data/src/main/java/com/example/slowclock/data/remote/repository/SettingsRepository.kt @@ -1,5 +1,6 @@ package com.example.slowclock.data.remote.repository +import android.app.UiModeManager import android.content.Context import android.content.SharedPreferences import com.example.slowclock.data.model.ThemeMode @@ -22,6 +23,7 @@ class SettingsRepository @ApplicationContext context: Context, ) { private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val uiModeManager by lazy { context.getSystemService(UiModeManager::class.java) } fun getShareCode(): String? = prefs.getString(KEY_SHARE_CODE, null)?.takeIf { it.isNotBlank() } @@ -43,6 +45,27 @@ class SettingsRepository fun setThemeMode(mode: ThemeMode) { prefs.edit().putString(KEY_THEME_MODE, mode.name).apply() + applyThemeMode() + } + + /** + * Android 12 이상의 시작 창도 Compose에서 고른 테마를 따른다. + * https://developer.android.com/develop/ui/views/theming/darktheme + * + * SYSTEM 에 MODE_NIGHT_AUTO 를 주는 것은 상수 이름만 보면 어긋나 보인다. 문서상 AUTO 는 + * "위치·시간에 따라 자동 전환" 이지만, UiModeManagerService 는 YES/NO 가 아닌 값을 모두 + * UI_MODE_NIGHT_UNDEFINED 로 바꿔 앱별 override 를 지운다. 그래서 실제 동작이 "기기 설정을 + * 따른다" 가 된다. override 를 지우는 별도 API 는 없다. 이름만 보고 MODE_NIGHT_NO 로 + * 고치면 SYSTEM 이 항상 밝은 테마로 굳으니 바꾸지 말 것. + */ + fun applyThemeMode() { + val nightMode = + when (getThemeMode()) { + ThemeMode.SYSTEM -> UiModeManager.MODE_NIGHT_AUTO + ThemeMode.LIGHT -> UiModeManager.MODE_NIGHT_NO + ThemeMode.DARK -> UiModeManager.MODE_NIGHT_YES + } + uiModeManager?.setApplicationNightMode(nightMode) } /** 현재 값을 먼저 내고, 바뀔 때마다 다시 낸다. */ diff --git a/core/data/src/test/java/com/example/slowclock/data/SettingsThemeTest.kt b/core/data/src/test/java/com/example/slowclock/data/SettingsThemeTest.kt new file mode 100644 index 00000000..d03d0653 --- /dev/null +++ b/core/data/src/test/java/com/example/slowclock/data/SettingsThemeTest.kt @@ -0,0 +1,42 @@ +package com.example.slowclock.data + +import android.app.UiModeManager +import android.content.Context +import android.content.SharedPreferences +import com.example.slowclock.data.model.ThemeMode +import com.example.slowclock.data.remote.repository.SettingsRepository +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test + +class SettingsThemeTest { + private val preferences = mockk() + private val manager = mockk(relaxed = true) + private val context = + mockk { + every { getSharedPreferences(any(), any()) } returns preferences + every { getSystemService(UiModeManager::class.java) } returns manager + } + + @Test + fun savedDarkModeOverridesLightSystemBeforeFirstScreen() { + every { preferences.getString("theme_mode", null) } returns ThemeMode.DARK.name + SettingsRepository(context).applyThemeMode() + verify(exactly = 1) { manager.setApplicationNightMode(UiModeManager.MODE_NIGHT_YES) } + } + + @Test + fun systemModeClearsAppOverride() { + every { preferences.getString("theme_mode", null) } returns ThemeMode.SYSTEM.name + SettingsRepository(context).applyThemeMode() + verify(exactly = 1) { manager.setApplicationNightMode(UiModeManager.MODE_NIGHT_AUTO) } + } + + @Test + fun savedLightModeOverridesDarkSystem() { + every { preferences.getString("theme_mode", null) } returns ThemeMode.LIGHT.name + SettingsRepository(context).applyThemeMode() + verify(exactly = 1) { manager.setApplicationNightMode(UiModeManager.MODE_NIGHT_NO) } + } +} 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 018e8992..df533e48 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 @@ -157,7 +157,10 @@ private fun ScheduleRowSurface( content: @Composable () -> Unit, ) { val color = if (completed) MaterialTheme.colorScheme.surfaceContainerLow else MaterialTheme.colorScheme.surface - val border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + // 어두운 모드에서는 surface 와 background 가 같은 값이라 칸을 가르는 것이 이 선 하나뿐이다. + // outlineVariant 는 배경 대비 2.02:1 로 WCAG 1.4.11 의 3:1 에 못 미쳐 칸 경계가 보이지 않았다. + // outline 은 같은 자리에서 5.84:1(어두운 모드)·4.13:1(밝은 모드)이다. + val border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline) if (onClick == null) { Surface(modifier = modifier, shape = MaterialTheme.shapes.medium, color = color, border = border, content = content) } else { diff --git a/docs/play-release.md b/docs/play-release.md index 3b20fc5c..ae3f8827 100644 --- a/docs/play-release.md +++ b/docs/play-release.md @@ -69,7 +69,7 @@ PR #199에서 Functions의 `qs`를 `6.16.0`으로 제한 업데이트하고 Node | 광고 | 광고 없음 | 광고 SDK 없음, `AD_ID` 권한 제거 | | 앱 액세스 | 일부 기능 제한(로그인 필요). 안내문: "Google 계정으로 로그인하면 일정·알람·공유 코드 열람을 사용할 수 있습니다. 별도 가입 승인, 유료 구독이나 초대 제한은 없습니다. 공유를 확인하려면 한 계정의 내 정보에 표시되는 코드를 다른 계정의 공유 화면에 입력하세요." | Firebase UI Google 로그인만 제공. 현재 동작하지 않는 보호자 푸시까지 모든 기능 사용 가능 문구로 약속하지 않음 | | 콘텐츠 등급 | 앱 유형 「유틸리티·생산성·커뮤니케이션·기타」. 폭력·성적 내용·욕설·약물·도박 전부 「아니오」. 사용자 간 상호작용 「예」(공유 코드를 받은 사람이 그 사람의 일정과 이름을 본다), 개인정보 공유 「예」(이름·일정을 본인이 코드를 알려 준 사람과 공유), 위치 공유 「아니오」, 디지털 구매 「아니오」 | 공유 코드 기능(정보 화면). 가족 그룹을 만드는 화면은 앱에 없다 — `familyGroups` 컬렉션은 계정 삭제 때 정리하는 용도로만 남아 있다 | -| 타겟층 | 18세 이상만 선택. 어린이 대상 아님 | 어르신·보호자용 앱 | +| 타겟층 | 18세 이상만 선택. 어린이 대상 아님 | 어르신·보호자용 앱. 추천 화면의 「학생」 묶음(자습·공부량 확인·휴식하기·복습하기)을 이 선언과 대조했다(#220). 넷 다 성인 학습자도 그대로 쓰는 말이고 교복·학교·학년처럼 미성년을 가리키는 표현, 아동 대상 소재, 학교 일과 전제가 없어 문구를 바꾸지 않았다 | | 뉴스 앱 | 아니오 | 정보 탭은 메디컬타임즈로 이동하는 버튼 하나뿐이고 기사를 앱 안에 표시하지 않는다(#51) | | 정부 앱 | 아니오 | | | 금융 기능 | 없음 | | @@ -93,14 +93,11 @@ Analytics 계정 데이터 공유는 Google 제품 및 서비스 꺼짐, 참여 ### 스크린샷 촬영 절차 -휴대전화 스크린샷은 2장 이상 8장 이하, 1080×2400(9:20) PNG 로 낸다. 로그인된 기기가 필요하다. +휴대전화 스크린샷은 2~8장이다. 현재 준비한 4장은 실제 화면 코드의 Preview를 Linux CI에서 1080×1920(9:16)으로 렌더한다. 계정에 접속하거나 운영 일정을 만들 필요가 없다. 이미지 위치·순서·재생성 절차는 [스토어 등록 소재](play/listing.md#스토어-등록-정보-자료)를 따른다. -1. 에뮬레이터 `Pixel_7_Claude_QA`(1080×2400, Play Store 이미지)에 Google 계정으로 로그인한다. -2. `./gradlew :app:installDebug` 로 설치하고 앱을 열어 Google 로그인을 마친다. -3. 첫 실행에 정확한 알람 권한 안내가 한 번 뜬다(#83). 「설정 열기」 로 허용해 두면 다음 실행부터 나오지 않는다. -4. 오늘 날짜로 일정을 서너 개 만든다. 하나는 완료로 표시한다. 더미 데이터는 #59 에서 지웠기 때문에 직접 넣어야 화면이 채워진다. -5. 화면마다 `adb exec-out screencap -p > docs/play/screenshot-.png` 로 찍는다. 순서: 메인(오늘 일정과 현재 할 일), 일정 추가, 타임라인, 완료, 알람 전체 화면. -6. 개인 정보(이메일·이름)가 보이는 화면은 테스트 계정으로 찍는다. +[Play 공식 규격](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en-GB)은 JPEG 또는 24비트 PNG(알파 없음), 짧은 변 320px 이상·긴 변 3840px 이하·긴 변이 짧은 변의 2배 이하를 요구한다. 앱 홍보에는 1080px 이상과 세로 9:16을 권장한다. 이전 1080×2400(9:20) 안내는 이 비율 요건에 맞지 않아 교체했다. + +추가 동작 증빙이 필요하면 전용 에뮬레이터와 테스트 데이터만 사용한다. 개인 정보가 보이는 화면은 제외한다. Play Console 업로드·미리보기 확인과 실물 기기 점검은 별도 단계다. ## AAB 빌드와 로컬 검증 diff --git a/docs/play/ic_launcher_512.png b/docs/play/ic_launcher_512.png index 55853f1a..5fed4ee5 100644 Binary files a/docs/play/ic_launcher_512.png and b/docs/play/ic_launcher_512.png differ diff --git a/docs/play/listing.md b/docs/play/listing.md index 0437dc7c..d44aa027 100644 --- a/docs/play/listing.md +++ b/docs/play/listing.md @@ -48,11 +48,13 @@ ## 스토어 등록 정보 자료 +규격은 [Play 미리보기 자료 사양](https://support.google.com/googleplay/android-developer/answer/9866151)의 요구를 그대로 적은 것이다. 아이콘만 32비트(알파 포함)를 요구하고, 그래픽 이미지와 스크린샷은 알파가 없는 24비트를 요구한다. + | 자료 | 파일 | 규격 | |---|---|---| -| 앱 아이콘 | `docs/play/ic_launcher_512.png` | 512×512 PNG, 투명 없음 | -| 그래픽 이미지 | `docs/play/feature_graphic_1024x500.png` | 1024×500 PNG | -| 휴대전화 스크린샷 | `docs/play/screenshots/` 4장 | 2~8장, 1080×2400 PNG | +| 앱 아이콘 | `docs/play/ic_launcher_512.png` | 512×512, 32비트 PNG(알파 포함), 1024KB 이하. 그림 자체는 불투명하다 | +| 그래픽 이미지 | `docs/play/feature_graphic_1024x500.png` | 1024×500, 24비트 PNG(알파 없음) | +| 휴대전화 스크린샷 | `docs/play/screenshots/` 4장 | 2~8장, 1080×1920, 24비트 PNG(알파 없음). 긴 변이 짧은 변의 2배를 넘으면 안 된다 | 올릴 순서와 파일이다. @@ -63,20 +65,29 @@ | 3 | `docs/play/screenshots/02-done.png` | 완료한 일 | | 4 | `docs/play/screenshots/04-dark.png` | 어두운 모드 | -네 장은 앱의 화면 코드를 그대로 그려 만들었다. 만드는 자리는 `feature/main/src/screenshotTest/.../StoreScreenshotTest.kt` 이고, 렌더 결과는 그 모듈의 baseline 으로 먼저 들어간다. 여기 있는 넉 장은 그 baseline 을 옮겨 온 사본이다. +네 장은 앱의 화면 코드를 그대로 그려 만들었다. 만드는 자리는 `feature/main/src/screenshotTest/.../StoreScreenshotTest.kt` 이고, 렌더 결과는 그 모듈의 baseline 으로 먼저 들어간다. 여기 있는 넉 장은 그 baseline의 RGB 픽셀을 그대로 보존한 24비트 PNG 내보내기다. -baseline 이 바뀌면 이 사본도 함께 옮겨야 한다. 화면을 고쳤는데 옮기지 않으면 스토어 그림만 옛 화면으로 남는다(#155). 옮기는 자리는 이 넷이다. +baseline이 바뀌면 이 사본도 함께 다시 내보내야 한다. 화면을 고쳤는데 옮기지 않으면 스토어 그림만 옛 화면으로 남는다(#155). 옮기는 자리는 이 넷이다. | baseline | 여기 | |---|---| -| `StoreMainScreenshot_스토어 메인_768e228a_0.png` | `01-main.png` | -| `StoreDoneScreenshot_스토어 완료_9c836483_0.png` | `02-done.png` | -| `StoreTimelineScreenshot_스토어 시간표_1683b525_0.png` | `03-timeline.png` | -| `StoreDarkScreenshot_스토어 어두운 모드_dde69d6e_0.png` | `04-dark.png` | +| `StoreMainScreenshot_스토어 메인_67c6756c_0.png` | `01-main.png` | +| `StoreDoneScreenshot_스토어 완료_eef9ef9e_0.png` | `02-done.png` | +| `StoreTimelineScreenshot_스토어 시간표_d0dba646_0.png` | `03-timeline.png` | +| `StoreDarkScreenshot_스토어 어두운 모드_665a3c63_0.png` | `04-dark.png` | + +baseline 은 macOS 렌더와 CI(Linux) 렌더가 달라 로컬에서 만들지 않는다. PR 에 `screenshot-baseline` 라벨을 붙여 CI 컨테이너가 갱신하게 한 뒤, 그 결과를 받은 뒤 `scripts/export-play-screenshots.sh`를 실행한다. 기존 PNG에 투명한 픽셀이 하나라도 있으면 내보내기를 거부한다. `scripts/export-play-screenshots.sh --check`는 1080×1920·24비트·알파 없음 및 CI baseline과 모든 RGB 픽셀이 같은지 검사한다. JDK에 포함된 ImageIO만 사용하므로 추가 라이브러리가 필요 없다. + +알람 전체 화면과 공유 코드 화면은 선택적인 추가 소재다. 현재 네 장으로 최소 장수 조건을 충족하며, 추가 촬영이 필요하면 전용 에뮬레이터와 테스트 데이터만 사용한다. 계정·개인 정보가 포함된 화면을 스토어 소재로 사용하지 않는다. -baseline 은 macOS 렌더와 CI(Linux) 렌더가 달라 로컬에서 만들지 않는다. PR 에 `screenshot-baseline` 라벨을 붙여 CI 컨테이너가 갱신하게 한 뒤, 그 결과를 받아 위 표대로 옮긴다. +### 대체 텍스트 -알람이 울리는 전체 화면과 공유 코드 화면은 남았다. 실제 동작과 계정이 있어야 자연스러워서 기기에서 찍는다. 에뮬레이터 `Pixel_7_Claude_QA`(1080×2400) 에 Google 계정으로 로그인한 뒤 `adb exec-out screencap -p > 파일.png` 로 찍는다. Play 는 두 장부터 받으므로 이 두 장이 없어도 등록은 막히지 않는다. +- 아이콘: 시계 바늘과 하트로 표현한 느린 시계 앱 아이콘. +- 그래픽: 느린 시계. 큰 글씨로 보는 오늘의 일정과 정시 알람. +- 메인: 오늘 할 일과 진행 상황, 다음 일정의 시간과 제목을 보여 주는 화면. +- 시간표: 선택한 날짜의 일정을 시간 순서대로 보여 주는 화면. +- 완료: 오늘 완료한 일정과 진행 상태를 확인하는 화면. +- 어두운 모드: 어두운 배경에서 오늘 할 일과 다음 일정을 확인하는 화면. ## 연락처 diff --git a/docs/play/screenshots/01-main.png b/docs/play/screenshots/01-main.png index 47fa6038..66a64346 100644 Binary files a/docs/play/screenshots/01-main.png and b/docs/play/screenshots/01-main.png differ diff --git a/docs/play/screenshots/02-done.png b/docs/play/screenshots/02-done.png index d4d1711c..20ff3f64 100644 Binary files a/docs/play/screenshots/02-done.png and b/docs/play/screenshots/02-done.png differ diff --git a/docs/play/screenshots/03-timeline.png b/docs/play/screenshots/03-timeline.png index f07f8d0d..1251bbe4 100644 Binary files a/docs/play/screenshots/03-timeline.png and b/docs/play/screenshots/03-timeline.png differ diff --git a/docs/play/screenshots/04-dark.png b/docs/play/screenshots/04-dark.png index 43acab74..15910b03 100644 Binary files a/docs/play/screenshots/04-dark.png and b/docs/play/screenshots/04-dark.png differ diff --git a/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/TimePickerSection.kt b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/TimePickerSection.kt index a4e53e42..d70b8057 100644 --- a/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/TimePickerSection.kt +++ b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/TimePickerSection.kt @@ -21,12 +21,20 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.example.slowclock.feature.addschedule.R import com.example.slowclock.ui.addschedule.ScheduleTimeInput +import com.example.slowclock.ui.common.components.rememberTimeText +import java.util.Calendar @Composable fun TimePickerSection( @@ -40,10 +48,15 @@ fun TimePickerSection( modifier = modifier .fillMaxWidth() - .padding(24.dp), + .padding(vertical = 24.dp), verticalArrangement = Arrangement.spacedBy(24.dp), ) { Text("시간 설정", style = MaterialTheme.typography.titleLarge) + Text( + stringResource(R.string.schedule_time_format_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) // 시작 시간 Card( @@ -67,6 +80,7 @@ fun TimePickerSection( onTimeSelect(startTime.copy(hour = it.filter { c -> c.isDigit() }.take(2))) }, label = { Text("시") }, + isError = !startTime.isValid, modifier = Modifier.weight(1f), textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), singleLine = true, @@ -79,12 +93,14 @@ fun TimePickerSection( onTimeSelect(startTime.copy(minute = it.filter { c -> c.isDigit() }.take(2))) }, label = { Text("분") }, + isError = !startTime.isValid, modifier = Modifier.weight(1f), textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), singleLine = true, keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number), ) } + TimeInputFeedback(startTime, required = true) } } @@ -110,6 +126,7 @@ fun TimePickerSection( onEndTimeSelect(endTime.copy(hour = it.filter { c -> c.isDigit() }.take(2))) }, label = { Text("시") }, + isError = !endTime.isEmpty && !endTime.isValid, modifier = Modifier.weight(1f), textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), singleLine = true, @@ -122,13 +139,49 @@ fun TimePickerSection( onEndTimeSelect(endTime.copy(minute = it.filter { c -> c.isDigit() }.take(2))) }, label = { Text("분") }, + isError = !endTime.isEmpty && !endTime.isValid, modifier = Modifier.weight(1f), textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), singleLine = true, keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number), ) } + TimeInputFeedback(endTime, required = false) } } } } + +/** + * 입력한 시각을 글로 되읽어 준다. [required] 인 시작 시간은 비어 있어도 안내를 내야 한다. + * 종전에는 시작 시간을 다 지우면 저장 버튼만 잠기고 화면 어디에도 이유가 없었다. + * + * liveRegion 을 두는 이유: 안내가 칸의 형제 Text 라 화면 낭독기가 칸에 머무는 동안에는 읽지 + * 않는다. 값이 바뀔 때 스스로 읽히게 해야 눈으로 보지 않는 사람도 되읽기를 받는다. + */ +@Composable +private fun TimeInputFeedback( + input: ScheduleTimeInput, + required: Boolean, +) { + val date = input.onDate(remember { Calendar.getInstance() }) + val feedbackModifier = + Modifier + .padding(top = 8.dp) + .semantics { liveRegion = LiveRegionMode.Polite } + if (date != null) { + Text( + stringResource(R.string.schedule_time_readback, rememberTimeText(date.time)), + modifier = feedbackModifier, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (required || !input.isEmpty) { + Text( + stringResource(R.string.schedule_time_range_hint), + modifier = feedbackModifier, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } +} diff --git a/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/TitleInputSection.kt b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/TitleInputSection.kt index 64cdb183..3b58d61c 100644 --- a/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/TitleInputSection.kt +++ b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/components/TitleInputSection.kt @@ -12,7 +12,9 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.example.slowclock.feature.addschedule.R @Composable fun TitleInputSection( @@ -28,8 +30,10 @@ fun TitleInputSection( elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), ) { Column(modifier = Modifier.padding(24.dp)) { + // 필수 표시는 머리글이 지고 칸에는 label 을 두지 않는다. label 을 두면 Material3 가 + // 빈 칸에서 placeholder 를 감춰, 처음 보는 화면에서 "무엇을 할까요?" 가 사라진다. Text( - text = "할 일", + text = stringResource(R.string.schedule_title_required), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 16.dp), @@ -37,6 +41,9 @@ fun TitleInputSection( OutlinedTextField( value = title, onValueChange = onTitleChange, + // 저장 조건은 빈칸일 때만 알리지 않는다. 글자를 처음 넣는 순간 안내가 사라지면 + // 그 높이만큼 아래 칸들이 딸려 올라온다. 큰 글자에서는 100dp 넘게 튄다. + supportingText = { Text(stringResource(R.string.schedule_title_required_hint)) }, placeholder = { Text( "무엇을 할까요?", diff --git a/feature/addschedule/src/main/res/values/strings.xml b/feature/addschedule/src/main/res/values/strings.xml index a4986150..d253bec4 100644 --- a/feature/addschedule/src/main/res/values/strings.xml +++ b/feature/addschedule/src/main/res/values/strings.xml @@ -2,4 +2,9 @@ 알람 다시 예약 목록으로 돌아가기 + 할 일 (필수) + 할 일을 적어야 저장할 수 있습니다. + 24시간제로 입력해 주세요. 오후 2시는 14시입니다. + 입력한 시간: %1$s + 시는 0~23, 분은 0~59를 모두 입력해 주세요. diff --git a/feature/main/src/main/java/com/example/slowclock/ui/timeline/TimelineDatePickerDialog.kt b/feature/main/src/main/java/com/example/slowclock/ui/timeline/TimelineDatePickerDialog.kt new file mode 100644 index 00000000..57b462f1 --- /dev/null +++ b/feature/main/src/main/java/com/example/slowclock/ui/timeline/TimelineDatePickerDialog.kt @@ -0,0 +1,38 @@ +package com.example.slowclock.ui.timeline + +import android.app.DatePickerDialog +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext +import java.util.Calendar + +/** 화면을 떠나거나 Activity가 바뀌면 달력 창도 함께 닫는다. */ +@Composable +fun TimelineDatePickerDialog( + selectedDate: Calendar, + onSelectDate: (Int, Int, Int) -> Unit, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val currentOnSelectDate by rememberUpdatedState(onSelectDate) + val currentOnDismiss by rememberUpdatedState(onDismiss) + DisposableEffect(context) { + val dialog = + DatePickerDialog( + context, + { _, year, month, day -> currentOnSelectDate(year, month, day) }, + selectedDate.get(Calendar.YEAR), + selectedDate.get(Calendar.MONTH), + selectedDate.get(Calendar.DAY_OF_MONTH), + ) + dialog.setOnDismissListener { currentOnDismiss() } + dialog.show() + onDispose { + // 폐기된 화면의 상태를 뒤늦게 바꾸지 않는다. + dialog.setOnDismissListener(null) + dialog.dismiss() + } + } +} 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 1639bfb0..68798ff8 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 @@ -1,7 +1,5 @@ package com.example.slowclock.ui.timeline -import android.app.DatePickerDialog -import android.widget.DatePicker import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -25,11 +23,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -38,7 +36,6 @@ import com.example.slowclock.ui.common.components.EmptyState import com.example.slowclock.ui.common.components.ErrorCard import com.example.slowclock.ui.common.components.ScreenHeader import com.example.slowclock.ui.common.components.rememberDayText -import java.util.Calendar private const val SWIPE_THRESHOLD_PX = 100f @@ -64,7 +61,7 @@ internal fun TimelineContent( onIntent: (TimelineIntent) -> Unit, modifier: Modifier = Modifier, ) { - val context = LocalContext.current + var showCalendar by rememberSaveable { mutableStateOf(false) } val currentOnIntent by rememberUpdatedState(onIntent) val selectedDate = state.selectedDate val dayText = rememberDayText(selectedDate.time) @@ -91,7 +88,7 @@ internal fun TimelineContent( dayText = dayText, onPrevious = { onIntent(TimelineIntent.PreviousDay) }, onNext = { onIntent(TimelineIntent.NextDay) }, - onPickDate = { showDatePicker(context, selectedDate, onIntent) }, + onPickDate = { showCalendar = true }, ) state.error?.let { error -> @@ -117,6 +114,13 @@ internal fun TimelineContent( Timeline(items = state.schedules) } } + if (showCalendar) { + TimelineDatePickerDialog( + selectedDate = selectedDate, + onSelectDate = { year, month, day -> currentOnIntent(TimelineIntent.SelectDate(year, month, day)) }, + onDismiss = { showCalendar = false }, + ) + } } /** 날짜 이동 줄. 화살표는 하루씩, 가운데 날짜는 달력을 연다. */ @@ -162,19 +166,3 @@ private fun DayPicker( } } } - -private fun showDatePicker( - context: android.content.Context, - selectedDate: Calendar, - onIntent: (TimelineIntent) -> Unit, -) { - DatePickerDialog( - context, - { _: DatePicker, year: Int, month: Int, dayOfMonth: Int -> - onIntent(TimelineIntent.SelectDate(year, month, dayOfMonth)) - }, - selectedDate.get(Calendar.YEAR), - selectedDate.get(Calendar.MONTH), - selectedDate.get(Calendar.DAY_OF_MONTH), - ).show() -} diff --git a/feature/main/src/screenshotTest/java/com/example/slowclock/ui/StoreScreenshotTest.kt b/feature/main/src/screenshotTest/java/com/example/slowclock/ui/StoreScreenshotTest.kt index 8cb1a675..8c072707 100644 --- a/feature/main/src/screenshotTest/java/com/example/slowclock/ui/StoreScreenshotTest.kt +++ b/feature/main/src/screenshotTest/java/com/example/slowclock/ui/StoreScreenshotTest.kt @@ -22,11 +22,11 @@ import java.util.Date // Play 스토어 등록 정보에 올릴 화면. 로그인한 기기가 있어야 찍을 수 있는 화면을 실제 화면 // 코드로 그려 낸다. 없는 기능을 보여 주지 않고, 화면을 그리는 코드도 앱과 같은 것이다(#115). // -// 크기는 Play 가 받는 1080x2400 이다. 360dp x 800dp 에 dpi 480(밀도 3.0)을 곱하면 그 값이 된다. +// 크기는 Play 가 받는 1080x1920 이다. 360dp x 640dp 에 dpi 480(밀도 3.0)을 곱하면 그 값이 된다. // 여기서 나온 PNG 를 docs/play/screenshots/ 에 옮겨 둔다. 다시 만들려면 이 파일의 미리보기를 // 렌더한 뒤 같은 자리에 덮어쓴다. -private const val STORE_DEVICE = "spec:width=360dp,height=800dp,dpi=480" +private const val STORE_DEVICE = "spec:width=360dp,height=640dp,dpi=480" private fun storeTime( hour: Int, @@ -60,6 +60,10 @@ private val fixedToday: Date = set(Calendar.MILLISECOND, 0) }.time +// 1080x1920 은 9:16 이라 종전 9:20 보다 아래가 짧다. 맨 아래 「남은 일정」 한 줄이 화면 끝에 +// 걸치고 「일정 추가」 버튼이 그 줄 제목의 일부를 덮는다. 목록 길이를 줄여도 위쪽 높이가 고정이라 +// 접히는 자리는 그대로이고, 덮이는 줄이 「끝낸 일정」 으로 바뀌어 스토어 그림으로는 오히려 +// 나빠진다(실제로 렌더해 확인). 버튼이 목록 위에 겹치는 것은 앱의 실제 모습이므로 그대로 둔다. private val storeMainState = MainUiState( todaySchedules = storeSchedules, 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" index dd5c2a80..aa93a5e3 100644 Binary files "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" 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" index f2b65da5..abc600be 100644 Binary files "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" 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/MainScreenshotTestKt/DoneContentScreenshot_\354\231\204\353\243\214 \355\231\224\353\251\264_994b70b5_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/MainScreenshotTestKt/DoneContentScreenshot_\354\231\204\353\243\214 \355\231\224\353\251\264_994b70b5_0.png" index 8c6780a4..994b475d 100644 Binary files "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/MainScreenshotTestKt/DoneContentScreenshot_\354\231\204\353\243\214 \355\231\224\353\251\264_994b70b5_0.png" and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/MainScreenshotTestKt/DoneContentScreenshot_\354\231\204\353\243\214 \355\231\224\353\251\264_994b70b5_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/MainScreenshotTestKt/MainContentDarkScreenshot_\353\251\224\354\235\270 \354\226\264\353\221\220\354\232\264 \353\252\250\353\223\234_6bd08bbe_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/MainScreenshotTestKt/MainContentDarkScreenshot_\353\251\224\354\235\270 \354\226\264\353\221\220\354\232\264 \353\252\250\353\223\234_6bd08bbe_0.png" index 99ad2599..0ee92b50 100644 Binary files "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/MainScreenshotTestKt/MainContentDarkScreenshot_\353\251\224\354\235\270 \354\226\264\353\221\220\354\232\264 \353\252\250\353\223\234_6bd08bbe_0.png" and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/MainScreenshotTestKt/MainContentDarkScreenshot_\353\251\224\354\235\270 \354\226\264\353\221\220\354\232\264 \353\252\250\353\223\234_6bd08bbe_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/MainScreenshotTestKt/MainContentLoadedScreenshot_\353\251\224\354\235\270 \354\235\274\354\240\225 \354\236\210\354\235\214_cbe76a43_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/MainScreenshotTestKt/MainContentLoadedScreenshot_\353\251\224\354\235\270 \354\235\274\354\240\225 \354\236\210\354\235\214_cbe76a43_0.png" index b6fb4e49..456b13e2 100644 Binary files "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/MainScreenshotTestKt/MainContentLoadedScreenshot_\353\251\224\354\235\270 \354\235\274\354\240\225 \354\236\210\354\235\214_cbe76a43_0.png" and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/MainScreenshotTestKt/MainContentLoadedScreenshot_\353\251\224\354\235\270 \354\235\274\354\240\225 \354\236\210\354\235\214_cbe76a43_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/MainScreenshotTestKt/MainNotificationsDisabledScreenshot_\354\225\214\353\236\214 \354\225\214\353\246\274 \354\260\250\353\213\250 \354\225\210\353\202\264_f300fa05_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/MainScreenshotTestKt/MainNotificationsDisabledScreenshot_\354\225\214\353\236\214 \354\225\214\353\246\274 \354\260\250\353\213\250 \354\225\210\353\202\264_f300fa05_0.png" index 8b1ccbb1..7c1f5d09 100644 Binary files "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/MainScreenshotTestKt/MainNotificationsDisabledScreenshot_\354\225\214\353\236\214 \354\225\214\353\246\274 \354\260\250\353\213\250 \354\225\210\353\202\264_f300fa05_0.png" and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/MainScreenshotTestKt/MainNotificationsDisabledScreenshot_\354\225\214\353\236\214 \354\225\214\353\246\274 \354\260\250\353\213\250 \354\225\210\353\202\264_f300fa05_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreDarkScreenshot_\354\212\244\355\206\240\354\226\264 \354\226\264\353\221\220\354\232\264 \353\252\250\353\223\234_665a3c63_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreDarkScreenshot_\354\212\244\355\206\240\354\226\264 \354\226\264\353\221\220\354\232\264 \353\252\250\353\223\234_665a3c63_0.png" new file mode 100644 index 00000000..347d8b3b Binary files /dev/null and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreDarkScreenshot_\354\212\244\355\206\240\354\226\264 \354\226\264\353\221\220\354\232\264 \353\252\250\353\223\234_665a3c63_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreDarkScreenshot_\354\212\244\355\206\240\354\226\264 \354\226\264\353\221\220\354\232\264 \353\252\250\353\223\234_dde69d6e_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreDarkScreenshot_\354\212\244\355\206\240\354\226\264 \354\226\264\353\221\220\354\232\264 \353\252\250\353\223\234_dde69d6e_0.png" deleted file mode 100644 index 43acab74..00000000 Binary files "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreDarkScreenshot_\354\212\244\355\206\240\354\226\264 \354\226\264\353\221\220\354\232\264 \353\252\250\353\223\234_dde69d6e_0.png" and /dev/null differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreDoneScreenshot_\354\212\244\355\206\240\354\226\264 \354\231\204\353\243\214_9c836483_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreDoneScreenshot_\354\212\244\355\206\240\354\226\264 \354\231\204\353\243\214_9c836483_0.png" deleted file mode 100644 index d4d1711c..00000000 Binary files "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreDoneScreenshot_\354\212\244\355\206\240\354\226\264 \354\231\204\353\243\214_9c836483_0.png" and /dev/null differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreDoneScreenshot_\354\212\244\355\206\240\354\226\264 \354\231\204\353\243\214_eef9ef9e_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreDoneScreenshot_\354\212\244\355\206\240\354\226\264 \354\231\204\353\243\214_eef9ef9e_0.png" new file mode 100644 index 00000000..d5be3dc4 Binary files /dev/null and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreDoneScreenshot_\354\212\244\355\206\240\354\226\264 \354\231\204\353\243\214_eef9ef9e_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreMainScreenshot_\354\212\244\355\206\240\354\226\264 \353\251\224\354\235\270_67c6756c_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreMainScreenshot_\354\212\244\355\206\240\354\226\264 \353\251\224\354\235\270_67c6756c_0.png" new file mode 100644 index 00000000..0d690670 Binary files /dev/null and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreMainScreenshot_\354\212\244\355\206\240\354\226\264 \353\251\224\354\235\270_67c6756c_0.png" differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreMainScreenshot_\354\212\244\355\206\240\354\226\264 \353\251\224\354\235\270_768e228a_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreMainScreenshot_\354\212\244\355\206\240\354\226\264 \353\251\224\354\235\270_768e228a_0.png" deleted file mode 100644 index 47fa6038..00000000 Binary files "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreMainScreenshot_\354\212\244\355\206\240\354\226\264 \353\251\224\354\235\270_768e228a_0.png" and /dev/null differ diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreTimelineScreenshot_\354\212\244\355\206\240\354\226\264 \354\213\234\352\260\204\355\221\234_1683b525_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreTimelineScreenshot_\354\212\244\355\206\240\354\226\264 \354\213\234\352\260\204\355\221\234_d0dba646_0.png" similarity index 92% rename from "feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreTimelineScreenshot_\354\212\244\355\206\240\354\226\264 \354\213\234\352\260\204\355\221\234_1683b525_0.png" rename to "feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreTimelineScreenshot_\354\212\244\355\206\240\354\226\264 \354\213\234\352\260\204\355\221\234_d0dba646_0.png" index f07f8d0d..1e41467d 100644 Binary files "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreTimelineScreenshot_\354\212\244\355\206\240\354\226\264 \354\213\234\352\260\204\355\221\234_1683b525_0.png" and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt/StoreTimelineScreenshot_\354\212\244\355\206\240\354\226\264 \354\213\234\352\260\204\355\221\234_d0dba646_0.png" differ diff --git a/feature/profile/src/main/java/com/example/slowclock/ui/profile/ProfileScreen.kt b/feature/profile/src/main/java/com/example/slowclock/ui/profile/ProfileScreen.kt index 84128856..cdb69580 100644 --- a/feature/profile/src/main/java/com/example/slowclock/ui/profile/ProfileScreen.kt +++ b/feature/profile/src/main/java/com/example/slowclock/ui/profile/ProfileScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Person import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -248,16 +247,11 @@ private fun ProfileBody( Button( onClick = { onIntent(ProfileIntent.SignOut) }, enabled = !state.isDeleting, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - ), // 높이를 고정하면 글자 배율을 올렸을 때 글자가 위아래로 잘린다(#107 과 같은 함정). modifier = Modifier.widthIn(min = 200.dp).heightIn(min = 64.dp), ) { Text( text = "로그아웃", - color = MaterialTheme.colorScheme.onError, style = MaterialTheme.typography.bodyLarge, ) } diff --git a/feature/recommendation/src/main/java/com/example/slowclock/ui/recommendation/components/RecommendationList.kt b/feature/recommendation/src/main/java/com/example/slowclock/ui/recommendation/components/RecommendationList.kt index 5c891d3d..c043cf32 100644 --- a/feature/recommendation/src/main/java/com/example/slowclock/ui/recommendation/components/RecommendationList.kt +++ b/feature/recommendation/src/main/java/com/example/slowclock/ui/recommendation/components/RecommendationList.kt @@ -17,8 +17,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.example.slowclock.data.model.Recommendation +import com.example.slowclock.feature.recommendation.R /** * 추천 일정 목록. @@ -37,6 +39,13 @@ fun RecommendationList( contentPadding = PaddingValues(top = 8.dp, bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + item(key = "health_notice") { + Text( + text = stringResource(R.string.recommendation_health_notice), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } items(recommendations, key = { it.title }) { item -> RecommendationRow( title = item.title, diff --git a/feature/recommendation/src/main/res/values/navigation_strings.xml b/feature/recommendation/src/main/res/values/navigation_strings.xml index 81c474a7..b86102c4 100644 --- a/feature/recommendation/src/main/res/values/navigation_strings.xml +++ b/feature/recommendation/src/main/res/values/navigation_strings.xml @@ -1,3 +1,4 @@ 고르지 않고 돌아가기 + 일상 일정 예시이며 의료 진단이나 치료를 위한 안내가 아닙니다. 건강에 관한 판단은 의료 전문가와 상담해 주세요. diff --git a/scripts/export-play-screenshots.sh b/scripts/export-play-screenshots.sh new file mode 100755 index 00000000..4e873c8b --- /dev/null +++ b/scripts/export-play-screenshots.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Export opaque CI-rendered screenshots as Play's 24-bit PNG without changing any RGB pixel. +set -euo pipefail +cd "$(dirname "$0")/.." +if [[ "${1:-}" != "" && "${1:-}" != "--check" ]]; then + echo 'Usage: scripts/export-play-screenshots.sh [--check]' >&2 + exit 2 +fi +export PLAY_SCREENSHOT_CHECK="${1:-}" +jshell -J-Djava.awt.headless=true --execution local --feedback silent - <<'JAVA' +import java.awt.image.BufferedImage; +import java.nio.file.*; +import java.util.*; +import javax.imageio.ImageIO; +try { + var sourceRoot = Path.of("feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/StoreScreenshotTestKt"); + var names = Map.of("StoreMainScreenshot_", "01-main.png", "StoreDoneScreenshot_", "02-done.png", "StoreTimelineScreenshot_", "03-timeline.png", "StoreDarkScreenshot_", "04-dark.png"); + if (!Files.isDirectory(sourceRoot)) throw new IllegalStateException("No CI baseline yet. Put the screenshot-baseline label on the PR and pull the commit CI makes, then run this again: " + sourceRoot); + for (var entry : names.entrySet()) { + List candidates; + try (var paths = Files.list(sourceRoot)) { + candidates = paths.filter(p -> p.getFileName().toString().startsWith(entry.getKey())).toList(); + } + if (candidates.size() != 1) throw new IllegalStateException("Expected one current CI baseline for " + entry.getKey()); + var source = ImageIO.read(candidates.get(0).toFile()); + if (source.getWidth() != 1080 || source.getHeight() != 1920) throw new IllegalStateException("Expected 1080x1920 CI baseline: " + candidates.get(0)); + var pixels = source.getRGB(0, 0, 1080, 1920, null, 0, 1080); + if (Arrays.stream(pixels).anyMatch(p -> (p >>> 24) != 255)) throw new IllegalStateException("Transparent source pixel: " + candidates.get(0)); + var target = Path.of("docs/play/screenshots", entry.getValue()); + if (!"--check".equals(System.getenv("PLAY_SCREENSHOT_CHECK"))) { + var rgb = new BufferedImage(1080, 1920, BufferedImage.TYPE_INT_RGB); + rgb.setRGB(0, 0, 1080, 1920, pixels, 0, 1080); + if (!ImageIO.write(rgb, "png", target.toFile())) throw new IllegalStateException("PNG writer unavailable"); + } + var exported = ImageIO.read(target.toFile()); + if (exported.getWidth() != 1080 || exported.getHeight() != 1920 || exported.getColorModel().hasAlpha() || exported.getColorModel().getPixelSize() != 24) throw new IllegalStateException("Invalid 24-bit Play export: " + target); + if (!Arrays.equals(pixels, exported.getRGB(0, 0, 1080, 1920, null, 0, 1080))) throw new IllegalStateException("RGB pixels differ from CI baseline: " + target); + System.out.println(target + ": 1080x1920, opaque RGB24, all RGB pixels identical to CI baseline"); + } +} catch (Exception error) { + error.printStackTrace(); + System.exit(1); +} +System.exit(0); +JAVA