diff --git a/app/src/debug/java/com/afternote/afternote_fe/debug/DebugSettingsActivity.kt b/app/src/debug/java/com/afternote/afternote_fe/debug/DebugSettingsActivity.kt index e897bf0b21..7863368128 100644 --- a/app/src/debug/java/com/afternote/afternote_fe/debug/DebugSettingsActivity.kt +++ b/app/src/debug/java/com/afternote/afternote_fe/debug/DebugSettingsActivity.kt @@ -4,7 +4,6 @@ import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -19,6 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.afternote.afternote_fe.EXTRA_DEBUG_START_TIMELETTER import com.afternote.afternote_fe.MainActivity +import com.afternote.afternote_fe.enableLightEdgeToEdge import dagger.hilt.android.AndroidEntryPoint /** @@ -30,7 +30,7 @@ import dagger.hilt.android.AndroidEntryPoint class DebugSettingsActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge() + enableLightEdgeToEdge() setContent { Surface( modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/com/afternote/afternote_fe/LightEdgeToEdge.kt b/app/src/main/java/com/afternote/afternote_fe/LightEdgeToEdge.kt new file mode 100644 index 0000000000..ca6d4034c4 --- /dev/null +++ b/app/src/main/java/com/afternote/afternote_fe/LightEdgeToEdge.kt @@ -0,0 +1,25 @@ +package com.afternote.afternote_fe + +import android.graphics.Color +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.enableEdgeToEdge + +/** + * 시스템바 아이콘까지 라이트로 고정한 edge-to-edge (#1719). + * + * 무인자 [enableEdgeToEdge] 는 `SystemBarStyle.auto` 라 `uiMode == NIGHT_YES` 면 + * `isAppearanceLightStatusBars` 를 `false` 로 잡아 **흰 아이콘**을 고른다. 그런데 + * `AfternoteTheme` 이 라이트로 잠긴 뒤로 Compose 는 다크 기기에서도 `gray1`(#FAFAFA) 을 + * 상태바 밑까지 그린다. 둘을 그대로 두면 밝은 배경 위에 흰 시계·배터리·제스처 바가 얹혀 + * 모든 화면에서 보이지 않는다. + * + * 그래서 시스템바 스타일도 함께 라이트로 못박는다. **`AfternoteTheme` 의 `isDarkTheme` + * 기본값을 시스템 추종으로 되돌릴 때 이 함수도 같이 되돌린다** — 두 자리는 한 쌍이다. + */ +fun ComponentActivity.enableLightEdgeToEdge() { + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT), + ) +} diff --git a/app/src/main/java/com/afternote/afternote_fe/MainActivity.kt b/app/src/main/java/com/afternote/afternote_fe/MainActivity.kt index 1a41c2a638..6590d6a330 100644 --- a/app/src/main/java/com/afternote/afternote_fe/MainActivity.kt +++ b/app/src/main/java/com/afternote/afternote_fe/MainActivity.kt @@ -3,7 +3,6 @@ package com.afternote.afternote_fe import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.compose.runtime.getValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen @@ -24,7 +23,7 @@ class MainActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) - enableEdgeToEdge() + enableLightEdgeToEdge() enqueueNotificationIntent(intent) diff --git a/app/src/main/res/values-v27/themes.xml b/app/src/main/res/values-v27/themes.xml new file mode 100644 index 0000000000..c929763c79 --- /dev/null +++ b/app/src/main/res/values-v27/themes.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 2fe44078a0..9b2b71e273 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -9,5 +9,14 @@ @color/splash_background @mipmap/ic_launcher @style/Theme.Afternotefe.Main + + + true \ No newline at end of file diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/theme/Theme.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/theme/Theme.kt index acb514a961..236ee06ea4 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/theme/Theme.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/theme/Theme.kt @@ -38,8 +38,22 @@ fun AfternoteTheme( colors: AfternoteColors = lightColors(), darkColors: AfternoteColors = darkColors(), typography: AfternoteTypography = AfternoteTypography(), - // 시스템 설정이 다크 모드인지 확인 - isDarkTheme: Boolean = isSystemInDarkTheme(), + /** + * 다크 팔레트를 물릴지. **기본값은 라이트 고정이다** (#1719). + * + * 배선만 있고 화면이 안 맞춰진 중간 상태가 양쪽 끝보다 나빴다 — 실측에서 「마음의 기록」 + * 헤더가 검정 배경에 검정 글자로 거의 보이지 않았고, 주간 요약 카드는 배경만 밝은 채로 + * 남아 라벨이 저대비로 흐려졌다. 토큰을 쓰지 않고 색을 박은 자리가 반전을 따라가지 + * 못해서다(`feature/mindrecord/presentation` main 에만 29곳). + * + * 시안의 다크 팔레트가 확정되고 하드코딩 색이 토큰으로 옮겨지면 기본값을 + * [isSystemInDarkTheme] 으로 되돌린다. [darkColors] 와 이 파라미터는 그때를 위해 남겨 + * 둔다 — 값을 넘기면 지금도 다크로 그릴 수 있다. + * + * **되돌릴 때 `app` 의 `enableLightEdgeToEdge()` 도 같이 되돌린다.** 이 잠금만 풀면 + * 시스템바 아이콘이 라이트로 고정된 채 배경만 다크가 되어 반대 방향으로 안 보인다. + */ + isDarkTheme: Boolean = false, content: @Composable () -> Unit, ) { val currentColor = if (isDarkTheme) darkColors else colors diff --git a/core/ui/src/test/kotlin/com/afternote/core/ui/theme/ThemeDarkModeLockTest.kt b/core/ui/src/test/kotlin/com/afternote/core/ui/theme/ThemeDarkModeLockTest.kt new file mode 100644 index 0000000000..906d178e25 --- /dev/null +++ b/core/ui/src/test/kotlin/com/afternote/core/ui/theme/ThemeDarkModeLockTest.kt @@ -0,0 +1,64 @@ +package com.afternote.core.ui.theme + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.junit4.v2.createComposeRule +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 시스템 다크모드를 따라가지 않는다는 계약 (#1719). + * + * 종전 기본값은 `isSystemInDarkTheme()` 이었는데, 다크 팔레트를 물려도 **화면이 따라오지 + * 않았다** — 색을 토큰이 아니라 코드에 박은 자리가 반전되지 않아, 「마음의 기록」 헤더가 + * 검정 배경에 검정 글자로 남고 주간 요약 카드는 배경만 밝은 채로 라벨이 흐려졌다. + * 「배선은 됐고 화면은 안 맞은」 중간 상태가 라이트 고정보다도, 완전한 다크 지원보다도 + * 나빴다. 그래서 팔레트가 확정될 때까지 라이트로 잠근다. + * + * 이 테스트가 지키는 것은 **잠금이 조용히 풀리지 않는 것**이다. 기본값을 + * `isSystemInDarkTheme()` 으로 되돌리면 첫 번째 테스트가 night 한정자에서 실패한다. + * 되돌릴 때는 하드코딩 색 29곳을 토큰으로 옮긴 뒤 이 테스트도 같이 고쳐야 한다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ThemeDarkModeLockTest { + @get:Rule + val composeRule = createComposeRule() + + private fun resolveIsLightMode(theme: @Composable (@Composable () -> Unit) -> Unit): Boolean { + var isLightMode: Boolean? = null + composeRule.setContent { + theme { isLightMode = AfternoteDesign.colors.isLightMode } + } + composeRule.waitForIdle() + return requireNotNull(isLightMode) { "테마가 content 를 부르지 않았다" } + } + + @Test + @Config(qualifiers = "night") + fun `시스템이 다크여도 기본값은 라이트 팔레트다`() { + assertEquals(true, resolveIsLightMode { content -> AfternoteTheme(content = content) }) + } + + @Test + @Config(qualifiers = "notnight") + fun `시스템이 라이트일 때도 당연히 라이트 팔레트다`() { + assertEquals(true, resolveIsLightMode { content -> AfternoteTheme(content = content) }) + } + + /** + * 잠금은 **기본값** 에만 걸린다. 다크 팔레트와 파라미터는 팔레트 확정 뒤를 위해 살아 있어야 + * 하고, 그때 이 값을 다시 시스템에 묶는 것으로 되돌린다. + */ + @Test + @Config(qualifiers = "notnight") + fun `명시로 넘기면 다크 팔레트를 여전히 그릴 수 있다`() { + assertEquals( + false, + resolveIsLightMode { content -> AfternoteTheme(isDarkTheme = true, content = content) }, + ) + } +} diff --git a/konsist/src/test/kotlin/com/afternote/konsist/LightSystemBarLockKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/LightSystemBarLockKonsistTest.kt new file mode 100644 index 0000000000..e0c0b1a297 --- /dev/null +++ b/konsist/src/test/kotlin/com/afternote/konsist/LightSystemBarLockKonsistTest.kt @@ -0,0 +1,38 @@ +package com.afternote.konsist + +import com.lemonappdev.konsist.api.verify.assertFalse +import org.junit.Test + +/** + * 시스템바가 라이트로 잠긴 채 남는지 (#1719). + * + * `AfternoteTheme` 이 라이트로 잠긴 동안 Compose 는 다크 기기에서도 `gray1`(#FAFAFA) 을 + * 상태바 밑까지 그린다. 무인자 `enableEdgeToEdge()` 는 `SystemBarStyle.auto` 라 그 기기에서 + * 흰 아이콘을 고르므로, 밝은 배경 위에 흰 시계·배터리가 얹혀 사라진다. + * + * 그래서 `app` 의 호출부는 [com.afternote.afternote_fe.enableLightEdgeToEdge] 하나로 모았다. + * 이 가드는 **무인자 호출이 다시 스며드는 것**을 막는다 — 새 Activity 를 만들 때 손이 먼저 + * 기억하는 형태가 그쪽이라서다. + * + * 다크 팔레트가 확정돼 잠금을 풀 때는 이 가드도 함께 지운다. 되돌릴 자리는 셋이다 — + * `AfternoteTheme` 의 `isDarkTheme` 기본값 · `enableLightEdgeToEdge()` · `themes.xml` 의 + * `windowLightStatusBar`. + */ +class LightSystemBarLockKonsistTest { + @Test + fun `app 은 무인자 enableEdgeToEdge 를 쓰지 않는다`() { + AfternoteKonsistScope + .files + .filter { file -> file.path.contains("/app/src/") } + .filterNot { file -> file.path.endsWith("/$HELPER_FILE") } + .assertFalse { file -> BARE_CALL.containsMatchIn(file.text) } + } + + private companion object { + /** 잠금을 실제로 거는 자리. 여기만 `enableEdgeToEdge` 를 인자와 함께 부른다. */ + const val HELPER_FILE = "LightEdgeToEdge.kt" + + /** `enableEdgeToEdge()` — 인자 없는 호출. 공백만 허용한다. */ + val BARE_CALL = Regex("""\benableEdgeToEdge\s*\(\s*\)""") + } +}