From 1e6404abfa4adb4d29496325822228c53ef32cde Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Fri, 10 Jul 2026 22:06:35 +0200 Subject: [PATCH] fix(ui): present approvals modally and package fresh native builds --- androidApp/build.gradle.kts | 2 +- shared/build.gradle.kts | 13 ++ .../commonMain/kotlin/com/vnidrop/app/App.kt | 66 +++++--- .../feature/approvals/ApprovalBannerHost.kt | 58 ------- .../feature/approvals/ApprovalCoordinator.kt | 4 +- .../feature/approvals/ApprovalModalHost.kt | 122 ++++++++++++++ .../app/ui/feedback/VniDropSnackbarHost.kt | 106 ++++++++++++- .../com/vnidrop/app/ui/shell/AppShell.kt | 15 +- .../approvals/ApprovalCoordinatorTest.kt | 16 +- .../vnidrop/app/ui/FoundationComposeTest.kt | 150 +++++++++++++++++- 10 files changed, 453 insertions(+), 99 deletions(-) delete mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalBannerHost.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalModalHost.kt diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 3866d0e..0930536 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -58,6 +58,6 @@ android { tasks.configureEach { if (name == "mergeDebugJniLibFolders" || name == "mergeDebugNativeLibs") { - dependsOn(":shared:cargoBuildAndroidArm64Debug") + dependsOn(":shared:copyAndroidAndroidArm64Debug") } } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index f1c783f..ab3c94d 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -2,6 +2,7 @@ import gobley.gradle.cargo.dsl.appleMobile import gobley.gradle.rust.targets.RustAndroidTarget +import org.gradle.api.tasks.PathSensitivity import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { @@ -102,6 +103,18 @@ uniffi { } tasks.configureEach { + // Gobley does not currently treat every Rust source/API change as an input of + // all platform cargo tasks. Without these inputs an incremental Android build + // can package an older .so next to freshly generated UniFFI Kotlin bindings. + if (name.startsWith("cargoBuild")) { + inputs.files( + fileTree(layout.projectDirectory.dir("../crates/vnidrop")) { + include("Cargo.toml", "build.rs", "src/**/*.rs") + }, + layout.projectDirectory.file("../Cargo.toml"), + layout.projectDirectory.file("../Cargo.lock"), + ).withPathSensitivity(PathSensitivity.RELATIVE) + } if (name.contains("Linux") || name.contains("MinGW") || name.contains("MacOSX64")) { enabled = false } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index 1905137..e3f87a3 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -3,11 +3,13 @@ package com.vnidrop.app import androidx.compose.foundation.layout.BoxWithConstraints 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.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -15,10 +17,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vnidrop.app.feature.app.AppViewModel import com.vnidrop.app.feature.app.AppGraphViewModel -import com.vnidrop.app.feature.approvals.ApprovalBannerHost +import com.vnidrop.app.feature.approvals.ApprovalModalHost import com.vnidrop.app.feature.receive.ReceiveRoute import com.vnidrop.app.feature.receive.ReceiveViewModel import com.vnidrop.app.feature.send.SendRoute +import com.vnidrop.app.feature.send.SendFloatingAction import com.vnidrop.app.feature.send.SendViewModel import com.vnidrop.app.feature.settings.SettingsRoute import com.vnidrop.app.feature.settings.SettingsViewModel @@ -26,6 +29,9 @@ 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.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.VniDropTheme import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme @@ -55,6 +61,8 @@ fun App(dependencies: AppDependencies) { ) } val appState by appViewModel.state.collectAsStateWithLifecycle() + val sendState by sendViewModel.state.collectAsStateWithLifecycle() + val sendCoreState by sendViewModel.coreState.collectAsStateWithLifecycle() val approvalState by graph.approvalCoordinator.state.collectAsStateWithLifecycle() val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner, graph, settingsViewModel) { @@ -77,32 +85,42 @@ fun App(dependencies: AppDependencies) { VniDropTheme(isDarkTheme = darkTheme) { BoxWithConstraints { val windowClass = windowClassFor(maxWidth.value) - Column(Modifier.fillMaxSize()) { - ApprovalBannerHost( - state = approvalState, - onAccept = graph.approvalCoordinator::accept, - onRefuse = graph.approvalCoordinator::refuse, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - AppShell( - modifier = Modifier.fillMaxSize().weight(1f), - selectedDestination = appState.destination, - windowClass = windowClass, - onDestinationSelected = appViewModel::selectDestination, - overlay = { - VniDropSnackbarHost(graph.messages, Modifier.align(Alignment.BottomCenter)) - }, - ) { - when (appState.destination) { - AppDestination.Send -> SendRoute(sendViewModel, windowClass) { ticket -> - receiveViewModel.setTicket(ticket) - appViewModel.selectDestination(AppDestination.Receive) - } - AppDestination.Receive -> ReceiveRoute(receiveViewModel) - AppDestination.Settings -> SettingsRoute(settingsViewModel, windowClass) + 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 } + AppShell( + modifier = Modifier.fillMaxSize(), + selectedDestination = appState.destination, + windowClass = windowClass, + 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 { + null + }, + ) { + when (appState.destination) { + AppDestination.Send -> SendRoute(sendViewModel, windowClass) + AppDestination.Receive -> ScreenScrollContainer { ReceiveRoute(receiveViewModel) } + AppDestination.Settings -> ScreenScrollContainer { SettingsRoute(settingsViewModel, windowClass) } } } + ApprovalModalHost( + state = approvalState, + onAccept = graph.approvalCoordinator::accept, + onRefuse = graph.approvalCoordinator::refuse, + ) } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalBannerHost.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalBannerHost.kt deleted file mode 100644 index 65da73f..0000000 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalBannerHost.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.vnidrop.app.feature.approvals - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.vnidrop.app.ui.components.PrimaryButton -import com.vnidrop.app.ui.components.SecondaryButton -import com.vnidrop.app.ui.theme.LocalVniDropColors -import org.jetbrains.compose.resources.stringResource -import vnidrop.shared.generated.resources.Res -import vnidrop.shared.generated.resources.approval_connection_request -import vnidrop.shared.generated.resources.approval_pending_count -import vnidrop.shared.generated.resources.button_approve -import vnidrop.shared.generated.resources.button_refuse - -@Composable -fun ApprovalBannerHost( - state: ApprovalState, - onAccept: (String) -> Unit, - onRefuse: (String) -> Unit, - modifier: Modifier = Modifier, -) { - val request = state.current ?: return - val busy = request.id in state.respondingIds - val receiver = request.receiverName ?: request.receiverDeviceName ?: "A nearby device" - val colors = LocalVniDropColors.current - Card( - modifier = modifier.widthIn(max = 640.dp).fillMaxWidth().padding(12.dp), - shape = RoundedCornerShape(14.dp), - colors = CardDefaults.cardColors(containerColor = colors.backgroundDialog), - border = BorderStroke(1.dp, colors.warningDefault), - ) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text(stringResource(Res.string.approval_connection_request), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - Text("$receiver wants to receive ${request.transferName}.") - if (state.pending.size > 1) { - Text(stringResource(Res.string.approval_pending_count, state.pending.size), style = MaterialTheme.typography.bodySmall) - } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - SecondaryButton(stringResource(Res.string.button_refuse), onClick = { onRefuse(request.id) }, enabled = !busy) - PrimaryButton(stringResource(Res.string.button_approve), onClick = { onAccept(request.id) }, enabled = !busy) - } - } - } -} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt index af58250..b05a9ca 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt @@ -2,6 +2,8 @@ package com.vnidrop.app.feature.approvals import com.vnidrop.app.core.CoreGateway import com.vnidrop.app.core.CoreSignal +import com.vnidrop.app.core.TransferDirection +import com.vnidrop.app.core.TransferStatus import com.vnidrop.app.core.ReceiverRequestModel import com.vnidrop.app.notifications.LocalNotification import com.vnidrop.app.notifications.LocalNotificationService @@ -60,7 +62,7 @@ class ApprovalCoordinator( repository.state.collectLatest { core -> if (core.isInitialized) { core.transfers - .filter { it.direction == "send" && it.status == "sharing" } + .filter { it.direction == TransferDirection.Send && it.status == TransferStatus.Sharing } .forEach { refresh(it.transferId) } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalModalHost.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalModalHost.kt new file mode 100644 index 0000000..d395624 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalModalHost.kt @@ -0,0 +1,122 @@ +package com.vnidrop.app.feature.approvals + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.graphics.vector.path +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.vnidrop.app.ui.components.PrimaryButton +import com.vnidrop.app.ui.components.SecondaryButton +import com.vnidrop.app.ui.theme.LocalVniDropColors +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.approval_connection_request +import vnidrop.shared.generated.resources.approval_pending_count +import vnidrop.shared.generated.resources.button_approve +import vnidrop.shared.generated.resources.button_refuse + +@Composable +fun ApprovalModalHost( + state: ApprovalState, + onAccept: (String) -> Unit, + onRefuse: (String) -> Unit, +) { + val request = state.current ?: return + val busy = request.id in state.respondingIds + val receiver = request.receiverName ?: request.receiverDeviceName ?: "A nearby device" + val colors = LocalVniDropColors.current + Dialog( + onDismissRequest = {}, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + usePlatformDefaultWidth = false, + ), + ) { + Surface( + modifier = Modifier.padding(24.dp).widthIn(max = 440.dp).fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + color = colors.backgroundDialog, + shadowElevation = 16.dp, + ) { + Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Surface(shape = RoundedCornerShape(14.dp), color = colors.backgroundSelection) { + Icon( + ApprovalIcon, + contentDescription = null, + tint = colors.brandLink, + modifier = Modifier.padding(11.dp).size(24.dp), + ) + } + Text( + stringResource(Res.string.approval_connection_request), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text( + "$receiver wants to receive ${request.transferName}.", + style = MaterialTheme.typography.bodyLarge, + color = colors.foregroundLight, + ) + if (state.pending.size > 1) { + Text( + stringResource(Res.string.approval_pending_count, state.pending.size), + style = MaterialTheme.typography.bodySmall, + color = colors.foregroundLighter, + ) + } + BoxWithConstraints(Modifier.fillMaxWidth()) { + if (maxWidth < 330.dp) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + PrimaryButton(stringResource(Res.string.button_approve), { onAccept(request.id) }, Modifier.fillMaxWidth(), !busy) + SecondaryButton(stringResource(Res.string.button_refuse), { onRefuse(request.id) }, Modifier.fillMaxWidth(), !busy) + } + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + Spacer(Modifier.weight(1f)) + SecondaryButton(stringResource(Res.string.button_refuse), { onRefuse(request.id) }, enabled = !busy) + Spacer(Modifier.width(10.dp)) + PrimaryButton(stringResource(Res.string.button_approve), { onAccept(request.id) }, enabled = !busy) + } + } + } + } + } + } +} + +private val ApprovalIcon: ImageVector = ImageVector.Builder( + name = "Approval", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, +).apply { + path { + moveTo(12f, 2f); lineTo(20f, 5.5f); verticalLineTo(11f) + curveTo(20f, 16.1f, 16.6f, 20.7f, 12f, 22f) + curveTo(7.4f, 20.7f, 4f, 16.1f, 4f, 11f); verticalLineTo(5.5f); close() + moveTo(8.2f, 11.8f); lineTo(10.7f, 14.3f); lineTo(15.9f, 9.1f) + } +}.build() diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/VniDropSnackbarHost.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/VniDropSnackbarHost.kt index c3cd91f..d3454bb 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/VniDropSnackbarHost.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/VniDropSnackbarHost.kt @@ -1,24 +1,44 @@ package com.vnidrop.app.ui.feedback +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Snackbar +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarData import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp import com.vnidrop.app.ui.theme.LocalVniDropColors import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.snackbar_dismiss @Composable fun VniDropSnackbarHost( @@ -33,7 +53,7 @@ fun VniDropSnackbarHost( val result = hostState.showSnackbar( message = message.text.resolve(), actionLabel = message.actionLabel?.resolve(), - withDismissAction = message.actionLabel == null, + withDismissAction = true, duration = if (message.tone == UiMessageTone.Error) SnackbarDuration.Long else SnackbarDuration.Short, ) if (result == SnackbarResult.ActionPerformed) message.onAction?.invoke() @@ -53,18 +73,88 @@ fun VniDropSnackbarHost( UiMessageTone.Warning -> colors.warningDefault UiMessageTone.Error -> colors.destructiveDefault } - Snackbar( - snackbarData = data, - modifier = Modifier.widthIn(max = 520.dp).fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + Surface( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp).widthIn(max = 520.dp).fillMaxWidth(), shape = RoundedCornerShape(10.dp), - containerColor = colors.backgroundDialog, + color = colors.backgroundSurface200, contentColor = colors.foregroundDefault, - actionColor = accent, - dismissActionContentColor = colors.foregroundLighter, + shadowElevation = 6.dp, + ) { + SnackbarContent(data, accent) + } + } +} + +@Composable +private fun SnackbarContent(data: SnackbarData, actionColor: Color) { + BoxWithConstraints { + val actionLabel = data.visuals.actionLabel + if (actionLabel != null && maxWidth < 420.dp) { + Column(Modifier.fillMaxWidth().padding(start = 16.dp, end = 6.dp, top = 8.dp, bottom = 6.dp)) { + MessageAndDismiss(data) + TextButton(onClick = data::performAction, modifier = Modifier.align(Alignment.End)) { + Text(actionLabel, color = actionColor) + } + } + } else { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 6.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SnackbarMessage(data.visuals.message, Modifier.weight(1f)) + actionLabel?.let { label -> + TextButton(onClick = data::performAction) { Text(label, color = actionColor) } + } + DismissButton(data::dismiss) + } + } + } +} + +@Composable +private fun MessageAndDismiss(data: SnackbarData) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + SnackbarMessage(data.visuals.message, Modifier.weight(1f)) + DismissButton(data::dismiss) + } +} + +@Composable +private fun SnackbarMessage(message: String, modifier: Modifier = Modifier) { + Text( + text = message, + modifier = modifier.padding(vertical = 6.dp), + style = MaterialTheme.typography.bodyMedium, + ) +} + +@Composable +private fun DismissButton(onClick: () -> Unit) { + IconButton(onClick = onClick, modifier = Modifier.size(40.dp)) { + Icon( + imageVector = CloseIcon, + contentDescription = stringResource(Res.string.snackbar_dismiss), + tint = LocalVniDropColors.current.foregroundLighter, + modifier = Modifier.size(18.dp), ) } } +private val CloseIcon = ImageVector.Builder("Close", 24.dp, 24.dp, 24f, 24f).apply { + path( + fill = SolidColor(Color.Transparent), + stroke = SolidColor(Color.Black), + strokeLineWidth = 2f, + strokeLineCap = StrokeCap.Round, + pathFillType = PathFillType.NonZero, + ) { + moveTo(6f, 6f) + lineTo(18f, 18f) + moveTo(18f, 6f) + lineTo(6f, 18f) + } +}.build() + private suspend fun UiText.resolve(): String = when (this) { is UiText.Dynamic -> value is UiText.Resource -> getString(resource) 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 c0f948d..8cd455e 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 @@ -28,6 +28,7 @@ fun AppShell( windowClass: WindowClass, onDestinationSelected: (AppDestination) -> Unit, overlay: @Composable BoxScope.() -> Unit = {}, + floatingAction: (@Composable BoxScope.() -> Unit)? = null, content: @Composable () -> Unit, ) { val colors = LocalVniDropColors.current @@ -42,6 +43,7 @@ fun AppShell( selectedDestination = selectedDestination, onDestinationSelected = onDestinationSelected, overlay = overlay, + floatingAction = floatingAction, content = content, ) } else { @@ -49,6 +51,7 @@ fun AppShell( selectedDestination = selectedDestination, onDestinationSelected = onDestinationSelected, overlay = overlay, + floatingAction = floatingAction, content = content, ) } @@ -60,6 +63,7 @@ private fun WideShell( selectedDestination: AppDestination, onDestinationSelected: (AppDestination) -> Unit, overlay: @Composable BoxScope.() -> Unit, + floatingAction: (@Composable BoxScope.() -> Unit)?, content: @Composable () -> Unit, ) { Row(modifier = Modifier.fillMaxSize()) { @@ -68,8 +72,9 @@ private fun WideShell( onDestinationSelected = onDestinationSelected, ) Box(modifier = Modifier.weight(1f).fillMaxSize()) { - ScreenScrollContainer(content = content) - overlay() + content() + floatingAction?.invoke(this) + Box(Modifier.fillMaxSize().padding(bottom = if (floatingAction == null) 0.dp else 72.dp)) { overlay() } } } } @@ -79,12 +84,14 @@ private fun PhoneShell( selectedDestination: AppDestination, onDestinationSelected: (AppDestination) -> Unit, overlay: @Composable BoxScope.() -> Unit, + floatingAction: (@Composable BoxScope.() -> Unit)?, content: @Composable () -> Unit, ) { Column(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.weight(1f).fillMaxSize()) { - ScreenScrollContainer(content = content) - overlay() + content() + floatingAction?.invoke(this) + Box(Modifier.fillMaxSize().padding(bottom = if (floatingAction == null) 0.dp else 72.dp)) { overlay() } } AppBottomNavigation( selected = selectedDestination, diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinatorTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinatorTest.kt index dc005c7..763a54f 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinatorTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinatorTest.kt @@ -94,7 +94,21 @@ class ApprovalCoordinatorTest { respondedAt = null, ) - private fun activeTransfer() = Transfer("local", 1UL, "send", "sharing", null, "Photos", 1UL, 1UL, null) + private fun activeTransfer() = Transfer( + localId = "local", + transferId = 1UL, + direction = com.vnidrop.app.core.TransferDirection.Send, + status = com.vnidrop.app.core.TransferStatus.Sharing, + peerId = null, + transferName = "Photos", + contentHash = "hash", + fileCount = 1UL, + totalSize = 1UL, + ticket = null, + accessPolicy = com.vnidrop.app.core.ShareAccessPolicy.RequireApproval, + createdAt = 1L, + updatedAt = 1L, + ) private fun preferences(enabled: Boolean) = FakePreferencesRepository( AppPreferences( 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 1ffcab3..031fac5 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -2,6 +2,7 @@ package com.vnidrop.app.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -9,6 +10,7 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onAllNodesWithText @@ -17,12 +19,20 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.compose.ui.test.v2.runComposeUiTest import androidx.compose.runtime.mutableStateOf -import com.vnidrop.app.feature.approvals.ApprovalBannerHost +import com.vnidrop.app.feature.approvals.ApprovalModalHost import com.vnidrop.app.feature.approvals.ApprovalState import com.vnidrop.app.feature.approvals.PendingApproval import com.vnidrop.app.feature.settings.SettingsScreen import com.vnidrop.app.feature.settings.SettingsSection import com.vnidrop.app.feature.settings.SettingsState +import com.vnidrop.app.feature.send.SendScreen +import com.vnidrop.app.feature.send.SendState +import com.vnidrop.app.core.CoreState +import com.vnidrop.app.core.PickedShareFile +import com.vnidrop.app.core.ShareAccessPolicy +import com.vnidrop.app.core.Transfer +import com.vnidrop.app.core.TransferDirection +import com.vnidrop.app.core.TransferStatus import com.vnidrop.app.notifications.NotificationPermission import com.vnidrop.app.ui.feedback.UiMessage import com.vnidrop.app.ui.feedback.UiMessageController @@ -43,7 +53,7 @@ class FoundationComposeTest { var accepted: String? = null setContent { VniDropTheme(isDarkTheme = false) { - ApprovalBannerHost( + ApprovalModalHost( state = ApprovalState(pending = listOf(approval())), onAccept = { accepted = it }, onRefuse = {}, @@ -131,6 +141,30 @@ class FoundationComposeTest { VniDropTheme(isDarkTheme = false) { VniDropSnackbarHost(controller) } } onNodeWithText("Saved successfully").assertIsDisplayed() + onNodeWithContentDescription("Dismiss").assertIsDisplayed() + } + + @Test + fun compactSnackbarMovesActionBelowMessageAndClose() = runComposeUiTest { + val controller = UiMessageController() + controller.tryShow( + UiMessage( + text = UiText.Dynamic("Notifications are turned off for VniDrop. You can enable them in Settings."), + actionLabel = UiText.Dynamic("Open Settings"), + ), + ) + setContent { + VniDropTheme(isDarkTheme = false) { + Box(Modifier.width(320.dp)) { VniDropSnackbarHost(controller) } + } + } + + val messageBottom = onNodeWithText("Notifications are turned off for VniDrop. You can enable them in Settings.") + .getUnclippedBoundsInRoot().bottom + val closeBottom = onNodeWithContentDescription("Dismiss").getUnclippedBoundsInRoot().bottom + val actionTop = onNodeWithText("Open Settings").getUnclippedBoundsInRoot().top + assertTrue(messageBottom <= actionTop) + assertTrue(closeBottom <= actionTop) } @Test @@ -144,6 +178,9 @@ class FoundationComposeTest { overlay = { Box(Modifier.align(Alignment.BottomCenter).size(20.dp).testTag("snackbar-overlay")) }, + floatingAction = { + Box(Modifier.align(Alignment.BottomEnd).size(56.dp).testTag("floating-action")) + }, ) { Text("Content") } @@ -151,10 +188,103 @@ class FoundationComposeTest { } val overlayBottom = onNodeWithTag("snackbar-overlay").getUnclippedBoundsInRoot().bottom + val floatingActionTop = onNodeWithTag("floating-action").getUnclippedBoundsInRoot().top val navigationLabelTop = onNodeWithText("Send").getUnclippedBoundsInRoot().top + assertTrue(overlayBottom <= floatingActionTop) assertTrue(overlayBottom <= navigationLabelTop) } + @Test + fun phoneSendEmptyStateOpensCreationDrawer() = runComposeUiTest { + val state = mutableStateOf(SendState()) + setContent { + VniDropTheme(isDarkTheme = false) { + SendScreen( + coreState = CoreState(isInitialized = true), + state = state.value, + windowClass = WindowClass.Phone, + onOpenComposer = { state.value = state.value.copy(isComposerOpen = true) }, + onDismissComposer = {}, + onSelectFile = {}, + onClearFile = {}, + onTransferNameChanged = {}, + onSenderNameChanged = {}, + onAccessPolicyChanged = {}, + onCreateShare = {}, + onTransferSelected = {}, + onCloseTransferDetails = {}, + onCopyTicket = {}, + ) + } + } + + onNodeWithText("New transfer").performClick() + onNodeWithText("Choose what to share").assertIsDisplayed() + onNodeWithText("Choose file").assertIsDisplayed() + } + + @Test + fun desktopTransferComposerReviewsFileAndAccessPolicy() = runComposeUiTest { + var selectedPolicy: ShareAccessPolicy? = null + setContent { + VniDropTheme(isDarkTheme = false) { + SendScreen( + coreState = CoreState(isInitialized = true), + state = SendState( + isComposerOpen = true, + selectedFile = PickedShareFile("/tmp/photos.zip", "photos.zip", 1536UL), + transferName = "photos.zip", + senderName = "Sender", + ), + windowClass = WindowClass.Desktop, + onOpenComposer = {}, + onDismissComposer = {}, + onSelectFile = {}, + onClearFile = {}, + onTransferNameChanged = {}, + onSenderNameChanged = {}, + onAccessPolicyChanged = { selectedPolicy = it }, + onCreateShare = {}, + onTransferSelected = {}, + onCloseTransferDetails = {}, + onCopyTicket = {}, + ) + } + } + + onNodeWithText("1.5 KB").assertIsDisplayed() + onNodeWithText("Anyone with this transfer").performClick() + runOnIdle { assertEquals(ShareAccessPolicy.AnyoneWithTransfer, selectedPolicy) } + } + + @Test + fun transferCatalogOpensSelectedTransfer() = runComposeUiTest { + var selectedId: ULong? = null + setContent { + VniDropTheme(isDarkTheme = false) { + SendScreen( + coreState = CoreState(isInitialized = true, transfers = listOf(outgoingTransfer())), + state = SendState(), + windowClass = WindowClass.Phone, + onOpenComposer = {}, + onDismissComposer = {}, + onSelectFile = {}, + onClearFile = {}, + onTransferNameChanged = {}, + onSenderNameChanged = {}, + onAccessPolicyChanged = {}, + onCreateShare = {}, + onTransferSelected = { selectedId = it }, + onCloseTransferDetails = {}, + onCopyTicket = {}, + ) + } + } + + onNodeWithText("Photos").performClick() + runOnIdle { assertEquals(9UL, selectedId) } + } + @Test fun snackbarActionAndCancellationAreForwarded() = runComposeUiTest { val controller = UiMessageController() @@ -184,4 +314,20 @@ class FoundationComposeTest { receiverDeviceName = "Phone", requestedAt = 1L, ) + + private fun outgoingTransfer() = Transfer( + localId = "local-9", + transferId = 9UL, + direction = TransferDirection.Send, + status = TransferStatus.Sharing, + peerId = null, + transferName = "Photos", + contentHash = "hash", + fileCount = 1UL, + totalSize = 1536UL, + ticket = "ticket", + accessPolicy = ShareAccessPolicy.RequireApproval, + createdAt = 1L, + updatedAt = 1L, + ) }