feat(ui): adapt presentation to each platform

This commit is contained in:
2026-07-22 10:04:32 +02:00
parent 4feff29c74
commit 627c205853
18 changed files with 771 additions and 199 deletions

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -34,11 +35,13 @@ import com.vnidrop.app.feature.settings.SettingsViewModel
import com.vnidrop.app.platform.PlatformSystemAppearance
import com.vnidrop.app.ui.feedback.VniDropSnackbarHost
import com.vnidrop.app.ui.navigation.AppDestination
import com.vnidrop.app.ui.platform.LocalUiPlatform
import com.vnidrop.app.ui.platform.contentWindowClassFor
import com.vnidrop.app.ui.platform.usesMobilePresentation
import com.vnidrop.app.ui.shell.AppShell
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
@@ -138,68 +141,72 @@ fun App(
val darkTheme = rememberResolvedDarkTheme(appState.themeMode)
PlatformSystemAppearance(darkTheme)
VniDropTheme(isDarkTheme = darkTheme) {
Box(
modifier = Modifier
.fillMaxSize()
.background(LocalVniDropColors.current.backgroundSurface200),
) {
BoxWithConstraints(
CompositionLocalProvider(LocalUiPlatform provides dependencies.environment.uiPlatform) {
VniDropTheme(isDarkTheme = darkTheme) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = windowChromeTopInset),
.background(LocalVniDropColors.current.backgroundSurface200),
) {
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
},
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 = contentWindowClassFor(dependencies.environment.uiPlatform, maxWidth.value)
val usesFloatingActions = usesMobilePresentation(dependencies.environment.uiPlatform, windowClass)
val showSendAction = appState.destination == AppDestination.Send &&
usesFloatingActions &&
sendState.selectedTransferId?.let { selectedId ->
sendCoreState.transfers.any { it.transferId == selectedId }
} != true &&
sendCoreState.transfers.any { it.direction == TransferDirection.Send }
val showReceiveAction = appState.destination == AppDestination.Receive &&
usesFloatingActions &&
!receiveState.isAcquisitionOpen &&
receiveCoreState.transfers.any { it.direction == TransferDirection.Receive }
AppShell(
modifier = Modifier.fillMaxSize(),
selectedDestination = appState.destination,
windowClass = windowClass,
uiPlatform = dependencies.environment.uiPlatform,
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()
}
windowChrome?.invoke()
}
}
}

View File

