Merge pull request #24 from sudosylabs/feat/native-platform-ui

feat(ui): add native platform experiences
This commit is contained in:
Hammed Abass
2026-07-22 16:35:46 +02:00
committed by GitHub
33 changed files with 2818 additions and 379 deletions

View File

@@ -24,6 +24,8 @@ dependencies {
implementation(projects.shared) implementation(projects.shared)
implementation(compose.desktop.currentOs) implementation(compose.desktop.currentOs)
implementation(libs.filekit.dialogs)
implementation(libs.jna.platform)
implementation(libs.kotlinx.coroutinesSwing) implementation(libs.kotlinx.coroutinesSwing)
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
@@ -48,6 +50,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

@@ -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()

File diff suppressed because it is too large Load Diff

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,12 +53,15 @@ 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()
val windows = DesktopAppearanceBridge.isWindows()
configureInvitationOpenHandler(externalInvitations) configureInvitationOpenHandler(externalInvitations)
args.asSequence() args.asSequence()
.map(::File) .map(::File)
@@ -73,8 +76,21 @@ fun main(args: Array<String>) {
// Compose keeps edge resizers active for this client-decorated Linux window. // Compose keeps edge resizers active for this client-decorated Linux window.
undecorated = linux, undecorated = linux,
) { ) {
val dependencies = rememberJvmAppDependencies(externalInvitations)
if (windows) {
WindowsWindowFrame(windowState) { chrome ->
App( App(
dependencies = rememberJvmAppDependencies(externalInvitations), 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, windowChromeTopInset = if (linux) LinuxTitleBarHeight else 0.dp,
windowContentTopStartRadius = if (linux) DesktopContentCornerRadius else 0.dp, windowContentTopStartRadius = if (linux) DesktopContentCornerRadius else 0.dp,
windowChrome = if (linux) { windowChrome = if (linux) {
@@ -95,6 +111,7 @@ fun main(args: Array<String>) {
} }
} }
} }
}
private fun configureInvitationOpenHandler(controller: ExternalInvitationController) { private fun configureInvitationOpenHandler(controller: ExternalInvitationController) {
if (!Desktop.isDesktopSupported()) return if (!Desktop.isDesktopSupported()) return
@@ -114,10 +131,11 @@ 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 DesktopContentCornerRadius = 20.dp private val LinuxWindowControlsWidth = 120.dp
internal val DesktopContentCornerRadius = 20.dp
@Composable @Composable
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@@ -156,7 +174,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 +209,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,
@@ -208,15 +226,41 @@ private fun LinuxWindowControlButton(
onClick = onClick, onClick = onClick,
), ),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.size(LinuxWindowControlVisualSize)
.background(background, CircleShape),
contentAlignment = Alignment.Center,
) { ) {
Image( Image(
painter = rememberVectorPainter(icon), painter = rememberVectorPainter(icon),
contentDescription = contentDescription, contentDescription = contentDescription,
colorFilter = ColorFilter.tint(if (isClose && active) Color.White else colors.foregroundLight), colorFilter = ColorFilter.tint(
modifier = Modifier.size(15.dp), 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

@@ -1,8 +1,12 @@
package com.vnidrop.app package com.vnidrop.app
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowPlacement
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import org.jetbrains.skiko.GraphicsApi
class DesktopWindowChromeTest { class DesktopWindowChromeTest {
@Test @Test
@@ -10,4 +14,138 @@ 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),
)
}
@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),
)
} }

View File

@@ -11,8 +11,9 @@ androidx-lifecycle = "2.11.0-beta01"
androidx-datastore = "1.2.1" 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" filekit = "0.14.2"
gobley = "0.3.7" gobley = "0.3.7"
jna = "5.19.1"
junit = "4.13.2" junit = "4.13.2"
kotlin = "2.4.0" kotlin = "2.4.0"
kotlinx-coroutines = "1.11.0" kotlinx-coroutines = "1.11.0"
@@ -41,7 +42,8 @@ compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMul
compose-uiTest = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "composeMultiplatform" } 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-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" } 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-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)
@@ -136,7 +139,6 @@ kotlin {
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.compose.components.resources) implementation(libs.compose.components.resources)
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
implementation(libs.compottie.lite)
implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.datastore) implementation(libs.androidx.datastore)

View File

