From 627c205853e1bf44f83cfc7383bee424752d661c Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Wed, 22 Jul 2026 10:04:32 +0200 Subject: [PATCH 1/6] feat(ui): adapt presentation to each platform --- .../com/vnidrop/app/Platform.android.kt | 1 + .../commonMain/kotlin/com/vnidrop/app/App.kt | 119 ++++----- .../kotlin/com/vnidrop/app/Platform.kt | 11 + .../app/feature/receive/ReceiveScreen.kt | 9 +- .../vnidrop/app/feature/send/SendCatalog.kt | 7 +- .../app/feature/settings/AboutSettings.kt | 183 ++++++++++++-- .../app/feature/settings/SettingsIcons.kt | 189 ++++++++++++++- .../app/ui/components/AdaptiveDrawer.kt | 8 +- .../com/vnidrop/app/ui/components/Buttons.kt | 26 +- .../com/vnidrop/app/ui/components/Field.kt | 34 ++- .../app/ui/navigation/AppNavigation.kt | 227 ++++++++++-------- .../com/vnidrop/app/ui/platform/PlatformUi.kt | 19 ++ .../com/vnidrop/app/ui/shell/AppShell.kt | 11 +- .../app/ui/navigation/NavigationModelTest.kt | 18 ++ .../vnidrop/app/ui/platform/PlatformUiTest.kt | 25 ++ .../kotlin/com/vnidrop/app/Platform.jvm.kt | 7 + .../kotlin/com/vnidrop/app/PlatformJvmTest.kt | 13 + .../vnidrop/app/ui/FoundationComposeTest.kt | 63 +++++ 18 files changed, 771 insertions(+), 199 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/platform/PlatformUi.kt create mode 100644 shared/src/commonTest/kotlin/com/vnidrop/app/ui/platform/PlatformUiTest.kt create mode 100644 shared/src/jvmTest/kotlin/com/vnidrop/app/PlatformJvmTest.kt diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt index b794cfd..cd5c21c 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt @@ -25,6 +25,7 @@ fun rememberAndroidAppDependencies(activity: ComponentActivity, externalInvitati appVersion = context.appVersion(), defaultCoreDataDir = context.filesDir.resolve("vnidrop").absolutePath, defaultUsername = Build.DEVICE.takeIf(String::isNotBlank) ?: "Receiver", + uiPlatform = UiPlatform.Android, ), deviceInfoProvider = AndroidDeviceInfoProvider(context), fileSystemService = fileSystemService, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index 5c6547b..c4ecf95 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -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() } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt index 647279a..bdc2982 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt @@ -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( diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt index 4b4db2e..f64da87 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt @@ -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) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt index f56d1f9..d4dc57e 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt @@ -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 { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt index ede24c5..e2f3d65 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt @@ -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>, +) { + 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 diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsIcons.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsIcons.kt index 9b9c885..97cbe79 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsIcons.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsIcons.kt @@ -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) +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt index 4750994..56e9ee3 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt @@ -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) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt index 980af93..78e8a79 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Field.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Field.kt index 3cca324..926672d 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Field.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Field.kt @@ -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), + ) + } } 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 8815212..9a56b9d 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 @@ -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, - ) - } -} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/platform/PlatformUi.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/platform/PlatformUi.kt new file mode 100644 index 0000000..ec76093 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/platform/PlatformUi.kt @@ -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)) +} 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 d149b62..dfac3df 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 @@ -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, ) diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/navigation/NavigationModelTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/navigation/NavigationModelTest.kt index 1d1107b..75a9325 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/navigation/NavigationModelTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/navigation/NavigationModelTest.kt @@ -1,5 +1,7 @@ package com.vnidrop.app.ui.navigation +import com.vnidrop.app.UiPlatform +import com.vnidrop.app.ui.state.WindowClass import kotlin.test.Test import kotlin.test.assertEquals @@ -12,4 +14,20 @@ class NavigationModelTest { ) assertEquals(3, primaryNavigationItems.map { it.label }.distinct().size) } + + @Test + fun androidNavigationFollowsMaterialWindowConventions() { + assertEquals(NavigationStyle.AndroidBottomBar, navigationStyleFor(UiPlatform.Android, WindowClass.Phone)) + assertEquals(NavigationStyle.AndroidRail, navigationStyleFor(UiPlatform.Android, WindowClass.Tablet)) + assertEquals(NavigationStyle.AndroidRail, navigationStyleFor(UiPlatform.Android, WindowClass.Desktop)) + } + + @Test + fun desktopPlatformsUseSourceListNavigationAtEveryWindowSize() { + listOf(UiPlatform.Windows, UiPlatform.Linux, UiPlatform.Desktop).forEach { platform -> + WindowClass.entries.forEach { windowClass -> + assertEquals(NavigationStyle.DesktopSidebar, navigationStyleFor(platform, windowClass)) + } + } + } } diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/platform/PlatformUiTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/platform/PlatformUiTest.kt new file mode 100644 index 0000000..1864da3 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/platform/PlatformUiTest.kt @@ -0,0 +1,25 @@ +package com.vnidrop.app.ui.platform + +import com.vnidrop.app.UiPlatform +import com.vnidrop.app.ui.state.WindowClass +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PlatformUiTest { + @Test + fun onlyCompactAndroidUsesMobilePresentation() { + assertTrue(usesMobilePresentation(UiPlatform.Android, WindowClass.Phone)) + assertFalse(usesMobilePresentation(UiPlatform.Android, WindowClass.Tablet)) + assertFalse(usesMobilePresentation(UiPlatform.Windows, WindowClass.Phone)) + assertFalse(usesMobilePresentation(UiPlatform.Linux, WindowClass.Phone)) + } + + @Test + fun desktopWindowClassAccountsForPersistentNavigation() { + assertEquals(WindowClass.Tablet, contentWindowClassFor(UiPlatform.Android, 800f)) + assertEquals(WindowClass.Phone, contentWindowClassFor(UiPlatform.Windows, 800f)) + assertEquals(WindowClass.Desktop, contentWindowClassFor(UiPlatform.Windows, 1_200f)) + } +} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt index d6a8451..7f71fd9 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt @@ -17,6 +17,7 @@ fun rememberJvmAppDependencies(externalInvitations: ExternalInvitationController appVersion = AppDependencies::class.java.`package`.implementationVersion ?: "0.1.0", defaultCoreDataDir = System.getProperty("user.home") + "/.vnidrop", defaultUsername = System.getenv("COMPUTERNAME") ?: System.getenv("HOSTNAME") ?: System.getProperty("user.name") ?: "Receiver", + uiPlatform = uiPlatformForJvm(System.getProperty("os.name")), ), deviceInfoProvider = JvmDeviceInfoProvider, fileSystemService = fileSystemService, @@ -26,6 +27,12 @@ fun rememberJvmAppDependencies(externalInvitations: ExternalInvitationController } } +internal fun uiPlatformForJvm(osName: String?): UiPlatform = when { + osName.orEmpty().contains("windows", ignoreCase = true) -> UiPlatform.Windows + osName.orEmpty().contains("linux", ignoreCase = true) -> UiPlatform.Linux + else -> UiPlatform.Desktop +} + private object JvmDeviceInfoProvider : DeviceInfoProvider { override suspend fun load(): DeviceInfo = DeviceInfo( deviceName = System.getenv("COMPUTERNAME") ?: System.getenv("HOSTNAME") ?: System.getProperty("user.name"), diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/PlatformJvmTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/PlatformJvmTest.kt new file mode 100644 index 0000000..ca29cae --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/PlatformJvmTest.kt @@ -0,0 +1,13 @@ +package com.vnidrop.app + +import kotlin.test.Test +import kotlin.test.assertEquals + +class PlatformJvmTest { + @Test + fun detectsSupportedDesktopPlatforms() { + assertEquals(UiPlatform.Windows, uiPlatformForJvm("Windows 11")) + assertEquals(UiPlatform.Linux, uiPlatformForJvm("Linux")) + assertEquals(UiPlatform.Desktop, uiPlatformForJvm("Mac OS X")) + } +} 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 e13b944..c48c3d7 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -34,6 +34,7 @@ import com.vnidrop.app.feature.settings.SettingsSection import com.vnidrop.app.feature.settings.SettingsState import com.vnidrop.app.feature.send.SendScreen import com.vnidrop.app.feature.send.SendState +import com.vnidrop.app.UiPlatform import com.vnidrop.app.core.CoreState import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.ShareAccessPolicy @@ -100,6 +101,44 @@ class FoundationComposeTest { onNodeWithText("Get notified about new receive requests while VniDrop is in the background.").assertIsDisplayed() } + @Test + fun aboutSettingsShowsTheSharedProductAndPrivacyContent() = runComposeUiTest { + setContent { + VniDropTheme(isDarkTheme = false) { + Box(Modifier.width(393.dp)) { + SettingsScreen( + state = SettingsState(selectedSection = SettingsSection.About), + windowClass = WindowClass.Phone, + onSectionSelected = {}, + onUsernameChanged = {}, + onThemeModeChanged = {}, + onChooseFolder = {}, + onResetFolder = {}, + onNotificationsChanged = {}, + onOpenNotificationSettings = {}, + onDiagnosticsChanged = {}, + onBugWhatChanged = {}, + onBugExpectedChanged = {}, + onBugStepsChanged = {}, + onBugContactChanged = {}, + onBugIncludeLogsChanged = {}, + onSubmitBugReport = {}, + ) + } + } + } + + onNodeWithText("Send files directly. Stay in control of who receives them.").assertIsDisplayed() + onNodeWithText("What VniDrop is").assertIsDisplayed() + onNodeWithText("What VniDrop isn’t").assertIsDisplayed() + onAllNodesWithText("Privacy & security").assertCountEquals(1) + onAllNodesWithText("Apache 2.0").assertCountEquals(1) + val explanationBounds = onNodeWithText( + "A direct device-to-device transfer — your files go straight to the receiver.", + ).getUnclippedBoundsInRoot() + assertTrue(explanationBounds.bottom - explanationBounds.top > 32.dp) + } + @Test fun notificationSettingCanBeToggledFromItsRow() = runComposeUiTest { var enabled = false @@ -202,6 +241,7 @@ class FoundationComposeTest { AppShell( selectedDestination = AppDestination.Send, windowClass = WindowClass.Phone, + uiPlatform = UiPlatform.Android, onDestinationSelected = {}, overlay = { Box(Modifier.align(Alignment.BottomCenter).size(20.dp).testTag("snackbar-overlay")) @@ -222,6 +262,29 @@ class FoundationComposeTest { assertTrue(overlayBottom <= navigationLabelTop) } + @Test + fun narrowDesktopWindowKeepsDesktopSourceListNavigation() = runComposeUiTest { + var selected = AppDestination.Send + setContent { + VniDropTheme(isDarkTheme = false) { + Box(Modifier.size(width = 560.dp, height = 640.dp)) { + AppShell( + selectedDestination = selected, + windowClass = WindowClass.Phone, + uiPlatform = UiPlatform.Windows, + onDestinationSelected = { selected = it }, + ) { + Text("Content") + } + } + } + } + + onNodeWithText("VniDrop").assertIsDisplayed() + onNodeWithText("Receive").performClick() + runOnIdle { assertEquals(AppDestination.Receive, selected) } + } + @Test fun phoneSendEmptyStateOpensCreationDrawer() = runComposeUiTest { val state = mutableStateOf(SendState()) From cf7734fb42ab1e4270ca0ef67efb5407fcc4113f Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Wed, 22 Jul 2026 11:23:10 +0200 Subject: [PATCH 2/6] feat(desktop): polish Linux native experience --- desktopApp/build.gradle.kts | 2 + .../src/main/kotlin/com/vnidrop/app/main.kt | 69 +++++--- .../vnidrop/app/DesktopWindowChromeTest.kt | 24 +++ gradle/libs.versions.toml | 2 + shared/build.gradle.kts | 3 + .../app/feature/receive/ReceiveScreen.kt | 27 ++- .../vnidrop/app/feature/send/SendCatalog.kt | 35 +++- .../app/feature/settings/SettingsOverview.kt | 14 +- .../app/ui/navigation/AppNavigation.kt | 50 ++---- .../com/vnidrop/app/ui/shell/AppShell.kt | 1 - .../com/vnidrop/app/core/FilePicker.jvm.kt | 154 +++++++++++++----- .../com/vnidrop/app/core/FilePickerJvmTest.kt | 18 ++ .../vnidrop/app/ui/FoundationComposeTest.kt | 62 +++++++ 13 files changed, 346 insertions(+), 115 deletions(-) create mode 100644 shared/src/jvmTest/kotlin/com/vnidrop/app/core/FilePickerJvmTest.kt diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 41dcc83..ab18b16 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(projects.shared) implementation(compose.desktop.currentOs) + implementation(libs.filekit.dialogs) implementation(libs.kotlinx.coroutinesSwing) implementation(libs.compose.uiToolingPreview) @@ -48,6 +49,7 @@ compose.desktop { linux { packageName = "vnidrop" iconFile.set(project.file("../assets/linux/app-icon.png")) + modules("jdk.security.auth") debMaintainer = "support@sudosy.fr" appRelease = "1" rpmLicenseType = "Apache-2.0" diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt index fe96cf2..eb9d3b5 100644 --- a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt +++ b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt @@ -9,13 +9,13 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.window.WindowDraggableArea import androidx.compose.runtime.Composable @@ -53,10 +53,12 @@ import com.vnidrop.app.feature.receive.VniDropInvitationExtension import com.vnidrop.app.feature.receive.decodeInvitationBytes import com.vnidrop.app.platform.DesktopAppearanceBridge import com.vnidrop.app.ui.theme.LocalVniDropColors +import io.github.vinceglb.filekit.FileKit import java.awt.Desktop import java.io.File fun main(args: Array) { + FileKit.init(appId = "vnidrop") val externalInvitations = ExternalInvitationController() val linux = DesktopAppearanceBridge.isLinux() configureInvitationOpenHandler(externalInvitations) @@ -114,9 +116,10 @@ private fun ExternalInvitationController.openFile(file: File) { } } -private val LinuxTitleBarHeight = 40.dp -private val LinuxWindowControlWidth = 46.dp -private val LinuxWindowControlsWidth = 138.dp +private val LinuxTitleBarHeight = 48.dp +private val LinuxWindowControlHitTargetSize = 34.dp +private val LinuxWindowControlVisualSize = 28.dp +private val LinuxWindowControlsWidth = 120.dp private val DesktopContentCornerRadius = 20.dp @Composable @@ -156,7 +159,9 @@ private fun WindowScope.LinuxTitleBar( Row( modifier = Modifier .align(Alignment.CenterEnd) - .fillMaxHeight(), + .padding(end = 10.dp) + .background(colors.backgroundSurface300, RoundedCornerShape(20.dp)) + .padding(3.dp), ) { LinuxWindowControlButton( icon = LinuxMinimizeIcon, @@ -189,17 +194,15 @@ private fun LinuxWindowControlButton( val interactionSource = remember { MutableInteractionSource() } val hovered by interactionSource.collectIsHoveredAsState() val pressed by interactionSource.collectIsPressedAsState() - val active = hovered || pressed - val background = when { - isClose && active -> colors.destructiveDefault - active -> colors.backgroundOverlayHover - else -> Color.Transparent + val visualState = linuxWindowControlVisualState(isClose, hovered, pressed) + val background = when (visualState) { + LinuxWindowControlVisualState.Default -> Color.Transparent + LinuxWindowControlVisualState.NeutralActive -> colors.backgroundOverlayHover + LinuxWindowControlVisualState.DestructiveActive -> colors.destructiveDefault } Box( modifier = Modifier - .width(LinuxWindowControlWidth) - .fillMaxHeight() - .background(background) + .size(LinuxWindowControlHitTargetSize) .hoverable(interactionSource) .clickable( interactionSource = interactionSource, @@ -209,15 +212,41 @@ private fun LinuxWindowControlButton( ), contentAlignment = Alignment.Center, ) { - Image( - painter = rememberVectorPainter(icon), - contentDescription = contentDescription, - colorFilter = ColorFilter.tint(if (isClose && active) Color.White else colors.foregroundLight), - modifier = Modifier.size(15.dp), - ) + Box( + modifier = Modifier + .size(LinuxWindowControlVisualSize) + .background(background, CircleShape), + contentAlignment = Alignment.Center, + ) { + Image( + painter = rememberVectorPainter(icon), + contentDescription = contentDescription, + colorFilter = ColorFilter.tint( + if (visualState == LinuxWindowControlVisualState.DestructiveActive) Color.White + else colors.foregroundLight, + ), + modifier = Modifier.size(14.dp), + ) + } } } +internal enum class LinuxWindowControlVisualState { + Default, + NeutralActive, + DestructiveActive, +} + +internal fun linuxWindowControlVisualState( + isClose: Boolean, + isHovered: Boolean, + isPressed: Boolean, +): LinuxWindowControlVisualState = when { + isClose && (isHovered || isPressed) -> LinuxWindowControlVisualState.DestructiveActive + isHovered || isPressed -> LinuxWindowControlVisualState.NeutralActive + else -> LinuxWindowControlVisualState.Default +} + internal fun toggledWindowPlacement(current: WindowPlacement): WindowPlacement = if (current == WindowPlacement.Maximized) WindowPlacement.Floating else WindowPlacement.Maximized diff --git a/desktopApp/src/test/kotlin/com/vnidrop/app/DesktopWindowChromeTest.kt b/desktopApp/src/test/kotlin/com/vnidrop/app/DesktopWindowChromeTest.kt index 09a5a1f..d20149e 100644 --- a/desktopApp/src/test/kotlin/com/vnidrop/app/DesktopWindowChromeTest.kt +++ b/desktopApp/src/test/kotlin/com/vnidrop/app/DesktopWindowChromeTest.kt @@ -10,4 +10,28 @@ class DesktopWindowChromeTest { assertEquals(WindowPlacement.Maximized, toggledWindowPlacement(WindowPlacement.Floating)) assertEquals(WindowPlacement.Floating, toggledWindowPlacement(WindowPlacement.Maximized)) } + + @Test + fun closeControlUsesDestructiveStateWhenHoveredOrPressed() { + assertEquals( + LinuxWindowControlVisualState.DestructiveActive, + linuxWindowControlVisualState(isClose = true, isHovered = true, isPressed = false), + ) + assertEquals( + LinuxWindowControlVisualState.DestructiveActive, + linuxWindowControlVisualState(isClose = true, isHovered = false, isPressed = true), + ) + } + + @Test + fun standardControlsKeepNeutralInteractionState() { + assertEquals( + LinuxWindowControlVisualState.NeutralActive, + linuxWindowControlVisualState(isClose = false, isHovered = true, isPressed = false), + ) + assertEquals( + LinuxWindowControlVisualState.Default, + linuxWindowControlVisualState(isClose = false, isHovered = false, isPressed = false), + ) + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d7fca50..6499538 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ androidx-datastore = "1.2.1" androidx-testExt = "1.3.0" composeMultiplatform = "1.11.1" compottie = "2.2.4" +filekit = "0.14.2" gobley = "0.3.7" junit = "4.13.2" kotlin = "2.4.0" @@ -42,6 +43,7 @@ compose-uiTest = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "c compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } compottie-lite = { module = "io.github.alexzhirkevich:compottie-lite", version.ref = "compottie" } +filekit-dialogs = { module = "io.github.vinceglb:filekit-dialogs", version.ref = "filekit" } kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index ff3f84b..2e792df 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -129,6 +129,9 @@ kotlin { implementation(libs.google.code.scanner) implementation(libs.compose.uiToolingPreview) } + jvmMain.dependencies { + implementation(libs.filekit.dialogs) + } commonMain.dependencies { implementation(libs.compose.runtime) implementation(libs.compose.foundation) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt index f64da87..ecb1f2d 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathBuilder import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -46,6 +47,7 @@ import com.vnidrop.app.core.FolderAccessStatus import com.vnidrop.app.core.Transfer import com.vnidrop.app.core.TransferDirection import com.vnidrop.app.core.TransferStatus +import com.vnidrop.app.isDesktop import com.vnidrop.app.ui.components.AdaptiveDrawer import com.vnidrop.app.ui.components.DestructiveButton import com.vnidrop.app.ui.components.DestructiveQuietButton @@ -57,6 +59,7 @@ 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.navigation.VniDropIcons import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.state.displayNameForStatus import com.vnidrop.app.ui.state.formatBytes @@ -163,7 +166,9 @@ 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 (!LocalUiPlatform.current.isDesktop) { + Text(stringResource(Res.string.receive_new_subtitle), color = LocalVniDropColors.current.foregroundLighter) + } } if (showAction) { Spacer(Modifier.width(16.dp)) @@ -175,15 +180,27 @@ private fun ReceiveHeader(showAction: Boolean, onOpen: () -> Unit) { @Composable private fun ReceiveEmptyState(onOpen: () -> Unit) { val colors = LocalVniDropColors.current + val desktop = LocalUiPlatform.current.isDesktop Column( Modifier.fillMaxWidth().heightIn(min = 430.dp).padding(horizontal = 20.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - EmptyStateAnimation( - assetPath = "files/animations/receive_empty_state.json", - modifier = Modifier.size(168.dp), - ) + if (desktop) { + Icon( + imageVector = VniDropIcons.Receive, + contentDescription = null, + tint = colors.brandLink, + modifier = Modifier + .size(88.dp) + .testTag("receive-empty-icon"), + ) + } else { + EmptyStateAnimation( + assetPath = "files/animations/receive_empty_state.json", + modifier = Modifier.size(168.dp), + ) + } Text(stringResource(Res.string.receive_empty_title), modifier = Modifier.padding(top = 12.dp), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) Text( stringResource(Res.string.receive_empty_body), diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt index d4dc57e..60d5aae 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -37,6 +38,7 @@ import androidx.compose.ui.unit.dp import com.vnidrop.app.core.CoreEventModel import com.vnidrop.app.core.Transfer import com.vnidrop.app.core.TransferStatus +import com.vnidrop.app.isDesktop import com.vnidrop.app.ui.components.EmptyStateAnimation import com.vnidrop.app.ui.components.PillTone import com.vnidrop.app.ui.components.PrimaryButton @@ -44,6 +46,7 @@ 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.navigation.VniDropIcons import com.vnidrop.app.ui.state.TransferProgress import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.state.activeSendProgress @@ -128,11 +131,13 @@ private fun CatalogHeader(showAction: Boolean, onOpenComposer: () -> Unit) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { Text(stringResource(Res.string.send_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) - Text( - stringResource(Res.string.send_subtitle), - color = LocalVniDropColors.current.foregroundLighter, - style = MaterialTheme.typography.bodyMedium, - ) + if (!LocalUiPlatform.current.isDesktop) { + Text( + stringResource(Res.string.send_subtitle), + color = LocalVniDropColors.current.foregroundLighter, + style = MaterialTheme.typography.bodyMedium, + ) + } } if (showAction) { Spacer(Modifier.width(16.dp)) @@ -144,15 +149,27 @@ private fun CatalogHeader(showAction: Boolean, onOpenComposer: () -> Unit) { @Composable private fun SendEmptyState(onOpenComposer: () -> Unit) { val colors = LocalVniDropColors.current + val desktop = LocalUiPlatform.current.isDesktop Column( modifier = Modifier.fillMaxWidth().heightIn(min = 430.dp).padding(horizontal = 20.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - EmptyStateAnimation( - assetPath = "files/animations/send_empty_state.json", - modifier = Modifier.size(168.dp), - ) + if (desktop) { + Icon( + imageVector = VniDropIcons.Send, + contentDescription = null, + tint = colors.brandLink, + modifier = Modifier + .size(88.dp) + .testTag("send-empty-icon"), + ) + } else { + EmptyStateAnimation( + assetPath = "files/animations/send_empty_state.json", + modifier = Modifier.size(168.dp), + ) + } Text( stringResource(Res.string.send_empty_title), modifier = Modifier.padding(top = 12.dp), diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt index 258a4d9..9c4d86e 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt @@ -7,6 +7,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.vnidrop.app.isDesktop +import com.vnidrop.app.ui.platform.LocalUiPlatform import com.vnidrop.app.ui.theme.ThemeMode import org.jetbrains.compose.resources.stringResource import com.vnidrop.app.ui.theme.LocalVniDropColors @@ -34,11 +36,13 @@ internal fun SettingsOverview( style = if (largeTitle) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, ) - Text( - stringResource(Res.string.settings_subtitle), - color = LocalVniDropColors.current.foregroundLighter, - style = MaterialTheme.typography.bodyMedium, - ) + if (!LocalUiPlatform.current.isDesktop) { + Text( + stringResource(Res.string.settings_subtitle), + color = LocalVniDropColors.current.foregroundLighter, + style = MaterialTheme.typography.bodyMedium, + ) + } } SettingsGroup { SettingsRow( 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 9a56b9d..fed2591 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 @@ -2,7 +2,6 @@ package com.vnidrop.app.ui.navigation import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -30,7 +29,6 @@ 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.TextOverflow -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.vnidrop.app.UiPlatform import com.vnidrop.app.isDesktop @@ -56,7 +54,6 @@ fun AppSidebarNavigation( selected: AppDestination, style: NavigationStyle, onDestinationSelected: (AppDestination) -> Unit, - dividerTopInset: Dp = 0.dp, modifier: Modifier = Modifier, ) { when (style) { @@ -64,7 +61,6 @@ fun AppSidebarNavigation( NavigationStyle.DesktopSidebar -> DesktopSidebarNavigation( selected = selected, onDestinationSelected = onDestinationSelected, - dividerTopInset = dividerTopInset, modifier = modifier, ) NavigationStyle.AndroidBottomBar -> error("Bottom navigation is rendered by the phone shell") @@ -106,43 +102,31 @@ private fun AndroidNavigationRail( private fun DesktopSidebarNavigation( selected: AppDestination, onDestinationSelected: (AppDestination) -> Unit, - dividerTopInset: Dp, modifier: Modifier = Modifier, ) { val colors = LocalVniDropColors.current - Box( + Column( modifier = modifier .width(DesktopNavigationWidthDp.dp) .fillMaxHeight() - .background(colors.backgroundSurface200), + .background(colors.backgroundSurface200) + .padding(horizontal = 12.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Column( - 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 -> - DesktopNavigationItem( - item = item, - selected = item.destination == selected, - onClick = { onDestinationSelected(item.destination) }, - ) - } - } - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = dividerTopInset) - .width(1.dp) - .fillMaxHeight() - .background(colors.borderDefault), + 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 -> + DesktopNavigationItem( + item = item, + selected = item.destination == selected, + onClick = { onDestinationSelected(item.destination) }, + ) + } } } 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 dfac3df..d524abf 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 @@ -88,7 +88,6 @@ private fun WideShell( AppSidebarNavigation( selected = selectedDestination, style = navigationStyle, - dividerTopInset = mainContentTopStartRadius, onDestinationSelected = onDestinationSelected, ) Box( diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt index 0643be2..1da8921 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt @@ -2,6 +2,12 @@ package com.vnidrop.app.core import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import io.github.vinceglb.filekit.dialogs.FileKitMode +import io.github.vinceglb.filekit.dialogs.openDirectoryPicker +import io.github.vinceglb.filekit.dialogs.openFilePicker import java.awt.EventQueue import java.awt.FileDialog import java.awt.Frame @@ -12,34 +18,59 @@ import java.io.File import javax.imageio.ImageIO import javax.swing.JFileChooser import javax.swing.filechooser.FileSystemView +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Composable actual fun rememberShareFilePicker( onFilesPicked: (List) -> Unit, onError: (String) -> Unit, -): ShareFilePicker = remember(onFilesPicked, onError) { - object : ShareFilePicker { - override fun pickFiles() { - openPicker(onError) { - val selected = pickShareFiles() - if (selected.isNotEmpty()) onFilesPicked(selected) +): ShareFilePicker { + val scope = rememberCoroutineScope() + return remember(onFilesPicked, onError, scope) { + object : ShareFilePicker { + override fun pickFiles() { + if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) { + scope.launch { + try { + val selected = withContext(Dispatchers.IO) { pickShareFilesWithPortal() } + if (selected.isNotEmpty()) onFilesPicked(selected) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + onError(error.message ?: error.toString()) + } + } + } else { + openPicker(onError) { + val selected = pickShareFiles() + if (selected.isNotEmpty()) onFilesPicked(selected) + } + } } - } - override fun pickFolder() { - openPicker(onError) { - val selected = pickDirectory(title = "Select folder to share") ?: return@openPicker - onFilesPicked( - listOf( - PickedShareFile( - value = selected.absolutePath, - displayName = selected.name.ifBlank { selected.absolutePath }, - sizeBytes = null, - thumbnailBytes = selected.systemIconPng(), - isDirectory = true, - ), - ), - ) + override fun pickFolder() { + if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) { + scope.launch { + try { + val selected = withContext(Dispatchers.IO) { + pickDirectoryWithPortal("Select folder to share")?.toPickedShareFile(isDirectory = true) + } ?: return@launch + onFilesPicked(listOf(selected)) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + onError(error.message ?: error.toString()) + } + } + } else { + openPicker(onError) { + val selected = pickDirectory(title = "Select folder to share") ?: return@openPicker + onFilesPicked(listOf(selected.toPickedShareFile(isDirectory = true))) + } + } } } } @@ -49,23 +80,43 @@ actual fun rememberShareFilePicker( actual fun rememberReceiveFolderPicker( onFolderPicked: (ReceiveFolder) -> Unit, onError: (String) -> Unit, -): ReceiveFolderPicker = remember(onFolderPicked, onError) { - object : ReceiveFolderPicker { - override fun pickFolder() { - openPicker(onError) { - val selected = pickDirectory(title = "Select receive folder") ?: return@openPicker - onFolderPicked( - ReceiveFolder( - kind = ReceiveFolderKind.FileSystemPath, - value = selected.absolutePath, - displayName = selected.name.ifBlank { selected.absolutePath }, - ), - ) +): ReceiveFolderPicker { + val scope = rememberCoroutineScope() + return remember(onFolderPicked, onError, scope) { + object : ReceiveFolderPicker { + override fun pickFolder() { + if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) { + scope.launch { + try { + val selected = withContext(Dispatchers.IO) { + pickDirectoryWithPortal("Select receive folder") + } ?: return@launch + onFolderPicked(selected.toReceiveFolder()) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + onError(error.message ?: error.toString()) + } + } + } else { + openPicker(onError) { + val selected = pickDirectory(title = "Select receive folder") ?: return@openPicker + onFolderPicked(selected.toReceiveFolder()) + } + } } } } } +internal enum class JvmFilePickerBackend { + XdgPortal, + AwtSwing, +} + +internal fun jvmFilePickerBackend(osName: String?): JvmFilePickerBackend = + if (osName.orEmpty().startsWith("Linux", ignoreCase = true)) JvmFilePickerBackend.XdgPortal else JvmFilePickerBackend.AwtSwing + private fun openPicker( onError: (String) -> Unit, block: () -> Unit, @@ -99,20 +150,39 @@ private fun pickShareFiles(): List { val names = dialog.files?.map { it.name }.orEmpty().ifEmpty { dialog.file?.let { listOf(it) }.orEmpty() } - names.map { name -> - val selected = File(directory, name) - PickedShareFile( - selected.absolutePath, - selected.name, - selected.length().takeIf { it >= 0L }?.toULong(), - selected.systemIconPng(), - ) - } + names.map { name -> File(directory, name).toPickedShareFile(isDirectory = false) } } finally { dialog.dispose() } } +private suspend fun pickShareFilesWithPortal(): List = + FileKit.openFilePicker( + mode = FileKitMode.Multiple(), + dialogSettings = FileKitDialogSettings(title = "Select files to share", parentWindow = activeFrame()), + ).orEmpty().map { it.file.toPickedShareFile(isDirectory = false) } + +private suspend fun pickDirectoryWithPortal(title: String): File? = + FileKit.openDirectoryPicker( + dialogSettings = FileKitDialogSettings(title = title, parentWindow = activeFrame()), + )?.file + +private fun File.toPickedShareFile(isDirectory: Boolean): PickedShareFile = + PickedShareFile( + value = absolutePath, + displayName = name.ifBlank { absolutePath }, + sizeBytes = if (isDirectory) null else length().takeIf { it >= 0L }?.toULong(), + thumbnailBytes = systemIconPng(), + isDirectory = isDirectory, + ) + +private fun File.toReceiveFolder(): ReceiveFolder = + ReceiveFolder( + kind = ReceiveFolderKind.FileSystemPath, + value = absolutePath, + displayName = name.ifBlank { absolutePath }, + ) + private fun File.systemIconPng(): ByteArray? = runCatching { val icon = FileSystemView.getFileSystemView().getSystemIcon(this, 128, 128) val image = BufferedImage(icon.iconWidth, icon.iconHeight, BufferedImage.TYPE_INT_ARGB) diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/core/FilePickerJvmTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/FilePickerJvmTest.kt new file mode 100644 index 0000000..a282ecc --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/FilePickerJvmTest.kt @@ -0,0 +1,18 @@ +package com.vnidrop.app.core + +import kotlin.test.Test +import kotlin.test.assertEquals + +class FilePickerJvmTest { + @Test + fun linuxUsesTheNativePortalPicker() { + assertEquals(JvmFilePickerBackend.XdgPortal, jvmFilePickerBackend("Linux")) + assertEquals(JvmFilePickerBackend.XdgPortal, jvmFilePickerBackend("linux")) + } + + @Test + fun otherDesktopPlatformsKeepTheirExistingPickers() { + assertEquals(JvmFilePickerBackend.AwtSwing, jvmFilePickerBackend("Windows 11")) + assertEquals(JvmFilePickerBackend.AwtSwing, jvmFilePickerBackend("Mac OS X")) + } +} 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 c48c3d7..2e182ef 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -1,6 +1,7 @@ package com.vnidrop.app.ui 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.material3.Text @@ -20,6 +21,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateOf import com.vnidrop.app.feature.approvals.ApprovalModalHost import com.vnidrop.app.feature.approvals.ApprovalState @@ -32,8 +34,10 @@ import com.vnidrop.app.feature.receive.ReceiveState import com.vnidrop.app.feature.settings.SettingsScreen import com.vnidrop.app.feature.settings.SettingsSection import com.vnidrop.app.feature.settings.SettingsState +import com.vnidrop.app.feature.settings.SettingsOverview import com.vnidrop.app.feature.send.SendScreen import com.vnidrop.app.feature.send.SendState +import com.vnidrop.app.feature.send.TransferCatalog import com.vnidrop.app.UiPlatform import com.vnidrop.app.core.CoreState import com.vnidrop.app.core.PickedShareFile @@ -48,6 +52,7 @@ import com.vnidrop.app.ui.feedback.UiText import com.vnidrop.app.ui.feedback.VniDropSnackbarHost import com.vnidrop.app.ui.state.WindowClass 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 kotlin.test.Test @@ -285,6 +290,63 @@ class FoundationComposeTest { runOnIdle { assertEquals(AppDestination.Receive, selected) } } + @Test + fun desktopPagesUseStaticFeatureIconsWithoutTitleDescriptions() = runComposeUiTest { + val actions = object : ReceiveInvitationActions { + override val fileAvailability = ReceiveMethodAvailability.Hidden + override val qrAvailability = ReceiveMethodAvailability.Hidden + override val nfcAvailability = ReceiveMethodAvailability.Hidden + override fun pickInvitation(onResult: (Result) -> Unit) = Unit + override fun scanQrCode(onResult: (Result) -> Unit) = Unit + override fun readNfcInvitation(onResult: (Result) -> Unit) = Unit + override fun cancel() = Unit + } + setContent { + CompositionLocalProvider(LocalUiPlatform provides UiPlatform.Linux) { + VniDropTheme(isDarkTheme = false) { + Row { + Box(Modifier.size(500.dp)) { + TransferCatalog( + transfers = emptyList(), + transferThumbnails = emptyMap(), + windowClass = WindowClass.Desktop, + onOpenComposer = {}, + onTransferSelected = {}, + ) + } + Box(Modifier.size(500.dp)) { + ReceiveScreen( + coreState = CoreState(isInitialized = true), + state = ReceiveState(), + windowClass = WindowClass.Desktop, + actions = actions, + onOpenAcquisition = {}, + onDismissAcquisition = {}, + onReceiverNameChanged = {}, + onInvitationResult = { _, _ -> }, + onWaitingForNfc = {}, + onReceive = {}, + onRequestDeleteHistoryItem = {}, + onRequestClearHistory = {}, + onDismissHistoryDelete = {}, + onConfirmHistoryDelete = {}, + ) + } + Box(Modifier.size(500.dp)) { + SettingsOverview(SettingsState(), onSectionSelected = {}, largeTitle = false) + } + } + } + } + } + + onNodeWithTag("send-empty-icon").assertIsDisplayed() + onNodeWithTag("receive-empty-icon").assertIsDisplayed() + onAllNodesWithText("Transfers you’re sharing from this device.").assertCountEquals(0) + onAllNodesWithText("Transfers you’ve received on this device.").assertCountEquals(0) + onAllNodesWithText("Your name, where transfers are saved, appearance, and notifications.").assertCountEquals(0) + } + @Test fun phoneSendEmptyStateOpensCreationDrawer() = runComposeUiTest { val state = mutableStateOf(SendState()) From a0d352895e69f85d34ad40c83358a04009cef189 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Wed, 22 Jul 2026 12:26:35 +0200 Subject: [PATCH 3/6] feat(ui): align Android empty states --- gradle/libs.versions.toml | 2 -- shared/build.gradle.kts | 1 - .../files/animations/receive_empty_state.json | 1 - .../files/animations/send_empty_state.json | 1 - .../app/feature/receive/ReceiveScreen.kt | 29 +++++----------- .../vnidrop/app/feature/send/SendCatalog.kt | 34 +++++-------------- .../app/feature/settings/SettingsOverview.kt | 23 +++---------- .../app/ui/components/EmptyStateAnimation.kt | 34 ------------------- .../vnidrop/app/ui/FoundationComposeTest.kt | 8 ++--- 9 files changed, 25 insertions(+), 108 deletions(-) delete mode 100644 shared/src/commonMain/composeResources/files/animations/receive_empty_state.json delete mode 100644 shared/src/commonMain/composeResources/files/animations/send_empty_state.json delete mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/EmptyStateAnimation.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6499538..6801ded 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,6 @@ androidx-lifecycle = "2.11.0-beta01" androidx-datastore = "1.2.1" androidx-testExt = "1.3.0" composeMultiplatform = "1.11.1" -compottie = "2.2.4" filekit = "0.14.2" gobley = "0.3.7" junit = "4.13.2" @@ -42,7 +41,6 @@ compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMul compose-uiTest = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "composeMultiplatform" } compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } -compottie-lite = { module = "io.github.alexzhirkevich:compottie-lite", version.ref = "compottie" } filekit-dialogs = { module = "io.github.vinceglb:filekit-dialogs", version.ref = "filekit" } kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 2e792df..86c3b3a 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -139,7 +139,6 @@ kotlin { implementation(libs.compose.ui) implementation(libs.compose.components.resources) implementation(libs.compose.uiToolingPreview) - implementation(libs.compottie.lite) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.androidx.datastore) diff --git a/shared/src/commonMain/composeResources/files/animations/receive_empty_state.json b/shared/src/commonMain/composeResources/files/animations/receive_empty_state.json deleted file mode 100644 index 68de6ec..0000000 --- a/shared/src/commonMain/composeResources/files/animations/receive_empty_state.json +++ /dev/null @@ -1 +0,0 @@ -{"nm":"VniDrop — Receive Empty","ddd":0,"h":256,"w":256,"meta":{"g":"@lottiefiles/creator@1.99.2"},"layers":[{"ty":4,"nm":"receive_arrival_spark","sr":1,"st":0,"op":84,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[30,30],"t":0},{"o":{"x":0.34,"y":1.45},"i":{"x":0.64,"y":1},"s":[30,30],"t":48},{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[105,105],"t":58},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[135,135],"t":70},{"s":[30,30],"t":84}]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[128,130]},"r":{"a":1,"k":[{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[35],"t":48},{"s":[0],"t":70}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":0},{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[0],"t":48},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":57},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":70},{"s":[0],"t":84}]}},"shapes":[{"ty":"sr","bm":0,"hd":false,"nm":"Star Shape 1","d":1,"ir":{"a":0,"k":3.5},"is":{"a":0,"k":100},"pt":{"a":0,"k":4},"p":{"a":0,"k":[0,0]},"or":{"a":0,"k":9},"os":{"a":0,"k":100},"r":{"a":0,"k":45},"sy":1},{"ty":"el","bm":0,"hd":false,"nm":"Ellipse Shape 2","d":1,"p":{"a":0,"k":[18,10]},"s":{"a":0,"k":[7,7]}},{"ty":"el","bm":0,"hd":false,"nm":"Ellipse Shape 3","d":1,"p":{"a":0,"k":[-7,19]},"s":{"a":0,"k":[5,5]}},{"ty":"fl","bm":0,"hd":false,"nm":"Fill","c":{"a":0,"k":[0.6588,0.3333,0.9686]},"r":1,"o":{"a":0,"k":100}}],"ind":1},{"ty":4,"nm":"receive_portal_lip","sr":1,"st":0,"op":84,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100,100],"t":0},{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[100,100],"t":46},{"o":{"x":0.34,"y":1.45},"i":{"x":0.64,"y":1},"s":[112,78],"t":54},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[94,118],"t":60},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100,100],"t":68},{"s":[100,100],"t":84}]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[128,166]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"rc","bm":0,"hd":false,"nm":"Rect Shape 1","d":1,"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":8},"s":{"a":0,"k":[106,15]}},{"ty":"fl","bm":0,"hd":false,"nm":"Fill","c":{"a":0,"k":[0.5765,0.2,0.9176]},"r":1,"o":{"a":0,"k":100}}],"ind":2},{"ty":4,"nm":"receive_file_front","sr":1,"st":0,"op":84,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[200,52]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":0},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":8},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100],"t":16},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":74},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":80},{"s":[0],"t":84}]}},"shapes":[{"ty":"rc","bm":0,"hd":false,"nm":"Rect Shape 1","d":1,"p":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0,0],"t":0},{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[0,0],"t":8},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[-6,6],"t":16},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[-22,26],"t":30},{"o":{"x":0.34,"y":1.45},"i":{"x":0.64,"y":1},"s":[-58,64],"t":46},{"o":{"x":0.34,"y":1.45},"i":{"x":0.64,"y":1},"s":[-72,94],"t":54},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[-72,84],"t":60},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[-72,88],"t":68},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[-72,88],"t":80},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0,0],"t":81},{"s":[0,0],"t":84}]},"r":{"a":0,"k":10},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[51.5,64.4],"t":0},{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[51.5,64.4],"t":8},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[56,70],"t":16},{"o":{"x":0.34,"y":1.45},"i":{"x":0.64,"y":1},"s":[58.2,72.8],"t":54},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[54.3,67.9],"t":60},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[56,70],"t":68},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[56,70],"t":80},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[51.5,64.4],"t":81},{"s":[51.5,64.4],"t":84}]}},{"ty":"fl","bm":0,"hd":false,"nm":"Fill","c":{"a":0,"k":[0.9373,0.8863,1]},"r":1,"o":{"a":0,"k":100}},{"ty":"st","bm":0,"hd":false,"nm":"Stroke","lc":2,"lj":2,"ml":1,"o":{"a":0,"k":100},"w":{"a":0,"k":3},"c":{"a":0,"k":[0.6588,0.3333,0.9686]}}],"ind":3},{"ty":4,"nm":"receive_travel_streak","sr":1,"st":0,"op":84,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":0},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":8},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[52],"t":16},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[52],"t":66},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":78},{"s":[0],"t":84}]}},"shapes":[{"ty":"sh","bm":0,"hd":false,"nm":"Path Curve 1","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[21,-32]],"o":[[-29,16],[0,0]],"v":[[198,54],[128,140]]}}},{"ty":"st","bm":0,"hd":false,"nm":"Stroke","lc":2,"lj":2,"ml":1,"o":{"a":0,"k":100},"w":{"a":0,"k":3},"c":{"a":0,"k":[0.6588,0.3333,0.9686]}},{"ty":"tm","bm":0,"hd":false,"nm":"Trim Path","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":0},{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[0],"t":8},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100],"t":54},{"s":[100],"t":84}]},"o":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":0},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":30},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100],"t":66},{"s":[100],"t":84}]},"m":1}],"ind":4},{"ty":4,"nm":"receive_arrival_ring","sr":1,"st":0,"op":84,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[62,62],"t":0},{"o":{"x":0.34,"y":1.45},"i":{"x":0.64,"y":1},"s":[62,62],"t":46},{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[100,100],"t":54},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[136,136],"t":72},{"s":[62,62],"t":84}]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[128,140]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":0},{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[0],"t":46},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[72],"t":54},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":72},{"s":[0],"t":84}]}},"shapes":[{"ty":"el","bm":0,"hd":false,"nm":"Ellipse Shape 1","d":1,"p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[72,72]}},{"ty":"st","bm":0,"hd":false,"nm":"Stroke","lc":2,"lj":2,"ml":1,"o":{"a":0,"k":100},"w":{"a":0,"k":3},"c":{"a":0,"k":[0.6588,0.3333,0.9686]}}],"ind":5},{"ty":4,"nm":"receive_portal_base","sr":1,"st":0,"op":84,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100,100],"t":0},{"o":{"x":0.34,"y":1.45},"i":{"x":0.64,"y":1},"s":[100,100],"t":48},{"o":{"x":0.34,"y":1.45},"i":{"x":0.64,"y":1},"s":[104,90],"t":56},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[98,106],"t":64},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100,100],"t":72},{"s":[100,100],"t":84}]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[128,178]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"rc","bm":0,"hd":false,"nm":"Rect Shape 1","d":1,"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":15},"s":{"a":0,"k":[132,30]}},{"ty":"fl","bm":0,"hd":false,"nm":"Fill","c":{"a":0,"k":[0.9373,0.8863,1]},"r":1,"o":{"a":0,"k":100}},{"ty":"st","bm":0,"hd":false,"nm":"Stroke","lc":2,"lj":2,"ml":1,"o":{"a":0,"k":100},"w":{"a":0,"k":2},"c":{"a":0,"k":[0.8471,0.7059,0.9961]}}],"ind":6},{"ty":4,"nm":"receive_ambient_halo","sr":1,"st":0,"op":84,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":1,"k":[{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[96,96],"t":0},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[103,103],"t":42},{"s":[96,96],"t":84}]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[128,163]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":18}},"shapes":[{"ty":"el","bm":0,"hd":false,"nm":"Ellipse Shape 1","d":1,"p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[170,90]}},{"ty":"fl","bm":0,"hd":false,"nm":"Fill","c":{"a":0,"k":[0.8471,0.7059,0.9961]},"r":1,"o":{"a":0,"k":100}}],"ind":7}],"v":"5.7.0","fr":30,"op":84,"ip":0,"assets":[{"nm":"vnidrop-mark","id":"precomp_Shape Layer - SVG_ZNP9xboZcj_aaf4b507-e380-44bc-82bc-d8c08a076b59","fr":30,"layers":[{"ty":4,"nm":"Shape Layer 1","sr":1,"st":0,"op":150,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":true,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"ef":[{"ty":25,"nm":"","en":1,"ef":[{"ty":2,"nm":"","v":{"a":0,"k":[0.302,0.1098,0.5804]}},{"ty":0,"nm":"","v":{"a":0,"k":122.4}},{"ty":1,"nm":"","v":{"a":0,"k":-146.31}},{"ty":0,"nm":"","v":{"a":0,"k":3.61}},{"ty":0,"nm":"","v":{"a":0,"k":12}}]}],"masksProperties":[{"nm":"","inv":false,"mode":"a","x":{"a":0,"k":0},"o":{"a":0,"k":100},"pt":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,-6.97],[0,0],[0,-6.56],[-21.32,0],[0,19.68],[0,8.2],[0,0],[-6.97,0],[0,0],[0,-9.02],[0,0],[45.51,0],[0,43.46],[0,0],[0,0],[0,0],[0,4.1],[0,0],[-3.69,0],[0,0]],"o":[[0,0],[6.97,0],[0,0],[0,8.2],[0,19.68],[21.32,0],[0,-6.56],[0,0],[0,-6.97],[0,0],[9.02,0],[0,0],[0,43.46],[-45.51,0],[0,0],[0,0],[0,0],[-6.15,0],[0,0],[0,-3.69],[0,0],[0,0]],"v":[[69.7,55.35],[95.12,55.35],[107.42,68.06],[107.42,159.49],[103.73,179.99],[141.04,215.25],[177.53,179.99],[174.66,159.49],[174.66,68.06],[186.96,55.35],[205.82,55.35],[222.22,71.75],[222.22,159.49],[141.04,238.21],[59.04,159.49],[59.04,111.93],[65.6,111.93],[65.6,94.71],[54.53,88.56],[54.53,79.95],[61.09,73.39],[69.7,73.39]]}}},{"nm":"","inv":false,"mode":"a","x":{"a":0,"k":0},"o":{"a":0,"k":100},"pt":{"a":0,"k":{"c":true,"i":[[0,0],[0,-1.23],[0,0],[-1.64,0],[0,0],[0,1.64],[0,0],[1.64,0]],"o":[[-1.23,0],[0,0],[0,1.64],[0,0],[1.64,0],[0,0],[0,-1.64],[0,0]],"v":[[74.62,78.31],[72.57,80.77],[72.57,87.33],[75.44,89.79],[81.18,89.79],[83.64,86.92],[83.64,81.18],[80.77,78.31]]}}}],"shapes":[{"ty":"gr","bm":0,"hd":false,"nm":"Group 1","np":2,"it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,-9.06],[0,0]],"o":[[0,0],[9.06,0],[0,0],[0,0]],"v":[[181.22,55.76],[205.82,55.76],[222.22,72.16],[222.22,95.53]]}}},{"ty":"gf","bm":0,"hd":false,"nm":"mark-fold","e":{"a":0,"k":[209.92,95.53]},"g":{"p":2,"k":{"a":0,"k":[0,0.9490196078431372,0.8705882352941177,1,1,0.7490196078431373,0.5215686274509804,0.9882352941176471]}},"t":1,"a":{"a":0,"k":0},"h":{"a":0,"k":0},"s":{"a":0,"k":[181.22,55.76]},"r":2,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":1},{"ty":4,"nm":"vnidrop-mark","sr":1,"st":0,"op":150,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[-0.12501681463547626,-1.1500000485544888]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","bm":0,"hd":false,"nm":"Group 2","np":2,"it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 2","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[5.74,-11.89],[-10.66,-8.2],[-10.66,6.97],[6.56,14.76],[6.56,8.2]],"o":[[-6.15,8.2],[-7.38,14.76],[10.25,6.56],[10.25,-7.38],[-5.74,-11.89],[0,0]],"v":[[140.63,126.28],[115.62,162.77],[122.18,201.31],[158.67,200.9],[165.64,162.77],[140.63,126.28]]}}},{"ty":"gf","bm":0,"hd":false,"nm":"mark-brand","e":{"a":0,"k":[169.03,206.18]},"g":{"p":3,"k":{"a":0,"k":[0,0.6588235294117647,0.32941176470588235,0.9686274509803922,0.48,0.6196078431372549,0.30196078431372547,0.9607843137254902,1,0.49019607843137253,0.1607843137254902,0.9411764705882353]}},"t":1,"a":{"a":0,"k":0},"h":{"a":0,"k":0},"s":{"a":0,"k":[111.64,126.28]},"r":2,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"gr","bm":0,"hd":false,"nm":"Group 3","np":3,"it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 3","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,-6.97],[0,0],[0,-6.56],[-21.32,0],[0,19.68],[0,8.2],[0,0],[-6.97,0],[0,0],[0,-9.02],[0,0],[45.51,0],[0,43.46],[0,0],[0,0],[0,0],[0,4.1],[0,0],[-3.69,0],[0,0]],"o":[[0,0],[6.97,0],[0,0],[0,8.2],[0,19.68],[21.32,0],[0,-6.56],[0,0],[0,-6.97],[0,0],[9.02,0],[0,0],[0,43.46],[-45.51,0],[0,0],[0,0],[0,0],[-6.15,0],[0,0],[0,-3.69],[0,0],[0,0]],"v":[[69.7,55.35],[95.12,55.35],[107.42,68.06],[107.42,159.49],[103.73,179.99],[141.04,215.25],[177.53,179.99],[174.66,159.49],[174.66,68.06],[186.96,55.35],[205.82,55.35],[222.22,71.75],[222.22,159.49],[141.04,238.21],[59.04,159.49],[59.04,111.93],[65.6,111.93],[65.6,94.71],[54.53,88.56],[54.53,79.95],[61.09,73.39],[69.7,73.39]]}}},{"ty":"sh","bm":0,"hd":false,"nm":"Path 4","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,-1.23],[0,0],[-1.64,0],[0,0],[0,1.64],[0,0],[1.64,0]],"o":[[-1.23,0],[0,0],[0,1.64],[0,0],[1.64,0],[0,0],[0,-1.64],[0,0]],"v":[[74.62,78.31],[72.57,80.77],[72.57,87.33],[75.44,89.79],[81.18,89.79],[83.64,86.92],[83.64,81.18],[80.77,78.31]]}}},{"ty":"gf","bm":0,"hd":false,"nm":"linearGradient5","e":{"a":0,"k":[222.22,238.21]},"g":{"p":3,"k":{"a":0,"k":[0,0.6588235294117647,0.32941176470588235,0.9686274509803922,0.48,0.6196078431372549,0.30196078431372547,0.9607843137254902,1,0.49019607843137253,0.1607843137254902,0.9411764705882353]}},"t":1,"a":{"a":0,"k":0},"h":{"a":0,"k":0},"s":{"a":0,"k":[54.53,55.35]},"r":2,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"gr","bm":0,"hd":false,"nm":"Group 8","np":4,"it":[{"ty":"gr","bm":0,"hd":false,"nm":"Group 4","np":2,"it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 5","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,-1.47],[0,0],[1.47,0],[0,0],[0,1.47],[0,0],[-1.47,0]],"o":[[0,0],[1.47,0],[0,0],[0,1.47],[0,0],[-1.47,0],[0,0],[0,-1.47],[0,0]],"v":[[36.7,67.24],[44.48,67.24],[47.15,69.91],[47.15,78.1],[44.48,80.77],[36.7,80.77],[34.03,78.1],[34.03,69.91],[36.7,67.24]]}}},{"ty":"gf","bm":0,"hd":false,"nm":"linearGradient4","e":{"a":0,"k":[47.15,80.77]},"g":{"p":3,"k":{"a":0,"k":[0,0.6588235294117647,0.32941176470588235,0.9686274509803922,0.48,0.6196078431372549,0.30196078431372547,0.9607843137254902,1,0.49019607843137253,0.1607843137254902,0.9411764705882353]}},"t":1,"a":{"a":0,"k":0},"h":{"a":0,"k":0},"s":{"a":0,"k":[34.03,67.24]},"r":2,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"gr","bm":0,"hd":false,"nm":"Group 5","np":2,"it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 6","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,-1.13],[0,0],[1.13,0],[0,0],[0,1.13],[0,0],[-1.13,0]],"o":[[0,0],[1.13,0],[0,0],[0,1.13],[0,0],[-1.13,0],[0,0],[0,-1.13],[0,0]],"v":[[43.46,46.33],[48.38,46.33],[50.43,48.38],[50.43,54.12],[48.38,56.17],[43.46,56.17],[41.41,54.12],[41.41,48.38],[43.46,46.33]]}}},{"ty":"gf","bm":0,"hd":false,"nm":"linearGradient3","e":{"a":0,"k":[50.43,56.17]},"g":{"p":3,"k":{"a":0,"k":[0,0.6588235294117647,0.32941176470588235,0.9686274509803922,0.48,0.6196078431372549,0.30196078431372547,0.9607843137254902,1,0.49019607843137253,0.1607843137254902,0.9411764705882353]}},"t":1,"a":{"a":0,"k":0},"h":{"a":0,"k":0},"s":{"a":0,"k":[41.41,46.33]},"r":2,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"gr","bm":0,"hd":false,"nm":"Group 6","np":2,"it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 7","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,-1.36],[0,0],[1.36,0],[0,0],[0,1.36],[0,0],[-1.36,0]],"o":[[0,0],[1.36,0],[0,0],[0,1.36],[0,0],[-1.36,0],[0,0],[0,-1.36],[0,0]],"v":[[59.45,36.49],[67.24,36.49],[69.7,38.95],[69.7,47.15],[67.24,49.61],[59.45,49.61],[56.99,47.15],[56.99,38.95],[59.45,36.49]]}}},{"ty":"gf","bm":0,"hd":false,"nm":"linearGradient2","e":{"a":0,"k":[69.7,49.61]},"g":{"p":3,"k":{"a":0,"k":[0,0.6588235294117647,0.32941176470588235,0.9686274509803922,0.48,0.6196078431372549,0.30196078431372547,0.9607843137254902,1,0.49019607843137253,0.1607843137254902,0.9411764705882353]}},"t":1,"a":{"a":0,"k":0},"h":{"a":0,"k":0},"s":{"a":0,"k":[56.99,36.49]},"r":2,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"gr","bm":0,"hd":false,"nm":"Group 7","np":2,"it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 8","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,-1.59],[0,0],[1.59,0],[0,0],[0,1.59],[0,0],[-1.59,0]],"o":[[0,0],[1.59,0],[0,0],[0,1.59],[0,0],[-1.59,0],[0,0],[0,-1.59],[0,0]],"v":[[38.54,20.09],[47.97,20.09],[50.84,22.96],[50.84,32.39],[47.97,35.26],[38.54,35.26],[35.67,32.39],[35.67,22.96],[38.54,20.09]]}}},{"ty":"gf","bm":0,"hd":false,"nm":"linearGradient1","e":{"a":0,"k":[50.84,35.26]},"g":{"p":3,"k":{"a":0,"k":[0,0.6588235294117647,0.32941176470588235,0.9686274509803922,0.48,0.6196078431372549,0.30196078431372547,0.9607843137254902,1,0.49019607843137253,0.1607843137254902,0.9411764705882353]}},"t":1,"a":{"a":0,"k":0},"h":{"a":0,"k":0},"s":{"a":0,"k":[35.67,20.09]},"r":2,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":2}]}]} diff --git a/shared/src/commonMain/composeResources/files/animations/send_empty_state.json b/shared/src/commonMain/composeResources/files/animations/send_empty_state.json deleted file mode 100644 index 520c61a..0000000 --- a/shared/src/commonMain/composeResources/files/animations/send_empty_state.json +++ /dev/null @@ -1 +0,0 @@ -{"nm":"VniDrop — Send Empty","ddd":0,"h":256,"w":256,"meta":{"g":"@lottiefiles/creator@1.99.2"},"layers":[{"ty":4,"nm":"send_endpoint_spark","sr":1,"st":0,"op":84,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[35,35],"t":0},{"o":{"x":0.34,"y":1.45},"i":{"x":0.64,"y":1},"s":[35,35],"t":48},{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[112,112],"t":58},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[142,142],"t":70},{"s":[35,35],"t":84}]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[196,55]},"r":{"a":1,"k":[{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[0],"t":48},{"s":[35],"t":70}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":0},{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[0],"t":48},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":57},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":70},{"s":[0],"t":84}]}},"shapes":[{"ty":"sr","bm":0,"hd":false,"nm":"Star Shape 1","d":1,"ir":{"a":0,"k":3.5},"is":{"a":0,"k":100},"pt":{"a":0,"k":4},"p":{"a":0,"k":[0,0]},"or":{"a":0,"k":9},"os":{"a":0,"k":100},"r":{"a":0,"k":45},"sy":1},{"ty":"el","bm":0,"hd":false,"nm":"Ellipse Shape 2","d":1,"p":{"a":0,"k":[18,10]},"s":{"a":0,"k":[7,7]}},{"ty":"el","bm":0,"hd":false,"nm":"Ellipse Shape 3","d":1,"p":{"a":0,"k":[-7,19]},"s":{"a":0,"k":[5,5]}},{"ty":"fl","bm":0,"hd":false,"nm":"Fill","c":{"a":0,"k":[0.6588,0.3333,0.9686]},"r":1,"o":{"a":0,"k":100}}],"ind":1},{"ty":4,"nm":"send_portal_lip","sr":1,"st":0,"op":84,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100,100],"t":0},{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[100,100],"t":10},{"o":{"x":0.34,"y":1.45},"i":{"x":0.64,"y":1},"s":[110,82],"t":18},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[96,110],"t":28},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100,100],"t":38},{"s":[100,100],"t":84}]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[128,166]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"rc","bm":0,"hd":false,"nm":"Rect Shape 1","d":1,"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":8},"s":{"a":0,"k":[106,15]}},{"ty":"fl","bm":0,"hd":false,"nm":"Fill","c":{"a":0,"k":[0.5765,0.2,0.9176]},"r":1,"o":{"a":0,"k":100}}],"ind":2},{"ty":4,"nm":"send_file_front","sr":1,"st":0,"op":84,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[128,137]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100],"t":0},{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[100],"t":56},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":66},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":73},{"s":[100],"t":84}]}},"shapes":[{"ty":"rc","bm":0,"hd":false,"nm":"Rect Shape 3","d":1,"p":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0,0],"t":0},{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[0,0],"t":10},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[-2,-16],"t":22},{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[55,-67],"t":50},{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[68,-82],"t":62},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[74,-87],"t":66},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0,0],"t":67},{"s":[0,0],"t":84}]},"r":{"a":0,"k":10},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[56,70],"t":0},{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[56,70],"t":10},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[58,73],"t":22},{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[54,67],"t":50},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[49,62],"t":66},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[56,70],"t":67},{"s":[56,70],"t":84}]}},{"ty":"fl","bm":0,"hd":false,"nm":"Fill","c":{"a":0,"k":[0.9373,0.8863,1]},"r":1,"o":{"a":0,"k":100}},{"ty":"st","bm":0,"hd":false,"nm":"Stroke","lc":2,"lj":2,"ml":1,"o":{"a":0,"k":100},"w":{"a":0,"k":3},"c":{"a":0,"k":[0.6588,0.3333,0.9686]}}],"ind":3},{"ty":4,"nm":"send_file_back_left","sr":1,"st":0,"op":84,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[114,141],"t":0},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[112,138],"t":42},{"s":[114,141],"t":84}]},"r":{"a":1,"k":[{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[-9],"t":0},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[-11],"t":42},{"s":[-9],"t":84}]},"sa":{"a":0,"k":0},"o":{"a":0,"k":62}},"shapes":[{"ty":"rc","bm":0,"hd":false,"nm":"Rect Shape 1","d":1,"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":10},"s":{"a":0,"k":[50,64]}},{"ty":"fl","bm":0,"hd":false,"nm":"Fill","c":{"a":0,"k":[0.8471,0.7059,0.9961]},"r":1,"o":{"a":0,"k":100}},{"ty":"st","bm":0,"hd":false,"nm":"Stroke","lc":2,"lj":2,"ml":1,"o":{"a":0,"k":100},"w":{"a":0,"k":2},"c":{"a":0,"k":[0.5765,0.2,0.9176]}}],"ind":4},{"ty":4,"nm":"send_file_back_right","sr":1,"st":0,"op":84,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[142,143],"t":0},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[145,140],"t":42},{"s":[142,143],"t":84}]},"r":{"a":1,"k":[{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[9],"t":0},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[11],"t":42},{"s":[9],"t":84}]},"sa":{"a":0,"k":0},"o":{"a":0,"k":46}},"shapes":[{"ty":"rc","bm":0,"hd":false,"nm":"Rect Shape 1","d":1,"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":10},"s":{"a":0,"k":[50,64]}},{"ty":"fl","bm":0,"hd":false,"nm":"Fill","c":{"a":0,"k":[0.8471,0.7059,0.9961]},"r":1,"o":{"a":0,"k":100}},{"ty":"st","bm":0,"hd":false,"nm":"Stroke","lc":2,"lj":2,"ml":1,"o":{"a":0,"k":100},"w":{"a":0,"k":2},"c":{"a":0,"k":[0.5765,0.2,0.9176]}}],"ind":5},{"ty":4,"nm":"send_travel_streak","sr":1,"st":0,"op":84,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":0},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":10},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[52],"t":18},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[52],"t":64},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":74},{"s":[0],"t":84}]}},"shapes":[{"ty":"sh","bm":0,"hd":false,"nm":"Path Curve 1","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[-18,30]],"o":[[29,-16],[0,0]],"v":[[126,132],[196,55]]}}},{"ty":"st","bm":0,"hd":false,"nm":"Stroke","lc":2,"lj":2,"ml":1,"o":{"a":0,"k":100},"w":{"a":0,"k":3},"c":{"a":0,"k":[0.6588,0.3333,0.9686]}},{"ty":"tm","bm":0,"hd":false,"nm":"Trim Path","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":0},{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[0],"t":10},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100],"t":48},{"s":[100],"t":84}]},"o":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":0},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":28},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100],"t":66},{"s":[100],"t":84}]},"m":1}],"ind":6},{"ty":4,"nm":"send_launch_ring","sr":1,"st":0,"op":84,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[62,62],"t":0},{"o":{"x":0.34,"y":1.45},"i":{"x":0.64,"y":1},"s":[62,62],"t":10},{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[100,100],"t":18},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[136,136],"t":36},{"s":[62,62],"t":84}]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[128,137]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":0},{"o":{"x":0.22,"y":1},"i":{"x":0.36,"y":1},"s":[0],"t":10},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[72],"t":18},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":36},{"s":[0],"t":84}]}},"shapes":[{"ty":"el","bm":0,"hd":false,"nm":"Ellipse Shape 1","d":1,"p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[72,72]}},{"ty":"st","bm":0,"hd":false,"nm":"Stroke","lc":2,"lj":2,"ml":1,"o":{"a":0,"k":100},"w":{"a":0,"k":3},"c":{"a":0,"k":[0.6588,0.3333,0.9686]}}],"ind":7},{"ty":4,"nm":"send_portal_base","sr":1,"st":0,"op":84,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[128,178]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"rc","bm":0,"hd":false,"nm":"Rect Shape 1","d":1,"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":15},"s":{"a":0,"k":[132,30]}},{"ty":"fl","bm":0,"hd":false,"nm":"Fill","c":{"a":0,"k":[0.9373,0.8863,1]},"r":1,"o":{"a":0,"k":100}},{"ty":"st","bm":0,"hd":false,"nm":"Stroke","lc":2,"lj":2,"ml":1,"o":{"a":0,"k":100},"w":{"a":0,"k":2},"c":{"a":0,"k":[0.8471,0.7059,0.9961]}}],"ind":8},{"ty":4,"nm":"send_ambient_halo","sr":1,"st":0,"op":84,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":1,"k":[{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[96,96],"t":0},{"o":{"x":0.42,"y":0},"i":{"x":0.58,"y":1},"s":[103,103],"t":42},{"s":[96,96],"t":84}]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[128,163]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":18}},"shapes":[{"ty":"el","bm":0,"hd":false,"nm":"Ellipse Shape 1","d":1,"p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[170,90]}},{"ty":"fl","bm":0,"hd":false,"nm":"Fill","c":{"a":0,"k":[0.8471,0.7059,0.9961]},"r":1,"o":{"a":0,"k":100}}],"ind":9}],"v":"5.7.0","fr":30,"op":84,"ip":0,"assets":[{"nm":"vnidrop-mark","id":"precomp_Shape Layer - SVG_ZNP9xboZcj_aaf4b507-e380-44bc-82bc-d8c08a076b59","fr":30,"layers":[{"ty":4,"nm":"Shape Layer 1","sr":1,"st":0,"op":150,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":true,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"ef":[{"ty":25,"nm":"","en":1,"ef":[{"ty":2,"nm":"","v":{"a":0,"k":[0.302,0.1098,0.5804]}},{"ty":0,"nm":"","v":{"a":0,"k":122.4}},{"ty":1,"nm":"","v":{"a":0,"k":-146.31}},{"ty":0,"nm":"","v":{"a":0,"k":3.61}},{"ty":0,"nm":"","v":{"a":0,"k":12}}]}],"masksProperties":[{"nm":"","inv":false,"mode":"a","x":{"a":0,"k":0},"o":{"a":0,"k":100},"pt":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,-6.97],[0,0],[0,-6.56],[-21.32,0],[0,19.68],[0,8.2],[0,0],[-6.97,0],[0,0],[0,-9.02],[0,0],[45.51,0],[0,43.46],[0,0],[0,0],[0,0],[0,4.1],[0,0],[-3.69,0],[0,0]],"o":[[0,0],[6.97,0],[0,0],[0,8.2],[0,19.68],[21.32,0],[0,-6.56],[0,0],[0,-6.97],[0,0],[9.02,0],[0,0],[0,43.46],[-45.51,0],[0,0],[0,0],[0,0],[-6.15,0],[0,0],[0,-3.69],[0,0],[0,0]],"v":[[69.7,55.35],[95.12,55.35],[107.42,68.06],[107.42,159.49],[103.73,179.99],[141.04,215.25],[177.53,179.99],[174.66,159.49],[174.66,68.06],[186.96,55.35],[205.82,55.35],[222.22,71.75],[222.22,159.49],[141.04,238.21],[59.04,159.49],[59.04,111.93],[65.6,111.93],[65.6,94.71],[54.53,88.56],[54.53,79.95],[61.09,73.39],[69.7,73.39]]}}},{"nm":"","inv":false,"mode":"a","x":{"a":0,"k":0},"o":{"a":0,"k":100},"pt":{"a":0,"k":{"c":true,"i":[[0,0],[0,-1.23],[0,0],[-1.64,0],[0,0],[0,1.64],[0,0],[1.64,0]],"o":[[-1.23,0],[0,0],[0,1.64],[0,0],[1.64,0],[0,0],[0,-1.64],[0,0]],"v":[[74.62,78.31],[72.57,80.77],[72.57,87.33],[75.44,89.79],[81.18,89.79],[83.64,86.92],[83.64,81.18],[80.77,78.31]]}}}],"shapes":[{"ty":"gr","bm":0,"hd":false,"nm":"Group 1","np":2,"it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,-9.06],[0,0]],"o":[[0,0],[9.06,0],[0,0],[0,0]],"v":[[181.22,55.76],[205.82,55.76],[222.22,72.16],[222.22,95.53]]}}},{"ty":"gf","bm":0,"hd":false,"nm":"mark-fold","e":{"a":0,"k":[209.92,95.53]},"g":{"p":2,"k":{"a":0,"k":[0,0.9490196078431372,0.8705882352941177,1,1,0.7490196078431373,0.5215686274509804,0.9882352941176471]}},"t":1,"a":{"a":0,"k":0},"h":{"a":0,"k":0},"s":{"a":0,"k":[181.22,55.76]},"r":2,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":1},{"ty":4,"nm":"vnidrop-mark","sr":1,"st":0,"op":150,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[-0.12501681463547626,-1.1500000485544888]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","bm":0,"hd":false,"nm":"Group 2","np":2,"it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 2","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[5.74,-11.89],[-10.66,-8.2],[-10.66,6.97],[6.56,14.76],[6.56,8.2]],"o":[[-6.15,8.2],[-7.38,14.76],[10.25,6.56],[10.25,-7.38],[-5.74,-11.89],[0,0]],"v":[[140.63,126.28],[115.62,162.77],[122.18,201.31],[158.67,200.9],[165.64,162.77],[140.63,126.28]]}}},{"ty":"gf","bm":0,"hd":false,"nm":"mark-brand","e":{"a":0,"k":[169.03,206.18]},"g":{"p":3,"k":{"a":0,"k":[0,0.6588235294117647,0.32941176470588235,0.9686274509803922,0.48,0.6196078431372549,0.30196078431372547,0.9607843137254902,1,0.49019607843137253,0.1607843137254902,0.9411764705882353]}},"t":1,"a":{"a":0,"k":0},"h":{"a":0,"k":0},"s":{"a":0,"k":[111.64,126.28]},"r":2,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"gr","bm":0,"hd":false,"nm":"Group 3","np":3,"it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 3","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,-6.97],[0,0],[0,-6.56],[-21.32,0],[0,19.68],[0,8.2],[0,0],[-6.97,0],[0,0],[0,-9.02],[0,0],[45.51,0],[0,43.46],[0,0],[0,0],[0,0],[0,4.1],[0,0],[-3.69,0],[0,0]],"o":[[0,0],[6.97,0],[0,0],[0,8.2],[0,19.68],[21.32,0],[0,-6.56],[0,0],[0,-6.97],[0,0],[9.02,0],[0,0],[0,43.46],[-45.51,0],[0,0],[0,0],[0,0],[-6.15,0],[0,0],[0,-3.69],[0,0],[0,0]],"v":[[69.7,55.35],[95.12,55.35],[107.42,68.06],[107.42,159.49],[103.73,179.99],[141.04,215.25],[177.53,179.99],[174.66,159.49],[174.66,68.06],[186.96,55.35],[205.82,55.35],[222.22,71.75],[222.22,159.49],[141.04,238.21],[59.04,159.49],[59.04,111.93],[65.6,111.93],[65.6,94.71],[54.53,88.56],[54.53,79.95],[61.09,73.39],[69.7,73.39]]}}},{"ty":"sh","bm":0,"hd":false,"nm":"Path 4","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,-1.23],[0,0],[-1.64,0],[0,0],[0,1.64],[0,0],[1.64,0]],"o":[[-1.23,0],[0,0],[0,1.64],[0,0],[1.64,0],[0,0],[0,-1.64],[0,0]],"v":[[74.62,78.31],[72.57,80.77],[72.57,87.33],[75.44,89.79],[81.18,89.79],[83.64,86.92],[83.64,81.18],[80.77,78.31]]}}},{"ty":"gf","bm":0,"hd":false,"nm":"linearGradient5","e":{"a":0,"k":[222.22,238.21]},"g":{"p":3,"k":{"a":0,"k":[0,0.6588235294117647,0.32941176470588235,0.9686274509803922,0.48,0.6196078431372549,0.30196078431372547,0.9607843137254902,1,0.49019607843137253,0.1607843137254902,0.9411764705882353]}},"t":1,"a":{"a":0,"k":0},"h":{"a":0,"k":0},"s":{"a":0,"k":[54.53,55.35]},"r":2,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"gr","bm":0,"hd":false,"nm":"Group 8","np":4,"it":[{"ty":"gr","bm":0,"hd":false,"nm":"Group 4","np":2,"it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 5","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,-1.47],[0,0],[1.47,0],[0,0],[0,1.47],[0,0],[-1.47,0]],"o":[[0,0],[1.47,0],[0,0],[0,1.47],[0,0],[-1.47,0],[0,0],[0,-1.47],[0,0]],"v":[[36.7,67.24],[44.48,67.24],[47.15,69.91],[47.15,78.1],[44.48,80.77],[36.7,80.77],[34.03,78.1],[34.03,69.91],[36.7,67.24]]}}},{"ty":"gf","bm":0,"hd":false,"nm":"linearGradient4","e":{"a":0,"k":[47.15,80.77]},"g":{"p":3,"k":{"a":0,"k":[0,0.6588235294117647,0.32941176470588235,0.9686274509803922,0.48,0.6196078431372549,0.30196078431372547,0.9607843137254902,1,0.49019607843137253,0.1607843137254902,0.9411764705882353]}},"t":1,"a":{"a":0,"k":0},"h":{"a":0,"k":0},"s":{"a":0,"k":[34.03,67.24]},"r":2,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"gr","bm":0,"hd":false,"nm":"Group 5","np":2,"it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 6","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,-1.13],[0,0],[1.13,0],[0,0],[0,1.13],[0,0],[-1.13,0]],"o":[[0,0],[1.13,0],[0,0],[0,1.13],[0,0],[-1.13,0],[0,0],[0,-1.13],[0,0]],"v":[[43.46,46.33],[48.38,46.33],[50.43,48.38],[50.43,54.12],[48.38,56.17],[43.46,56.17],[41.41,54.12],[41.41,48.38],[43.46,46.33]]}}},{"ty":"gf","bm":0,"hd":false,"nm":"linearGradient3","e":{"a":0,"k":[50.43,56.17]},"g":{"p":3,"k":{"a":0,"k":[0,0.6588235294117647,0.32941176470588235,0.9686274509803922,0.48,0.6196078431372549,0.30196078431372547,0.9607843137254902,1,0.49019607843137253,0.1607843137254902,0.9411764705882353]}},"t":1,"a":{"a":0,"k":0},"h":{"a":0,"k":0},"s":{"a":0,"k":[41.41,46.33]},"r":2,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"gr","bm":0,"hd":false,"nm":"Group 6","np":2,"it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 7","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,-1.36],[0,0],[1.36,0],[0,0],[0,1.36],[0,0],[-1.36,0]],"o":[[0,0],[1.36,0],[0,0],[0,1.36],[0,0],[-1.36,0],[0,0],[0,-1.36],[0,0]],"v":[[59.45,36.49],[67.24,36.49],[69.7,38.95],[69.7,47.15],[67.24,49.61],[59.45,49.61],[56.99,47.15],[56.99,38.95],[59.45,36.49]]}}},{"ty":"gf","bm":0,"hd":false,"nm":"linearGradient2","e":{"a":0,"k":[69.7,49.61]},"g":{"p":3,"k":{"a":0,"k":[0,0.6588235294117647,0.32941176470588235,0.9686274509803922,0.48,0.6196078431372549,0.30196078431372547,0.9607843137254902,1,0.49019607843137253,0.1607843137254902,0.9411764705882353]}},"t":1,"a":{"a":0,"k":0},"h":{"a":0,"k":0},"s":{"a":0,"k":[56.99,36.49]},"r":2,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"gr","bm":0,"hd":false,"nm":"Group 7","np":2,"it":[{"ty":"sh","bm":0,"hd":false,"nm":"Path 8","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[0,0],[0,-1.59],[0,0],[1.59,0],[0,0],[0,1.59],[0,0],[-1.59,0]],"o":[[0,0],[1.59,0],[0,0],[0,1.59],[0,0],[-1.59,0],[0,0],[0,-1.59],[0,0]],"v":[[38.54,20.09],[47.97,20.09],[50.84,22.96],[50.84,32.39],[47.97,35.26],[38.54,35.26],[35.67,32.39],[35.67,22.96],[38.54,20.09]]}}},{"ty":"gf","bm":0,"hd":false,"nm":"linearGradient1","e":{"a":0,"k":[50.84,35.26]},"g":{"p":3,"k":{"a":0,"k":[0,0.6588235294117647,0.32941176470588235,0.9686274509803922,0.48,0.6196078431372549,0.30196078431372547,0.9607843137254902,1,0.49019607843137253,0.1607843137254902,0.9411764705882353]}},"t":1,"a":{"a":0,"k":0},"h":{"a":0,"k":0},"s":{"a":0,"k":[35.67,20.09]},"r":2,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":2}]}]} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt index ecb1f2d..dd82fb5 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt @@ -47,11 +47,9 @@ import com.vnidrop.app.core.FolderAccessStatus import com.vnidrop.app.core.Transfer import com.vnidrop.app.core.TransferDirection import com.vnidrop.app.core.TransferStatus -import com.vnidrop.app.isDesktop import com.vnidrop.app.ui.components.AdaptiveDrawer import com.vnidrop.app.ui.components.DestructiveButton import com.vnidrop.app.ui.components.DestructiveQuietButton -import com.vnidrop.app.ui.components.EmptyStateAnimation import com.vnidrop.app.ui.components.Field import com.vnidrop.app.ui.components.PrimaryButton import com.vnidrop.app.ui.components.ProgressRow @@ -166,9 +164,6 @@ 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) - if (!LocalUiPlatform.current.isDesktop) { - Text(stringResource(Res.string.receive_new_subtitle), color = LocalVniDropColors.current.foregroundLighter) - } } if (showAction) { Spacer(Modifier.width(16.dp)) @@ -180,27 +175,19 @@ private fun ReceiveHeader(showAction: Boolean, onOpen: () -> Unit) { @Composable private fun ReceiveEmptyState(onOpen: () -> Unit) { val colors = LocalVniDropColors.current - val desktop = LocalUiPlatform.current.isDesktop Column( Modifier.fillMaxWidth().heightIn(min = 430.dp).padding(horizontal = 20.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - if (desktop) { - Icon( - imageVector = VniDropIcons.Receive, - contentDescription = null, - tint = colors.brandLink, - modifier = Modifier - .size(88.dp) - .testTag("receive-empty-icon"), - ) - } else { - EmptyStateAnimation( - assetPath = "files/animations/receive_empty_state.json", - modifier = Modifier.size(168.dp), - ) - } + Icon( + imageVector = VniDropIcons.Receive, + contentDescription = null, + tint = colors.brandLink, + modifier = Modifier + .size(88.dp) + .testTag("receive-empty-icon"), + ) Text(stringResource(Res.string.receive_empty_title), modifier = Modifier.padding(top = 12.dp), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) Text( stringResource(Res.string.receive_empty_body), diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt index 60d5aae..065d2c5 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt @@ -38,8 +38,6 @@ import androidx.compose.ui.unit.dp import com.vnidrop.app.core.CoreEventModel import com.vnidrop.app.core.Transfer import com.vnidrop.app.core.TransferStatus -import com.vnidrop.app.isDesktop -import com.vnidrop.app.ui.components.EmptyStateAnimation import com.vnidrop.app.ui.components.PillTone import com.vnidrop.app.ui.components.PrimaryButton import com.vnidrop.app.ui.components.ProgressRow @@ -62,7 +60,6 @@ import vnidrop.shared.generated.resources.send_empty_body import vnidrop.shared.generated.resources.send_empty_title import vnidrop.shared.generated.resources.send_new_transfer_description import vnidrop.shared.generated.resources.send_new_transfer_title -import vnidrop.shared.generated.resources.send_subtitle import vnidrop.shared.generated.resources.send_title import vnidrop.shared.generated.resources.send_transfers_title @@ -131,13 +128,6 @@ private fun CatalogHeader(showAction: Boolean, onOpenComposer: () -> Unit) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { Text(stringResource(Res.string.send_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) - if (!LocalUiPlatform.current.isDesktop) { - Text( - stringResource(Res.string.send_subtitle), - color = LocalVniDropColors.current.foregroundLighter, - style = MaterialTheme.typography.bodyMedium, - ) - } } if (showAction) { Spacer(Modifier.width(16.dp)) @@ -149,27 +139,19 @@ private fun CatalogHeader(showAction: Boolean, onOpenComposer: () -> Unit) { @Composable private fun SendEmptyState(onOpenComposer: () -> Unit) { val colors = LocalVniDropColors.current - val desktop = LocalUiPlatform.current.isDesktop Column( modifier = Modifier.fillMaxWidth().heightIn(min = 430.dp).padding(horizontal = 20.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - if (desktop) { - Icon( - imageVector = VniDropIcons.Send, - contentDescription = null, - tint = colors.brandLink, - modifier = Modifier - .size(88.dp) - .testTag("send-empty-icon"), - ) - } else { - EmptyStateAnimation( - assetPath = "files/animations/send_empty_state.json", - modifier = Modifier.size(168.dp), - ) - } + Icon( + imageVector = VniDropIcons.Send, + contentDescription = null, + tint = colors.brandLink, + modifier = Modifier + .size(88.dp) + .testTag("send-empty-icon"), + ) Text( stringResource(Res.string.send_empty_title), modifier = Modifier.padding(top = 12.dp), diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt index 9c4d86e..583b7e8 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt @@ -7,11 +7,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.vnidrop.app.isDesktop -import com.vnidrop.app.ui.platform.LocalUiPlatform import com.vnidrop.app.ui.theme.ThemeMode import org.jetbrains.compose.resources.stringResource -import com.vnidrop.app.ui.theme.LocalVniDropColors import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.about_title import vnidrop.shared.generated.resources.appearance_dark_mode @@ -20,7 +17,6 @@ import vnidrop.shared.generated.resources.appearance_system_mode import vnidrop.shared.generated.resources.appearance_title import vnidrop.shared.generated.resources.notifications_title import vnidrop.shared.generated.resources.preferences_title -import vnidrop.shared.generated.resources.settings_subtitle import vnidrop.shared.generated.resources.settings_title @Composable @@ -30,20 +26,11 @@ internal fun SettingsOverview( largeTitle: Boolean, ) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - stringResource(Res.string.settings_title), - style = if (largeTitle) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - ) - if (!LocalUiPlatform.current.isDesktop) { - Text( - stringResource(Res.string.settings_subtitle), - color = LocalVniDropColors.current.foregroundLighter, - style = MaterialTheme.typography.bodyMedium, - ) - } - } + Text( + stringResource(Res.string.settings_title), + style = if (largeTitle) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) SettingsGroup { SettingsRow( icon = SettingsIcons.Device, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/EmptyStateAnimation.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/EmptyStateAnimation.kt deleted file mode 100644 index 97653f1..0000000 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/EmptyStateAnimation.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.vnidrop.app.ui.components - -import androidx.compose.foundation.Image -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import io.github.alexzhirkevich.compottie.Compottie -import io.github.alexzhirkevich.compottie.LottieCompositionSpec -import io.github.alexzhirkevich.compottie.rememberLottieComposition -import io.github.alexzhirkevich.compottie.rememberLottiePainter -import vnidrop.shared.generated.resources.Res - -@Composable -internal fun EmptyStateAnimation( - assetPath: String, - modifier: Modifier = Modifier, -) { - val composition by rememberLottieComposition { - LottieCompositionSpec.JsonString( - Res.readBytes(assetPath).decodeToString(), - ) - } - - Image( - painter = rememberLottiePainter( - composition = composition, - iterations = Compottie.IterateForever, - ), - contentDescription = null, - modifier = modifier, - contentScale = ContentScale.Fit, - ) -} 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 2e182ef..fc425ec 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -291,7 +291,7 @@ class FoundationComposeTest { } @Test - fun desktopPagesUseStaticFeatureIconsWithoutTitleDescriptions() = runComposeUiTest { + fun androidPagesUseStaticFeatureIconsWithoutTitleDescriptions() = runComposeUiTest { val actions = object : ReceiveInvitationActions { override val fileAvailability = ReceiveMethodAvailability.Hidden override val qrAvailability = ReceiveMethodAvailability.Hidden @@ -302,14 +302,14 @@ class FoundationComposeTest { override fun cancel() = Unit } setContent { - CompositionLocalProvider(LocalUiPlatform provides UiPlatform.Linux) { + CompositionLocalProvider(LocalUiPlatform provides UiPlatform.Android) { VniDropTheme(isDarkTheme = false) { Row { Box(Modifier.size(500.dp)) { TransferCatalog( transfers = emptyList(), transferThumbnails = emptyMap(), - windowClass = WindowClass.Desktop, + windowClass = WindowClass.Phone, onOpenComposer = {}, onTransferSelected = {}, ) @@ -318,7 +318,7 @@ class FoundationComposeTest { ReceiveScreen( coreState = CoreState(isInitialized = true), state = ReceiveState(), - windowClass = WindowClass.Desktop, + windowClass = WindowClass.Phone, actions = actions, onOpenAcquisition = {}, onDismissAcquisition = {}, From 0ab076e5b2e17c9e42e3fe43dba0e686e187bee0 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Wed, 22 Jul 2026 15:48:16 +0200 Subject: [PATCH 4/6] feat(ui): support native desktop backdrops --- .../commonMain/kotlin/com/vnidrop/app/App.kt | 12 +++- .../app/ui/navigation/AppNavigation.kt | 5 +- .../com/vnidrop/app/ui/shell/AppShell.kt | 26 +++++-- .../vnidrop/app/ui/FoundationComposeTest.kt | 69 +++++++++++++++++++ 4 files changed, 103 insertions(+), 9 deletions(-) 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 { From 0e97c014a1d13e526de39c25e451b4e452494019 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Wed, 22 Jul 2026 15:49:11 +0200 Subject: [PATCH 5/6] feat(desktop): add native Windows window controller --- desktopApp/build.gradle.kts | 1 + .../com/vnidrop/app/WindowsWindowProcedure.kt | 1240 +++++++++++++++++ .../vnidrop/app/DesktopWindowChromeTest.kt | 114 ++ gradle/libs.versions.toml | 2 + 4 files changed, 1357 insertions(+) create mode 100644 desktopApp/src/main/kotlin/com/vnidrop/app/WindowsWindowProcedure.kt diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index ab18b16..7423fd8 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { implementation(compose.desktop.currentOs) implementation(libs.filekit.dialogs) + implementation(libs.jna.platform) implementation(libs.kotlinx.coroutinesSwing) implementation(libs.compose.uiToolingPreview) diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/WindowsWindowProcedure.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/WindowsWindowProcedure.kt new file mode 100644 index 0000000..4bbecae --- /dev/null +++ b/desktopApp/src/main/kotlin/com/vnidrop/app/WindowsWindowProcedure.kt @@ -0,0 +1,1240 @@ +package com.vnidrop.app + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.awt.ComposeWindow +import androidx.compose.ui.geometry.Rect +import com.sun.jna.CallbackReference +import com.sun.jna.Native +import com.sun.jna.Platform +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.platform.win32.BaseTSD.LONG_PTR +import com.sun.jna.platform.win32.BaseTSD.ULONG_PTRByReference +import com.sun.jna.platform.win32.User32 +import com.sun.jna.platform.win32.WinDef.HWND +import com.sun.jna.platform.win32.WinDef.LPARAM +import com.sun.jna.platform.win32.WinDef.LRESULT +import com.sun.jna.platform.win32.WinDef.POINT +import com.sun.jna.platform.win32.WinDef.RECT +import com.sun.jna.platform.win32.WinDef.UINT +import com.sun.jna.platform.win32.WinDef.WPARAM +import com.sun.jna.platform.win32.WinNT.HRESULT +import com.sun.jna.platform.win32.WinUser +import com.sun.jna.platform.win32.WinUser.WindowProc +import com.sun.jna.ptr.IntByReference +import com.sun.jna.win32.StdCallLibrary +import com.sun.jna.win32.W32APIOptions +import org.jetbrains.skiko.GraphicsApi +import org.jetbrains.skiko.SkiaLayer +import java.awt.AlphaComposite +import java.awt.BorderLayout +import java.awt.Color as AwtColor +import java.awt.Component +import java.awt.Container +import java.awt.EventQueue +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.Window +import java.util.concurrent.ConcurrentHashMap +import javax.swing.JComponent +import javax.swing.JPanel +import kotlin.math.roundToInt + +internal const val WindowsTitleBarHeightDip = 48 + +internal data class WindowsFrameInsets( + val left: Int = 0, + val top: Int = 0, + val right: Int = 0, + val bottom: Int = 0, +) + +internal fun windowsContentInsets( + isMaximized: Boolean, + horizontalResizeBorder: Int, + verticalResizeBorder: Int, +): WindowsFrameInsets = if (isMaximized) { + WindowsFrameInsets( + left = horizontalResizeBorder, + top = verticalResizeBorder, + right = horizontalResizeBorder, + bottom = verticalResizeBorder, + ) +} else { + WindowsFrameInsets() +} + +internal data class WindowsHitTestGeometry( + val width: Int, + val height: Int, + val horizontalResizeBorder: Int, + val verticalResizeBorder: Int, + val isMaximized: Boolean, + val caption: Rect, + val minimizeButton: Rect = Rect.Zero, + val maximizeButton: Rect = Rect.Zero, + val closeButton: Rect = Rect.Zero, +) + +internal enum class WindowsCaptionButton { + Minimize, + Maximize, + Close, +} + +internal data class WindowsScreenPoint(val x: Int, val y: Int) + +internal data class WindowsCaptionButtonBounds( + val minimize: Rect, + val maximize: Rect, + val close: Rect, +) + +internal fun windowsScreenPoint(packedCoordinates: Long): WindowsScreenPoint = WindowsScreenPoint( + x = (packedCoordinates and 0xffff).toShort().toInt(), + y = ((packedCoordinates shr 16) and 0xffff).toShort().toInt(), +) + +internal fun splitWindowsCaptionButtonBounds(bounds: Rect): WindowsCaptionButtonBounds { + val buttonWidth = bounds.width / 3f + return WindowsCaptionButtonBounds( + minimize = Rect(bounds.left, bounds.top, bounds.left + buttonWidth, bounds.bottom), + maximize = Rect(bounds.left + buttonWidth, bounds.top, bounds.right - buttonWidth, bounds.bottom), + close = Rect(bounds.right - buttonWidth, bounds.top, bounds.right, bounds.bottom), + ) +} + +internal fun windowsTitleBarHeightPixels(dpi: Int): Int = + (WindowsTitleBarHeightDip * dpi / DefaultDpi.toFloat()).roundToInt().coerceAtLeast(1) + +internal object WindowsHitTestResult { + const val Client = 1 + const val Caption = 2 + const val MinimizeButton = 8 + const val MaximizeButton = 9 + const val Left = 10 + const val Right = 11 + const val Top = 12 + const val TopLeft = 13 + const val TopRight = 14 + const val Bottom = 15 + const val BottomLeft = 16 + const val BottomRight = 17 + const val CloseButton = 20 + const val Transparent = -1 +} + +internal fun windowsHitTest( + x: Int, + y: Int, + geometry: WindowsHitTestGeometry, +): Int { + if (!geometry.isMaximized) { + val left = x < geometry.horizontalResizeBorder + val right = x >= geometry.width - geometry.horizontalResizeBorder + val top = y < geometry.verticalResizeBorder + val bottom = y >= geometry.height - geometry.verticalResizeBorder + when { + top && left -> return WindowsHitTestResult.TopLeft + top && right -> return WindowsHitTestResult.TopRight + bottom && left -> return WindowsHitTestResult.BottomLeft + bottom && right -> return WindowsHitTestResult.BottomRight + left -> return WindowsHitTestResult.Left + right -> return WindowsHitTestResult.Right + top -> return WindowsHitTestResult.Top + bottom -> return WindowsHitTestResult.Bottom + } + } + + return when { + geometry.closeButton.contains(x, y) -> WindowsHitTestResult.CloseButton + geometry.maximizeButton.contains(x, y) -> WindowsHitTestResult.MaximizeButton + geometry.minimizeButton.contains(x, y) -> WindowsHitTestResult.MinimizeButton + geometry.caption.contains(x, y) -> WindowsHitTestResult.Caption + else -> WindowsHitTestResult.Client + } +} + +internal class WindowsNativeWindowController private constructor( + private val window: ComposeWindow, + private val skiaLayer: SkiaLayer, + private val windowHandle: HWND, + private val user32: WindowsUser32, + private val dwm: WindowsDwmApi?, +) : AutoCloseable { + private val outerProcedure = OuterWindowProcedure(this) + private val contentProcedure = ContentWindowProcedure(this) + @Volatile + private var outerSubclassInstalled = false + + @Volatile + private var contentSubclassInstalled = false + + @Volatile + private var previousOuterProcedure: LONG_PTR? = null + + @Volatile + private var previousContentProcedure: LONG_PTR? = null + private var contentHandle: HWND? = null + private var originalContentPane: Container? = null + private var backdropContentPane: WindowsAlphaClearingContentPane? = null + private var originalContentChildren = emptyList() + private var transparentSurfaceConfigured = false + + @Volatile + private var systemFrameExtended = false + + @Volatile + private var systemCaptionButtonBoundsAvailable = false + + @Volatile + private var closed = false + + @Volatile + private var nativePressedCaptionButton: WindowsCaptionButton? = null + private var trackingNonClientMouse = false + private var darkTheme: Boolean? = null + + @Volatile + private var metrics = WindowsWindowMetrics() + + @Volatile + private var regions = WindowsHitRegions() + + var frameInsets by mutableStateOf(WindowsFrameInsets()) + private set + + var usesNativeBackdrop by mutableStateOf(false) + private set + + var usesCustomChrome by mutableStateOf(true) + private set + + var usesSystemCaptionButtons by mutableStateOf(false) + private set + + var hoveredCaptionButton by mutableStateOf(null) + private set + + var pressedCaptionButton by mutableStateOf(null) + private set + + var isWindowActive by mutableStateOf(true) + private set + + fun updateCaptionBounds(bounds: Rect) { + regions = regions.copy(caption = bounds) + } + + fun updateMinimizeButtonBounds(bounds: Rect) { + regions = regions.copy(minimizeButton = bounds) + } + + fun updateMaximizeButtonBounds(bounds: Rect) { + regions = regions.copy(maximizeButton = bounds) + } + + fun updateCloseButtonBounds(bounds: Rect) { + regions = regions.copy(closeButton = bounds) + } + + fun minimize() { + user32.ShowWindow(windowHandle, WinUser.SW_MINIMIZE) + } + + fun toggleMaximize() { + val command = if (isMaximized()) WinUser.SW_RESTORE else WinUser.SW_MAXIMIZE + user32.ShowWindow(windowHandle, command) + } + + fun postCloseRequest() { + user32.PostMessage(windowHandle, WindowsMessage.Close, WPARAM(0), LPARAM(0)) + } + + fun setDarkTheme(isDarkTheme: Boolean) { + if (darkTheme == isDarkTheme || closed) return + darkTheme = isDarkTheme + val value = if (isDarkTheme) 1 else 0 + val currentAttributeApplied = dwm?.setIntAttribute( + windowHandle, + DwmWindowAttribute.UseImmersiveDarkMode, + value, + ) == true + if (!currentAttributeApplied) { + dwm?.setIntAttribute(windowHandle, DwmWindowAttribute.UseImmersiveDarkModeBefore20H1, value) + } + } + + private fun attach() { + val outerPrevious = user32.installWindowProcedure(windowHandle, outerProcedure) + outerPrevious.requireInstalled("top-level") + previousOuterProcedure = outerPrevious + outerSubclassInstalled = true + activeControllers[windowHandle.value()] = this + try { + installContentWindowProcedure() + refreshMetrics() + applyCustomFrameBorder() + requestFrameRecalculation() + EventQueue.invokeLater { + if (!closed) usesNativeBackdrop = enableNativeBackdrop() + } + } catch (error: Throwable) { + close() + throw error + } + } + + private fun LONG_PTR.requireInstalled(target: String) { + require(toLong() != 0L) { "Could not subclass the $target window" } + } + + private fun installContentWindowProcedure() { + check(!contentSubclassInstalled) + val finalContentHandle = nativeContentHandle(skiaLayer) + val contentPrevious = user32.installWindowProcedure(finalContentHandle, contentProcedure) + contentPrevious.requireInstalled("Compose content") + contentHandle = finalContentHandle + previousContentProcedure = contentPrevious + contentSubclassInstalled = true + } + + private fun detachContentWindowProcedure(): Boolean { + if (!contentSubclassInstalled) return true + val currentContentHandle = contentHandle ?: return false + val previousContent = previousContentProcedure ?: return false + val restoreResult = runCatching { + user32.restoreWindowProcedureIfOwned(currentContentHandle, contentProcedure, previousContent) + }.getOrDefault(WindowProcedureRestoreResult.StillInstalled) + if (restoreResult == WindowProcedureRestoreResult.StillInstalled) return false + contentSubclassInstalled = false + previousContentProcedure = null + contentHandle = null + return true + } + + private fun enableNativeBackdrop(): Boolean { + if (!window.renderApi.supportsWindowsTransparentBackground()) return false + val frameExtended = dwm?.extendFrame(windowHandle, extendedFrameMargins()) == true + val backdropApplied = frameExtended && dwm.setIntAttribute( + windowHandle, + DwmWindowAttribute.SystemBackdropType, + DwmSystemBackdropType.MainWindow, + ) == true + if (!backdropApplied) { + resetDwmBackdrop() + return false + } + applySystemFrameBorder() + systemFrameExtended = true + dwm.setIntAttribute( + windowHandle, + DwmWindowAttribute.RedirectionBitmapAlpha, + DwmAttributeValue.Enabled, + ) + if (!detachContentWindowProcedure()) { + resetDwmBackdrop() + return false + } + + return runCatching { + skiaLayer.transparency = true + installBackdropContentPane() + transparentSurfaceConfigured = true + installContentWindowProcedure() + refreshSystemCaptionButtonBounds() + publishUsesSystemCaptionButtons(systemCaptionButtonBoundsAvailable) + true + }.getOrElse { + runCatching { restoreContentPane() } + runCatching { skiaLayer.transparency = false } + transparentSurfaceConfigured = false + resetDwmBackdrop() + val contentHookRecovered = runCatching { + if (!closed && !contentSubclassInstalled) installContentWindowProcedure() + }.isSuccess + if (!contentHookRecovered) transitionToDecoratedFallback() + false + } + } + + private fun reapplyNativeBackdrop() { + if (!transparentSurfaceConfigured) return + val frameExtended = dwm?.extendFrame(windowHandle, extendedFrameMargins()) == true + val backdropApplied = frameExtended && dwm.setIntAttribute( + windowHandle, + DwmWindowAttribute.SystemBackdropType, + DwmSystemBackdropType.MainWindow, + ) == true + if (backdropApplied) { + applySystemFrameBorder() + systemFrameExtended = true + dwm.setIntAttribute( + windowHandle, + DwmWindowAttribute.RedirectionBitmapAlpha, + DwmAttributeValue.Enabled, + ) + refreshSystemCaptionButtonBounds() + publishUsesSystemCaptionButtons(systemCaptionButtonBoundsAvailable) + } else { + resetDwmBackdrop() + } + usesNativeBackdrop = backdropApplied + } + + private fun disableNativeBackdrop() { + resetDwmBackdrop() + runCatching { restoreContentPane() } + runCatching { skiaLayer.transparency = false } + transparentSurfaceConfigured = false + usesNativeBackdrop = false + } + + private fun resetDwmBackdrop() { + systemFrameExtended = false + systemCaptionButtonBoundsAvailable = false + publishUsesSystemCaptionButtons(false) + dwm?.setIntAttribute( + windowHandle, + DwmWindowAttribute.RedirectionBitmapAlpha, + DwmAttributeValue.Disabled, + ) + dwm?.setIntAttribute(windowHandle, DwmWindowAttribute.SystemBackdropType, DwmSystemBackdropType.Auto) + dwm?.extendFrame(windowHandle, WindowsWindowMargins()) + applyCustomFrameBorder() + } + + private fun applyCustomFrameBorder() { + dwm?.setIntAttribute( + windowHandle, + DwmWindowAttribute.BorderColor, + DwmColor.None, + ) + } + + private fun applySystemFrameBorder() { + dwm?.setIntAttribute( + windowHandle, + DwmWindowAttribute.BorderColor, + DwmColor.Default, + ) + } + + private fun installBackdropContentPane() { + if (backdropContentPane != null) return + val oldContentPane = window.contentPane + val oldBorderLayout = oldContentPane.layout as? BorderLayout + val children = oldContentPane.components.map { component -> + WindowsContentPaneChild(component, oldBorderLayout?.getConstraints(component)) + } + val newContentPane = WindowsAlphaClearingContentPane().apply { + name = "${window.name}.contentPane" + layout = object : BorderLayout() { + override fun addLayoutComponent(component: Component, constraints: Any?) { + super.addLayoutComponent(component, constraints ?: CENTER) + } + } + background = TransparentAwtColor + } + + originalContentPane = oldContentPane + originalContentChildren = children + backdropContentPane = newContentPane + children.forEach { newContentPane.add(it.component) } + window.contentPane = newContentPane + window.validate() + } + + private fun restoreContentPane() { + val oldContentPane = originalContentPane ?: return + val currentContentPane = backdropContentPane ?: return + currentContentPane.components.toList().forEach { component -> + val original = originalContentChildren.firstOrNull { it.component === component } + if (original?.constraint != null) { + oldContentPane.add(component, original.constraint) + } else { + oldContentPane.add(component) + } + } + window.contentPane = oldContentPane + window.validate() + originalContentPane = null + originalContentChildren = emptyList() + backdropContentPane = null + } + + private fun handleOuterMessage( + hWnd: HWND, + message: Int, + wParam: WPARAM, + lParam: LPARAM, + ): LRESULT { + if (closed && message != WindowsMessage.NonClientDestroy) { + return callPreviousOuter(hWnd, message, wParam, lParam) + } + return when (message) { + WindowsMessage.NonClientDestroy -> { + val result = callPreviousOuter(hWnd, message, wParam, lParam) + runCatching { + closed = true + outerSubclassInstalled = false + previousOuterProcedure = null + releaseControllerIfDetached() + } + result + } + WindowsMessage.NonClientCalculateSize -> { + runCatching { refreshMetrics() } + if (wParam.toLong() != 0L) LRESULT(0) else callPreviousOuter(hWnd, message, wParam, lParam) + } + WindowsMessage.Activate -> { + runCatching { + publishWindowActive(wParam.toInt() and 0xffff != WindowsActivation.Inactive) + } + callPreviousOuter(hWnd, message, wParam, lParam) + } + WindowsMessage.NonClientHitTest -> + (if (usesSystemCaptionButtons) dwm?.defaultWindowProcedure(hWnd, message, wParam, lParam) else null) + ?: runCatching { + pointInWindow(lParam)?.let { point -> LRESULT(hitTest(point.x, point.y).toLong()) } + }.getOrNull() + ?: callPreviousOuter(hWnd, message, wParam, lParam) + WindowsMessage.NonClientMouseMove -> { + runCatching { + trackNonClientMouseLeave() + publishCaptionInteraction( + hovered = wParam.toInt().captionButton(), + pressed = nativePressedCaptionButton, + ) + } + callWindowsCaptionProcedure(hWnd, message, wParam, lParam) + } + WindowsMessage.NonClientLeftButtonDown -> { + val button = wParam.toInt().captionButton() + if (button == null) { + callPreviousOuter(hWnd, message, wParam, lParam) + } else if (usesSystemCaptionButtons) { + callWindowsCaptionProcedure(hWnd, message, wParam, lParam) + } else { + beginCaptionButtonPress(button) + LRESULT(0) + } + } + WindowsMessage.NonClientLeftButtonUp -> { + if (nativePressedCaptionButton != null) { + finishCaptionButtonPress() + } else { + callWindowsCaptionProcedure(hWnd, message, wParam, lParam) + } + } + WindowsMessage.NonClientLeftButtonDoubleClick -> when { + wParam.toInt().captionButton() == null -> callPreviousOuter(hWnd, message, wParam, lParam) + usesSystemCaptionButtons -> callWindowsCaptionProcedure(hWnd, message, wParam, lParam) + else -> LRESULT(0) + } + WindowsMessage.NonClientMouseLeave -> { + trackingNonClientMouse = false + runCatching { + publishCaptionInteraction(hovered = null, pressed = nativePressedCaptionButton) + } + dwm?.defaultWindowProcedure(hWnd, message, wParam, lParam) + ?: user32.DefWindowProc(hWnd, message, wParam, lParam) + } + WindowsMessage.MouseMove -> if (nativePressedCaptionButton != null) { + val hovered = captionButtonUnderCursor() + publishCaptionInteraction(hovered = hovered, pressed = nativePressedCaptionButton) + LRESULT(0) + } else { + callPreviousOuter(hWnd, message, wParam, lParam) + } + WindowsMessage.LeftButtonUp -> if (nativePressedCaptionButton != null) { + finishCaptionButtonPress() + } else { + callPreviousOuter(hWnd, message, wParam, lParam) + } + WindowsMessage.CancelMode -> { + cancelCaptionButtonPress(releaseCapture = true) + callPreviousOuter(hWnd, message, wParam, lParam) + } + WindowsMessage.CaptureChanged -> { + cancelCaptionButtonPress(releaseCapture = false) + callPreviousOuter(hWnd, message, wParam, lParam) + } + WindowsMessage.Size, + WindowsMessage.DpiChanged, + -> { + val result = callPreviousOuter(hWnd, message, wParam, lParam) + runCatching { refreshMetrics() } + if (message == WindowsMessage.DpiChanged) runCatching { reapplyNativeBackdrop() } + result + } + WindowsMessage.DwmCompositionChanged -> { + val result = callPreviousOuter(hWnd, message, wParam, lParam) + runCatching { reapplyNativeBackdrop() } + result + } + else -> callPreviousOuter(hWnd, message, wParam, lParam) + } + } + + private fun handleContentMessage( + hWnd: HWND, + message: Int, + wParam: WPARAM, + lParam: LPARAM, + ): LRESULT { + if (closed && message != WindowsMessage.NonClientDestroy) { + return callPreviousContent(hWnd, message, wParam, lParam) + } + return when (message) { + WindowsMessage.NonClientDestroy -> { + val result = callPreviousContent(hWnd, message, wParam, lParam) + runCatching { + contentSubclassInstalled = false + previousContentProcedure = null + contentHandle = null + releaseControllerIfDetached() + } + result + } + WindowsMessage.NonClientHitTest -> runCatching { + pointInWindow(lParam)?.let { point -> + val result = hitTest(point.x, point.y) + if (result == WindowsHitTestResult.Client) { + LRESULT(WindowsHitTestResult.Client.toLong()) + } else { + LRESULT(WindowsHitTestResult.Transparent.toLong()) + } + } + }.getOrNull() ?: callPreviousContent(hWnd, message, wParam, lParam) + else -> callPreviousContent(hWnd, message, wParam, lParam) + } + } + + private fun hitTest(x: Int, y: Int): Int { + refreshMetrics() + val currentMetrics = metrics + val currentRegions = regions + return windowsHitTest( + x = x, + y = y, + geometry = WindowsHitTestGeometry( + width = currentMetrics.width, + height = currentMetrics.height, + horizontalResizeBorder = currentMetrics.horizontalResizeBorder, + verticalResizeBorder = currentMetrics.verticalResizeBorder, + isMaximized = currentMetrics.isMaximized, + caption = currentRegions.caption, + minimizeButton = currentRegions.minimizeButton, + maximizeButton = currentRegions.maximizeButton, + closeButton = currentRegions.closeButton, + ), + ) + } + + private fun refreshMetrics() { + if (closed) return + val clientRect = RECT() + if (!user32.GetClientRect(windowHandle, clientRect)) return + clientRect.read() + val dpi = runCatching { user32.GetDpiForWindow(windowHandle) }.getOrDefault(UINT(DefaultDpi)) + val frameX = metricForDpi(WinUser.SM_CXFRAME, dpi) + val frameY = metricForDpi(WinUser.SM_CYFRAME, dpi) + val paddedBorder = metricForDpi(WinUser.SM_CXPADDEDBORDER, dpi) + val maximized = isMaximized() + val nextMetrics = WindowsWindowMetrics( + width = clientRect.right - clientRect.left, + height = clientRect.bottom - clientRect.top, + horizontalResizeBorder = frameX + paddedBorder, + verticalResizeBorder = frameY + paddedBorder, + isMaximized = maximized, + ) + metrics = nextMetrics + refreshSystemCaptionButtonBounds() + val nextInsets = windowsContentInsets( + isMaximized = maximized, + horizontalResizeBorder = nextMetrics.horizontalResizeBorder, + verticalResizeBorder = nextMetrics.verticalResizeBorder, + ) + publishFrameInsets(nextInsets) + } + + private fun refreshSystemCaptionButtonBounds() { + val nativeBounds = nativeCaptionButtonBounds() ?: return + systemCaptionButtonBoundsAvailable = true + regions = regions.copy( + minimizeButton = nativeBounds.minimize, + maximizeButton = nativeBounds.maximize, + closeButton = nativeBounds.close, + ) + if (systemFrameExtended) publishUsesSystemCaptionButtons(true) + } + + private fun extendedFrameMargins(): WindowsWindowMargins { + val dpi = runCatching { user32.GetDpiForWindow(windowHandle).toInt() } + .getOrDefault(DefaultDpi.toInt()) + return WindowsWindowMargins(top = windowsTitleBarHeightPixels(dpi)) + } + + private fun nativeCaptionButtonBounds(): WindowsCaptionButtonBounds? { + val nativeRect = dwm?.getRectAttribute( + windowHandle, + DwmWindowAttribute.CaptionButtonBounds, + ) ?: return null + if (nativeRect.right <= nativeRect.left || nativeRect.bottom <= nativeRect.top) return null + + val windowRect = RECT() + if (!user32.GetWindowRect(windowHandle, windowRect)) return null + windowRect.read() + val clientOrigin = POINT(0, 0) + if (!user32.ClientToScreen(windowHandle, clientOrigin)) return null + clientOrigin.read() + val clientBounds = Rect( + left = (windowRect.left + nativeRect.left - clientOrigin.x).toFloat(), + top = (windowRect.top + nativeRect.top - clientOrigin.y).toFloat(), + right = (windowRect.left + nativeRect.right - clientOrigin.x).toFloat(), + bottom = (windowRect.top + nativeRect.bottom - clientOrigin.y).toFloat(), + ) + return splitWindowsCaptionButtonBounds(clientBounds) + } + + private fun metricForDpi(metric: Int, dpi: UINT): Int = runCatching { + user32.GetSystemMetricsForDpi(metric, dpi) + }.getOrElse { + user32.GetSystemMetrics(metric) + } + + private fun isMaximized(): Boolean { + val placement = WinUser.WINDOWPLACEMENT() + return user32.GetWindowPlacement(windowHandle, placement).booleanValue() && + placement.showCmd == WinUser.SW_SHOWMAXIMIZED + } + + private fun publishFrameInsets(nextInsets: WindowsFrameInsets) { + if (nextInsets == frameInsets) return + if (EventQueue.isDispatchThread()) { + frameInsets = nextInsets + } else { + EventQueue.invokeLater { + if (!closed) frameInsets = nextInsets + } + } + } + + private fun publishUsesSystemCaptionButtons(available: Boolean) { + if (available == usesSystemCaptionButtons) return + if (EventQueue.isDispatchThread()) { + usesSystemCaptionButtons = available + } else { + EventQueue.invokeLater { + if (!closed) usesSystemCaptionButtons = available + } + } + } + + private fun publishCaptionInteraction( + hovered: WindowsCaptionButton?, + pressed: WindowsCaptionButton?, + ) { + if (hovered == hoveredCaptionButton && pressed == pressedCaptionButton) return + val update = { + if (!closed) { + hoveredCaptionButton = hovered + pressedCaptionButton = pressed + } + } + if (EventQueue.isDispatchThread()) update() else EventQueue.invokeLater { update() } + } + + private fun publishWindowActive(active: Boolean) { + if (active == isWindowActive) return + if (EventQueue.isDispatchThread()) { + isWindowActive = active + } else { + EventQueue.invokeLater { + if (!closed) isWindowActive = active + } + } + } + + private fun pointInWindow(lParam: LPARAM): POINT? = pointInClient(windowHandle, lParam) + + private fun pointInClient(handle: HWND, lParam: LPARAM): POINT? { + val screenPoint = windowsScreenPoint(lParam.toLong()) + val point = POINT(screenPoint.x, screenPoint.y) + return if (user32.ScreenToClient(handle, point)) { + point.read() + point + } else { + null + } + } + + private fun callPreviousOuter(hWnd: HWND, message: Int, wParam: WPARAM, lParam: LPARAM): LRESULT { + val previous = previousOuterProcedure + return if (previous != null) { + user32.CallWindowProc(previous.toPointer(), hWnd, message, wParam, lParam) + } else { + user32.DefWindowProc(hWnd, message, wParam, lParam) + } + } + + private fun callPreviousContent(hWnd: HWND, message: Int, wParam: WPARAM, lParam: LPARAM): LRESULT { + val previous = previousContentProcedure + return if (previous != null) { + user32.CallWindowProc(previous.toPointer(), hWnd, message, wParam, lParam) + } else { + user32.DefWindowProc(hWnd, message, wParam, lParam) + } + } + + private fun callWindowsCaptionProcedure( + hWnd: HWND, + message: Int, + wParam: WPARAM, + lParam: LPARAM, + ): LRESULT = if (wParam.toInt().captionButton() != null) { + dwm?.defaultWindowProcedure(hWnd, message, wParam, lParam) + ?: user32.DefWindowProc(hWnd, message, wParam, lParam) + } else { + callPreviousOuter(hWnd, message, wParam, lParam) + } + + private fun trackNonClientMouseLeave() { + if (trackingNonClientMouse) return + val event = WindowsTrackMouseEvent( + flags = TrackMouseEventFlag.Leave or TrackMouseEventFlag.NonClient, + window = windowHandle, + ) + event.write() + trackingNonClientMouse = user32.TrackMouseEvent(event) + } + + private fun beginCaptionButtonPress(button: WindowsCaptionButton) { + nativePressedCaptionButton = button + user32.SetCapture(windowHandle) + publishCaptionInteraction(hovered = button, pressed = button) + } + + private fun finishCaptionButtonPress(): LRESULT { + val pressed = nativePressedCaptionButton + val released = captionButtonUnderCursor() + nativePressedCaptionButton = null + user32.ReleaseCapture() + publishCaptionInteraction(hovered = released, pressed = null) + if (shouldActivateWindowsCaptionButton(pressed, released)) { + checkNotNull(pressed) + when (pressed) { + WindowsCaptionButton.Minimize -> minimize() + WindowsCaptionButton.Maximize -> toggleMaximize() + WindowsCaptionButton.Close -> postCloseRequest() + } + } + return LRESULT(0) + } + + private fun cancelCaptionButtonPress(releaseCapture: Boolean) { + val hadPressedButton = nativePressedCaptionButton != null + nativePressedCaptionButton = null + if (hadPressedButton && releaseCapture && user32.GetCapture()?.value() == windowHandle.value()) { + user32.ReleaseCapture() + } + publishCaptionInteraction(hovered = null, pressed = null) + } + + private fun captionButtonUnderCursor(): WindowsCaptionButton? { + val point = POINT() + if (!user32.GetCursorPos(point) || !user32.ScreenToClient(windowHandle, point)) return null + point.read() + return hitTest(point.x, point.y).captionButton() + } + + private fun requestFrameRecalculation() { + user32.SetWindowPos( + windowHandle, + null, + 0, + 0, + 0, + 0, + SetWindowPositionFlag.NoMove or + SetWindowPositionFlag.NoSize or + SetWindowPositionFlag.NoZOrder or + SetWindowPositionFlag.NoActivate or + SetWindowPositionFlag.FrameChanged, + ) + } + + private fun transitionToDecoratedFallback() { + if (!EventQueue.isDispatchThread()) { + EventQueue.invokeLater { transitionToDecoratedFallback() } + return + } + closed = true + detachContentWindowProcedure() + restoreOuterWindowProcedure() + runCatching { resetDwmBackdrop() } + runCatching { applySystemFrameBorder() } + runCatching { restoreContentPane() } + runCatching { skiaLayer.transparency = false } + transparentSurfaceConfigured = false + usesNativeBackdrop = false + usesSystemCaptionButtons = false + usesCustomChrome = false + if (window.isDisplayable) runCatching { requestFrameRecalculation() } + releaseControllerIfDetached() + } + + private fun restoreOuterWindowProcedure() { + if (!outerSubclassInstalled) return + val previousOuter = previousOuterProcedure ?: return + val restoreResult = runCatching { + user32.restoreWindowProcedureIfOwned(windowHandle, outerProcedure, previousOuter) + }.getOrDefault(WindowProcedureRestoreResult.StillInstalled) + if (restoreResult != WindowProcedureRestoreResult.StillInstalled) { + outerSubclassInstalled = false + previousOuterProcedure = null + } + } + + override fun close() { + if (!EventQueue.isDispatchThread()) { + EventQueue.invokeLater { close() } + return + } + if (closed && !outerSubclassInstalled && !contentSubclassInstalled) return + closed = true + detachContentWindowProcedure() + restoreOuterWindowProcedure() + runCatching { disableNativeBackdrop() } + if (window.isDisplayable) { + runCatching { requestFrameRecalculation() } + } + releaseControllerIfDetached() + } + + private fun releaseControllerIfDetached() { + if (!outerSubclassInstalled && !contentSubclassInstalled) { + activeControllers.remove(windowHandle.value(), this) + } + } + + private class OuterWindowProcedure( + private val owner: WindowsNativeWindowController, + ) : WindowProc { + override fun callback(hWnd: HWND, message: Int, wParam: WPARAM, lParam: LPARAM): LRESULT = + owner.handleOuterMessage(hWnd, message, wParam, lParam) + } + + private class ContentWindowProcedure( + private val owner: WindowsNativeWindowController, + ) : WindowProc { + override fun callback(hWnd: HWND, message: Int, wParam: WPARAM, lParam: LPARAM): LRESULT = + owner.handleContentMessage(hWnd, message, wParam, lParam) + } + + companion object { + fun install(window: Window): WindowsNativeWindowController? { + if (!Platform.isWindows()) return null + return runCatching { + check(EventQueue.isDispatchThread()) { "Windows chrome must be installed on the AWT event thread" } + val composeWindow = requireNotNull(window as? ComposeWindow) { + "Windows window is ${window.javaClass.name}, not ComposeWindow" + } + val skiaLayer = requireNotNull(composeWindow.findComponent()) { + "Compose did not create a Skia layer" + } + val nativeWindow = composeWindow.windowHandle + require(nativeWindow != 0L) { "Compose did not create a native window" } + WindowsNativeWindowController( + window = composeWindow, + skiaLayer = skiaLayer, + windowHandle = HWND(Pointer(nativeWindow)), + user32 = WindowsUser32.instance, + dwm = WindowsDwmApi.load(), + ).also { it.attach() } + }.getOrNull() + } + + private val activeControllers = ConcurrentHashMap() + + private fun nativeContentHandle(skiaLayer: SkiaLayer): HWND { + val nativeContent = Native.getComponentPointer(skiaLayer.canvas) + require(Pointer.nativeValue(nativeContent) != 0L) { "Compose did not create a native content window" } + return HWND(nativeContent) + } + } +} + +private data class WindowsWindowMetrics( + val width: Int = 0, + val height: Int = 0, + val horizontalResizeBorder: Int = 0, + val verticalResizeBorder: Int = 0, + val isMaximized: Boolean = false, +) + +private data class WindowsHitRegions( + val caption: Rect = Rect.Zero, + val minimizeButton: Rect = Rect.Zero, + val maximizeButton: Rect = Rect.Zero, + val closeButton: Rect = Rect.Zero, +) + +private data class WindowsContentPaneChild( + val component: Component, + val constraint: Any?, +) + +private class WindowsAlphaClearingContentPane : JPanel() { + override fun paint(graphics: Graphics) { + if (background.alpha != 255) { + val copy = graphics.create() + try { + if (copy is Graphics2D) { + copy.color = background + copy.composite = AlphaComposite.getInstance(AlphaComposite.SRC) + copy.fillRect(0, 0, width, height) + } + } finally { + copy.dispose() + } + } + super.paint(graphics) + } +} + +@Structure.FieldOrder("size", "flags", "window", "hoverTime") +private class WindowsTrackMouseEvent( + @JvmField var flags: Int = 0, + @JvmField var window: HWND? = null, +) : Structure() { + @JvmField var size: Int = size() + @JvmField var hoverTime: Int = 0 +} + +private fun Rect.contains(x: Int, y: Int): Boolean = + x.toFloat() >= left && x.toFloat() < right && y.toFloat() >= top && y.toFloat() < bottom + +private fun Int.captionButton(): WindowsCaptionButton? = when (this) { + WindowsHitTestResult.MinimizeButton -> WindowsCaptionButton.Minimize + WindowsHitTestResult.MaximizeButton -> WindowsCaptionButton.Maximize + WindowsHitTestResult.CloseButton -> WindowsCaptionButton.Close + else -> null +} + +private fun HWND.value(): Long = Pointer.nativeValue(pointer) + +private fun Container.findComponent(type: Class): T? { + components.forEach { component -> + if (type.isInstance(component)) return type.cast(component) + if (component is Container) { + component.findComponent(type)?.let { return it } + } + } + return null +} + +private inline fun Container.findComponent(): T? = findComponent(T::class.java) + +@Suppress("FunctionName") +private interface WindowsUser32 : User32 { + fun SetWindowLong(hWnd: HWND, nIndex: Int, procedure: WindowProc): Int + fun SetWindowLongPtr(hWnd: HWND, nIndex: Int, procedure: WindowProc): LONG_PTR + fun SetCapture(hWnd: HWND): HWND? + fun GetCapture(): HWND? + fun ReleaseCapture(): Boolean + fun TrackMouseEvent(eventTrack: WindowsTrackMouseEvent): Boolean + fun GetSystemMetricsForDpi(nIndex: Int, dpi: UINT): Int + fun GetDpiForWindow(hWnd: HWND): UINT + fun ScreenToClient(hWnd: HWND, point: POINT): Boolean + fun ClientToScreen(hWnd: HWND, point: POINT): Boolean + + companion object { + val instance: WindowsUser32 by lazy { + Native.load("user32", WindowsUser32::class.java, W32APIOptions.DEFAULT_OPTIONS) + } + } +} + +private enum class WindowProcedureRestoreResult { + Restored, + AlreadyDetached, + StillInstalled, +} + +private fun WindowsUser32.installWindowProcedure(handle: HWND, procedure: WindowProc): LONG_PTR { + Native.setLastError(0) + val previous = if (Platform.is64Bit()) { + SetWindowLongPtr(handle, WinUser.GWL_WNDPROC, procedure) + } else { + LONG_PTR(SetWindowLong(handle, WinUser.GWL_WNDPROC, procedure).toLong()) + } + check(previous.toLong() != 0L || Native.getLastError() == 0) { + "SetWindowLongPtr failed with error ${Native.getLastError()}" + } + return previous +} + +private fun WindowsUser32.restoreWindowProcedureIfOwned( + handle: HWND, + procedure: WindowProc, + previous: LONG_PTR, +): WindowProcedureRestoreResult { + if (!IsWindow(handle)) return WindowProcedureRestoreResult.AlreadyDetached + val current = getWindowProcedure(handle) + val callback = Pointer.nativeValue(CallbackReference.getFunctionPointer(procedure)) + if (current.toLong() == previous.toLong()) return WindowProcedureRestoreResult.AlreadyDetached + if (current.toLong() != callback) return WindowProcedureRestoreResult.StillInstalled + + Native.setLastError(0) + val replaced = if (Platform.is64Bit()) { + LONG_PTR(Pointer.nativeValue(SetWindowLongPtr(handle, WinUser.GWL_WNDPROC, previous.toPointer()))) + } else { + LONG_PTR(SetWindowLong(handle, WinUser.GWL_WNDPROC, previous.toInt()).toLong()) + } + if (replaced.toLong() == 0L && Native.getLastError() != 0) { + return WindowProcedureRestoreResult.StillInstalled + } + return if (getWindowProcedure(handle).toLong() == previous.toLong()) { + WindowProcedureRestoreResult.Restored + } else { + WindowProcedureRestoreResult.StillInstalled + } +} + +private fun WindowsUser32.getWindowProcedure(handle: HWND): LONG_PTR = + if (Platform.is64Bit()) GetWindowLongPtr(handle, WinUser.GWL_WNDPROC) + else LONG_PTR(GetWindowLong(handle, WinUser.GWL_WNDPROC).toLong()) + +@Structure.FieldOrder("left", "right", "top", "bottom") +internal class WindowsWindowMargins( + @JvmField var left: Int = 0, + @JvmField var right: Int = 0, + @JvmField var top: Int = 0, + @JvmField var bottom: Int = 0, +) : Structure() + +@Suppress("FunctionName") +private interface WindowsDwmApi : StdCallLibrary { + fun DwmDefWindowProc( + hWnd: HWND, + message: Int, + wParam: WPARAM, + lParam: LPARAM, + result: ULONG_PTRByReference, + ): Boolean + + fun DwmExtendFrameIntoClientArea(hWnd: HWND, margins: WindowsWindowMargins): HRESULT + fun DwmGetWindowAttribute(hWnd: HWND, attribute: Int, value: RECT, valueSize: Int): HRESULT + fun DwmSetWindowAttribute(hWnd: HWND, attribute: Int, value: IntByReference, valueSize: Int): HRESULT + + fun extendFrame(handle: HWND, margins: WindowsWindowMargins): Boolean = + DwmExtendFrameIntoClientArea(handle, margins).succeeded() + + fun setIntAttribute(handle: HWND, attribute: Int, value: Int): Boolean = + DwmSetWindowAttribute(handle, attribute, IntByReference(value), Int.SIZE_BYTES).succeeded() + + fun getRectAttribute(handle: HWND, attribute: Int): RECT? { + val value = RECT() + if (!DwmGetWindowAttribute(handle, attribute, value, value.size()).succeeded()) return null + value.read() + return value + } + + fun defaultWindowProcedure(hWnd: HWND, message: Int, wParam: WPARAM, lParam: LPARAM): LRESULT? { + val result = ULONG_PTRByReference() + return if (DwmDefWindowProc(hWnd, message, wParam, lParam, result)) { + LRESULT(result.value.toLong()) + } else { + null + } + } + + companion object { + fun load(): WindowsDwmApi? = runCatching { + Native.load("dwmapi", WindowsDwmApi::class.java, W32APIOptions.DEFAULT_OPTIONS) + }.getOrNull() + } +} + +private fun HRESULT.succeeded(): Boolean = toInt() >= 0 + +internal fun GraphicsApi.supportsWindowsTransparentBackground(): Boolean = when (this) { + GraphicsApi.UNKNOWN, + GraphicsApi.SOFTWARE_FAST, + GraphicsApi.SOFTWARE_COMPAT, + -> false + else -> true +} + +internal fun shouldActivateWindowsCaptionButton( + pressed: WindowsCaptionButton?, + released: WindowsCaptionButton?, +): Boolean = pressed != null && pressed == released + +private object WindowsMessage { + const val Size = 0x0005 + const val Activate = 0x0006 + const val Close = 0x0010 + const val CancelMode = 0x001f + const val NonClientDestroy = 0x0082 + const val NonClientCalculateSize = 0x0083 + const val NonClientHitTest = 0x0084 + const val NonClientMouseMove = 0x00a0 + const val NonClientLeftButtonDown = 0x00a1 + const val NonClientLeftButtonUp = 0x00a2 + const val NonClientLeftButtonDoubleClick = 0x00a3 + const val MouseMove = 0x0200 + const val LeftButtonUp = 0x0202 + const val CaptureChanged = 0x0215 + const val NonClientMouseLeave = 0x02a2 + const val DwmCompositionChanged = 0x031e + const val DpiChanged = 0x02e0 +} + +private object WindowsActivation { + const val Inactive = 0 +} + +private object SetWindowPositionFlag { + const val NoSize = 0x0001 + const val NoMove = 0x0002 + const val NoZOrder = 0x0004 + const val NoActivate = 0x0010 + const val FrameChanged = 0x0020 +} + +private object DwmWindowAttribute { + const val CaptionButtonBounds = 5 + const val UseImmersiveDarkModeBefore20H1 = 19 + const val UseImmersiveDarkMode = 20 + const val BorderColor = 34 + const val SystemBackdropType = 38 + const val RedirectionBitmapAlpha = 39 +} + +private object DwmColor { + const val Default = -1 + const val None = -2 +} + +private object TrackMouseEventFlag { + const val Leave = 0x00000002 + const val NonClient = 0x00000010 +} + +private object DwmAttributeValue { + const val Disabled = 0 + const val Enabled = 1 +} + +private object DwmSystemBackdropType { + const val Auto = 0 + const val MainWindow = 2 +} + +private val TransparentAwtColor = AwtColor(0, 0, 0, 0) +private const val DefaultDpi = 96L diff --git a/desktopApp/src/test/kotlin/com/vnidrop/app/DesktopWindowChromeTest.kt b/desktopApp/src/test/kotlin/com/vnidrop/app/DesktopWindowChromeTest.kt index d20149e..ea6f312 100644 --- a/desktopApp/src/test/kotlin/com/vnidrop/app/DesktopWindowChromeTest.kt +++ b/desktopApp/src/test/kotlin/com/vnidrop/app/DesktopWindowChromeTest.kt @@ -1,8 +1,12 @@ package com.vnidrop.app +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.window.WindowPlacement import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.jetbrains.skiko.GraphicsApi class DesktopWindowChromeTest { @Test @@ -34,4 +38,114 @@ class DesktopWindowChromeTest { linuxWindowControlVisualState(isClose = false, isHovered = false, isPressed = false), ) } + + @Test + fun windowsHitTestingPreservesNativeResizeAndCaptionBehavior() { + val geometry = windowsGeometry() + + assertEquals(WindowsHitTestResult.TopLeft, windowsHitTest(2, 2, geometry)) + assertEquals(WindowsHitTestResult.Right, windowsHitTest(1_198, 300, geometry)) + assertEquals(WindowsHitTestResult.Bottom, windowsHitTest(600, 798, geometry)) + assertEquals(WindowsHitTestResult.Caption, windowsHitTest(600, 24, geometry)) + assertEquals(WindowsHitTestResult.Client, windowsHitTest(600, 200, geometry)) + } + + @Test + fun windowsCaptionButtonsReturnNativeHitCodesForSnapLayouts() { + val geometry = windowsGeometry() + + assertEquals(WindowsHitTestResult.MinimizeButton, windowsHitTest(1_075, 20, geometry)) + assertEquals(WindowsHitTestResult.MaximizeButton, windowsHitTest(1_120, 20, geometry)) + assertEquals(WindowsHitTestResult.CloseButton, windowsHitTest(1_175, 20, geometry)) + } + + @Test + fun nativeCaptionStripIsSplitIntoThreeAdjacentButtons() { + val bounds = splitWindowsCaptionButtonBounds(Rect(646f, 0f, 793f, 30f)) + + assertEquals(Rect(646f, 0f, 695f, 30f), bounds.minimize) + assertEquals(Rect(695f, 0f, 744f, 30f), bounds.maximize) + assertEquals(Rect(744f, 0f, 793f, 30f), bounds.close) + } + + @Test + fun extendedDwmTitleBarMarginScalesWithWindowDpi() { + assertEquals(48, windowsTitleBarHeightPixels(96)) + assertEquals(60, windowsTitleBarHeightPixels(120)) + assertEquals(72, windowsTitleBarHeightPixels(144)) + } + + @Test + fun maximizedWindowsDoNotExposeResizeBorders() { + val geometry = windowsGeometry().copy(isMaximized = true) + + assertEquals(WindowsHitTestResult.Caption, windowsHitTest(2, 2, geometry)) + } + + @Test + fun restoredWindowsPaintThroughTheResizeBorderWhileMaximizedWindowsStayInset() { + assertEquals( + WindowsFrameInsets(), + windowsContentInsets( + isMaximized = false, + horizontalResizeBorder = 8, + verticalResizeBorder = 8, + ), + ) + assertEquals( + WindowsFrameInsets(left = 8, top = 9, right = 8, bottom = 9), + windowsContentInsets( + isMaximized = true, + horizontalResizeBorder = 8, + verticalResizeBorder = 9, + ), + ) + } + + @Test + fun nativeMouseCoordinatesPreserveNegativeMonitorPositions() { + val x = -320 + val y = -48 + val packed = ((y and 0xffff).toLong() shl 16) or (x and 0xffff).toLong() + + assertEquals(WindowsScreenPoint(x, y), windowsScreenPoint(packed)) + } + + @Test + fun nativeBackdropRequiresAGpuRendererWithTransparentSwapChainSupport() { + assertTrue(GraphicsApi.DIRECT3D.supportsWindowsTransparentBackground()) + assertTrue(GraphicsApi.OPENGL.supportsWindowsTransparentBackground()) + assertFalse(GraphicsApi.UNKNOWN.supportsWindowsTransparentBackground()) + assertFalse(GraphicsApi.SOFTWARE_FAST.supportsWindowsTransparentBackground()) + assertFalse(GraphicsApi.SOFTWARE_COMPAT.supportsWindowsTransparentBackground()) + } + + @Test + fun captionActionRequiresReleaseOnTheOriginallyPressedButton() { + assertTrue( + shouldActivateWindowsCaptionButton( + WindowsCaptionButton.Maximize, + WindowsCaptionButton.Maximize, + ), + ) + assertFalse( + shouldActivateWindowsCaptionButton( + WindowsCaptionButton.Close, + WindowsCaptionButton.Maximize, + ), + ) + assertFalse(shouldActivateWindowsCaptionButton(WindowsCaptionButton.Close, null)) + } + + private fun windowsGeometry() = WindowsHitTestGeometry( + width = 1_200, + height = 800, + horizontalResizeBorder = 8, + verticalResizeBorder = 8, + isMaximized = false, + caption = Rect(0f, 0f, 1_200f, 48f), + minimizeButton = Rect(1_062f, 8f, 1_108f, 40f), + maximizeButton = Rect(1_108f, 8f, 1_154f, 40f), + closeButton = Rect(1_154f, 8f, 1_200f, 40f), + ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6801ded..cb251c0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ androidx-testExt = "1.3.0" composeMultiplatform = "1.11.1" filekit = "0.14.2" gobley = "0.3.7" +jna = "5.19.1" junit = "4.13.2" kotlin = "2.4.0" kotlinx-coroutines = "1.11.0" @@ -42,6 +43,7 @@ compose-uiTest = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "c compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } filekit-dialogs = { module = "io.github.vinceglb:filekit-dialogs", version.ref = "filekit" } +jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" } kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } From be7a61e948a58c9521128845b35003cd450273c6 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Wed, 22 Jul 2026 15:49:41 +0200 Subject: [PATCH 6/6] feat(desktop): integrate native Windows titlebar --- .../com/vnidrop/app/WindowsWindowChrome.kt | 241 ++++++++++++++++++ .../src/main/kotlin/com/vnidrop/app/main.kt | 55 ++-- .../platform/PlatformSystemAppearance.jvm.kt | 4 + .../platform/DesktopAppearanceBridgeTest.kt | 8 + 4 files changed, 288 insertions(+), 20 deletions(-) create mode 100644 desktopApp/src/main/kotlin/com/vnidrop/app/WindowsWindowChrome.kt diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/WindowsWindowChrome.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/WindowsWindowChrome.kt new file mode 100644 index 0000000..07a5af4 --- /dev/null +++ b/desktopApp/src/main/kotlin/com/vnidrop/app/WindowsWindowChrome.kt @@ -0,0 +1,241 @@ +package com.vnidrop.app + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathBuilder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowScope +import androidx.compose.ui.window.WindowState +import com.vnidrop.app.ui.theme.LocalVniDropColors + +internal class WindowsChromeConfiguration( + val topInset: Dp = 0.dp, + val contentTopStartRadius: Dp = 0.dp, + val useNativeBackdrop: Boolean = false, + val onDarkThemeChanged: (Boolean) -> Unit = {}, + val chrome: (@Composable () -> Unit)? = null, +) + +@Composable +internal fun WindowScope.WindowsWindowFrame( + windowState: WindowState, + content: @Composable (WindowsChromeConfiguration) -> Unit, +) { + val controller = remember(window) { WindowsNativeWindowController.install(window) } + DisposableEffect(controller) { + onDispose { controller?.close() } + } + if (controller == null) { + content(WindowsChromeConfiguration()) + return + } + if (!controller.usesCustomChrome) { + content(WindowsChromeConfiguration()) + return + } + + val density = LocalDensity.current + val insets = controller.frameInsets + Box( + modifier = Modifier + .fillMaxSize() + .padding( + start = with(density) { insets.left.toDp() }, + top = with(density) { insets.top.toDp() }, + end = with(density) { insets.right.toDp() }, + bottom = with(density) { insets.bottom.toDp() }, + ), + ) { + content( + WindowsChromeConfiguration( + topInset = WindowsTitleBarHeight, + contentTopStartRadius = DesktopContentCornerRadius, + useNativeBackdrop = controller.usesNativeBackdrop, + onDarkThemeChanged = controller::setDarkTheme, + chrome = { + WindowsTitleBar( + controller = controller, + isMaximized = windowState.placement == WindowPlacement.Maximized, + ) + }, + ), + ) + } +} + +@Composable +private fun WindowsTitleBar( + controller: WindowsNativeWindowController, + isMaximized: Boolean, +) { + val colors = LocalVniDropColors.current + val foreground = colors.foregroundDefault.copy(alpha = if (controller.isWindowActive) 1f else 0.55f) + Box( + modifier = Modifier + .fillMaxWidth() + .height(WindowsTitleBarHeight) + .background(if (controller.usesNativeBackdrop) Color.Transparent else colors.backgroundSurface200) + .onGloballyPositioned { controller.updateCaptionBounds(it.boundsInWindow()) }, + ) { + BasicText( + text = "VniDrop", + modifier = Modifier.align(Alignment.Center), + style = TextStyle( + color = foreground, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ), + ) + if (!controller.usesSystemCaptionButtons) { + Row(modifier = Modifier.align(Alignment.TopEnd)) { + WindowsCaptionButton( + button = WindowsCaptionButton.Minimize, + icon = WindowsMinimizeIcon, + contentDescription = "Minimize window", + controller = controller, + onClick = controller::minimize, + ) + WindowsCaptionButton( + button = WindowsCaptionButton.Maximize, + icon = if (isMaximized) WindowsRestoreIcon else WindowsMaximizeIcon, + contentDescription = if (isMaximized) "Restore window" else "Maximize window", + controller = controller, + onClick = controller::toggleMaximize, + ) + WindowsCaptionButton( + button = WindowsCaptionButton.Close, + icon = WindowsCloseIcon, + contentDescription = "Close window", + controller = controller, + onClick = { controller.postCloseRequest() }, + ) + } + } + } +} + +@Composable +private fun WindowsCaptionButton( + button: WindowsCaptionButton, + icon: ImageVector, + contentDescription: String, + controller: WindowsNativeWindowController, + onClick: () -> Unit, +) { + val colors = LocalVniDropColors.current + val hovered = controller.hoveredCaptionButton == button + val pressed = controller.pressedCaptionButton == button + val destructive = button == WindowsCaptionButton.Close && (hovered || pressed) + val background = when { + destructive -> colors.destructiveDefault + pressed -> colors.backgroundOverlayHover + hovered -> colors.backgroundOverlayHover + else -> Color.Transparent + } + val foreground = when { + destructive -> Color.White + controller.isWindowActive -> colors.foregroundDefault + else -> colors.foregroundDefault.copy(alpha = 0.55f) + } + Box( + modifier = Modifier + .size(width = WindowsCaptionButtonWidth, height = WindowsCaptionButtonHeight) + .background(background) + .clickable(role = Role.Button, onClick = onClick) + .onGloballyPositioned { coordinates -> + when (button) { + WindowsCaptionButton.Minimize -> controller.updateMinimizeButtonBounds(coordinates.boundsInWindow()) + WindowsCaptionButton.Maximize -> controller.updateMaximizeButtonBounds(coordinates.boundsInWindow()) + WindowsCaptionButton.Close -> controller.updateCloseButtonBounds(coordinates.boundsInWindow()) + } + }, + contentAlignment = Alignment.Center, + ) { + Image( + painter = rememberVectorPainter(icon), + contentDescription = contentDescription, + colorFilter = ColorFilter.tint(foreground), + modifier = Modifier.size(24.dp), + ) + } +} + +internal val WindowsTitleBarHeight = WindowsTitleBarHeightDip.dp +private val WindowsCaptionButtonWidth = 48.dp +private val WindowsCaptionButtonHeight = 48.dp + +private val WindowsMinimizeIcon = windowsCaptionIcon("Minimize") { + moveTo(5f, 12f) + lineTo(19f, 12f) +} + +private val WindowsMaximizeIcon = windowsCaptionIcon("Maximize") { + moveTo(6.5f, 6.5f) + lineTo(17.5f, 6.5f) + lineTo(17.5f, 17.5f) + lineTo(6.5f, 17.5f) + close() +} + +private val WindowsRestoreIcon = windowsCaptionIcon("Restore") { + moveTo(8.5f, 8.5f) + lineTo(18f, 8.5f) + lineTo(18f, 18f) + lineTo(8.5f, 18f) + close() + moveTo(6f, 15.5f) + lineTo(6f, 6f) + lineTo(15.5f, 6f) +} + +private val WindowsCloseIcon = windowsCaptionIcon("Close") { + moveTo(6.5f, 6.5f) + lineTo(17.5f, 17.5f) + moveTo(17.5f, 6.5f) + lineTo(6.5f, 17.5f) +} + +private fun windowsCaptionIcon(name: String, block: PathBuilder.() -> Unit): ImageVector = + ImageVector.Builder(name, 24.dp, 24.dp, 24f, 24f).apply { + path( + fill = SolidColor(Color.Transparent), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.4f, + strokeLineCap = StrokeCap.Square, + strokeLineJoin = StrokeJoin.Miter, + pathFillType = PathFillType.NonZero, + pathBuilder = block, + ) + }.build() diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt index eb9d3b5..965a932 100644 --- a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt +++ b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt @@ -61,6 +61,7 @@ fun main(args: Array) { FileKit.init(appId = "vnidrop") val externalInvitations = ExternalInvitationController() val linux = DesktopAppearanceBridge.isLinux() + val windows = DesktopAppearanceBridge.isWindows() configureInvitationOpenHandler(externalInvitations) args.asSequence() .map(::File) @@ -75,25 +76,39 @@ fun main(args: Array) { // Compose keeps edge resizers active for this client-decorated Linux window. undecorated = linux, ) { - App( - dependencies = rememberJvmAppDependencies(externalInvitations), - windowChromeTopInset = if (linux) LinuxTitleBarHeight else 0.dp, - windowContentTopStartRadius = if (linux) DesktopContentCornerRadius else 0.dp, - windowChrome = if (linux) { - { - LinuxTitleBar( - isMaximized = windowState.placement == WindowPlacement.Maximized, - onMinimize = { windowState.isMinimized = true }, - onToggleMaximize = { - windowState.placement = toggledWindowPlacement(windowState.placement) - }, - onClose = ::exitApplication, - ) - } - } else { - null - }, - ) + val dependencies = rememberJvmAppDependencies(externalInvitations) + if (windows) { + WindowsWindowFrame(windowState) { chrome -> + App( + dependencies = dependencies, + windowChromeTopInset = chrome.topInset, + windowContentTopStartRadius = chrome.contentTopStartRadius, + useNativeWindowBackdrop = chrome.useNativeBackdrop, + onResolvedDarkThemeChanged = chrome.onDarkThemeChanged, + windowChrome = chrome.chrome, + ) + } + } else { + App( + dependencies = dependencies, + windowChromeTopInset = if (linux) LinuxTitleBarHeight else 0.dp, + windowContentTopStartRadius = if (linux) DesktopContentCornerRadius else 0.dp, + windowChrome = if (linux) { + { + LinuxTitleBar( + isMaximized = windowState.placement == WindowPlacement.Maximized, + onMinimize = { windowState.isMinimized = true }, + onToggleMaximize = { + windowState.placement = toggledWindowPlacement(windowState.placement) + }, + onClose = ::exitApplication, + ) + } + } else { + null + }, + ) + } } } } @@ -120,7 +135,7 @@ private val LinuxTitleBarHeight = 48.dp private val LinuxWindowControlHitTargetSize = 34.dp private val LinuxWindowControlVisualSize = 28.dp private val LinuxWindowControlsWidth = 120.dp -private val DesktopContentCornerRadius = 20.dp +internal val DesktopContentCornerRadius = 20.dp @Composable @OptIn(ExperimentalComposeUiApi::class) diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.jvm.kt index 30abff5..6b9e849 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.jvm.kt @@ -8,10 +8,14 @@ actual fun PlatformSystemAppearance(isDarkTheme: Boolean) = Unit object DesktopAppearanceBridge { fun isLinux(): Boolean = isLinux(System.getProperty("os.name")) + fun isWindows(): Boolean = isWindows(System.getProperty("os.name")) internal fun isLinux(osName: String): Boolean = osName.startsWith("Linux", ignoreCase = true) + internal fun isWindows(osName: String): Boolean = + osName.startsWith("Windows", ignoreCase = true) + internal fun toggledWindowState(currentState: Int): Int = if (currentState and Frame.MAXIMIZED_BOTH == Frame.MAXIMIZED_BOTH) { Frame.NORMAL diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopAppearanceBridgeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopAppearanceBridgeTest.kt index 8b2932c..608e67a 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopAppearanceBridgeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopAppearanceBridgeTest.kt @@ -14,6 +14,14 @@ class DesktopAppearanceBridgeTest { assertFalse(DesktopAppearanceBridge.isLinux("Windows 11")) } + @Test + fun nativeWindowsChromeIsLimitedToWindows() { + assertFalse(DesktopAppearanceBridge.isWindows("Mac OS X")) + assertFalse(DesktopAppearanceBridge.isWindows("Linux")) + assertTrue(DesktopAppearanceBridge.isWindows("Windows 10")) + assertTrue(DesktopAppearanceBridge.isWindows("Windows 11")) + } + @Test fun titlebarDoubleClickTogglesMaximizedWindowState() { assertEquals(Frame.MAXIMIZED_BOTH, DesktopAppearanceBridge.toggledWindowState(Frame.NORMAL))