From cf7734fb42ab1e4270ca0ef67efb5407fcc4113f Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Wed, 22 Jul 2026 11:23:10 +0200 Subject: [PATCH] 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())