feat(desktop): unify macOS window chrome

This commit is contained in:
2026-07-13 08:34:44 +02:00
parent b90148f6b8
commit 4059bce3e4
6 changed files with 246 additions and 67 deletions

View File

@@ -1,5 +1,7 @@
package com.vnidrop.app
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -10,6 +12,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.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -36,6 +39,7 @@ import com.vnidrop.app.ui.shell.ScreenScrollContainer
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.windowClassFor
import com.vnidrop.app.ui.theme.LocalVniDropColors
import com.vnidrop.app.ui.theme.VniDropTheme
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
import kotlinx.coroutines.flow.filter
@@ -43,7 +47,12 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
@Composable
fun App(dependencies: AppDependencies) {
fun App(
dependencies: AppDependencies,
windowChromeTopInset: Dp = 0.dp,
windowContentTopStartRadius: Dp = 0.dp,
windowChrome: (@Composable () -> Unit)? = null,
) {
val graphHolder = viewModel { AppGraphViewModel(dependencies) }
val graph = graphHolder.graph
@@ -122,55 +131,67 @@ fun App(dependencies: AppDependencies) {
val darkTheme = rememberResolvedDarkTheme(appState.themeMode)
PlatformSystemAppearance(darkTheme)
VniDropTheme(isDarkTheme = darkTheme) {
BoxWithConstraints {
val windowClass = windowClassFor(maxWidth.value)
val showSendAction = appState.destination == AppDestination.Send &&
windowClass == WindowClass.Phone &&
sendState.selectedTransferId?.let { selectedId ->
sendCoreState.transfers.any { it.transferId == selectedId }
} != true &&
sendCoreState.transfers.any { it.direction == TransferDirection.Send }
val showReceiveAction = appState.destination == AppDestination.Receive &&
windowClass == WindowClass.Phone &&
!receiveState.isAcquisitionOpen &&
receiveCoreState.transfers.any { it.direction == TransferDirection.Receive }
AppShell(
modifier = Modifier.fillMaxSize(),
selectedDestination = appState.destination,
windowClass = windowClass,
onDestinationSelected = appViewModel::selectDestination,
overlay = {
VniDropSnackbarHost(graph.messages, Modifier.align(Alignment.BottomCenter))
},
floatingAction = if (showSendAction) {
{
SendFloatingAction(
onClick = sendViewModel::openComposer,
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
)
}
} else if (showReceiveAction) {
{
ReceiveFloatingAction(
onClick = receiveViewModel::openAcquisition,
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
)
}
} else {
null
},
Box(
modifier = Modifier
.fillMaxSize()
.background(LocalVniDropColors.current.backgroundSurface200),
) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.padding(top = windowChromeTopInset),
) {
when (appState.destination) {
AppDestination.Send -> SendRoute(sendViewModel, windowClass)
AppDestination.Receive -> ReceiveRoute(receiveViewModel, windowClass)
AppDestination.Settings -> ScreenScrollContainer { SettingsRoute(settingsViewModel, windowClass) }
val windowClass = windowClassFor(maxWidth.value)
val showSendAction = appState.destination == AppDestination.Send &&
windowClass == WindowClass.Phone &&
sendState.selectedTransferId?.let { selectedId ->
sendCoreState.transfers.any { it.transferId == selectedId }
} != true &&
sendCoreState.transfers.any { it.direction == TransferDirection.Send }
val showReceiveAction = appState.destination == AppDestination.Receive &&
windowClass == WindowClass.Phone &&
!receiveState.isAcquisitionOpen &&
receiveCoreState.transfers.any { it.direction == TransferDirection.Receive }
AppShell(
modifier = Modifier.fillMaxSize(),
selectedDestination = appState.destination,
windowClass = windowClass,
mainContentTopStartRadius = windowContentTopStartRadius,
onDestinationSelected = appViewModel::selectDestination,
overlay = {
VniDropSnackbarHost(graph.messages, Modifier.align(Alignment.BottomCenter))
},
floatingAction = if (showSendAction) {
{
SendFloatingAction(
onClick = sendViewModel::openComposer,
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
)
}
} else if (showReceiveAction) {
{
ReceiveFloatingAction(
onClick = receiveViewModel::openAcquisition,
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
)
}
} else {
null
},
) {
when (appState.destination) {
AppDestination.Send -> SendRoute(sendViewModel, windowClass)
AppDestination.Receive -> ReceiveRoute(receiveViewModel, windowClass)
AppDestination.Settings -> ScreenScrollContainer { SettingsRoute(settingsViewModel, windowClass) }
}
}
ApprovalModalHost(
state = approvalState,
onAccept = graph.approvalCoordinator::accept,
onRefuse = graph.approvalCoordinator::refuse,
)
}
ApprovalModalHost(
state = approvalState,
onAccept = graph.approvalCoordinator::accept,
onRefuse = graph.approvalCoordinator::refuse,
)
windowChrome?.invoke()
}
}
}

View File

@@ -28,6 +28,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
@@ -36,6 +37,7 @@ import org.jetbrains.compose.resources.stringResource
fun AppSidebarNavigation(
selected: AppDestination,
onDestinationSelected: (AppDestination) -> Unit,
dividerTopInset: Dp = 0.dp,
modifier: Modifier = Modifier,
) {
val colors = LocalVniDropColors.current
@@ -62,7 +64,8 @@ fun AppSidebarNavigation(
}
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.align(Alignment.TopEnd)
.padding(top = dividerTopInset)
.width(1.dp)
.fillMaxHeight()
.background(colors.borderDefault),

View File

@@ -10,9 +10,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
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.unit.Dp
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.navigation.AppBottomNavigation
import com.vnidrop.app.ui.navigation.AppDestination
@@ -26,6 +29,7 @@ fun AppShell(
modifier: Modifier = Modifier,
selectedDestination: AppDestination,
windowClass: WindowClass,
mainContentTopStartRadius: Dp = 0.dp,
onDestinationSelected: (AppDestination) -> Unit,
overlay: @Composable BoxScope.() -> Unit = {},
floatingAction: (@Composable BoxScope.() -> Unit)? = null,
@@ -49,6 +53,7 @@ fun AppShell(
} else {
WideShell(
selectedDestination = selectedDestination,
mainContentTopStartRadius = mainContentTopStartRadius,
onDestinationSelected = onDestinationSelected,
overlay = overlay,
floatingAction = floatingAction,
@@ -61,17 +66,38 @@ fun AppShell(
@Composable
private fun WideShell(
selectedDestination: AppDestination,
mainContentTopStartRadius: Dp,
onDestinationSelected: (AppDestination) -> Unit,
overlay: @Composable BoxScope.() -> Unit,
floatingAction: (@Composable BoxScope.() -> Unit)?,
content: @Composable () -> Unit,
) {
Row(modifier = Modifier.fillMaxSize()) {
val colors = LocalVniDropColors.current
val roundedContent = mainContentTopStartRadius > 0.dp
Row(
modifier = Modifier
.fillMaxSize()
.then(if (roundedContent) Modifier.background(colors.backgroundSurface200) else Modifier),
) {
AppSidebarNavigation(
selected = selectedDestination,
dividerTopInset = mainContentTopStartRadius,
onDestinationSelected = onDestinationSelected,
)
Box(modifier = Modifier.weight(1f).fillMaxSize()) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxSize()
.then(
if (roundedContent) {
Modifier
.clip(RoundedCornerShape(topStart = mainContentTopStartRadius))
.background(colors.backgroundDashCanvas)
} else {
Modifier
},
),
) {
content()
floatingAction?.invoke(this)
Box(Modifier.fillMaxSize().padding(bottom = if (floatingAction == null) 0.dp else 72.dp)) { overlay() }