feat(ui): adapt presentation to each platform

This commit is contained in:
2026-07-22 10:04:32 +02:00
parent 4feff29c74
commit 627c205853
18 changed files with 771 additions and 199 deletions

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,

View File

@@ -7,6 +7,7 @@ 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
@@ -34,11 +35,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
@@ -138,6 +141,7 @@ fun App(
val darkTheme = rememberResolvedDarkTheme(appState.themeMode) val darkTheme = rememberResolvedDarkTheme(appState.themeMode)
PlatformSystemAppearance(darkTheme) PlatformSystemAppearance(darkTheme)
CompositionLocalProvider(LocalUiPlatform provides dependencies.environment.uiPlatform) {
VniDropTheme(isDarkTheme = darkTheme) { VniDropTheme(isDarkTheme = darkTheme) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -149,21 +153,23 @@ fun App(
.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,
onDestinationSelected = appViewModel::selectDestination, onDestinationSelected = appViewModel::selectDestination,
overlay = { overlay = {
@@ -202,4 +208,5 @@ fun App(
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.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

@@ -55,6 +55,8 @@ 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.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 +95,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 +159,13 @@ 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) 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)
} }

View File

@@ -42,6 +42,8 @@ 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.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
@@ -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 {

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

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

@@ -6,19 +6,22 @@ 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,36 +29,106 @@ 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 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, selected: AppDestination,
style: NavigationStyle,
onDestinationSelected: (AppDestination) -> Unit, onDestinationSelected: (AppDestination) -> Unit,
dividerTopInset: Dp = 0.dp, dividerTopInset: Dp = 0.dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) {
when (style) {
NavigationStyle.AndroidRail -> AndroidNavigationRail(selected, onDestinationSelected, modifier)
NavigationStyle.DesktopSidebar -> DesktopSidebarNavigation(
selected = selected,
onDestinationSelected = onDestinationSelected,
dividerTopInset = dividerTopInset,
modifier = modifier,
)
NavigationStyle.AndroidBottomBar -> error("Bottom navigation is rendered by the phone shell")
}
}
@Composable
private fun AndroidNavigationRail(
selected: AppDestination,
onDestinationSelected: (AppDestination) -> Unit,
modifier: Modifier = Modifier,
) {
val colors = LocalVniDropColors.current
NavigationRail(
modifier = modifier.fillMaxHeight(),
containerColor = colors.backgroundSurface200,
) {
Spacer(Modifier.height(8.dp))
primaryNavigationItems.forEach { item ->
val label = stringResource(item.label)
NavigationRailItem(
selected = item.destination == selected,
onClick = { onDestinationSelected(item.destination) },
icon = { Icon(item.icon, contentDescription = label) },
label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) },
colors = NavigationRailItemDefaults.colors(
selectedIconColor = colors.brandLink,
selectedTextColor = colors.brandLink,
indicatorColor = colors.backgroundSelection,
unselectedIconColor = colors.foregroundLight,
unselectedTextColor = colors.foregroundLight,
),
)
}
}
}
@Composable
private fun DesktopSidebarNavigation(
selected: AppDestination,
onDestinationSelected: (AppDestination) -> Unit,
dividerTopInset: Dp,
modifier: Modifier = Modifier,
) { ) {
val colors = LocalVniDropColors.current val colors = LocalVniDropColors.current
Box( Box(
modifier = modifier modifier = modifier
.width(88.dp) .width(DesktopNavigationWidthDp.dp)
.fillMaxHeight() .fillMaxHeight()
.background(colors.backgroundSurface200), .background(colors.backgroundSurface200),
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxHeight().padding(horizontal = 12.dp, vertical = 14.dp),
.fillMaxHeight() verticalArrangement = Arrangement.spacedBy(4.dp),
.padding(vertical = 10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.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 -> primaryNavigationItems.forEach { item ->
SidebarNavigationItem( DesktopNavigationItem(
item = item, item = item,
selected = item.destination == selected, selected = item.destination == selected,
onClick = { onDestinationSelected(item.destination) }, onClick = { onDestinationSelected(item.destination) },
@@ -73,6 +146,37 @@ fun AppSidebarNavigation(
} }
} }
@Composable
private fun DesktopNavigationItem(
item: NavigationItem,
selected: Boolean,
onClick: () -> Unit,
) {
val colors = LocalVniDropColors.current
val foreground = if (selected) colors.foregroundDefault else colors.foregroundLight
val label = stringResource(item.label)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(if (selected) colors.backgroundSelection else Color.Transparent)
.selectable(selected = selected, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(item.icon, contentDescription = label, tint = if (selected) colors.brandLink else foreground, modifier = Modifier.size(20.dp))
Text(
text = label,
color = foreground,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable @Composable
fun AppBottomNavigation( fun AppBottomNavigation(
selected: AppDestination, selected: AppDestination,
@@ -80,92 +184,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

@@ -17,11 +17,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
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,6 +31,7 @@ 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,
onDestinationSelected: (AppDestination) -> Unit, onDestinationSelected: (AppDestination) -> Unit,
overlay: @Composable BoxScope.() -> Unit = {}, overlay: @Composable BoxScope.() -> Unit = {},
@@ -36,13 +39,14 @@ fun AppShell(
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val colors = LocalVniDropColors.current val colors = LocalVniDropColors.current
val navigationStyle = navigationStyleFor(uiPlatform, windowClass)
Surface( Surface(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(colors.backgroundDashCanvas), .background(colors.backgroundDashCanvas),
color = colors.backgroundDashCanvas, color = colors.backgroundDashCanvas,
) { ) {
if (useBottomNavigation(windowClass)) { if (navigationStyle == NavigationStyle.AndroidBottomBar) {
PhoneShell( PhoneShell(
selectedDestination = selectedDestination, selectedDestination = selectedDestination,
onDestinationSelected = onDestinationSelected, onDestinationSelected = onDestinationSelected,
@@ -53,6 +57,7 @@ fun AppShell(
} else { } else {
WideShell( WideShell(
selectedDestination = selectedDestination, selectedDestination = selectedDestination,
navigationStyle = navigationStyle,
mainContentTopStartRadius = mainContentTopStartRadius, mainContentTopStartRadius = mainContentTopStartRadius,
onDestinationSelected = onDestinationSelected, onDestinationSelected = onDestinationSelected,
overlay = overlay, overlay = overlay,
@@ -66,6 +71,7 @@ fun AppShell(
@Composable @Composable
private fun WideShell( private fun WideShell(
selectedDestination: AppDestination, selectedDestination: AppDestination,
navigationStyle: NavigationStyle,
mainContentTopStartRadius: Dp, mainContentTopStartRadius: Dp,
onDestinationSelected: (AppDestination) -> Unit, onDestinationSelected: (AppDestination) -> Unit,
overlay: @Composable BoxScope.() -> Unit, overlay: @Composable BoxScope.() -> Unit,
@@ -81,6 +87,7 @@ private fun WideShell(
) { ) {
AppSidebarNavigation( AppSidebarNavigation(
selected = selectedDestination, selected = selectedDestination,
style = navigationStyle,
dividerTopInset = mainContentTopStartRadius, dividerTopInset = mainContentTopStartRadius,
onDestinationSelected = onDestinationSelected, onDestinationSelected = onDestinationSelected,
) )

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

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

@@ -34,6 +34,7 @@ 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.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.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
@@ -100,6 +101,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 +241,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 +262,29 @@ 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 @Test
fun phoneSendEmptyStateOpensCreationDrawer() = runComposeUiTest { fun phoneSendEmptyStateOpensCreationDrawer() = runComposeUiTest {
val state = mutableStateOf(SendState()) val state = mutableStateOf(SendState())