Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.devil.phoenixproject.presentation.theme

import kotlin.test.Test
import kotlin.test.assertEquals
import androidx.compose.ui.unit.dp

/**
* Behavior contract for the `RoutineCard` 2.dp / 8.dp elevation flicker under
* Dark + Material You (issue #640).
*
* The RCA traces the per-card "flicker" the reporter sees on expand/collapse to
* RoutineCard's collapsed/expanded elevation delta. The production call site now
* reads this value through [routineCardDefaultElevation], which gives this test a
* stable semantic contract instead of parsing formatting-sensitive Kotlin source.
*/
class RoutineCardElevationContractTest {

@Test
fun routineCard_elevationIsTwoOrEightDp() {
assertEquals(
2.dp,
routineCardDefaultElevation(expanded = false),
"Collapsed RoutineCard elevation must stay 2.dp so the elevation-tint " +
"delta remains the RCA/acceptance-tested 6-dp step.",
)
assertEquals(
8.dp,
routineCardDefaultElevation(expanded = true),
"Expanded RoutineCard elevation must stay 8.dp so the elevation-tint " +
"delta remains the RCA/acceptance-tested 6-dp step.",
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.devil.phoenixproject.presentation.theme

import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.ui.graphics.Color
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals

/**
* Behavior contract guarding the rendered chrome luminance invariant under
* Dark + Material You (issue #640).
*
* Production chrome call sites read their high-visibility container colors through
* the helpers in `PhoenixChromeColorContracts.kt`. These tests assert those helpers
* forward the runtime [ColorScheme] roles that `clampDynamicDarkScheme` rewrites,
* without reading physical source files or matching fragile formatting-sensitive
* strings in `.kt` files.
*/
class RoutinesChromeLuminanceContractTest {

private fun chromeScheme(): ColorScheme = darkColorScheme(
surface = Color(0xFF101820),
surfaceContainerHigh = Color(0xFF1E293B),
surfaceContainerHighest = Color(0xFF334155),
primaryContainer = Color(0xFF7C2D12),
)

@Test
fun topAppBar_containerColor_readsColorSchemeSurface() {
val scheme = chromeScheme().copy(surface = Color(0xFF123456))

assertEquals(
scheme.surface,
phoenixTopAppBarContainerColor(scheme),
"Top app bar container color must read ColorScheme.surface so the " +
"dark+Material You clamp propagates through.",
)
}

@Test
fun bottomNavigation_containerColor_readsColorSchemeSurfaceContainerHigh() {
val scheme = chromeScheme().copy(surfaceContainerHigh = Color(0xFF234567))

assertEquals(
scheme.surfaceContainerHigh,
phoenixBottomNavigationContainerColor(scheme),
"Bottom navigation container color must read ColorScheme.surfaceContainerHigh " +
"so the dark+Material You clamp propagates through.",
)
}

@Test
fun routineCard_unselectedContainerColor_readsColorSchemeSurfaceContainerHighest() {
val scheme = chromeScheme().copy(surfaceContainerHighest = Color(0xFF345678))

assertEquals(
scheme.surfaceContainerHighest,
routineCardContainerColor(scheme, isSelected = false),
"Unselected RoutineCard container color must read " +
"ColorScheme.surfaceContainerHighest so the dark+Material You clamp propagates through.",
)
}

@Test
fun routineCard_selectedContainerColor_preservesPrimaryContainerSelectionTint() {
val scheme = chromeScheme().copy(primaryContainer = Color(0xFF456789))

assertEquals(
scheme.primaryContainer.copy(alpha = 0.4f),
routineCardContainerColor(scheme, isSelected = true),
"Selected RoutineCard container color must remain the primaryContainer " +
"selection tint, not a hardcoded surface color.",
)
}

@Test
fun chromeHelpers_areDrivenByInputSchemeNotHardcodedLightColors() {
val darkScheme = chromeScheme()
val lightSentinel = Color.White

val returnedColors = listOf(
phoenixTopAppBarContainerColor(darkScheme),
phoenixBottomNavigationContainerColor(darkScheme),
routineCardContainerColor(darkScheme, isSelected = false),
)

returnedColors.forEach { color ->
assertNotEquals(
lightSentinel,
color,
"Chrome helper returned Color.White for a dark scheme; this would " +
"bypass the issue #640 dark-surface clamp.",
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package com.devil.phoenixproject.ui.theme

import java.io.File
import kotlin.test.Test
import kotlin.test.assertTrue

/**
* Source-level contract test for the dark + Material You clamp wired into [Theme.kt].
*
* The fix for issue #640 only takes effect if `VitruvianTheme` calls
* `clampDynamicDarkScheme(dynamic, DarkColorScheme)` on the dark branch and *only*
* the dark branch. A contract test is the right tool here: a Robolectric / Compose
* runtime round-trip would not exercise any additional code paths, and the
* `clampDynamicDarkScheme` helper itself is covered by [ClampedDynamicDarkSchemeTest]
* in `commonTest` (the JVM-only test suite).
*
* If a future refactor removes the clamp call or moves it outside the dark branch,
* this test fails loudly. The contract is intentionally narrow: just one assertion
* per behavior, all anchored on the exact phrasing we expect.
*/
class MaterialYouDarkClampContractTest {

private val projectRoot: File by lazy {
var dir = File(System.getProperty("user.dir") ?: ".")
while (!File(dir, "shared/src/commonMain").exists()) {
dir = dir.parentFile ?: break
}
dir
}

private fun read(relativePath: String): String =
File(projectRoot, relativePath).readText()

private val themeKt: String by lazy {
read("shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/Theme.kt")
}

@Test
fun darkBranch_invokesClampDynamicDarkScheme() {
// The dark branch of `VitruvianTheme` must call `clampDynamicDarkScheme` with
// the dynamic dark scheme and the static `DarkColorScheme` as the fallback.
// This is the single line of glue that makes the fix for issue #640 take
// effect at the theme boundary.
assertTrue(
themeKt.contains("clampDynamicDarkScheme(dynamic, DarkColorScheme)"),
"Theme.kt must call clampDynamicDarkScheme(dynamic, DarkColorScheme) " +
"on the dark + Material You branch. The dynamic surface family " +
"otherwise leaks high-luminance wallpaper values into the chrome.",
)
}

@Test
fun lightBranch_doesNotInvokeClamp() {
// The clamp is dark-mode only. Light + Material You must continue to use the
// wallpaper-derived palette as today.
// The literal substring `clampDynamicDarkScheme` must appear, and it must be
// gated by `useDarkColors` (we asserted the call shape in darkBranch_invokes...
// above). This test asserts the structural shape of the call site.
assertTrue(
themeKt.contains("dynamic != null && useDarkColors -> clampDynamicDarkScheme"),
"Theme.kt must gate clampDynamicDarkScheme on useDarkColors=true so light " +
"+ Material You keeps wallpaper hues across the entire scheme.",
)
}

@Test
fun clampHelperExists_alongsideTheme() {
// The helper must live next to Theme.kt so the surface family token list is
// discoverable from the theme module. If a future refactor moves the helper
// into a non-theme package, surface the failure here and re-document.
val helper = File(
projectRoot,
"shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/ClampedDynamicDarkScheme.kt",
)
assertTrue(
helper.exists(),
"ClampedDynamicDarkScheme.kt must live in " +
"shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/ " +
"alongside Theme.kt so the surface family token list is discoverable " +
"from the theme module. Found missing at ${helper.absolutePath}.",
)
}

@Test
fun staticDarkColorScheme_unmodifiedByFix() {
// Non-goal: do not change the static DarkColorScheme. The clamp must compose
// against the existing static palette; if the static palette itself is
// modified, the iOS / non-dynamic Android brand contract breaks.
assertTrue(
themeKt.contains("surfaceContainerHighest = SurfaceContainerHighestDark"),
"DarkColorScheme must still anchor surfaceContainerHighest to " +
"SurfaceContainerHighestDark (Slate700). The clamp composes against " +
"the existing static palette; it does not redefine it.",
)
}

@Test
fun clampHelper_clampsInverseSurfaceAndSurfaceTintFromFallback() {
// Material 3 components such as Snackbar read `inverseSurface` for their
// container color and `inverseOnSurface` for content. If the clamp leaves
// these from `dynamic`, a wallpaper that produces a light surface produces
// a dark `inverseSurface`, and the Snackbar renders dark-on-dark under the
// very wallpaper this fix is meant to tame. Material 3 also applies
// `surfaceTint` to elevated surface containers, so pin that to fallback too
// and prevent bright/saturated wallpaper tints from reintroducing chrome drift.
val helper = read(
"shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/ClampedDynamicDarkScheme.kt",
)
assertTrue(
helper.contains("inverseSurface = fallback.inverseSurface") &&
helper.contains("inverseOnSurface = fallback.inverseOnSurface") &&
helper.contains("surfaceTint = fallback.surfaceTint"),
"ClampedDynamicDarkScheme must clamp inverseSurface, inverseOnSurface, " +
"and surfaceTint from fallback so Material 3 Snackbar / Tooltip / " +
"similar components and elevated surfaces stay on the brand-controlled " +
"dark ramp described in the automated reviews on PR #642.",
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ import com.devil.phoenixproject.presentation.components.HapticFeedbackEffect
import com.devil.phoenixproject.presentation.components.ProfileSidePanel
import com.devil.phoenixproject.presentation.navigation.NavGraph
import com.devil.phoenixproject.presentation.navigation.NavigationRoutes
import com.devil.phoenixproject.presentation.theme.phoenixBottomNavigationContainerColor
import com.devil.phoenixproject.presentation.theme.phoenixTopAppBarContainerColor
import com.devil.phoenixproject.presentation.util.LocalPlatformAccessibilitySettings
import com.devil.phoenixproject.presentation.util.LocalWindowSizeClass
import com.devil.phoenixproject.presentation.util.WindowHeightSizeClass
Expand Down Expand Up @@ -332,7 +334,7 @@ fun EnhancedMainScreen(
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
containerColor = phoenixTopAppBarContainerColor(MaterialTheme.colorScheme),
titleContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
),
Expand Down Expand Up @@ -497,7 +499,7 @@ private fun PhoenixBottomNavigationBar(
onInsightsClick: () -> Unit,
onSettingsClick: () -> Unit,
) {
val containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
val containerColor = phoenixBottomNavigationContainerColor(MaterialTheme.colorScheme)
val barHeight = remember(isCompactHeight) { if (isCompactHeight) 56.dp else 60.dp }

Box(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
Expand All @@ -92,6 +91,8 @@ import com.devil.phoenixproject.presentation.components.DestructiveConfirmDialog
import com.devil.phoenixproject.presentation.components.EmptyState
import com.devil.phoenixproject.presentation.components.ProfileColors
import com.devil.phoenixproject.presentation.components.RoutineModifierDialog
import com.devil.phoenixproject.presentation.theme.routineCardContainerColor
import com.devil.phoenixproject.presentation.theme.routineCardDefaultElevation
import com.devil.phoenixproject.presentation.util.isCompactAccessibilityLayout
import com.devil.phoenixproject.ui.theme.Spacing
import com.devil.phoenixproject.ui.theme.ThemeMode
Expand Down Expand Up @@ -1077,14 +1078,13 @@ fun RoutineCard(
),
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
},
containerColor = routineCardContainerColor(
colorScheme = MaterialTheme.colorScheme,
isSelected = isSelected,
),
),
elevation = CardDefaults.cardElevation(
defaultElevation = if (expanded) 8.dp else 2.dp,
defaultElevation = routineCardDefaultElevation(expanded),
),
border = BorderStroke(
2.dp,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.devil.phoenixproject.presentation.theme

import androidx.compose.material3.ColorScheme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

/**
* Semantic color/elevation accessors for Phoenix's high-visibility chrome surfaces.
*
* Issue #640 was caused by Android dynamic dark color schemes returning light
* wallpaper-derived values for surface roles that the Routines screen uses as
* structural chrome: the top app bar, bottom navigation bar, and routine cards.
* Keeping these reads behind tiny helpers gives tests a stable behavior contract
* without parsing Kotlin source files or matching formatting-sensitive call-site
* strings.
*/
internal fun phoenixTopAppBarContainerColor(colorScheme: ColorScheme): Color =
colorScheme.surface

internal fun phoenixBottomNavigationContainerColor(colorScheme: ColorScheme): Color =
colorScheme.surfaceContainerHigh

internal fun routineCardContainerColor(
colorScheme: ColorScheme,
isSelected: Boolean,
): Color = if (isSelected) {
colorScheme.primaryContainer.copy(alpha = 0.4f)
} else {
colorScheme.surfaceContainerHighest
}

internal fun routineCardDefaultElevation(expanded: Boolean): Dp =
if (expanded) 8.dp else 2.dp
Loading
Loading