feat(desktop): polish Linux native experience

This commit is contained in:
2026-07-22 11:23:10 +02:00
parent 627c205853
commit cf7734fb42
13 changed files with 346 additions and 115 deletions

View File

@@ -24,6 +24,7 @@ dependencies {
implementation(projects.shared) implementation(projects.shared)
implementation(compose.desktop.currentOs) implementation(compose.desktop.currentOs)
implementation(libs.filekit.dialogs)
implementation(libs.kotlinx.coroutinesSwing) implementation(libs.kotlinx.coroutinesSwing)
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
@@ -48,6 +49,7 @@ compose.desktop {
linux { linux {
packageName = "vnidrop" packageName = "vnidrop"
iconFile.set(project.file("../assets/linux/app-icon.png")) iconFile.set(project.file("../assets/linux/app-icon.png"))
modules("jdk.security.auth")
debMaintainer = "support@sudosy.fr" debMaintainer = "support@sudosy.fr"
appRelease = "1" appRelease = "1"
rpmLicenseType = "Apache-2.0" rpmLicenseType = "Apache-2.0"

View File

@@ -9,13 +9,13 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.text.BasicText
import androidx.compose.foundation.window.WindowDraggableArea import androidx.compose.foundation.window.WindowDraggableArea
import androidx.compose.runtime.Composable 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.feature.receive.decodeInvitationBytes
import com.vnidrop.app.platform.DesktopAppearanceBridge import com.vnidrop.app.platform.DesktopAppearanceBridge
import com.vnidrop.app.ui.theme.LocalVniDropColors import com.vnidrop.app.ui.theme.LocalVniDropColors
import io.github.vinceglb.filekit.FileKit
import java.awt.Desktop import java.awt.Desktop
import java.io.File import java.io.File
fun main(args: Array<String>) { fun main(args: Array<String>) {
FileKit.init(appId = "vnidrop")
val externalInvitations = ExternalInvitationController() val externalInvitations = ExternalInvitationController()
val linux = DesktopAppearanceBridge.isLinux() val linux = DesktopAppearanceBridge.isLinux()
configureInvitationOpenHandler(externalInvitations) configureInvitationOpenHandler(externalInvitations)
@@ -114,9 +116,10 @@ private fun ExternalInvitationController.openFile(file: File) {
} }
} }
private val LinuxTitleBarHeight = 40.dp private val LinuxTitleBarHeight = 48.dp
private val LinuxWindowControlWidth = 46.dp private val LinuxWindowControlHitTargetSize = 34.dp
private val LinuxWindowControlsWidth = 138.dp private val LinuxWindowControlVisualSize = 28.dp
private val LinuxWindowControlsWidth = 120.dp
private val DesktopContentCornerRadius = 20.dp private val DesktopContentCornerRadius = 20.dp
@Composable @Composable
@@ -156,7 +159,9 @@ private fun WindowScope.LinuxTitleBar(
Row( Row(
modifier = Modifier modifier = Modifier
.align(Alignment.CenterEnd) .align(Alignment.CenterEnd)
.fillMaxHeight(), .padding(end = 10.dp)
.background(colors.backgroundSurface300, RoundedCornerShape(20.dp))
.padding(3.dp),
) { ) {
LinuxWindowControlButton( LinuxWindowControlButton(
icon = LinuxMinimizeIcon, icon = LinuxMinimizeIcon,
@@ -189,17 +194,15 @@ private fun LinuxWindowControlButton(
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val hovered by interactionSource.collectIsHoveredAsState() val hovered by interactionSource.collectIsHoveredAsState()
val pressed by interactionSource.collectIsPressedAsState() val pressed by interactionSource.collectIsPressedAsState()
val active = hovered || pressed val visualState = linuxWindowControlVisualState(isClose, hovered, pressed)
val background = when { val background = when (visualState) {
isClose && active -> colors.destructiveDefault LinuxWindowControlVisualState.Default -> Color.Transparent
active -> colors.backgroundOverlayHover LinuxWindowControlVisualState.NeutralActive -> colors.backgroundOverlayHover
else -> Color.Transparent LinuxWindowControlVisualState.DestructiveActive -> colors.destructiveDefault
} }
Box( Box(
modifier = Modifier modifier = Modifier
.width(LinuxWindowControlWidth) .size(LinuxWindowControlHitTargetSize)
.fillMaxHeight()
.background(background)
.hoverable(interactionSource) .hoverable(interactionSource)
.clickable( .clickable(
interactionSource = interactionSource, interactionSource = interactionSource,
@@ -209,15 +212,41 @@ private fun LinuxWindowControlButton(
), ),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Image( Box(
painter = rememberVectorPainter(icon), modifier = Modifier
contentDescription = contentDescription, .size(LinuxWindowControlVisualSize)
colorFilter = ColorFilter.tint(if (isClose && active) Color.White else colors.foregroundLight), .background(background, CircleShape),
modifier = Modifier.size(15.dp), 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 = internal fun toggledWindowPlacement(current: WindowPlacement): WindowPlacement =
if (current == WindowPlacement.Maximized) WindowPlacement.Floating else WindowPlacement.Maximized if (current == WindowPlacement.Maximized) WindowPlacement.Floating else WindowPlacement.Maximized

View File

@@ -10,4 +10,28 @@ class DesktopWindowChromeTest {
assertEquals(WindowPlacement.Maximized, toggledWindowPlacement(WindowPlacement.Floating)) assertEquals(WindowPlacement.Maximized, toggledWindowPlacement(WindowPlacement.Floating))
assertEquals(WindowPlacement.Floating, toggledWindowPlacement(WindowPlacement.Maximized)) 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),
)
}
} }

View File

@@ -12,6 +12,7 @@ androidx-datastore = "1.2.1"
androidx-testExt = "1.3.0" androidx-testExt = "1.3.0"
composeMultiplatform = "1.11.1" composeMultiplatform = "1.11.1"
compottie = "2.2.4" compottie = "2.2.4"
filekit = "0.14.2"
gobley = "0.3.7" gobley = "0.3.7"
junit = "4.13.2" junit = "4.13.2"
kotlin = "2.4.0" 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-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" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
compottie-lite = { module = "io.github.alexzhirkevich:compottie-lite", version.ref = "compottie" } 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-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-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" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }

View File

@@ -129,6 +129,9 @@ kotlin {
implementation(libs.google.code.scanner) implementation(libs.google.code.scanner)
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
} }
jvmMain.dependencies {
implementation(libs.filekit.dialogs)
}
commonMain.dependencies { commonMain.dependencies {
implementation(libs.compose.runtime) implementation(libs.compose.runtime)
implementation(libs.compose.foundation) implementation(libs.compose.foundation)

View File

@@ -37,6 +37,7 @@ import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathBuilder import androidx.compose.ui.graphics.vector.PathBuilder
import androidx.compose.ui.graphics.vector.path 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow 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.Transfer
import com.vnidrop.app.core.TransferDirection import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.isDesktop
import com.vnidrop.app.ui.components.AdaptiveDrawer import com.vnidrop.app.ui.components.AdaptiveDrawer
import com.vnidrop.app.ui.components.DestructiveButton import com.vnidrop.app.ui.components.DestructiveButton
import com.vnidrop.app.ui.components.DestructiveQuietButton 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.feedback.UiText
import com.vnidrop.app.ui.platform.LocalUiPlatform import com.vnidrop.app.ui.platform.LocalUiPlatform
import com.vnidrop.app.ui.platform.usesMobilePresentation 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.WindowClass
import com.vnidrop.app.ui.state.displayNameForStatus import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes import com.vnidrop.app.ui.state.formatBytes
@@ -163,7 +166,9 @@ private fun ReceiveHeader(showAction: Boolean, onOpen: () -> Unit) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { 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_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) { if (showAction) {
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))
@@ -175,15 +180,27 @@ private fun ReceiveHeader(showAction: Boolean, onOpen: () -> Unit) {
@Composable @Composable
private fun ReceiveEmptyState(onOpen: () -> Unit) { private fun ReceiveEmptyState(onOpen: () -> Unit) {
val colors = LocalVniDropColors.current val colors = LocalVniDropColors.current
val desktop = LocalUiPlatform.current.isDesktop
Column( Column(
Modifier.fillMaxWidth().heightIn(min = 430.dp).padding(horizontal = 20.dp), Modifier.fillMaxWidth().heightIn(min = 430.dp).padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
EmptyStateAnimation( if (desktop) {
assetPath = "files/animations/receive_empty_state.json", Icon(
modifier = Modifier.size(168.dp), 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_title), modifier = Modifier.padding(top = 12.dp), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text( Text(
stringResource(Res.string.receive_empty_body), stringResource(Res.string.receive_empty_body),

View File

@@ -30,6 +30,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow 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.CoreEventModel
import com.vnidrop.app.core.Transfer import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferStatus import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.isDesktop
import com.vnidrop.app.ui.components.EmptyStateAnimation import com.vnidrop.app.ui.components.EmptyStateAnimation
import com.vnidrop.app.ui.components.PillTone import com.vnidrop.app.ui.components.PillTone
import com.vnidrop.app.ui.components.PrimaryButton 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.components.StatusPill
import com.vnidrop.app.ui.platform.LocalUiPlatform import com.vnidrop.app.ui.platform.LocalUiPlatform
import com.vnidrop.app.ui.platform.usesMobilePresentation 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.TransferProgress
import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.activeSendProgress 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) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { 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_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text( if (!LocalUiPlatform.current.isDesktop) {
stringResource(Res.string.send_subtitle), Text(
color = LocalVniDropColors.current.foregroundLighter, stringResource(Res.string.send_subtitle),
style = MaterialTheme.typography.bodyMedium, color = LocalVniDropColors.current.foregroundLighter,
) style = MaterialTheme.typography.bodyMedium,
)
}
} }
if (showAction) { if (showAction) {
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))
@@ -144,15 +149,27 @@ private fun CatalogHeader(showAction: Boolean, onOpenComposer: () -> Unit) {
@Composable @Composable
private fun SendEmptyState(onOpenComposer: () -> Unit) { private fun SendEmptyState(onOpenComposer: () -> Unit) {
val colors = LocalVniDropColors.current val colors = LocalVniDropColors.current
val desktop = LocalUiPlatform.current.isDesktop
Column( Column(
modifier = Modifier.fillMaxWidth().heightIn(min = 430.dp).padding(horizontal = 20.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 430.dp).padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
EmptyStateAnimation( if (desktop) {
assetPath = "files/animations/send_empty_state.json", Icon(
modifier = Modifier.size(168.dp), 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( Text(
stringResource(Res.string.send_empty_title), stringResource(Res.string.send_empty_title),
modifier = Modifier.padding(top = 12.dp), modifier = Modifier.padding(top = 12.dp),

View File

@@ -7,6 +7,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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 com.vnidrop.app.ui.theme.ThemeMode
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import com.vnidrop.app.ui.theme.LocalVniDropColors import com.vnidrop.app.ui.theme.LocalVniDropColors
@@ -34,11 +36,13 @@ internal fun SettingsOverview(
style = if (largeTitle) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium, style = if (largeTitle) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
Text( if (!LocalUiPlatform.current.isDesktop) {
stringResource(Res.string.settings_subtitle), Text(
color = LocalVniDropColors.current.foregroundLighter, stringResource(Res.string.settings_subtitle),
style = MaterialTheme.typography.bodyMedium, color = LocalVniDropColors.current.foregroundLighter,
) style = MaterialTheme.typography.bodyMedium,
)
}
} }
SettingsGroup { SettingsGroup {
SettingsRow( SettingsRow(

View File

@@ -2,7 +2,6 @@ package com.vnidrop.app.ui.navigation
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vnidrop.app.UiPlatform import com.vnidrop.app.UiPlatform
import com.vnidrop.app.isDesktop import com.vnidrop.app.isDesktop
@@ -56,7 +54,6 @@ fun AppSidebarNavigation(
selected: AppDestination, selected: AppDestination,
style: NavigationStyle, style: NavigationStyle,
onDestinationSelected: (AppDestination) -> Unit, onDestinationSelected: (AppDestination) -> Unit,
dividerTopInset: Dp = 0.dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when (style) { when (style) {
@@ -64,7 +61,6 @@ fun AppSidebarNavigation(
NavigationStyle.DesktopSidebar -> DesktopSidebarNavigation( NavigationStyle.DesktopSidebar -> DesktopSidebarNavigation(
selected = selected, selected = selected,
onDestinationSelected = onDestinationSelected, onDestinationSelected = onDestinationSelected,
dividerTopInset = dividerTopInset,
modifier = modifier, modifier = modifier,
) )
NavigationStyle.AndroidBottomBar -> error("Bottom navigation is rendered by the phone shell") NavigationStyle.AndroidBottomBar -> error("Bottom navigation is rendered by the phone shell")
@@ -106,43 +102,31 @@ private fun AndroidNavigationRail(
private fun DesktopSidebarNavigation( private fun DesktopSidebarNavigation(
selected: AppDestination, selected: AppDestination,
onDestinationSelected: (AppDestination) -> Unit, onDestinationSelected: (AppDestination) -> Unit,
dividerTopInset: Dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val colors = LocalVniDropColors.current val colors = LocalVniDropColors.current
Box( Column(
modifier = modifier modifier = modifier
.width(DesktopNavigationWidthDp.dp) .width(DesktopNavigationWidthDp.dp)
.fillMaxHeight() .fillMaxHeight()
.background(colors.backgroundSurface200), .background(colors.backgroundSurface200)
.padding(horizontal = 12.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Column( Text(
modifier = Modifier.fillMaxHeight().padding(horizontal = 12.dp, vertical = 14.dp), text = "VniDrop",
verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
) { style = MaterialTheme.typography.titleMedium,
Text( fontWeight = FontWeight.SemiBold,
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),
) )
Spacer(Modifier.height(4.dp))
primaryNavigationItems.forEach { item ->
DesktopNavigationItem(
item = item,
selected = item.destination == selected,
onClick = { onDestinationSelected(item.destination) },
)
}
} }
} }

View File

@@ -88,7 +88,6 @@ private fun WideShell(
AppSidebarNavigation( AppSidebarNavigation(
selected = selectedDestination, selected = selectedDestination,
style = navigationStyle, style = navigationStyle,
dividerTopInset = mainContentTopStartRadius,
onDestinationSelected = onDestinationSelected, onDestinationSelected = onDestinationSelected,
) )
Box( Box(

View File

@@ -2,6 +2,12 @@ package com.vnidrop.app.core
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember 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.EventQueue
import java.awt.FileDialog import java.awt.FileDialog
import java.awt.Frame import java.awt.Frame
@@ -12,34 +18,59 @@ import java.io.File
import javax.imageio.ImageIO import javax.imageio.ImageIO
import javax.swing.JFileChooser import javax.swing.JFileChooser
import javax.swing.filechooser.FileSystemView import javax.swing.filechooser.FileSystemView
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable @Composable
actual fun rememberShareFilePicker( actual fun rememberShareFilePicker(
onFilesPicked: (List<PickedShareFile>) -> Unit, onFilesPicked: (List<PickedShareFile>) -> Unit,
onError: (String) -> Unit, onError: (String) -> Unit,
): ShareFilePicker = remember(onFilesPicked, onError) { ): ShareFilePicker {
object : ShareFilePicker { val scope = rememberCoroutineScope()
override fun pickFiles() { return remember(onFilesPicked, onError, scope) {
openPicker(onError) { object : ShareFilePicker {
val selected = pickShareFiles() override fun pickFiles() {
if (selected.isNotEmpty()) onFilesPicked(selected) 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() { override fun pickFolder() {
openPicker(onError) { if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) {
val selected = pickDirectory(title = "Select folder to share") ?: return@openPicker scope.launch {
onFilesPicked( try {
listOf( val selected = withContext(Dispatchers.IO) {
PickedShareFile( pickDirectoryWithPortal("Select folder to share")?.toPickedShareFile(isDirectory = true)
value = selected.absolutePath, } ?: return@launch
displayName = selected.name.ifBlank { selected.absolutePath }, onFilesPicked(listOf(selected))
sizeBytes = null, } catch (error: CancellationException) {
thumbnailBytes = selected.systemIconPng(), throw error
isDirectory = true, } 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( actual fun rememberReceiveFolderPicker(
onFolderPicked: (ReceiveFolder) -> Unit, onFolderPicked: (ReceiveFolder) -> Unit,
onError: (String) -> Unit, onError: (String) -> Unit,
): ReceiveFolderPicker = remember(onFolderPicked, onError) { ): ReceiveFolderPicker {
object : ReceiveFolderPicker { val scope = rememberCoroutineScope()
override fun pickFolder() { return remember(onFolderPicked, onError, scope) {
openPicker(onError) { object : ReceiveFolderPicker {
val selected = pickDirectory(title = "Select receive folder") ?: return@openPicker override fun pickFolder() {
onFolderPicked( if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) {
ReceiveFolder( scope.launch {
kind = ReceiveFolderKind.FileSystemPath, try {
value = selected.absolutePath, val selected = withContext(Dispatchers.IO) {
displayName = selected.name.ifBlank { selected.absolutePath }, 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( private fun openPicker(
onError: (String) -> Unit, onError: (String) -> Unit,
block: () -> Unit, block: () -> Unit,
@@ -99,20 +150,39 @@ private fun pickShareFiles(): List<PickedShareFile> {
val names = dialog.files?.map { it.name }.orEmpty().ifEmpty { val names = dialog.files?.map { it.name }.orEmpty().ifEmpty {
dialog.file?.let { listOf(it) }.orEmpty() dialog.file?.let { listOf(it) }.orEmpty()
} }
names.map { name -> names.map { name -> File(directory, name).toPickedShareFile(isDirectory = false) }
val selected = File(directory, name)
PickedShareFile(
selected.absolutePath,
selected.name,
selected.length().takeIf { it >= 0L }?.toULong(),
selected.systemIconPng(),
)
}
} finally { } finally {
dialog.dispose() dialog.dispose()
} }
} }
private suspend fun pickShareFilesWithPortal(): List<PickedShareFile> =
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 { private fun File.systemIconPng(): ByteArray? = runCatching {
val icon = FileSystemView.getFileSystemView().getSystemIcon(this, 128, 128) val icon = FileSystemView.getFileSystemView().getSystemIcon(this, 128, 128)
val image = BufferedImage(icon.iconWidth, icon.iconHeight, BufferedImage.TYPE_INT_ARGB) val image = BufferedImage(icon.iconWidth, icon.iconHeight, BufferedImage.TYPE_INT_ARGB)

View File

@@ -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"))
}
}

View File

@@ -1,6 +1,7 @@
package com.vnidrop.app.ui package com.vnidrop.app.ui
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text 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.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.test.v2.runComposeUiTest import androidx.compose.ui.test.v2.runComposeUiTest
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import com.vnidrop.app.feature.approvals.ApprovalModalHost import com.vnidrop.app.feature.approvals.ApprovalModalHost
import com.vnidrop.app.feature.approvals.ApprovalState 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.SettingsScreen
import com.vnidrop.app.feature.settings.SettingsSection import com.vnidrop.app.feature.settings.SettingsSection
import com.vnidrop.app.feature.settings.SettingsState 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.SendScreen
import com.vnidrop.app.feature.send.SendState import com.vnidrop.app.feature.send.SendState
import com.vnidrop.app.feature.send.TransferCatalog
import com.vnidrop.app.UiPlatform import com.vnidrop.app.UiPlatform
import com.vnidrop.app.core.CoreState import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.PickedShareFile 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.feedback.VniDropSnackbarHost
import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.navigation.AppDestination 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.shell.AppShell
import com.vnidrop.app.ui.theme.VniDropTheme import com.vnidrop.app.ui.theme.VniDropTheme
import kotlin.test.Test import kotlin.test.Test
@@ -285,6 +290,63 @@ class FoundationComposeTest {
runOnIdle { assertEquals(AppDestination.Receive, selected) } 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<String>) -> Unit) = Unit
override fun scanQrCode(onResult: (Result<String>) -> Unit) = Unit
override fun readNfcInvitation(onResult: (Result<String>) -> 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 youre sharing from this device.").assertCountEquals(0)
onAllNodesWithText("Transfers youve received on this device.").assertCountEquals(0)
onAllNodesWithText("Your name, where transfers are saved, appearance, and notifications.").assertCountEquals(0)
}
@Test @Test
fun phoneSendEmptyStateOpensCreationDrawer() = runComposeUiTest { fun phoneSendEmptyStateOpensCreationDrawer() = runComposeUiTest {
val state = mutableStateOf(SendState()) val state = mutableStateOf(SendState())