@@ -25,6 +25,7 @@ fun rememberAndroidAppDependencies(activity: ComponentActivity, externalInvitati
appVersion = context.appVersion(), appVersion = context.appVersion(),
defaultCoreDataDir = context.filesDir.resolve("vnidrop").absolutePath, defaultCoreDataDir = context.filesDir.resolve("vnidrop").absolutePath,
defaultUsername = Build.DEVICE.takeIf(String::isNotBlank) ?: "Receiver", defaultUsername = Build.DEVICE.takeIf(String::isNotBlank) ?: "Receiver",
uiPlatform = UiPlatform.Android,
), ),
deviceInfoProvider = AndroidDeviceInfoProvider(context), deviceInfoProvider = AndroidDeviceInfoProvider(context),
fileSystemService = fileSystemService, fileSystemService = fileSystemService,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,11 +7,13 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@@ -34,11 +36,13 @@ import com.vnidrop.app.feature.settings.SettingsViewModel
import com.vnidrop.app.platform.PlatformSystemAppearance import com.vnidrop.app.platform.PlatformSystemAppearance
import com.vnidrop.app.ui.feedback.VniDropSnackbarHost import com.vnidrop.app.ui.feedback.VniDropSnackbarHost
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.platform.contentWindowClassFor
import com.vnidrop.app.ui.platform.usesMobilePresentation
import com.vnidrop.app.ui.shell.AppShell import com.vnidrop.app.ui.shell.AppShell
import com.vnidrop.app.ui.shell.ScreenScrollContainer import com.vnidrop.app.ui.shell.ScreenScrollContainer
import com.vnidrop.app.core.TransferDirection import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.ui.state.WindowClass 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.LocalVniDropColors
import com.vnidrop.app.ui.theme.VniDropTheme import com.vnidrop.app.ui.theme.VniDropTheme
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
@@ -51,6 +55,8 @@ fun App(
dependencies: AppDependencies, dependencies: AppDependencies,
windowChromeTopInset: Dp = 0.dp, windowChromeTopInset: Dp = 0.dp,
windowContentTopStartRadius: Dp = 0.dp, windowContentTopStartRadius: Dp = 0.dp,
useNativeWindowBackdrop: Boolean = false,
onResolvedDarkThemeChanged: (Boolean) -> Unit = {},
windowChrome: (@Composable () -> Unit)? = null, windowChrome: (@Composable () -> Unit)? = null,
) { ) {
val graphHolder = viewModel { AppGraphViewModel(dependencies) } val graphHolder = viewModel { AppGraphViewModel(dependencies) }
@@ -137,34 +143,44 @@ fun App(
} }
val darkTheme = rememberResolvedDarkTheme(appState.themeMode) val darkTheme = rememberResolvedDarkTheme(appState.themeMode)
LaunchedEffect(darkTheme, onResolvedDarkThemeChanged) {
onResolvedDarkThemeChanged(darkTheme)
}
PlatformSystemAppearance(darkTheme) PlatformSystemAppearance(darkTheme)
CompositionLocalProvider(LocalUiPlatform provides dependencies.environment.uiPlatform) {
VniDropTheme(isDarkTheme = darkTheme) { VniDropTheme(isDarkTheme = darkTheme) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(LocalVniDropColors.current.backgroundSurface200), .background(
if (useNativeWindowBackdrop) Color.Transparent
else LocalVniDropColors.current.backgroundSurface200,
),
) { ) {
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(top = windowChromeTopInset), .padding(top = windowChromeTopInset),
) { ) {
val windowClass = windowClassFor(maxWidth.value) val windowClass = contentWindowClassFor(dependencies.environment.uiPlatform, maxWidth.value)
val usesFloatingActions = usesMobilePresentation(dependencies.environment.uiPlatform, windowClass)
val showSendAction = appState.destination == AppDestination.Send && val showSendAction = appState.destination == AppDestination.Send &&
windowClass == WindowClass.Phone && usesFloatingActions &&
sendState.selectedTransferId?.let { selectedId -> sendState.selectedTransferId?.let { selectedId ->
sendCoreState.transfers.any { it.transferId == selectedId } sendCoreState.transfers.any { it.transferId == selectedId }
} != true && } != true &&
sendCoreState.transfers.any { it.direction == TransferDirection.Send } sendCoreState.transfers.any { it.direction == TransferDirection.Send }
val showReceiveAction = appState.destination == AppDestination.Receive && val showReceiveAction = appState.destination == AppDestination.Receive &&
windowClass == WindowClass.Phone && usesFloatingActions &&
!receiveState.isAcquisitionOpen && !receiveState.isAcquisitionOpen &&
receiveCoreState.transfers.any { it.direction == TransferDirection.Receive } receiveCoreState.transfers.any { it.direction == TransferDirection.Receive }
AppShell( AppShell(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
selectedDestination = appState.destination, selectedDestination = appState.destination,
windowClass = windowClass, windowClass = windowClass,
uiPlatform = dependencies.environment.uiPlatform,
mainContentTopStartRadius = windowContentTopStartRadius, mainContentTopStartRadius = windowContentTopStartRadius,
useNativeWindowBackdrop = useNativeWindowBackdrop,
onDestinationSelected = appViewModel::selectDestination, onDestinationSelected = appViewModel::selectDestination,
overlay = { overlay = {
VniDropSnackbarHost(graph.messages, Modifier.align(Alignment.BottomCenter)) VniDropSnackbarHost(graph.messages, Modifier.align(Alignment.BottomCenter))
@@ -203,3 +219,4 @@ fun App(
} }
} }
} }
}