@@ -4,11 +4,22 @@ import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.notifications.LocalNotificationService
import com.vnidrop.app.feature.receive.ExternalInvitationController
enum class UiPlatform {
Android,
Windows,
Linux,
Desktop,
}
val UiPlatform.isDesktop: Boolean
get() = this != UiPlatform.Android
data class PlatformEnvironment(
val name: String,
val appVersion: String,
val defaultCoreDataDir: String,
val defaultUsername: String = "Receiver",
val uiPlatform: UiPlatform = UiPlatform.Android,
)
data class DeviceInfo(

View File

@@ -55,6 +55,8 @@ import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.ProgressRow
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.feedback.UiText
import com.vnidrop.app.ui.platform.LocalUiPlatform
import com.vnidrop.app.ui.platform.usesMobilePresentation
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes
@@ -93,12 +95,13 @@ fun ReceiveScreen(
) {
val transfers = coreState.transfers.filter { it.direction == TransferDirection.Receive }
val deletableTransfers = transfers.filter { it.status.isTerminalReceiveHistory() }
val usesFloatingAction = usesMobilePresentation(LocalUiPlatform.current, windowClass)
LazyColumn(
modifier = Modifier.fillMaxSize().statusBarsPadding(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item { ReceiveHeader(transfers.isNotEmpty(), windowClass, onOpenAcquisition) }
item { ReceiveHeader(transfers.isNotEmpty() && !usesFloatingAction, onOpenAcquisition) }
if (transfers.isEmpty()) item { ReceiveEmptyState(onOpenAcquisition) }
else {
item {
@@ -156,13 +159,13 @@ fun ReceiveScreen(
}
@Composable
private fun ReceiveHeader(showAction: Boolean, windowClass: WindowClass, onOpen: () -> Unit) {
private fun ReceiveHeader(showAction: Boolean, onOpen: () -> Unit) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(Res.string.receive_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text(stringResource(Res.string.receive_new_subtitle), color = LocalVniDropColors.current.foregroundLighter)
}
if (showAction && windowClass != WindowClass.Phone) {
if (showAction) {
Spacer(Modifier.width(16.dp))
PrimaryButton(stringResource(Res.string.button_receive_files), onClick = onOpen)
}

View File

@@ -42,6 +42,8 @@ import com.vnidrop.app.ui.components.PillTone
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.ProgressRow
import com.vnidrop.app.ui.components.StatusPill
import com.vnidrop.app.ui.platform.LocalUiPlatform
import com.vnidrop.app.ui.platform.usesMobilePresentation
import com.vnidrop.app.ui.state.TransferProgress
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.activeSendProgress
@@ -82,17 +84,18 @@ internal fun TransferCatalog(
onOpenComposer: () -> Unit,
onTransferSelected: (ULong) -> Unit,
) {
val usesFloatingAction = usesMobilePresentation(LocalUiPlatform.current, windowClass)
LazyColumn(
modifier = Modifier.fillMaxSize().statusBarsPadding(),
contentPadding = PaddingValues(
start = 16.dp,
top = 16.dp,
end = 16.dp,
bottom = if (windowClass == WindowClass.Phone && transfers.isNotEmpty()) 96.dp else 24.dp,
bottom = if (usesFloatingAction && transfers.isNotEmpty()) 96.dp else 24.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { CatalogHeader(showAction = windowClass != WindowClass.Phone && transfers.isNotEmpty(), onOpenComposer) }
item { CatalogHeader(showAction = !usesFloatingAction && transfers.isNotEmpty(), onOpenComposer) }
if (transfers.isEmpty()) {
item { SendEmptyState(onOpenComposer) }
} else {

View File

@@ -2,24 +2,58 @@ package com.vnidrop.app.feature.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.about_bug_report
import vnidrop.shared.generated.resources.about_privacy
import vnidrop.shared.generated.resources.about_description
import vnidrop.shared.generated.resources.about_is_direct
import vnidrop.shared.generated.resources.about_is_encrypted
import vnidrop.shared.generated.resources.about_is_in_control
import vnidrop.shared.generated.resources.about_is_no_account
import vnidrop.shared.generated.resources.about_is_open
import vnidrop.shared.generated.resources.about_is_title
import vnidrop.shared.generated.resources.about_isnt_cloud
import vnidrop.shared.generated.resources.about_isnt_public
import vnidrop.shared.generated.resources.about_isnt_sync
import vnidrop.shared.generated.resources.about_isnt_title
import vnidrop.shared.generated.resources.about_license_label
import vnidrop.shared.generated.resources.about_privacy_capability
import vnidrop.shared.generated.resources.about_privacy_deny
import vnidrop.shared.generated.resources.about_privacy_local
import vnidrop.shared.generated.resources.about_privacy_policy_label
import vnidrop.shared.generated.resources.about_privacy_relay
import vnidrop.shared.generated.resources.about_privacy_title
import vnidrop.shared.generated.resources.about_tagline
import vnidrop.shared.generated.resources.about_title
import vnidrop.shared.generated.resources.battery_level_title
import vnidrop.shared.generated.resources.device_model_title
import vnidrop.shared.generated.resources.device_name_title
import vnidrop.shared.generated.resources.diagnostics_description
import vnidrop.shared.generated.resources.diagnostics_title
import vnidrop.shared.generated.resources.network_title
import vnidrop.shared.generated.resources.os_version_title
import vnidrop.shared.generated.resources.value_unavailable
import vnidrop.shared.generated.resources.version_title
private const val PrivacyPolicyUrl = "https://github.com/vnidrop/vnidrop"
@Composable
internal fun AboutSettings(
state: SettingsState,
@@ -28,18 +62,75 @@ internal fun AboutSettings(
onBack: () -> Unit,
showBack: Boolean,
) {
val colors = LocalVniDropColors.current
val unavailable = stringResource(Res.string.value_unavailable)
val info = state.deviceInfo
val uriHandler = LocalUriHandler.current
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SettingsTopBar(stringResource(Res.string.about_title), onBack, showBack)
SettingsGroup {
SettingsRow(
icon = SettingsIcons.Document,
title = stringResource(Res.string.about_privacy),
iconTone = SettingsIconTone.Neutral,
Text(
stringResource(Res.string.about_tagline),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
SettingsDivider(startPadding = 16.dp)
Text(
stringResource(Res.string.about_description),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
color = colors.foregroundLighter,
style = MaterialTheme.typography.bodyMedium,
)
}
AboutSection(
title = stringResource(Res.string.about_is_title),
points = listOf(
SettingsIcons.PaperPlane to stringResource(Res.string.about_is_direct),
SettingsIcons.AccountOff to stringResource(Res.string.about_is_no_account),
SettingsIcons.ShieldCheck to stringResource(Res.string.about_is_in_control),
SettingsIcons.Lock to stringResource(Res.string.about_is_encrypted),
SettingsIcons.Code to stringResource(Res.string.about_is_open),
),
)
AboutSection(
title = stringResource(Res.string.about_isnt_title),
points = listOf(
SettingsIcons.CloudOff to stringResource(Res.string.about_isnt_cloud),
SettingsIcons.Sync to stringResource(Res.string.about_isnt_sync),
SettingsIcons.Megaphone to stringResource(Res.string.about_isnt_public),
),
)
AboutSection(
title = stringResource(Res.string.about_privacy_title),
points = listOf(
SettingsIcons.QrCode to stringResource(Res.string.about_privacy_capability),
SettingsIcons.Hand to stringResource(Res.string.about_privacy_deny),
SettingsIcons.Radio to stringResource(Res.string.about_privacy_relay),
SettingsIcons.Drive to stringResource(Res.string.about_privacy_local),
),
)
SettingsGroup {
AboutInfoItem(stringResource(Res.string.version_title), state.appVersion)
SettingsDivider(startPadding = 16.dp)
AboutInfoItem(stringResource(Res.string.device_model_title), info?.deviceModel.orUnavailable(unavailable))
SettingsDivider(startPadding = 16.dp)
AboutInfoItem(stringResource(Res.string.os_version_title), info?.operatingSystem ?: unavailable)
SettingsDivider(startPadding = 16.dp)
AboutInfoItem(stringResource(Res.string.about_license_label), "Apache 2.0")
SettingsDivider()
SettingsRow(
icon = SettingsIcons.Shield,
title = stringResource(Res.string.about_privacy_policy_label),
iconTone = SettingsIconTone.Brand,
onClick = { uriHandler.openUri(PrivacyPolicyUrl) },
)
}
SettingsGroup {
if (DiagnosticsBuildConfig.INCLUDED) {
SettingsDivider()
SettingsToggleRow(
icon = SettingsIcons.Info,
title = stringResource(Res.string.diagnostics_title),
@@ -48,8 +139,8 @@ internal fun AboutSettings(
enabled = true,
onCheckedChange = onDiagnosticsChanged,
)
SettingsDivider()
}
SettingsDivider()
SettingsRow(
icon = SettingsIcons.Bug,
title = stringResource(Res.string.about_bug_report),
@@ -57,20 +148,70 @@ internal fun AboutSettings(
onClick = onReportBug,
)
}
}
}
@Composable
private fun AboutSection(
title: String,
points: List<Pair<ImageVector, String>>,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
title,
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
SettingsGroup {
InfoItem(stringResource(Res.string.version_title), state.appVersion)
SettingsDivider(startPadding = 16.dp)
InfoItem(stringResource(Res.string.device_name_title), info?.deviceName.orUnavailable(unavailable))
SettingsDivider(startPadding = 16.dp)
InfoItem(stringResource(Res.string.device_model_title), info?.deviceModel.orUnavailable(unavailable))
SettingsDivider(startPadding = 16.dp)
InfoItem(stringResource(Res.string.os_version_title), info?.operatingSystem ?: unavailable)
SettingsDivider(startPadding = 16.dp)
InfoItem(stringResource(Res.string.network_title), info?.network.orUnavailable(unavailable))
SettingsDivider(startPadding = 16.dp)
InfoItem(stringResource(Res.string.battery_level_title), info?.batteryLevel.orUnavailable(unavailable))
points.forEachIndexed { index, (icon, text) ->
if (index > 0) SettingsDivider(startPadding = 54.dp)
AboutPoint(icon, text)
}
}
}
}
@Composable
private fun AboutPoint(icon: ImageVector, text: String) {
val colors = LocalVniDropColors.current
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, contentDescription = null, tint = colors.brandLink, modifier = Modifier.size(24.dp))
Spacer(Modifier.width(16.dp))
Text(
text,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Normal,
)
}
}
@Composable
private fun AboutInfoItem(title: String, value: String) {
val colors = LocalVniDropColors.current
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(title, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.width(16.dp))
Text(
value,
color = colors.foregroundLighter,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.End,
)
}
}
private fun String?.orUnavailable(fallback: String): String = this?.takeIf(String::isNotBlank) ?: fallback

View File

@@ -28,6 +28,181 @@ internal object SettingsIcons {
lineTo(9f, 17f)
lineTo(4f, 12f)
}
val PaperPlane = lineIcon("PaperPlane") {
moveTo(22f, 2f)
lineTo(15f, 22f)
lineTo(11f, 13f)
lineTo(2f, 9f)
close()
moveTo(22f, 2f)
lineTo(11f, 13f)
}
val AccountOff = lineIcon("AccountOff") {
circle(9f, 8f, 4f)
moveTo(2f, 21f)
curveTo(2.8f, 16.8f, 5f, 15f, 9f, 15f)
curveTo(11.1f, 15f, 12.7f, 15.5f, 14f, 16.5f)
circle(18f, 18f, 4f)
moveTo(16.6f, 16.6f)
lineTo(19.4f, 19.4f)
moveTo(19.4f, 16.6f)
lineTo(16.6f, 19.4f)
}
val ShieldCheck = lineIcon("ShieldCheck") {
moveTo(12f, 22f)
curveTo(17f, 19.5f, 20f, 16.5f, 20f, 11f)
lineTo(20f, 5f)
lineTo(12f, 2f)
lineTo(4f, 5f)
lineTo(4f, 11f)
curveTo(4f, 16.5f, 7f, 19.5f, 12f, 22f)
moveTo(8f, 12f)
lineTo(11f, 15f)
lineTo(16f, 9f)
}
val Shield = lineIcon("Shield") {
moveTo(12f, 22f)
curveTo(17f, 19.5f, 20f, 16.5f, 20f, 11f)
lineTo(20f, 5f)
lineTo(12f, 2f)
lineTo(4f, 5f)
lineTo(4f, 11f)
curveTo(4f, 16.5f, 7f, 19.5f, 12f, 22f)
}
val Lock = lineIcon("Lock") {
roundRect(5f, 10f, 14f, 11f, 2f)
moveTo(8f, 10f)
lineTo(8f, 7f)
arcTo(4f, 4f, 0f, false, true, 16f, 7f)
lineTo(16f, 10f)
}
val Code = lineIcon("Code") {
moveTo(8f, 9f)
lineTo(3f, 14f)
lineTo(8f, 19f)
moveTo(16f, 9f)
lineTo(21f, 14f)
lineTo(16f, 19f)
moveTo(14f, 4f)
lineTo(10f, 22f)
}
val CloudOff = lineIcon("CloudOff") {
moveTo(5.5f, 5.5f)
lineTo(18.5f, 18.5f)
moveTo(7f, 18f)
lineTo(6f, 18f)
curveTo(2.7f, 18f, 1f, 16.2f, 1f, 13.5f)
curveTo(1f, 10.7f, 3.1f, 8.5f, 6f, 8.1f)
curveTo(7.5f, 4.9f, 10.1f, 3f, 13.5f, 3f)
curveTo(18f, 3f, 21f, 6.5f, 21f, 11f)
curveTo(22.3f, 12f, 23f, 13.4f, 23f, 15f)
curveTo(23f, 16.1f, 22.7f, 17f, 22f, 18f)
}
val Sync = lineIcon("Sync") {
moveTo(20f, 7f)
lineTo(20f, 3f)
lineTo(16f, 3f)
moveTo(20f, 3f)
curveTo(17.7f, 1.4f, 14.8f, 1f, 12f, 2f)
curveTo(9.6f, 2.8f, 7.7f, 4.6f, 7f, 7f)
moveTo(4f, 17f)
lineTo(4f, 21f)
lineTo(8f, 21f)
moveTo(4f, 21f)
curveTo(6.3f, 22.6f, 9.2f, 23f, 12f, 22f)
curveTo(14.4f, 21.2f, 16.3f, 19.4f, 17f, 17f)
}
val Megaphone = lineIcon("Megaphone") {
moveTo(3f, 11f)
lineTo(3f, 15f)
lineTo(7f, 15f)
lineTo(18f, 20f)
lineTo(18f, 6f)
lineTo(7f, 11f)
close()
moveTo(7f, 15f)
lineTo(9f, 21f)
lineTo(13f, 21f)
lineTo(11.5f, 17f)
moveTo(21f, 10f)
lineTo(21f, 16f)
}
val QrCode = lineIcon("QrCode") {
moveTo(3f, 9f)
lineTo(3f, 3f)
lineTo(9f, 3f)
moveTo(15f, 3f)
lineTo(21f, 3f)
lineTo(21f, 9f)
moveTo(3f, 15f)
lineTo(3f, 21f)
lineTo(9f, 21f)
moveTo(15f, 21f)
lineTo(15f, 15f)
lineTo(21f, 15f)
moveTo(7f, 7f)
lineTo(7.01f, 7f)
moveTo(17f, 7f)
lineTo(17.01f, 7f)
moveTo(7f, 17f)
lineTo(7.01f, 17f)
moveTo(20f, 20f)
lineTo(20.01f, 20f)
}
val Hand = lineIcon("Hand", strokeWidth = 1.6f) {
moveTo(4f, 14f)
lineTo(4f, 10f)
curveTo(4f, 8.9f, 4.9f, 8f, 6f, 8f)
curveTo(7.1f, 8f, 8f, 8.9f, 8f, 10f)
lineTo(8f, 12f)
lineTo(8.5f, 12f)
lineTo(8.5f, 6f)
curveTo(8.5f, 4.9f, 9.4f, 4f, 10.5f, 4f)
curveTo(11.6f, 4f, 12.5f, 4.9f, 12.5f, 6f)
lineTo(12.5f, 11f)
lineTo(13f, 11f)
lineTo(13f, 4f)
curveTo(13f, 2.9f, 13.9f, 2f, 15f, 2f)
curveTo(16.1f, 2f, 17f, 2.9f, 17f, 4f)
lineTo(17f, 12f)
lineTo(17.5f, 12f)
lineTo(17.5f, 7f)
curveTo(17.5f, 5.9f, 18.4f, 5f, 19.5f, 5f)
curveTo(20.6f, 5f, 21.5f, 5.9f, 21.5f, 7f)
lineTo(21.5f, 14f)
lineTo(22f, 13.5f)
curveTo(22.4f, 13.1f, 23f, 13.2f, 23.4f, 13.7f)
curveTo(23.9f, 14.4f, 23.7f, 15.3f, 23.1f, 16f)
lineTo(18.8f, 21f)
curveTo(17.7f, 22.3f, 16.1f, 23f, 14.3f, 23f)
lineTo(12f, 23f)
curveTo(7.6f, 23f, 4f, 19.4f, 4f, 15f)
close()
}
val Radio = lineIcon("Radio") {
moveTo(12f, 12f)
lineTo(12f, 22f)
moveTo(9f, 22f)
lineTo(15f, 22f)
moveTo(9f, 9f)
curveTo(7.5f, 10.7f, 7.5f, 13.3f, 9f, 15f)
moveTo(15f, 9f)
curveTo(16.5f, 10.7f, 16.5f, 13.3f, 15f, 15f)
moveTo(6f, 6f)
curveTo(2.7f, 9.3f, 2.7f, 14.7f, 6f, 18f)
moveTo(18f, 6f)
curveTo(21.3f, 9.3f, 21.3f, 14.7f, 18f, 18f)
circle(12f, 12f, 1f)
}
val Drive = lineIcon("Drive") {
roundRect(2f, 6f, 20f, 12f, 3f)
moveTo(2f, 14f)
lineTo(22f, 14f)
moveTo(17f, 16f)
lineTo(17.01f, 16f)
moveTo(20f, 16f)
lineTo(20.01f, 16f)
}
val Sun = lineIcon("Sun") {
moveTo(12f, 4f)
lineTo(12f, 2f)
@@ -117,12 +292,16 @@ internal object SettingsIcons {
}
}
private fun lineIcon(name: String, block: PathBuilder.() -> Unit): ImageVector =
private fun lineIcon(
name: String,
strokeWidth: Float = 2f,
block: PathBuilder.() -> Unit,
): ImageVector =
ImageVector.Builder(name, 24.dp, 24.dp, 24f, 24f).apply {
path(
fill = SolidColor(Color.Transparent),
stroke = SolidColor(Color.Black),
strokeLineWidth = 2f,
strokeLineWidth = strokeWidth,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round,
pathFillType = PathFillType.NonZero,
@@ -141,3 +320,9 @@ private fun PathBuilder.roundRect(x: Float, y: Float, width: Float, height: Floa
lineTo(x, y + radius)
arcTo(radius, radius, 0f, false, true, x + radius, y)
}
private fun PathBuilder.circle(centerX: Float, centerY: Float, radius: Float) {
moveTo(centerX + radius, centerY)
arcTo(radius, radius, 0f, true, true, centerX - radius, centerY)
arcTo(radius, radius, 0f, true, true, centerX + radius, centerY)
}

View File

@@ -23,6 +23,9 @@ import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vnidrop.app.isDesktop
import com.vnidrop.app.ui.platform.LocalUiPlatform
import com.vnidrop.app.ui.platform.usesMobilePresentation
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
@@ -36,7 +39,8 @@ fun AdaptiveDrawer(
onDismissRequest: () -> Unit,
content: @Composable () -> Unit,
) {
if (windowClass == WindowClass.Phone) {
val uiPlatform = LocalUiPlatform.current
if (usesMobilePresentation(uiPlatform, windowClass)) {
ModalBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
@@ -51,7 +55,7 @@ fun AdaptiveDrawer(
) {
Surface(
modifier = Modifier.fillMaxWidth(0.86f).widthIn(max = 560.dp),
shape = RoundedCornerShape(20.dp),
shape = RoundedCornerShape(if (uiPlatform.isDesktop) 10.dp else 24.dp),
color = LocalVniDropColors.current.backgroundDialog,
shadowElevation = 12.dp,
) { ClosableModalContent(onDismissRequest, content = content) }

View File

@@ -12,15 +12,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vnidrop.app.isDesktop
import com.vnidrop.app.ui.platform.LocalUiPlatform
import com.vnidrop.app.ui.theme.LocalVniDropColors
@Composable
fun PrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
val desktop = LocalUiPlatform.current.isDesktop
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 44.dp),
shape = RoundedCornerShape(8.dp),
modifier = modifier.heightIn(min = if (desktop) 36.dp else 44.dp),
shape = RoundedCornerShape(if (desktop) 6.dp else 8.dp),
colors = ButtonDefaults.buttonColors(containerColor = LocalVniDropColors.current.brandButton, contentColor = Color.White),
) {
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
@@ -29,24 +32,32 @@ fun PrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifi
@Composable
fun SecondaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
OutlinedButton(onClick = onClick, enabled = enabled, modifier = modifier.heightIn(min = 44.dp), shape = RoundedCornerShape(8.dp)) {
val desktop = LocalUiPlatform.current.isDesktop
OutlinedButton(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = if (desktop) 36.dp else 44.dp),
shape = RoundedCornerShape(if (desktop) 6.dp else 8.dp),
) {
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
fun QuietButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
TextButton(onClick = onClick, enabled = enabled, modifier = modifier.heightIn(min = 40.dp)) {
val desktop = LocalUiPlatform.current.isDesktop
TextButton(onClick = onClick, enabled = enabled, modifier = modifier.heightIn(min = if (desktop) 32.dp else 40.dp)) {
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
fun DestructiveQuietButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
val desktop = LocalUiPlatform.current.isDesktop
TextButton(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 40.dp),
modifier = modifier.heightIn(min = if (desktop) 32.dp else 40.dp),
colors = ButtonDefaults.textButtonColors(contentColor = LocalVniDropColors.current.destructiveDefault),
) {
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
@@ -55,11 +66,12 @@ fun DestructiveQuietButton(text: String, onClick: () -> Unit, modifier: Modifier
@Composable
fun DestructiveButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
val desktop = LocalUiPlatform.current.isDesktop
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 44.dp),
shape = RoundedCornerShape(8.dp),
modifier = modifier.heightIn(min = if (desktop) 36.dp else 44.dp),
shape = RoundedCornerShape(if (desktop) 6.dp else 8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = LocalVniDropColors.current.destructiveDefault,
contentColor = Color.White,

View File

@@ -1,12 +1,18 @@
package com.vnidrop.app.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vnidrop.app.isDesktop
import com.vnidrop.app.ui.platform.LocalUiPlatform
import com.vnidrop.app.ui.theme.LocalVniDropColors
@Composable
fun Field(
@@ -17,13 +23,23 @@ fun Field(
minLines: Int = 1,
enabled: Boolean = true,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
modifier = modifier.fillMaxWidth(),
minLines = minLines,
enabled = enabled,
shape = RoundedCornerShape(8.dp),
)
val desktop = LocalUiPlatform.current.isDesktop
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (desktop) {
Text(label, color = LocalVniDropColors.current.foregroundLight, style = MaterialTheme.typography.bodySmall)
}
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = if (desktop) {
null
} else {
{ Text(label) }
},
modifier = Modifier.fillMaxWidth(),
minLines = minLines,
enabled = enabled,
shape = RoundedCornerShape(if (desktop) 5.dp else 8.dp),
)
}
}

View File

@@ -6,19 +6,22 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.NavigationRailItemDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -26,36 +29,106 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.UiPlatform
import com.vnidrop.app.isDesktop
import com.vnidrop.app.ui.platform.DesktopNavigationWidthDp
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
enum class NavigationStyle {
AndroidBottomBar,
AndroidRail,
DesktopSidebar,
}
fun navigationStyleFor(uiPlatform: UiPlatform, windowClass: WindowClass): NavigationStyle = when {
uiPlatform.isDesktop -> NavigationStyle.DesktopSidebar
windowClass == WindowClass.Phone -> NavigationStyle.AndroidBottomBar
else -> NavigationStyle.AndroidRail
}
@Composable
fun AppSidebarNavigation(
selected: AppDestination,
style: NavigationStyle,
onDestinationSelected: (AppDestination) -> Unit,
dividerTopInset: Dp = 0.dp,
modifier: Modifier = Modifier,
) {
when (style) {
NavigationStyle.AndroidRail -> AndroidNavigationRail(selected, onDestinationSelected, modifier)
NavigationStyle.DesktopSidebar -> DesktopSidebarNavigation(
selected = selected,
onDestinationSelected = onDestinationSelected,
dividerTopInset = dividerTopInset,
modifier = modifier,
)
NavigationStyle.AndroidBottomBar -> error("Bottom navigation is rendered by the phone shell")
}
}
@Composable
private fun AndroidNavigationRail(
selected: AppDestination,
onDestinationSelected: (AppDestination) -> Unit,
modifier: Modifier = Modifier,
) {
val colors = LocalVniDropColors.current
NavigationRail(
modifier = modifier.fillMaxHeight(),
containerColor = colors.backgroundSurface200,
) {
Spacer(Modifier.height(8.dp))
primaryNavigationItems.forEach { item ->
val label = stringResource(item.label)
NavigationRailItem(
selected = item.destination == selected,
onClick = { onDestinationSelected(item.destination) },
icon = { Icon(item.icon, contentDescription = label) },
label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) },
colors = NavigationRailItemDefaults.colors(
selectedIconColor = colors.brandLink,
selectedTextColor = colors.brandLink,
indicatorColor = colors.backgroundSelection,
unselectedIconColor = colors.foregroundLight,
unselectedTextColor = colors.foregroundLight,
),
)
}
}
}
@Composable
private fun DesktopSidebarNavigation(
selected: AppDestination,
onDestinationSelected: (AppDestination) -> Unit,
dividerTopInset: Dp,
modifier: Modifier = Modifier,
) {
val colors = LocalVniDropColors.current
Box(
modifier = modifier
.width(88.dp)
.width(DesktopNavigationWidthDp.dp)
.fillMaxHeight()
.background(colors.backgroundSurface200),
) {
Column(
modifier = Modifier
.fillMaxHeight()
.padding(vertical = 10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.fillMaxHeight().padding(horizontal = 12.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "VniDrop",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(4.dp))
primaryNavigationItems.forEach { item ->
SidebarNavigationItem(
DesktopNavigationItem(
item = item,
selected = item.destination == selected,
onClick = { onDestinationSelected(item.destination) },
@@ -73,6 +146,37 @@ fun AppSidebarNavigation(
}
}
@Composable
private fun DesktopNavigationItem(
item: NavigationItem,
selected: Boolean,
onClick: () -> Unit,
) {
val colors = LocalVniDropColors.current
val foreground = if (selected) colors.foregroundDefault else colors.foregroundLight
val label = stringResource(item.label)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(if (selected) colors.backgroundSelection else Color.Transparent)
.selectable(selected = selected, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(item.icon, contentDescription = label, tint = if (selected) colors.brandLink else foreground, modifier = Modifier.size(20.dp))
Text(
text = label,
color = foreground,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
fun AppBottomNavigation(
selected: AppDestination,
@@ -80,92 +184,25 @@ fun AppBottomNavigation(
modifier: Modifier = Modifier,
) {
val colors = LocalVniDropColors.current
Column(
modifier = modifier
.fillMaxWidth()
.background(colors.backgroundSurface200),
NavigationBar(
modifier = modifier.fillMaxWidth(),
containerColor = colors.backgroundSurface200,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
primaryNavigationItems.forEach { item ->
BottomNavigationItem(
item = item,
selected = item.destination == selected,
onClick = { onDestinationSelected(item.destination) },
modifier = Modifier.weight(1f),
)
}
}
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
}
@Composable
private fun SidebarNavigationItem(
item: NavigationItem,
selected: Boolean,
onClick: () -> Unit,
) {
val colors = LocalVniDropColors.current
val foreground = if (selected) colors.brandLink else colors.foregroundLight
val label = stringResource(item.label)
Box(
modifier = Modifier
.fillMaxWidth()
.selectable(selected = selected, onClick = onClick)
.padding(vertical = 13.dp),
) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
Icon(imageVector = item.icon, contentDescription = label, tint = foreground, modifier = Modifier.size(24.dp))
Text(
text = label,
color = foreground,
style = MaterialTheme.typography.labelSmall,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
primaryNavigationItems.forEach { item ->
val label = stringResource(item.label)
NavigationBarItem(
selected = item.destination == selected,
onClick = { onDestinationSelected(item.destination) },
icon = { Icon(item.icon, contentDescription = label, modifier = Modifier.size(24.dp)) },
label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = colors.brandLink,
selectedTextColor = colors.brandLink,
indicatorColor = colors.backgroundSelection,
unselectedIconColor = colors.foregroundLight,
unselectedTextColor = colors.foregroundLight,
),
)
}
}
}
@Composable
private fun BottomNavigationItem(
item: NavigationItem,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = LocalVniDropColors.current
val foreground = if (selected) colors.brandLink else colors.foregroundLight
val label = stringResource(item.label)
Column(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.selectable(selected = selected, onClick = onClick)
.fillMaxHeight()
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
) {
Icon(imageVector = item.icon, contentDescription = label, tint = foreground, modifier = Modifier.size(24.dp))
Text(
text = label,
color = foreground,
style = MaterialTheme.typography.labelSmall,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}

View File

@@ -0,0 +1,19 @@
package com.vnidrop.app.ui.platform
import androidx.compose.runtime.staticCompositionLocalOf
import com.vnidrop.app.UiPlatform
import com.vnidrop.app.isDesktop
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.windowClassFor
const val DesktopNavigationWidthDp = 220f
val LocalUiPlatform = staticCompositionLocalOf { UiPlatform.Android }
fun usesMobilePresentation(uiPlatform: UiPlatform, windowClass: WindowClass): Boolean =
uiPlatform == UiPlatform.Android && windowClass == WindowClass.Phone
fun contentWindowClassFor(uiPlatform: UiPlatform, widthDp: Float): WindowClass {
val navigationWidth = if (uiPlatform.isDesktop) DesktopNavigationWidthDp else 0f
return windowClassFor((widthDp - navigationWidth).coerceAtLeast(0f))
}

View File

@@ -17,11 +17,13 @@ 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.UiPlatform
import com.vnidrop.app.ui.navigation.AppBottomNavigation
import com.vnidrop.app.ui.navigation.AppDestination
import com.vnidrop.app.ui.navigation.AppSidebarNavigation
import com.vnidrop.app.ui.navigation.NavigationStyle
import com.vnidrop.app.ui.navigation.navigationStyleFor
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.useBottomNavigation
import com.vnidrop.app.ui.theme.LocalVniDropColors
@Composable
@@ -29,6 +31,7 @@ fun AppShell(
modifier: Modifier = Modifier,
selectedDestination: AppDestination,
windowClass: WindowClass,
uiPlatform: UiPlatform,
mainContentTopStartRadius: Dp = 0.dp,
onDestinationSelected: (AppDestination) -> Unit,
overlay: @Composable BoxScope.() -> Unit = {},
@@ -36,13 +39,14 @@ fun AppShell(
content: @Composable () -> Unit,
) {
val colors = LocalVniDropColors.current
val navigationStyle = navigationStyleFor(uiPlatform, windowClass)
Surface(
modifier = modifier
.fillMaxSize()
.background(colors.backgroundDashCanvas),
color = colors.backgroundDashCanvas,
) {
if (useBottomNavigation(windowClass)) {
if (navigationStyle == NavigationStyle.AndroidBottomBar) {
PhoneShell(
selectedDestination = selectedDestination,
onDestinationSelected = onDestinationSelected,
@@ -53,6 +57,7 @@ fun AppShell(
} else {
WideShell(
selectedDestination = selectedDestination,
navigationStyle = navigationStyle,
mainContentTopStartRadius = mainContentTopStartRadius,
onDestinationSelected = onDestinationSelected,
overlay = overlay,
@@ -66,6 +71,7 @@ fun AppShell(
@Composable
private fun WideShell(
selectedDestination: AppDestination,
navigationStyle: NavigationStyle,
mainContentTopStartRadius: Dp,
onDestinationSelected: (AppDestination) -> Unit,
overlay: @Composable BoxScope.() -> Unit,
@@ -81,6 +87,7 @@ private fun WideShell(
) {
AppSidebarNavigation(
selected = selectedDestination,
style = navigationStyle,
dividerTopInset = mainContentTopStartRadius,
onDestinationSelected = onDestinationSelected,
)