fix(ui): present approvals modally and package fresh native builds

This commit is contained in:
2026-07-10 22:06:35 +02:00
parent b4306d6694
commit 1e6404abfa
10 changed files with 453 additions and 99 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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