diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index c4ecf95..152998d 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle @@ -54,6 +55,8 @@ fun App( dependencies: AppDependencies, windowChromeTopInset: Dp = 0.dp, windowContentTopStartRadius: Dp = 0.dp, + useNativeWindowBackdrop: Boolean = false, + onResolvedDarkThemeChanged: (Boolean) -> Unit = {}, windowChrome: (@Composable () -> Unit)? = null, ) { val graphHolder = viewModel { AppGraphViewModel(dependencies) } @@ -140,13 +143,19 @@ fun App( } val darkTheme = rememberResolvedDarkTheme(appState.themeMode) + LaunchedEffect(darkTheme, onResolvedDarkThemeChanged) { + onResolvedDarkThemeChanged(darkTheme) + } PlatformSystemAppearance(darkTheme) CompositionLocalProvider(LocalUiPlatform provides dependencies.environment.uiPlatform) { VniDropTheme(isDarkTheme = darkTheme) { Box( modifier = Modifier .fillMaxSize() - .background(LocalVniDropColors.current.backgroundSurface200), + .background( + if (useNativeWindowBackdrop) Color.Transparent + else LocalVniDropColors.current.backgroundSurface200, + ), ) { BoxWithConstraints( modifier = Modifier @@ -171,6 +180,7 @@ fun App( windowClass = windowClass, uiPlatform = dependencies.environment.uiPlatform, mainContentTopStartRadius = windowContentTopStartRadius, + useNativeWindowBackdrop = useNativeWindowBackdrop, onDestinationSelected = appViewModel::selectDestination, overlay = { VniDropSnackbarHost(graph.messages, Modifier.align(Alignment.BottomCenter)) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppNavigation.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppNavigation.kt index fed2591..37c55f2 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppNavigation.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppNavigation.kt @@ -55,6 +55,7 @@ fun AppSidebarNavigation( style: NavigationStyle, onDestinationSelected: (AppDestination) -> Unit, modifier: Modifier = Modifier, + useNativeWindowBackdrop: Boolean = false, ) { when (style) { NavigationStyle.AndroidRail -> AndroidNavigationRail(selected, onDestinationSelected, modifier) @@ -62,6 +63,7 @@ fun AppSidebarNavigation( selected = selected, onDestinationSelected = onDestinationSelected, modifier = modifier, + useNativeWindowBackdrop = useNativeWindowBackdrop, ) NavigationStyle.AndroidBottomBar -> error("Bottom navigation is rendered by the phone shell") } @@ -103,13 +105,14 @@ private fun DesktopSidebarNavigation( selected: AppDestination, onDestinationSelected: (AppDestination) -> Unit, modifier: Modifier = Modifier, + useNativeWindowBackdrop: Boolean = false, ) { val colors = LocalVniDropColors.current Column( modifier = modifier .width(DesktopNavigationWidthDp.dp) .fillMaxHeight() - .background(colors.backgroundSurface200) + .background(if (useNativeWindowBackdrop) Color.Transparent else colors.backgroundSurface200) .padding(horizontal = 12.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt index d524abf..5caf2ea 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.vnidrop.app.UiPlatform @@ -33,6 +34,7 @@ fun AppShell( windowClass: WindowClass, uiPlatform: UiPlatform, mainContentTopStartRadius: Dp = 0.dp, + useNativeWindowBackdrop: Boolean = false, onDestinationSelected: (AppDestination) -> Unit, overlay: @Composable BoxScope.() -> Unit = {}, floatingAction: (@Composable BoxScope.() -> Unit)? = null, @@ -40,11 +42,13 @@ fun AppShell( ) { val colors = LocalVniDropColors.current val navigationStyle = navigationStyleFor(uiPlatform, windowClass) + val windowSurface = if (useNativeWindowBackdrop) Color.Transparent else colors.backgroundDashCanvas Surface( modifier = modifier .fillMaxSize() - .background(colors.backgroundDashCanvas), - color = colors.backgroundDashCanvas, + .background(windowSurface), + color = windowSurface, + contentColor = colors.foregroundDefault, ) { if (navigationStyle == NavigationStyle.AndroidBottomBar) { PhoneShell( @@ -59,6 +63,7 @@ fun AppShell( selectedDestination = selectedDestination, navigationStyle = navigationStyle, mainContentTopStartRadius = mainContentTopStartRadius, + useNativeWindowBackdrop = useNativeWindowBackdrop, onDestinationSelected = onDestinationSelected, overlay = overlay, floatingAction = floatingAction, @@ -73,6 +78,7 @@ private fun WideShell( selectedDestination: AppDestination, navigationStyle: NavigationStyle, mainContentTopStartRadius: Dp, + useNativeWindowBackdrop: Boolean, onDestinationSelected: (AppDestination) -> Unit, overlay: @Composable BoxScope.() -> Unit, floatingAction: (@Composable BoxScope.() -> Unit)?, @@ -83,11 +89,18 @@ private fun WideShell( Row( modifier = Modifier .fillMaxSize() - .then(if (roundedContent) Modifier.background(colors.backgroundSurface200) else Modifier), + .then( + if (roundedContent && !useNativeWindowBackdrop) { + Modifier.background(colors.backgroundSurface200) + } else { + Modifier + }, + ), ) { AppSidebarNavigation( selected = selectedDestination, style = navigationStyle, + useNativeWindowBackdrop = useNativeWindowBackdrop, onDestinationSelected = onDestinationSelected, ) Box( @@ -96,13 +109,12 @@ private fun WideShell( .fillMaxSize() .then( if (roundedContent) { - Modifier - .clip(RoundedCornerShape(topStart = mainContentTopStartRadius)) - .background(colors.backgroundDashCanvas) + Modifier.clip(RoundedCornerShape(topStart = mainContentTopStartRadius)) } else { Modifier }, - ), + ) + .background(colors.backgroundDashCanvas), ) { content() floatingAction?.invoke(this) diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt index fc425ec..056d7f3 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.background import androidx.compose.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -18,7 +19,12 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.onRoot import androidx.compose.ui.platform.testTag +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.toPixelMap import androidx.compose.ui.unit.dp import androidx.compose.ui.test.v2.runComposeUiTest import androidx.compose.runtime.CompositionLocalProvider @@ -55,6 +61,7 @@ import com.vnidrop.app.ui.navigation.AppDestination import com.vnidrop.app.ui.platform.LocalUiPlatform import com.vnidrop.app.ui.shell.AppShell import com.vnidrop.app.ui.theme.VniDropTheme +import com.vnidrop.app.ui.theme.LocalVniDropColors import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -290,6 +297,68 @@ class FoundationComposeTest { runOnIdle { assertEquals(AppDestination.Receive, selected) } } + @Test + fun nativeWindowBackdropShowsThroughDesktopChromeButNotMainContent() = runComposeUiTest { + val sentinel = Color.Magenta + var expectedMain = Color.Unspecified + setContent { + VniDropTheme(isDarkTheme = false) { + expectedMain = LocalVniDropColors.current.backgroundDashCanvas + Box( + Modifier + .size(width = 320.dp, height = 200.dp) + .background(sentinel) + .testTag("native-backdrop-shell"), + ) { + AppShell( + selectedDestination = AppDestination.Send, + windowClass = WindowClass.Desktop, + uiPlatform = UiPlatform.Windows, + mainContentTopStartRadius = 20.dp, + useNativeWindowBackdrop = true, + onDestinationSelected = {}, + ) { + Text("Content") + } + } + } + } + + val pixels = onNodeWithTag("native-backdrop-shell").captureToImage().toPixelMap() + assertEquals(sentinel.toArgb(), pixels[pixels.width / 20, pixels.height * 9 / 10].toArgb()) + assertEquals(expectedMain.toArgb(), pixels[pixels.width * 19 / 20, pixels.height * 9 / 10].toArgb()) + } + + @Test + fun desktopChromeKeepsSolidFallbackWithoutNativeBackdrop() = runComposeUiTest { + var expectedSidebar = Color.Unspecified + setContent { + VniDropTheme(isDarkTheme = false) { + expectedSidebar = LocalVniDropColors.current.backgroundSurface200 + Box( + Modifier + .size(width = 320.dp, height = 200.dp) + .background(Color.Magenta) + .testTag("solid-backdrop-shell"), + ) { + AppShell( + selectedDestination = AppDestination.Send, + windowClass = WindowClass.Desktop, + uiPlatform = UiPlatform.Windows, + mainContentTopStartRadius = 20.dp, + useNativeWindowBackdrop = false, + onDestinationSelected = {}, + ) { + Text("Content") + } + } + } + } + + val pixels = onNodeWithTag("solid-backdrop-shell").captureToImage().toPixelMap() + assertEquals(expectedSidebar.toArgb(), pixels[pixels.width / 20, pixels.height * 9 / 10].toArgb()) + } + @Test fun androidPagesUseStaticFeatureIconsWithoutTitleDescriptions() = runComposeUiTest { val actions = object : ReceiveInvitationActions {