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(compose.desktop.currentOs)
implementation(libs.filekit.dialogs)
implementation(libs.jna.platform)
implementation(libs.kotlinx.coroutinesSwing)
implementation(libs.compose.uiToolingPreview)
@@ -48,6 +50,7 @@ compose.desktop {
linux {
packageName = "vnidrop"
iconFile.set(project.file("../assets/linux/app-icon.png"))
modules("jdk.security.auth")
debMaintainer = "support@sudosy.fr"
appRelease = "1"
rpmLicenseType = "Apache-2.0"

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.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.window.WindowDraggableArea
import androidx.compose.runtime.Composable
@@ -53,12 +53,15 @@ import com.vnidrop.app.feature.receive.VniDropInvitationExtension
import com.vnidrop.app.feature.receive.decodeInvitationBytes
import com.vnidrop.app.platform.DesktopAppearanceBridge
import com.vnidrop.app.ui.theme.LocalVniDropColors
import io.github.vinceglb.filekit.FileKit
import java.awt.Desktop
import java.io.File
fun main(args: Array<String>) {
FileKit.init(appId = "vnidrop")
val externalInvitations = ExternalInvitationController()
val linux = DesktopAppearanceBridge.isLinux()
val windows = DesktopAppearanceBridge.isWindows()
configureInvitationOpenHandler(externalInvitations)
args.asSequence()
.map(::File)
@@ -73,25 +76,39 @@ fun main(args: Array<String>) {
// Compose keeps edge resizers active for this client-decorated Linux window.
undecorated = linux,
) {
App(
dependencies = rememberJvmAppDependencies(externalInvitations),
windowChromeTopInset = if (linux) LinuxTitleBarHeight else 0.dp,
windowContentTopStartRadius = if (linux) DesktopContentCornerRadius else 0.dp,
windowChrome = if (linux) {
{
LinuxTitleBar(
isMaximized = windowState.placement == WindowPlacement.Maximized,
onMinimize = { windowState.isMinimized = true },
onToggleMaximize = {
windowState.placement = toggledWindowPlacement(windowState.placement)
},
onClose = ::exitApplication,
)
}
} else {
null
},
)
val dependencies = rememberJvmAppDependencies(externalInvitations)
if (windows) {
WindowsWindowFrame(windowState) { chrome ->
App(
dependencies = dependencies,
windowChromeTopInset = chrome.topInset,
windowContentTopStartRadius = chrome.contentTopStartRadius,
useNativeWindowBackdrop = chrome.useNativeBackdrop,
onResolvedDarkThemeChanged = chrome.onDarkThemeChanged,
windowChrome = chrome.chrome,
)
}
} else {
App(
dependencies = dependencies,
windowChromeTopInset = if (linux) LinuxTitleBarHeight else 0.dp,
windowContentTopStartRadius = if (linux) DesktopContentCornerRadius else 0.dp,
windowChrome = if (linux) {
{
LinuxTitleBar(
isMaximized = windowState.placement == WindowPlacement.Maximized,
onMinimize = { windowState.isMinimized = true },
onToggleMaximize = {
windowState.placement = toggledWindowPlacement(windowState.placement)
},
onClose = ::exitApplication,
)
}
} else {
null
},
)
}
}
}
}
@@ -114,10 +131,11 @@ private fun ExternalInvitationController.openFile(file: File) {
}
}
private val LinuxTitleBarHeight = 40.dp
private val LinuxWindowControlWidth = 46.dp
private val LinuxWindowControlsWidth = 138.dp
private val DesktopContentCornerRadius = 20.dp
private val LinuxTitleBarHeight = 48.dp
private val LinuxWindowControlHitTargetSize = 34.dp
private val LinuxWindowControlVisualSize = 28.dp
private val LinuxWindowControlsWidth = 120.dp
internal val DesktopContentCornerRadius = 20.dp
@Composable
@OptIn(ExperimentalComposeUiApi::class)
@@ -156,7 +174,9 @@ private fun WindowScope.LinuxTitleBar(
Row(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight(),
.padding(end = 10.dp)
.background(colors.backgroundSurface300, RoundedCornerShape(20.dp))
.padding(3.dp),
) {
LinuxWindowControlButton(
icon = LinuxMinimizeIcon,
@@ -189,17 +209,15 @@ private fun LinuxWindowControlButton(
val interactionSource = remember { MutableInteractionSource() }
val hovered by interactionSource.collectIsHoveredAsState()
val pressed by interactionSource.collectIsPressedAsState()
val active = hovered || pressed
val background = when {
isClose && active -> colors.destructiveDefault
active -> colors.backgroundOverlayHover
else -> Color.Transparent
val visualState = linuxWindowControlVisualState(isClose, hovered, pressed)
val background = when (visualState) {
LinuxWindowControlVisualState.Default -> Color.Transparent
LinuxWindowControlVisualState.NeutralActive -> colors.backgroundOverlayHover
LinuxWindowControlVisualState.DestructiveActive -> colors.destructiveDefault
}
Box(
modifier = Modifier
.width(LinuxWindowControlWidth)
.fillMaxHeight()
.background(background)
.size(LinuxWindowControlHitTargetSize)
.hoverable(interactionSource)
.clickable(
interactionSource = interactionSource,
@@ -209,15 +227,41 @@ private fun LinuxWindowControlButton(
),
contentAlignment = Alignment.Center,
) {
Image(
painter = rememberVectorPainter(icon),
contentDescription = contentDescription,
colorFilter = ColorFilter.tint(if (isClose && active) Color.White else colors.foregroundLight),
modifier = Modifier.size(15.dp),
)
Box(
modifier = Modifier
.size(LinuxWindowControlVisualSize)
.background(background, CircleShape),
contentAlignment = Alignment.Center,
) {
Image(
painter = rememberVectorPainter(icon),
contentDescription = contentDescription,
colorFilter = ColorFilter.tint(
if (visualState == LinuxWindowControlVisualState.DestructiveActive) Color.White
else colors.foregroundLight,
),
modifier = Modifier.size(14.dp),
)
}
}
}
internal enum class LinuxWindowControlVisualState {
Default,
NeutralActive,
DestructiveActive,
}
internal fun linuxWindowControlVisualState(
isClose: Boolean,
isHovered: Boolean,
isPressed: Boolean,
): LinuxWindowControlVisualState = when {
isClose && (isHovered || isPressed) -> LinuxWindowControlVisualState.DestructiveActive
isHovered || isPressed -> LinuxWindowControlVisualState.NeutralActive
else -> LinuxWindowControlVisualState.Default
}
internal fun toggledWindowPlacement(current: WindowPlacement): WindowPlacement =
if (current == WindowPlacement.Maximized) WindowPlacement.Floating else WindowPlacement.Maximized

View File

@@ -1,8 +1,12 @@
package com.vnidrop.app
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.window.WindowPlacement
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import org.jetbrains.skiko.GraphicsApi
class DesktopWindowChromeTest {
@Test
@@ -10,4 +14,138 @@ class DesktopWindowChromeTest {
assertEquals(WindowPlacement.Maximized, toggledWindowPlacement(WindowPlacement.Floating))
assertEquals(WindowPlacement.Floating, toggledWindowPlacement(WindowPlacement.Maximized))
}
@Test
fun closeControlUsesDestructiveStateWhenHoveredOrPressed() {
assertEquals(
LinuxWindowControlVisualState.DestructiveActive,
linuxWindowControlVisualState(isClose = true, isHovered = true, isPressed = false),
)
assertEquals(
LinuxWindowControlVisualState.DestructiveActive,
linuxWindowControlVisualState(isClose = true, isHovered = false, isPressed = true),
)
}
@Test
fun standardControlsKeepNeutralInteractionState() {
assertEquals(
LinuxWindowControlVisualState.NeutralActive,
linuxWindowControlVisualState(isClose = false, isHovered = true, isPressed = false),
)
assertEquals(
LinuxWindowControlVisualState.Default,
linuxWindowControlVisualState(isClose = false, isHovered = false, isPressed = false),
)
}
@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-testExt = "1.3.0"
composeMultiplatform = "1.11.1"
compottie = "2.2.4"
filekit = "0.14.2"
gobley = "0.3.7"
jna = "5.19.1"
junit = "4.13.2"
kotlin = "2.4.0"
kotlinx-coroutines = "1.11.0"
@@ -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-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
compottie-lite = { module = "io.github.alexzhirkevich:compottie-lite", version.ref = "compottie" }
filekit-dialogs = { module = "io.github.vinceglb:filekit-dialogs", version.ref = "filekit" }
jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" }
kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }

View File

@@ -129,6 +129,9 @@ kotlin {
implementation(libs.google.code.scanner)
implementation(libs.compose.uiToolingPreview)
}
jvmMain.dependencies {
implementation(libs.filekit.dialogs)
}
commonMain.dependencies {
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
@@ -136,7 +139,6 @@ kotlin {
implementation(libs.compose.ui)
implementation(libs.compose.components.resources)
implementation(libs.compose.uiToolingPreview)
implementation(libs.compottie.lite)
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.datastore)

View File

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

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

View File

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

View File

@@ -37,6 +37,7 @@ import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathBuilder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -49,12 +50,14 @@ import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.ui.components.AdaptiveDrawer
import com.vnidrop.app.ui.components.DestructiveButton
import com.vnidrop.app.ui.components.DestructiveQuietButton
import com.vnidrop.app.ui.components.EmptyStateAnimation
import com.vnidrop.app.ui.components.Field
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.ProgressRow
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.feedback.UiText
import com.vnidrop.app.ui.platform.LocalUiPlatform
import com.vnidrop.app.ui.platform.usesMobilePresentation
import com.vnidrop.app.ui.navigation.VniDropIcons
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes
@@ -93,12 +96,13 @@ fun ReceiveScreen(
) {
val transfers = coreState.transfers.filter { it.direction == TransferDirection.Receive }
val deletableTransfers = transfers.filter { it.status.isTerminalReceiveHistory() }
val usesFloatingAction = usesMobilePresentation(LocalUiPlatform.current, windowClass)
LazyColumn(
modifier = Modifier.fillMaxSize().statusBarsPadding(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item { ReceiveHeader(transfers.isNotEmpty(), windowClass, onOpenAcquisition) }
item { ReceiveHeader(transfers.isNotEmpty() && !usesFloatingAction, onOpenAcquisition) }
if (transfers.isEmpty()) item { ReceiveEmptyState(onOpenAcquisition) }
else {
item {
@@ -156,13 +160,12 @@ fun ReceiveScreen(
}
@Composable
private fun ReceiveHeader(showAction: Boolean, windowClass: WindowClass, onOpen: () -> Unit) {
private fun ReceiveHeader(showAction: Boolean, onOpen: () -> Unit) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(Res.string.receive_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text(stringResource(Res.string.receive_new_subtitle), color = LocalVniDropColors.current.foregroundLighter)
}
if (showAction && windowClass != WindowClass.Phone) {
if (showAction) {
Spacer(Modifier.width(16.dp))
PrimaryButton(stringResource(Res.string.button_receive_files), onClick = onOpen)
}
@@ -177,9 +180,13 @@ private fun ReceiveEmptyState(onOpen: () -> Unit) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
EmptyStateAnimation(
assetPath = "files/animations/receive_empty_state.json",
modifier = Modifier.size(168.dp),
Icon(
imageVector = VniDropIcons.Receive,
contentDescription = null,
tint = colors.brandLink,
modifier = Modifier
.size(88.dp)
.testTag("receive-empty-icon"),
)
Text(stringResource(Res.string.receive_empty_title), modifier = Modifier.padding(top = 12.dp), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(

View File

@@ -30,6 +30,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -37,11 +38,13 @@ import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.CoreEventModel
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.ui.components.EmptyStateAnimation
import com.vnidrop.app.ui.components.PillTone
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.ProgressRow
import com.vnidrop.app.ui.components.StatusPill
import com.vnidrop.app.ui.platform.LocalUiPlatform
import com.vnidrop.app.ui.platform.usesMobilePresentation
import com.vnidrop.app.ui.navigation.VniDropIcons
import com.vnidrop.app.ui.state.TransferProgress
import com.vnidrop.app.ui.state.WindowClass
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_new_transfer_description
import vnidrop.shared.generated.resources.send_new_transfer_title
import vnidrop.shared.generated.resources.send_subtitle
import vnidrop.shared.generated.resources.send_title
import vnidrop.shared.generated.resources.send_transfers_title
@@ -82,17 +84,18 @@ internal fun TransferCatalog(
onOpenComposer: () -> Unit,
onTransferSelected: (ULong) -> Unit,
) {
val usesFloatingAction = usesMobilePresentation(LocalUiPlatform.current, windowClass)
LazyColumn(
modifier = Modifier.fillMaxSize().statusBarsPadding(),
contentPadding = PaddingValues(
start = 16.dp,
top = 16.dp,
end = 16.dp,
bottom = if (windowClass == WindowClass.Phone && transfers.isNotEmpty()) 96.dp else 24.dp,
bottom = if (usesFloatingAction && transfers.isNotEmpty()) 96.dp else 24.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { CatalogHeader(showAction = windowClass != WindowClass.Phone && transfers.isNotEmpty(), onOpenComposer) }
item { CatalogHeader(showAction = !usesFloatingAction && transfers.isNotEmpty(), onOpenComposer) }
if (transfers.isEmpty()) {
item { SendEmptyState(onOpenComposer) }
} else {
@@ -125,11 +128,6 @@ private fun CatalogHeader(showAction: Boolean, onOpenComposer: () -> Unit) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(Res.string.send_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text(
stringResource(Res.string.send_subtitle),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodyMedium,
)
}
if (showAction) {
Spacer(Modifier.width(16.dp))
@@ -146,9 +144,13 @@ private fun SendEmptyState(onOpenComposer: () -> Unit) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
EmptyStateAnimation(
assetPath = "files/animations/send_empty_state.json",
modifier = Modifier.size(168.dp),
Icon(
imageVector = VniDropIcons.Send,
contentDescription = null,
tint = colors.brandLink,
modifier = Modifier
.size(88.dp)
.testTag("send-empty-icon"),
)
Text(
stringResource(Res.string.send_empty_title),

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.theme.ThemeMode
import org.jetbrains.compose.resources.stringResource
import com.vnidrop.app.ui.theme.LocalVniDropColors
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.about_title
import vnidrop.shared.generated.resources.appearance_dark_mode
@@ -18,7 +17,6 @@ import vnidrop.shared.generated.resources.appearance_system_mode
import vnidrop.shared.generated.resources.appearance_title
import vnidrop.shared.generated.resources.notifications_title
import vnidrop.shared.generated.resources.preferences_title
import vnidrop.shared.generated.resources.settings_subtitle
import vnidrop.shared.generated.resources.settings_title
@Composable
@@ -28,18 +26,11 @@ internal fun SettingsOverview(
largeTitle: Boolean,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
stringResource(Res.string.settings_title),
style = if (largeTitle) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
)
Text(
stringResource(Res.string.settings_subtitle),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodyMedium,
)
}
Text(
stringResource(Res.string.settings_title),
style = if (largeTitle) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
)
SettingsGroup {
SettingsRow(
icon = SettingsIcons.Device,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
package com.vnidrop.app.ui.navigation
import com.vnidrop.app.UiPlatform
import com.vnidrop.app.ui.state.WindowClass
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -12,4 +14,20 @@ class NavigationModelTest {
)
assertEquals(3, primaryNavigationItems.map { it.label }.distinct().size)
}
@Test
fun androidNavigationFollowsMaterialWindowConventions() {
assertEquals(NavigationStyle.AndroidBottomBar, navigationStyleFor(UiPlatform.Android, WindowClass.Phone))
assertEquals(NavigationStyle.AndroidRail, navigationStyleFor(UiPlatform.Android, WindowClass.Tablet))
assertEquals(NavigationStyle.AndroidRail, navigationStyleFor(UiPlatform.Android, WindowClass.Desktop))
}
@Test
fun desktopPlatformsUseSourceListNavigationAtEveryWindowSize() {
listOf(UiPlatform.Windows, UiPlatform.Linux, UiPlatform.Desktop).forEach { platform ->
WindowClass.entries.forEach { windowClass ->
assertEquals(NavigationStyle.DesktopSidebar, navigationStyleFor(platform, windowClass))
}
}
}
}

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",
defaultCoreDataDir = System.getProperty("user.home") + "/.vnidrop",
defaultUsername = System.getenv("COMPUTERNAME") ?: System.getenv("HOSTNAME") ?: System.getProperty("user.name") ?: "Receiver",
uiPlatform = uiPlatformForJvm(System.getProperty("os.name")),
),
deviceInfoProvider = JvmDeviceInfoProvider,
fileSystemService = fileSystemService,
@@ -26,6 +27,12 @@ fun rememberJvmAppDependencies(externalInvitations: ExternalInvitationController
}
}
internal fun uiPlatformForJvm(osName: String?): UiPlatform = when {
osName.orEmpty().contains("windows", ignoreCase = true) -> UiPlatform.Windows
osName.orEmpty().contains("linux", ignoreCase = true) -> UiPlatform.Linux
else -> UiPlatform.Desktop
}
private object JvmDeviceInfoProvider : DeviceInfoProvider {
override suspend fun load(): DeviceInfo = DeviceInfo(
deviceName = System.getenv("COMPUTERNAME") ?: System.getenv("HOSTNAME") ?: System.getProperty("user.name"),

View File

@@ -2,6 +2,12 @@ package com.vnidrop.app.core
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.github.vinceglb.filekit.FileKit
import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings
import io.github.vinceglb.filekit.dialogs.FileKitMode
import io.github.vinceglb.filekit.dialogs.openDirectoryPicker
import io.github.vinceglb.filekit.dialogs.openFilePicker
import java.awt.EventQueue
import java.awt.FileDialog
import java.awt.Frame
@@ -12,34 +18,59 @@ import java.io.File
import javax.imageio.ImageIO
import javax.swing.JFileChooser
import javax.swing.filechooser.FileSystemView
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
actual fun rememberShareFilePicker(
onFilesPicked: (List<PickedShareFile>) -> Unit,
onError: (String) -> Unit,
): ShareFilePicker = remember(onFilesPicked, onError) {
object : ShareFilePicker {
override fun pickFiles() {
openPicker(onError) {
val selected = pickShareFiles()
if (selected.isNotEmpty()) onFilesPicked(selected)
): ShareFilePicker {
val scope = rememberCoroutineScope()
return remember(onFilesPicked, onError, scope) {
object : ShareFilePicker {
override fun pickFiles() {
if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) {
scope.launch {
try {
val selected = withContext(Dispatchers.IO) { pickShareFilesWithPortal() }
if (selected.isNotEmpty()) onFilesPicked(selected)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
onError(error.message ?: error.toString())
}
}
} else {
openPicker(onError) {
val selected = pickShareFiles()
if (selected.isNotEmpty()) onFilesPicked(selected)
}
}
}
}
override fun pickFolder() {
openPicker(onError) {
val selected = pickDirectory(title = "Select folder to share") ?: return@openPicker
onFilesPicked(
listOf(
PickedShareFile(
value = selected.absolutePath,
displayName = selected.name.ifBlank { selected.absolutePath },
sizeBytes = null,
thumbnailBytes = selected.systemIconPng(),
isDirectory = true,
),
),
)
override fun pickFolder() {
if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) {
scope.launch {
try {
val selected = withContext(Dispatchers.IO) {
pickDirectoryWithPortal("Select folder to share")?.toPickedShareFile(isDirectory = true)
} ?: return@launch
onFilesPicked(listOf(selected))
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
onError(error.message ?: error.toString())
}
}
} else {
openPicker(onError) {
val selected = pickDirectory(title = "Select folder to share") ?: return@openPicker
onFilesPicked(listOf(selected.toPickedShareFile(isDirectory = true)))
}
}
}
}
}
@@ -49,23 +80,43 @@ actual fun rememberShareFilePicker(
actual fun rememberReceiveFolderPicker(
onFolderPicked: (ReceiveFolder) -> Unit,
onError: (String) -> Unit,
): ReceiveFolderPicker = remember(onFolderPicked, onError) {
object : ReceiveFolderPicker {
override fun pickFolder() {
openPicker(onError) {
val selected = pickDirectory(title = "Select receive folder") ?: return@openPicker
onFolderPicked(
ReceiveFolder(
kind = ReceiveFolderKind.FileSystemPath,
value = selected.absolutePath,
displayName = selected.name.ifBlank { selected.absolutePath },
),
)
): ReceiveFolderPicker {
val scope = rememberCoroutineScope()
return remember(onFolderPicked, onError, scope) {
object : ReceiveFolderPicker {
override fun pickFolder() {
if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) {
scope.launch {
try {
val selected = withContext(Dispatchers.IO) {
pickDirectoryWithPortal("Select receive folder")
} ?: return@launch
onFolderPicked(selected.toReceiveFolder())
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
onError(error.message ?: error.toString())
}
}
} else {
openPicker(onError) {
val selected = pickDirectory(title = "Select receive folder") ?: return@openPicker
onFolderPicked(selected.toReceiveFolder())
}
}
}
}
}
}
internal enum class JvmFilePickerBackend {
XdgPortal,
AwtSwing,
}
internal fun jvmFilePickerBackend(osName: String?): JvmFilePickerBackend =
if (osName.orEmpty().startsWith("Linux", ignoreCase = true)) JvmFilePickerBackend.XdgPortal else JvmFilePickerBackend.AwtSwing
private fun openPicker(
onError: (String) -> Unit,
block: () -> Unit,
@@ -99,20 +150,39 @@ private fun pickShareFiles(): List<PickedShareFile> {
val names = dialog.files?.map { it.name }.orEmpty().ifEmpty {
dialog.file?.let { listOf(it) }.orEmpty()
}
names.map { name ->
val selected = File(directory, name)
PickedShareFile(
selected.absolutePath,
selected.name,
selected.length().takeIf { it >= 0L }?.toULong(),
selected.systemIconPng(),
)
}
names.map { name -> File(directory, name).toPickedShareFile(isDirectory = false) }
} finally {
dialog.dispose()
}
}
private suspend fun pickShareFilesWithPortal(): List<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 {
val icon = FileSystemView.getFileSystemView().getSystemIcon(this, 128, 128)
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 {
fun isLinux(): Boolean = isLinux(System.getProperty("os.name"))
fun isWindows(): Boolean = isWindows(System.getProperty("os.name"))
internal fun isLinux(osName: String): Boolean =
osName.startsWith("Linux", ignoreCase = true)
internal fun isWindows(osName: String): Boolean =
osName.startsWith("Windows", ignoreCase = true)
internal fun toggledWindowState(currentState: Int): Int =
if (currentState and Frame.MAXIMIZED_BOTH == Frame.MAXIMIZED_BOTH) {
Frame.NORMAL

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"))
}
@Test
fun nativeWindowsChromeIsLimitedToWindows() {
assertFalse(DesktopAppearanceBridge.isWindows("Mac OS X"))
assertFalse(DesktopAppearanceBridge.isWindows("Linux"))
assertTrue(DesktopAppearanceBridge.isWindows("Windows 10"))
assertTrue(DesktopAppearanceBridge.isWindows("Windows 11"))
}
@Test
fun titlebarDoubleClickTogglesMaximizedWindowState() {
assertEquals(Frame.MAXIMIZED_BOTH, DesktopAppearanceBridge.toggledWindowState(Frame.NORMAL))

View File

@@ -1,8 +1,10 @@
package com.vnidrop.app.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.background
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
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.onAllNodesWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.unit.dp
import androidx.compose.ui.test.v2.runComposeUiTest
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import com.vnidrop.app.feature.approvals.ApprovalModalHost
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.SettingsSection
import com.vnidrop.app.feature.settings.SettingsState
import com.vnidrop.app.feature.settings.SettingsOverview
import com.vnidrop.app.feature.send.SendScreen
import com.vnidrop.app.feature.send.SendState
import com.vnidrop.app.feature.send.TransferCatalog
import com.vnidrop.app.UiPlatform
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.PickedShareFile
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.state.WindowClass
import com.vnidrop.app.ui.navigation.AppDestination
import com.vnidrop.app.ui.platform.LocalUiPlatform
import com.vnidrop.app.ui.shell.AppShell
import com.vnidrop.app.ui.theme.VniDropTheme
import com.vnidrop.app.ui.theme.LocalVniDropColors
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@@ -100,6 +113,44 @@ class FoundationComposeTest {
onNodeWithText("Get notified about new receive requests while VniDrop is in the background.").assertIsDisplayed()
}
@Test
fun aboutSettingsShowsTheSharedProductAndPrivacyContent() = runComposeUiTest {
setContent {
VniDropTheme(isDarkTheme = false) {
Box(Modifier.width(393.dp)) {
SettingsScreen(
state = SettingsState(selectedSection = SettingsSection.About),
windowClass = WindowClass.Phone,
onSectionSelected = {},
onUsernameChanged = {},
onThemeModeChanged = {},
onChooseFolder = {},
onResetFolder = {},
onNotificationsChanged = {},
onOpenNotificationSettings = {},
onDiagnosticsChanged = {},
onBugWhatChanged = {},
onBugExpectedChanged = {},
onBugStepsChanged = {},
onBugContactChanged = {},
onBugIncludeLogsChanged = {},
onSubmitBugReport = {},
)
}
}
}
onNodeWithText("Send files directly. Stay in control of who receives them.").assertIsDisplayed()
onNodeWithText("What VniDrop is").assertIsDisplayed()
onNodeWithText("What VniDrop 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
fun notificationSettingCanBeToggledFromItsRow() = runComposeUiTest {
var enabled = false
@@ -202,6 +253,7 @@ class FoundationComposeTest {
AppShell(
selectedDestination = AppDestination.Send,
windowClass = WindowClass.Phone,
uiPlatform = UiPlatform.Android,
onDestinationSelected = {},
overlay = {
Box(Modifier.align(Alignment.BottomCenter).size(20.dp).testTag("snackbar-overlay"))
@@ -222,6 +274,148 @@ class FoundationComposeTest {
assertTrue(overlayBottom <= navigationLabelTop)
}
@Test
fun narrowDesktopWindowKeepsDesktopSourceListNavigation() = runComposeUiTest {
var selected = AppDestination.Send
setContent {
VniDropTheme(isDarkTheme = false) {
Box(Modifier.size(width = 560.dp, height = 640.dp)) {
AppShell(
selectedDestination = selected,
windowClass = WindowClass.Phone,
uiPlatform = UiPlatform.Windows,
onDestinationSelected = { selected = it },
) {
Text("Content")
}
}
}
}
onNodeWithText("VniDrop").assertIsDisplayed()
onNodeWithText("Receive").performClick()
runOnIdle { assertEquals(AppDestination.Receive, selected) }
}
@Test
fun 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
fun phoneSendEmptyStateOpensCreationDrawer() = runComposeUiTest {
val state = mutableStateOf(SendState())