View File

@@ -4,11 +4,22 @@ import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.notifications.LocalNotificationService import com.vnidrop.app.notifications.LocalNotificationService
import com.vnidrop.app.feature.receive.ExternalInvitationController 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( data class PlatformEnvironment(
val name: String, val name: String,
val appVersion: String, val appVersion: String,
val defaultCoreDataDir: String, val defaultCoreDataDir: String,
val defaultUsername: String = "Receiver", val defaultUsername: String = "Receiver",
val uiPlatform: UiPlatform = UiPlatform.Android,
) )
data class DeviceInfo( data class DeviceInfo(

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
@@ -49,12 +50,14 @@ import com.vnidrop.app.core.TransferStatus
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
import com.vnidrop.app.ui.components.EmptyStateAnimation
import com.vnidrop.app.ui.components.Field import com.vnidrop.app.ui.components.Field
import com.vnidrop.app.ui.components.PrimaryButton import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.ProgressRow import com.vnidrop.app.ui.components.ProgressRow
import com.vnidrop.app.ui.components.SecondaryButton 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.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
@@ -93,12 +96,13 @@ fun ReceiveScreen(
) { ) {
val transfers = coreState.transfers.filter { it.direction == TransferDirection.Receive } val transfers = coreState.transfers.filter { it.direction == TransferDirection.Receive }
val deletableTransfers = transfers.filter { it.status.isTerminalReceiveHistory() } val deletableTransfers = transfers.filter { it.status.isTerminalReceiveHistory() }
val usesFloatingAction = usesMobilePresentation(LocalUiPlatform.current, windowClass)
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().statusBarsPadding(), modifier = Modifier.fillMaxSize().statusBarsPadding(),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
) { ) {
item { ReceiveHeader(transfers.isNotEmpty(), windowClass, onOpenAcquisition) } item { ReceiveHeader(transfers.isNotEmpty() && !usesFloatingAction, onOpenAcquisition) }
if (transfers.isEmpty()) item { ReceiveEmptyState(onOpenAcquisition) } if (transfers.isEmpty()) item { ReceiveEmptyState(onOpenAcquisition) }
else { else {
item { item {
@@ -156,13 +160,12 @@ fun ReceiveScreen(
} }
@Composable @Composable
private fun ReceiveHeader(showAction: Boolean, windowClass: WindowClass, onOpen: () -> Unit) { 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 (showAction && windowClass != WindowClass.Phone) { if (showAction) {
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))
PrimaryButton(stringResource(Res.string.button_receive_files), onClick = onOpen) PrimaryButton(stringResource(Res.string.button_receive_files), onClick = onOpen)
} }
@@ -177,9 +180,13 @@ private fun ReceiveEmptyState(onOpen: () -> Unit) {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
EmptyStateAnimation( Icon(
assetPath = "files/animations/receive_empty_state.json", imageVector = VniDropIcons.Receive,
modifier = Modifier.size(168.dp), 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_title), modifier = Modifier.padding(top = 12.dp), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text( Text(

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,11 +38,13 @@ 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.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
import com.vnidrop.app.ui.components.ProgressRow 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.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
@@ -57,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_empty_title
import vnidrop.shared.generated.resources.send_new_transfer_description import vnidrop.shared.generated.resources.send_new_transfer_description
import vnidrop.shared.generated.resources.send_new_transfer_title 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_title
import vnidrop.shared.generated.resources.send_transfers_title import vnidrop.shared.generated.resources.send_transfers_title
@@ -82,17 +84,18 @@ internal fun TransferCatalog(
onOpenComposer: () -> Unit, onOpenComposer: () -> Unit,
onTransferSelected: (ULong) -> Unit, onTransferSelected: (ULong) -> Unit,
) { ) {
val usesFloatingAction = usesMobilePresentation(LocalUiPlatform.current, windowClass)
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().statusBarsPadding(), modifier = Modifier.fillMaxSize().statusBarsPadding(),
contentPadding = PaddingValues( contentPadding = PaddingValues(
start = 16.dp, start = 16.dp,
top = 16.dp, top = 16.dp,
end = 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), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
item { CatalogHeader(showAction = windowClass != WindowClass.Phone && transfers.isNotEmpty(), onOpenComposer) } item { CatalogHeader(showAction = !usesFloatingAction && transfers.isNotEmpty(), onOpenComposer) }
if (transfers.isEmpty()) { if (transfers.isEmpty()) {
item { SendEmptyState(onOpenComposer) } item { SendEmptyState(onOpenComposer) }
} else { } else {
@@ -125,11 +128,6 @@ 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(
stringResource(Res.string.send_subtitle),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodyMedium,
)
} }
if (showAction) { if (showAction) {
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))
@@ -146,9 +144,13 @@ private fun SendEmptyState(onOpenComposer: () -> Unit) {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
EmptyStateAnimation( Icon(
assetPath = "files/animations/send_empty_state.json", imageVector = VniDropIcons.Send,
modifier = Modifier.size(168.dp), contentDescription = null,
tint = colors.brandLink,
modifier = Modifier
.size(88.dp)
.testTag("send-empty-icon"),
) )
Text( Text(
stringResource(Res.string.send_empty_title), stringResource(Res.string.send_empty_title),

View File

@@ -2,24 +2,58 @@ package com.vnidrop.app.feature.settings
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.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 androidx.compose.ui.unit.dp
import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.about_bug_report 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.about_title
import vnidrop.shared.generated.resources.battery_level_title
import vnidrop.shared.generated.resources.device_model_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_description
import vnidrop.shared.generated.resources.diagnostics_title 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.os_version_title
import vnidrop.shared.generated.resources.value_unavailable import vnidrop.shared.generated.resources.value_unavailable
import vnidrop.shared.generated.resources.version_title import vnidrop.shared.generated.resources.version_title
private const val PrivacyPolicyUrl = "https://github.com/vnidrop/vnidrop"
@Composable @Composable
internal fun AboutSettings( internal fun AboutSettings(
state: SettingsState, state: SettingsState,
@@ -28,18 +62,75 @@ internal fun AboutSettings(
onBack: () -> Unit, onBack: () -> Unit,
showBack: Boolean, showBack: Boolean,
) { ) {
val colors = LocalVniDropColors.current
val unavailable = stringResource(Res.string.value_unavailable) val unavailable = stringResource(Res.string.value_unavailable)
val info = state.deviceInfo val info = state.deviceInfo
val uriHandler = LocalUriHandler.current
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SettingsTopBar(stringResource(Res.string.about_title), onBack, showBack) SettingsTopBar(stringResource(Res.string.about_title), onBack, showBack)
SettingsGroup { SettingsGroup {
SettingsRow( Text(
icon = SettingsIcons.Document, stringResource(Res.string.about_tagline),
title = stringResource(Res.string.about_privacy), modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
iconTone = SettingsIconTone.Neutral, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
) )
if (DiagnosticsBuildConfig.INCLUDED) { 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() SettingsDivider()
SettingsRow(
icon = SettingsIcons.Shield,
title = stringResource(Res.string.about_privacy_policy_label),
iconTone = SettingsIconTone.Brand,
onClick = { uriHandler.openUri(PrivacyPolicyUrl) },
)
}
SettingsGroup {
if (DiagnosticsBuildConfig.INCLUDED) {
SettingsToggleRow( SettingsToggleRow(
icon = SettingsIcons.Info, icon = SettingsIcons.Info,
title = stringResource(Res.string.diagnostics_title), title = stringResource(Res.string.diagnostics_title),
@@ -48,8 +139,8 @@ internal fun AboutSettings(
enabled = true, enabled = true,
onCheckedChange = onDiagnosticsChanged, onCheckedChange = onDiagnosticsChanged,
) )
}
SettingsDivider() SettingsDivider()
}
SettingsRow( SettingsRow(
icon = SettingsIcons.Bug, icon = SettingsIcons.Bug,
title = stringResource(Res.string.about_bug_report), title = stringResource(Res.string.about_bug_report),
@@ -57,20 +148,70 @@ internal fun AboutSettings(
onClick = onReportBug, onClick = onReportBug,
) )
} }
}
}
@Composable
private fun AboutSection(
title: String,
points: List<Pair<ImageVector, String>>,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
title,
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
SettingsGroup { SettingsGroup {
InfoItem(stringResource(Res.string.version_title), state.appVersion) points.forEachIndexed { index, (icon, text) ->
SettingsDivider(startPadding = 16.dp) if (index > 0) SettingsDivider(startPadding = 54.dp)
InfoItem(stringResource(Res.string.device_name_title), info?.deviceName.orUnavailable(unavailable)) AboutPoint(icon, text)
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))
} }
} }
} }
@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 private fun String?.orUnavailable(fallback: String): String = this?.takeIf(String::isNotBlank) ?: fallback

View File

@@ -28,6 +28,181 @@ internal object SettingsIcons {
lineTo(9f, 17f) lineTo(9f, 17f)
lineTo(4f, 12f) 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") { val Sun = lineIcon("Sun") {
moveTo(12f, 4f) moveTo(12f, 4f)
lineTo(12f, 2f) 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 { ImageVector.Builder(name, 24.dp, 24.dp, 24f, 24f).apply {
path( path(
fill = SolidColor(Color.Transparent), fill = SolidColor(Color.Transparent),
stroke = SolidColor(Color.Black), stroke = SolidColor(Color.Black),
strokeLineWidth = 2f, strokeLineWidth = strokeWidth,
strokeLineCap = StrokeCap.Round, strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round, strokeLineJoin = StrokeJoin.Round,
pathFillType = PathFillType.NonZero, pathFillType = PathFillType.NonZero,
@@ -141,3 +320,9 @@ private fun PathBuilder.roundRect(x: Float, y: Float, width: Float, height: Floa
lineTo(x, y + radius) lineTo(x, y + radius)
arcTo(radius, radius, 0f, false, true, x + radius, y) arcTo(radius, radius, 0f, false, true, x + radius, y)
} }
private fun PathBuilder.circle(centerX: Float, centerY: Float, radius: Float) {
moveTo(centerX + radius, centerY)
arcTo(radius, radius, 0f, true, true, centerX - radius, centerY)
arcTo(radius, radius, 0f, true, true, centerX + radius, centerY)
}

View File

@@ -9,7 +9,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
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 vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.about_title import vnidrop.shared.generated.resources.about_title
import vnidrop.shared.generated.resources.appearance_dark_mode import vnidrop.shared.generated.resources.appearance_dark_mode
@@ -18,7 +17,6 @@ import vnidrop.shared.generated.resources.appearance_system_mode
import vnidrop.shared.generated.resources.appearance_title import vnidrop.shared.generated.resources.appearance_title
import vnidrop.shared.generated.resources.notifications_title import vnidrop.shared.generated.resources.notifications_title
import vnidrop.shared.generated.resources.preferences_title import vnidrop.shared.generated.resources.preferences_title
import vnidrop.shared.generated.resources.settings_subtitle
import vnidrop.shared.generated.resources.settings_title import vnidrop.shared.generated.resources.settings_title
@Composable @Composable
@@ -28,18 +26,11 @@ internal fun SettingsOverview(
largeTitle: Boolean, largeTitle: Boolean,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text( Text(
stringResource(Res.string.settings_title), stringResource(Res.string.settings_title),
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(
stringResource(Res.string.settings_subtitle),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodyMedium,
)
}
SettingsGroup { SettingsGroup {
SettingsRow( SettingsRow(
icon = SettingsIcons.Device, icon = SettingsIcons.Device,

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,18 @@
package com.vnidrop.app.ui.components 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.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.LocalVniDropColors
@Composable @Composable
fun Field( fun Field(
@@ -17,13 +23,23 @@ fun Field(
minLines: Int = 1, minLines: Int = 1,
enabled: Boolean = true, enabled: Boolean = true,
) { ) {
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( OutlinedTextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
label = { Text(label) }, label = if (desktop) {
modifier = modifier.fillMaxWidth(), null
} else {
{ Text(label) }
},
modifier = Modifier.fillMaxWidth(),
minLines = minLines, minLines = minLines,
enabled = enabled, enabled = enabled,
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(if (desktop) 5.dp else 8.dp),
) )
} }
}

View File

@@ -2,23 +2,25 @@ 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
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
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.navigationBars
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.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -26,49 +28,138 @@ 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.text.font.FontWeight 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.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.isDesktop
import com.vnidrop.app.ui.platform.DesktopNavigationWidthDp
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.theme.LocalVniDropColors import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource 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 @Composable
fun AppSidebarNavigation( fun AppSidebarNavigation(
selected: AppDestination,
style: NavigationStyle,
onDestinationSelected: (AppDestination) -> Unit,
modifier: Modifier = Modifier,
useNativeWindowBackdrop: Boolean = false,
) {
when (style) {
NavigationStyle.AndroidRail -> AndroidNavigationRail(selected, onDestinationSelected, modifier)
NavigationStyle.DesktopSidebar -> DesktopSidebarNavigation(
selected = selected,
onDestinationSelected = onDestinationSelected,
modifier = modifier,
useNativeWindowBackdrop = useNativeWindowBackdrop,
)
NavigationStyle.AndroidBottomBar -> error("Bottom navigation is rendered by the phone shell")
}
}
@Composable
private fun AndroidNavigationRail(
selected: AppDestination, selected: AppDestination,
onDestinationSelected: (AppDestination) -> Unit, onDestinationSelected: (AppDestination) -> Unit,
dividerTopInset: Dp = 0.dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val colors = LocalVniDropColors.current val colors = LocalVniDropColors.current
Box( NavigationRail(
modifier = modifier modifier = modifier.fillMaxHeight(),
.width(88.dp) containerColor = colors.backgroundSurface200,
.fillMaxHeight()
.background(colors.backgroundSurface200),
) {
Column(
modifier = Modifier
.fillMaxHeight()
.padding(vertical = 10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
Spacer(Modifier.height(8.dp))
primaryNavigationItems.forEach { item -> primaryNavigationItems.forEach { item ->
SidebarNavigationItem( 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,
modifier: Modifier = Modifier,
useNativeWindowBackdrop: Boolean = false,
) {
val colors = LocalVniDropColors.current
Column(
modifier = modifier
.width(DesktopNavigationWidthDp.dp)
.fillMaxHeight()
.background(if (useNativeWindowBackdrop) Color.Transparent else colors.backgroundSurface200)
.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, item = item,
selected = item.destination == selected, selected = item.destination == selected,
onClick = { onDestinationSelected(item.destination) }, onClick = { onDestinationSelected(item.destination) },
) )
} }
} }
Box( }
@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 modifier = Modifier
.align(Alignment.TopEnd) .fillMaxWidth()
.padding(top = dividerTopInset) .clip(RoundedCornerShape(8.dp))
.width(1.dp) .background(if (selected) colors.backgroundSelection else Color.Transparent)
.fillMaxHeight() .selectable(selected = selected, onClick = onClick)
.background(colors.borderDefault), .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,
) )
} }
} }
@@ -80,92 +171,25 @@ fun AppBottomNavigation(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val colors = LocalVniDropColors.current val colors = LocalVniDropColors.current
Column( NavigationBar(
modifier = modifier modifier = modifier.fillMaxWidth(),
.fillMaxWidth() containerColor = colors.backgroundSurface200,
.background(colors.backgroundSurface200),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
primaryNavigationItems.forEach { item -> primaryNavigationItems.forEach { item ->
BottomNavigationItem( val label = stringResource(item.label)
item = item, NavigationBarItem(
selected = item.destination == selected, selected = item.destination == selected,
onClick = { onDestinationSelected(item.destination) }, onClick = { onDestinationSelected(item.destination) },
modifier = Modifier.weight(1f), 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,
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) selectedTextColor = colors.brandLink,
} indicatorColor = colors.backgroundSelection,
} unselectedIconColor = colors.foregroundLight,
unselectedTextColor = colors.foregroundLight,
@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,
) )
} }
} }
} }
@Composable
private fun BottomNavigationItem(
item: NavigationItem,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = LocalVniDropColors.current
val foreground = if (selected) colors.brandLink else colors.foregroundLight
val label = stringResource(item.label)
Column(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.selectable(selected = selected, onClick = onClick)
.fillMaxHeight()
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
) {
Icon(imageVector = item.icon, contentDescription = label, tint = foreground, modifier = Modifier.size(24.dp))
Text(
text = label,
color = foreground,
style = MaterialTheme.typography.labelSmall,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}

View File

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

View File

@@ -15,13 +15,16 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier 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.unit.Dp 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.ui.navigation.AppBottomNavigation import com.vnidrop.app.ui.navigation.AppBottomNavigation
import com.vnidrop.app.ui.navigation.AppDestination import com.vnidrop.app.ui.navigation.AppDestination
import com.vnidrop.app.ui.navigation.AppSidebarNavigation 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.WindowClass
import com.vnidrop.app.ui.state.useBottomNavigation
import com.vnidrop.app.ui.theme.LocalVniDropColors import com.vnidrop.app.ui.theme.LocalVniDropColors
@Composable @Composable
@@ -29,20 +32,25 @@ fun AppShell(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
selectedDestination: AppDestination, selectedDestination: AppDestination,
windowClass: WindowClass, windowClass: WindowClass,
uiPlatform: UiPlatform,
mainContentTopStartRadius: Dp = 0.dp, mainContentTopStartRadius: Dp = 0.dp,
useNativeWindowBackdrop: Boolean = false,
onDestinationSelected: (AppDestination) -> Unit, onDestinationSelected: (AppDestination) -> Unit,
overlay: @Composable BoxScope.() -> Unit = {}, overlay: @Composable BoxScope.() -> Unit = {},
floatingAction: (@Composable BoxScope.() -> Unit)? = null, floatingAction: (@Composable BoxScope.() -> Unit)? = null,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val colors = LocalVniDropColors.current val colors = LocalVniDropColors.current
val navigationStyle = navigationStyleFor(uiPlatform, windowClass)
val windowSurface = if (useNativeWindowBackdrop) Color.Transparent else colors.backgroundDashCanvas
Surface( Surface(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(colors.backgroundDashCanvas), .background(windowSurface),
color = colors.backgroundDashCanvas, color = windowSurface,
contentColor = colors.foregroundDefault,
) { ) {
if (useBottomNavigation(windowClass)) { if (navigationStyle == NavigationStyle.AndroidBottomBar) {
PhoneShell( PhoneShell(
selectedDestination = selectedDestination, selectedDestination = selectedDestination,
onDestinationSelected = onDestinationSelected, onDestinationSelected = onDestinationSelected,
@@ -53,7 +61,9 @@ fun AppShell(
} else { } else {
WideShell( WideShell(
selectedDestination = selectedDestination, selectedDestination = selectedDestination,
navigationStyle = navigationStyle,
mainContentTopStartRadius = mainContentTopStartRadius, mainContentTopStartRadius = mainContentTopStartRadius,
useNativeWindowBackdrop = useNativeWindowBackdrop,
onDestinationSelected = onDestinationSelected, onDestinationSelected = onDestinationSelected,
overlay = overlay, overlay = overlay,
floatingAction = floatingAction, floatingAction = floatingAction,
@@ -66,7 +76,9 @@ fun AppShell(
@Composable @Composable
private fun WideShell( private fun WideShell(
selectedDestination: AppDestination, selectedDestination: AppDestination,
navigationStyle: NavigationStyle,
mainContentTopStartRadius: Dp, mainContentTopStartRadius: Dp,
useNativeWindowBackdrop: Boolean,
onDestinationSelected: (AppDestination) -> Unit, onDestinationSelected: (AppDestination) -> Unit,
overlay: @Composable BoxScope.() -> Unit, overlay: @Composable BoxScope.() -> Unit,
floatingAction: (@Composable BoxScope.() -> Unit)?, floatingAction: (@Composable BoxScope.() -> Unit)?,
@@ -77,11 +89,18 @@ private fun WideShell(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.then(if (roundedContent) Modifier.background(colors.backgroundSurface200) else Modifier), .then(
if (roundedContent && !useNativeWindowBackdrop) {
Modifier.background(colors.backgroundSurface200)
} else {
Modifier
},
),
) { ) {
AppSidebarNavigation( AppSidebarNavigation(
selected = selectedDestination, selected = selectedDestination,
dividerTopInset = mainContentTopStartRadius, style = navigationStyle,
useNativeWindowBackdrop = useNativeWindowBackdrop,
onDestinationSelected = onDestinationSelected, onDestinationSelected = onDestinationSelected,
) )
Box( Box(
@@ -90,13 +109,12 @@ private fun WideShell(
.fillMaxSize() .fillMaxSize()
.then( .then(
if (roundedContent) { if (roundedContent) {
Modifier Modifier.clip(RoundedCornerShape(topStart = mainContentTopStartRadius))
.clip(RoundedCornerShape(topStart = mainContentTopStartRadius))
.background(colors.backgroundDashCanvas)
} else { } else {
Modifier Modifier
}, },
), )
.background(colors.backgroundDashCanvas),
) { ) {
content() content()
floatingAction?.invoke(this) floatingAction?.invoke(this)

View File

@@ -1,5 +1,7 @@
package com.vnidrop.app.ui.navigation 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.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -12,4 +14,20 @@ class NavigationModelTest {
) )
assertEquals(3, primaryNavigationItems.map { it.label }.distinct().size) 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))
}
}
}
} }

View File

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

View File

@@ -17,6 +17,7 @@ fun rememberJvmAppDependencies(externalInvitations: ExternalInvitationController
appVersion = AppDependencies::class.java.`package`.implementationVersion ?: "0.1.0", appVersion = AppDependencies::class.java.`package`.implementationVersion ?: "0.1.0",
defaultCoreDataDir = System.getProperty("user.home") + "/.vnidrop", defaultCoreDataDir = System.getProperty("user.home") + "/.vnidrop",
defaultUsername = System.getenv("COMPUTERNAME") ?: System.getenv("HOSTNAME") ?: System.getProperty("user.name") ?: "Receiver", defaultUsername = System.getenv("COMPUTERNAME") ?: System.getenv("HOSTNAME") ?: System.getProperty("user.name") ?: "Receiver",
uiPlatform = uiPlatformForJvm(System.getProperty("os.name")),
), ),
deviceInfoProvider = JvmDeviceInfoProvider, deviceInfoProvider = JvmDeviceInfoProvider,
fileSystemService = fileSystemService, 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 { private object JvmDeviceInfoProvider : DeviceInfoProvider {
override suspend fun load(): DeviceInfo = DeviceInfo( override suspend fun load(): DeviceInfo = DeviceInfo(
deviceName = System.getenv("COMPUTERNAME") ?: System.getenv("HOSTNAME") ?: System.getProperty("user.name"), deviceName = System.getenv("COMPUTERNAME") ?: System.getenv("HOSTNAME") ?: System.getProperty("user.name"),

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 {
val scope = rememberCoroutineScope()
return remember(onFilesPicked, onError, scope) {
object : ShareFilePicker { object : ShareFilePicker {
override fun pickFiles() { 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) { openPicker(onError) {
val selected = pickShareFiles() val selected = pickShareFiles()
if (selected.isNotEmpty()) onFilesPicked(selected) if (selected.isNotEmpty()) onFilesPicked(selected)
} }
} }
}
override fun pickFolder() { 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) { openPicker(onError) {
val selected = pickDirectory(title = "Select folder to share") ?: return@openPicker val selected = pickDirectory(title = "Select folder to share") ?: return@openPicker
onFilesPicked( onFilesPicked(listOf(selected.toPickedShareFile(isDirectory = true)))
listOf( }
PickedShareFile( }
value = selected.absolutePath,
displayName = selected.name.ifBlank { selected.absolutePath },
sizeBytes = null,
thumbnailBytes = selected.systemIconPng(),
isDirectory = true,
),
),
)
} }
} }
} }
@@ -49,22 +80,42 @@ 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 {
val scope = rememberCoroutineScope()
return remember(onFolderPicked, onError, scope) {
object : ReceiveFolderPicker { object : ReceiveFolderPicker {
override fun pickFolder() { 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) { openPicker(onError) {
val selected = pickDirectory(title = "Select receive folder") ?: return@openPicker val selected = pickDirectory(title = "Select receive folder") ?: return@openPicker
onFolderPicked( onFolderPicked(selected.toReceiveFolder())
ReceiveFolder(
kind = ReceiveFolderKind.FileSystemPath,
value = selected.absolutePath,
displayName = selected.name.ifBlank { selected.absolutePath },
),
)
} }
} }
} }
} }
}
}
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,
@@ -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

@@ -8,10 +8,14 @@ actual fun PlatformSystemAppearance(isDarkTheme: Boolean) = Unit
object DesktopAppearanceBridge { object DesktopAppearanceBridge {
fun isLinux(): Boolean = isLinux(System.getProperty("os.name")) fun isLinux(): Boolean = isLinux(System.getProperty("os.name"))
fun isWindows(): Boolean = isWindows(System.getProperty("os.name"))
internal fun isLinux(osName: String): Boolean = internal fun isLinux(osName: String): Boolean =
osName.startsWith("Linux", ignoreCase = true) osName.startsWith("Linux", ignoreCase = true)
internal fun isWindows(osName: String): Boolean =
osName.startsWith("Windows", ignoreCase = true)
internal fun toggledWindowState(currentState: Int): Int = internal fun toggledWindowState(currentState: Int): Int =
if (currentState and Frame.MAXIMIZED_BOTH == Frame.MAXIMIZED_BOTH) { if (currentState and Frame.MAXIMIZED_BOTH == Frame.MAXIMIZED_BOTH) {
Frame.NORMAL Frame.NORMAL

View File

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

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

@@ -14,6 +14,14 @@ class DesktopAppearanceBridgeTest {
assertFalse(DesktopAppearanceBridge.isLinux("Windows 11")) 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 @Test
fun titlebarDoubleClickTogglesMaximizedWindowState() { fun titlebarDoubleClickTogglesMaximizedWindowState() {
assertEquals(Frame.MAXIMIZED_BOTH, DesktopAppearanceBridge.toggledWindowState(Frame.NORMAL)) assertEquals(Frame.MAXIMIZED_BOTH, DesktopAppearanceBridge.toggledWindowState(Frame.NORMAL))

View File

@@ -1,8 +1,10 @@
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.foundation.background
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -17,9 +19,15 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.performClick 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.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.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 +40,11 @@ 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.core.CoreState import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.ShareAccessPolicy
@@ -47,8 +58,10 @@ 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 com.vnidrop.app.ui.theme.LocalVniDropColors
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
@@ -100,6 +113,44 @@ class FoundationComposeTest {
onNodeWithText("Get notified about new receive requests while VniDrop is in the background.").assertIsDisplayed() 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 isnt").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 @Test
fun notificationSettingCanBeToggledFromItsRow() = runComposeUiTest { fun notificationSettingCanBeToggledFromItsRow() = runComposeUiTest {
var enabled = false var enabled = false
@@ -202,6 +253,7 @@ class FoundationComposeTest {
AppShell( AppShell(
selectedDestination = AppDestination.Send, selectedDestination = AppDestination.Send,
windowClass = WindowClass.Phone, windowClass = WindowClass.Phone,
uiPlatform = UiPlatform.Android,
onDestinationSelected = {}, onDestinationSelected = {},
overlay = { overlay = {
Box(Modifier.align(Alignment.BottomCenter).size(20.dp).testTag("snackbar-overlay")) Box(Modifier.align(Alignment.BottomCenter).size(20.dp).testTag("snackbar-overlay"))
@@ -222,6 +274,148 @@ class FoundationComposeTest {
assertTrue(overlayBottom <= navigationLabelTop) 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 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 {
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.Android) {
VniDropTheme(isDarkTheme = false) {
Row {
Box(Modifier.size(500.dp)) {
TransferCatalog(
transfers = emptyList(),
transferThumbnails = emptyMap(),
windowClass = WindowClass.Phone,
onOpenComposer = {},
onTransferSelected = {},
)
}
Box(Modifier.size(500.dp)) {
ReceiveScreen(
coreState = CoreState(isInitialized = true),
state = ReceiveState(),
windowClass = WindowClass.Phone,
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())