diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt index b794cfd..cd5c21c 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/Platform.android.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index 5c6547b..c4ecf95 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -7,6 +7,7 @@ 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 @@ -34,11 +35,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 @@ -138,68 +141,72 @@ fun App( val darkTheme = rememberResolvedDarkTheme(appState.themeMode) 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(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, + 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() } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt index 647279a..bdc2982 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/Platform.kt @@ -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( diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt index 4b4db2e..f64da87 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt @@ -55,6 +55,8 @@ 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.state.WindowClass import com.vnidrop.app.ui.state.displayNameForStatus import com.vnidrop.app.ui.state.formatBytes @@ -93,12 +95,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 +159,13 @@ 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) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt index f56d1f9..d4dc57e 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt @@ -42,6 +42,8 @@ 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.state.TransferProgress import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.state.activeSendProgress @@ -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 { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt index ede24c5..e2f3d65 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AboutSettings.kt @@ -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>, +) { + 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 diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsIcons.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsIcons.kt index 9b9c885..97cbe79 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsIcons.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsIcons.kt @@ -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) +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt index 4750994..56e9ee3 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt @@ -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) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt index 980af93..78e8a79 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Field.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Field.kt index 3cca324..926672d 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Field.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Field.kt @@ -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), + ) + } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppNavigation.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppNavigation.kt index 8815212..9a56b9d 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppNavigation.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppNavigation.kt @@ -6,19 +6,22 @@ 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,36 +29,106 @@ 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, dividerTopInset: Dp = 0.dp, 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 Box( modifier = modifier - .width(88.dp) + .width(DesktopNavigationWidthDp.dp) .fillMaxHeight() .background(colors.backgroundSurface200), ) { Column( - modifier = Modifier - .fillMaxHeight() - .padding(vertical = 10.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.fillMaxHeight().padding(horizontal = 12.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { + Text( + text = "VniDrop", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) primaryNavigationItems.forEach { item -> - SidebarNavigationItem( + DesktopNavigationItem( item = item, selected = item.destination == selected, 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 fun AppBottomNavigation( selected: AppDestination, @@ -80,92 +184,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, - ) - } -} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/platform/PlatformUi.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/platform/PlatformUi.kt new file mode 100644 index 0000000..ec76093 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/platform/PlatformUi.kt @@ -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)) +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt index d149b62..dfac3df 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt @@ -17,11 +17,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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,6 +31,7 @@ fun AppShell( modifier: Modifier = Modifier, selectedDestination: AppDestination, windowClass: WindowClass, + uiPlatform: UiPlatform, mainContentTopStartRadius: Dp = 0.dp, onDestinationSelected: (AppDestination) -> Unit, overlay: @Composable BoxScope.() -> Unit = {}, @@ -36,13 +39,14 @@ fun AppShell( content: @Composable () -> Unit, ) { val colors = LocalVniDropColors.current + val navigationStyle = navigationStyleFor(uiPlatform, windowClass) Surface( modifier = modifier .fillMaxSize() .background(colors.backgroundDashCanvas), color = colors.backgroundDashCanvas, ) { - if (useBottomNavigation(windowClass)) { + if (navigationStyle == NavigationStyle.AndroidBottomBar) { PhoneShell( selectedDestination = selectedDestination, onDestinationSelected = onDestinationSelected, @@ -53,6 +57,7 @@ fun AppShell( } else { WideShell( selectedDestination = selectedDestination, + navigationStyle = navigationStyle, mainContentTopStartRadius = mainContentTopStartRadius, onDestinationSelected = onDestinationSelected, overlay = overlay, @@ -66,6 +71,7 @@ fun AppShell( @Composable private fun WideShell( selectedDestination: AppDestination, + navigationStyle: NavigationStyle, mainContentTopStartRadius: Dp, onDestinationSelected: (AppDestination) -> Unit, overlay: @Composable BoxScope.() -> Unit, @@ -81,6 +87,7 @@ private fun WideShell( ) { AppSidebarNavigation( selected = selectedDestination, + style = navigationStyle, dividerTopInset = mainContentTopStartRadius, onDestinationSelected = onDestinationSelected, ) diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/navigation/NavigationModelTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/navigation/NavigationModelTest.kt index 1d1107b..75a9325 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/navigation/NavigationModelTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/navigation/NavigationModelTest.kt @@ -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)) + } + } + } } diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/platform/PlatformUiTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/platform/PlatformUiTest.kt new file mode 100644 index 0000000..1864da3 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/platform/PlatformUiTest.kt @@ -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)) + } +} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt index d6a8451..7f71fd9 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/Platform.jvm.kt @@ -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"), diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/PlatformJvmTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/PlatformJvmTest.kt new file mode 100644 index 0000000..ca29cae --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/PlatformJvmTest.kt @@ -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")) + } +} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt index e13b944..c48c3d7 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -34,6 +34,7 @@ import com.vnidrop.app.feature.settings.SettingsSection import com.vnidrop.app.feature.settings.SettingsState import com.vnidrop.app.feature.send.SendScreen import com.vnidrop.app.feature.send.SendState +import com.vnidrop.app.UiPlatform import com.vnidrop.app.core.CoreState import com.vnidrop.app.core.PickedShareFile 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() } + @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 isn’t").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 +241,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 +262,29 @@ 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 phoneSendEmptyStateOpensCreationDrawer() = runComposeUiTest { val state = mutableStateOf(SendState())