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

@@ -58,6 +58,6 @@ android {
tasks.configureEach { tasks.configureEach {
if (name == "mergeDebugJniLibFolders" || name == "mergeDebugNativeLibs") { if (name == "mergeDebugJniLibFolders" || name == "mergeDebugNativeLibs") {
dependsOn(":shared:cargoBuildAndroidArm64Debug") dependsOn(":shared:copyAndroidAndroidArm64Debug")
} }
} }

View File

@@ -2,6 +2,7 @@
import gobley.gradle.cargo.dsl.appleMobile import gobley.gradle.cargo.dsl.appleMobile
import gobley.gradle.rust.targets.RustAndroidTarget import gobley.gradle.rust.targets.RustAndroidTarget
import org.gradle.api.tasks.PathSensitivity
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
@@ -102,6 +103,18 @@ uniffi {
} }
tasks.configureEach { 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")) { if (name.contains("Linux") || name.contains("MinGW") || name.contains("MacOSX64")) {
enabled = false enabled = false
} }

View File

@@ -3,11 +3,13 @@ package com.vnidrop.app
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -15,10 +17,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vnidrop.app.feature.app.AppViewModel import com.vnidrop.app.feature.app.AppViewModel
import com.vnidrop.app.feature.app.AppGraphViewModel 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.ReceiveRoute
import com.vnidrop.app.feature.receive.ReceiveViewModel import com.vnidrop.app.feature.receive.ReceiveViewModel
import com.vnidrop.app.feature.send.SendRoute 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.send.SendViewModel
import com.vnidrop.app.feature.settings.SettingsRoute import com.vnidrop.app.feature.settings.SettingsRoute
import com.vnidrop.app.feature.settings.SettingsViewModel 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.feedback.VniDropSnackbarHost
import com.vnidrop.app.ui.navigation.AppDestination import com.vnidrop.app.ui.navigation.AppDestination
import com.vnidrop.app.ui.shell.AppShell import com.vnidrop.app.ui.shell.AppShell
import com.vnidrop.app.ui.shell.ScreenScrollContainer
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.windowClassFor import com.vnidrop.app.ui.state.windowClassFor
import com.vnidrop.app.ui.theme.VniDropTheme import com.vnidrop.app.ui.theme.VniDropTheme
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
@@ -55,6 +61,8 @@ fun App(dependencies: AppDependencies) {
) )
} }
val appState by appViewModel.state.collectAsStateWithLifecycle() 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 approvalState by graph.approvalCoordinator.state.collectAsStateWithLifecycle()
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, graph, settingsViewModel) { DisposableEffect(lifecycleOwner, graph, settingsViewModel) {
@@ -77,32 +85,42 @@ fun App(dependencies: AppDependencies) {
VniDropTheme(isDarkTheme = darkTheme) { VniDropTheme(isDarkTheme = darkTheme) {
BoxWithConstraints { BoxWithConstraints {
val windowClass = windowClassFor(maxWidth.value) val windowClass = windowClassFor(maxWidth.value)
Column(Modifier.fillMaxSize()) { val showSendAction = appState.destination == AppDestination.Send &&
ApprovalBannerHost( windowClass == WindowClass.Phone &&
state = approvalState, sendState.selectedTransferId?.let { selectedId ->
onAccept = graph.approvalCoordinator::accept, sendCoreState.transfers.any { it.transferId == selectedId }
onRefuse = graph.approvalCoordinator::refuse, } != true &&
modifier = Modifier.align(Alignment.CenterHorizontally), sendCoreState.transfers.any { it.direction == TransferDirection.Send }
)
AppShell( AppShell(
modifier = Modifier.fillMaxSize().weight(1f), modifier = Modifier.fillMaxSize(),
selectedDestination = appState.destination, selectedDestination = appState.destination,
windowClass = windowClass, windowClass = windowClass,
onDestinationSelected = appViewModel::selectDestination, onDestinationSelected = appViewModel::selectDestination,
overlay = { overlay = {
VniDropSnackbarHost(graph.messages, Modifier.align(Alignment.BottomCenter)) 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) { when (appState.destination) {
AppDestination.Send -> SendRoute(sendViewModel, windowClass) { ticket -> AppDestination.Send -> SendRoute(sendViewModel, windowClass)
receiveViewModel.setTicket(ticket) AppDestination.Receive -> ScreenScrollContainer { ReceiveRoute(receiveViewModel) }
appViewModel.selectDestination(AppDestination.Receive) AppDestination.Settings -> ScreenScrollContainer { SettingsRoute(settingsViewModel, windowClass) }
}
AppDestination.Receive -> ReceiveRoute(receiveViewModel)
AppDestination.Settings -> 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.CoreGateway
import com.vnidrop.app.core.CoreSignal 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.core.ReceiverRequestModel
import com.vnidrop.app.notifications.LocalNotification import com.vnidrop.app.notifications.LocalNotification
import com.vnidrop.app.notifications.LocalNotificationService import com.vnidrop.app.notifications.LocalNotificationService
@@ -60,7 +62,7 @@ class ApprovalCoordinator(
repository.state.collectLatest { core -> repository.state.collectLatest { core ->
if (core.isInitialized) { if (core.isInitialized) {
core.transfers core.transfers
.filter { it.direction == "send" && it.status == "sharing" } .filter { it.direction == TransferDirection.Send && it.status == TransferStatus.Sharing }
.forEach { refresh(it.transferId) } .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 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape 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.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.theme.LocalVniDropColors import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.getString 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 @Composable
fun VniDropSnackbarHost( fun VniDropSnackbarHost(
@@ -33,7 +53,7 @@ fun VniDropSnackbarHost(
val result = hostState.showSnackbar( val result = hostState.showSnackbar(
message = message.text.resolve(), message = message.text.resolve(),
actionLabel = message.actionLabel?.resolve(), actionLabel = message.actionLabel?.resolve(),
withDismissAction = message.actionLabel == null, withDismissAction = true,
duration = if (message.tone == UiMessageTone.Error) SnackbarDuration.Long else SnackbarDuration.Short, duration = if (message.tone == UiMessageTone.Error) SnackbarDuration.Long else SnackbarDuration.Short,
) )
if (result == SnackbarResult.ActionPerformed) message.onAction?.invoke() if (result == SnackbarResult.ActionPerformed) message.onAction?.invoke()
@@ -53,18 +73,88 @@ fun VniDropSnackbarHost(
UiMessageTone.Warning -> colors.warningDefault UiMessageTone.Warning -> colors.warningDefault
UiMessageTone.Error -> colors.destructiveDefault UiMessageTone.Error -> colors.destructiveDefault
} }
Snackbar( Surface(
snackbarData = data, modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp).widthIn(max = 520.dp).fillMaxWidth(),
modifier = Modifier.widthIn(max = 520.dp).fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
containerColor = colors.backgroundDialog, color = colors.backgroundSurface200,
contentColor = colors.foregroundDefault, contentColor = colors.foregroundDefault,
actionColor = accent, shadowElevation = 6.dp,
dismissActionContentColor = colors.foregroundLighter, ) {
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) { private suspend fun UiText.resolve(): String = when (this) {
is UiText.Dynamic -> value is UiText.Dynamic -> value
is UiText.Resource -> getString(resource) is UiText.Resource -> getString(resource)

View File

@@ -28,6 +28,7 @@ fun AppShell(
windowClass: WindowClass, windowClass: WindowClass,
onDestinationSelected: (AppDestination) -> Unit, onDestinationSelected: (AppDestination) -> Unit,
overlay: @Composable BoxScope.() -> Unit = {}, overlay: @Composable BoxScope.() -> Unit = {},
floatingAction: (@Composable BoxScope.() -> Unit)? = null,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val colors = LocalVniDropColors.current val colors = LocalVniDropColors.current
@@ -42,6 +43,7 @@ fun AppShell(
selectedDestination = selectedDestination, selectedDestination = selectedDestination,
onDestinationSelected = onDestinationSelected, onDestinationSelected = onDestinationSelected,
overlay = overlay, overlay = overlay,
floatingAction = floatingAction,
content = content, content = content,
) )
} else { } else {
@@ -49,6 +51,7 @@ fun AppShell(
selectedDestination = selectedDestination, selectedDestination = selectedDestination,
onDestinationSelected = onDestinationSelected, onDestinationSelected = onDestinationSelected,
overlay = overlay, overlay = overlay,
floatingAction = floatingAction,
content = content, content = content,
) )
} }
@@ -60,6 +63,7 @@ private fun WideShell(
selectedDestination: AppDestination, selectedDestination: AppDestination,
onDestinationSelected: (AppDestination) -> Unit, onDestinationSelected: (AppDestination) -> Unit,
overlay: @Composable BoxScope.() -> Unit, overlay: @Composable BoxScope.() -> Unit,
floatingAction: (@Composable BoxScope.() -> Unit)?,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {
@@ -68,8 +72,9 @@ private fun WideShell(
onDestinationSelected = onDestinationSelected, onDestinationSelected = onDestinationSelected,
) )
Box(modifier = Modifier.weight(1f).fillMaxSize()) { Box(modifier = Modifier.weight(1f).fillMaxSize()) {
ScreenScrollContainer(content = content) content()
overlay() 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, selectedDestination: AppDestination,
onDestinationSelected: (AppDestination) -> Unit, onDestinationSelected: (AppDestination) -> Unit,
overlay: @Composable BoxScope.() -> Unit, overlay: @Composable BoxScope.() -> Unit,
floatingAction: (@Composable BoxScope.() -> Unit)?,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.weight(1f).fillMaxSize()) { Box(modifier = Modifier.weight(1f).fillMaxSize()) {
ScreenScrollContainer(content = content) content()
overlay() floatingAction?.invoke(this)
Box(Modifier.fillMaxSize().padding(bottom = if (floatingAction == null) 0.dp else 72.dp)) { overlay() }
} }
AppBottomNavigation( AppBottomNavigation(
selected = selectedDestination, selected = selectedDestination,

View File

@@ -94,7 +94,21 @@ class ApprovalCoordinatorTest {
respondedAt = null, 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( private fun preferences(enabled: Boolean) = FakePreferencesRepository(
AppPreferences( AppPreferences(

View File

@@ -2,6 +2,7 @@ package com.vnidrop.app.ui
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.assertCountEquals
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onAllNodesWithText 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.unit.dp
import androidx.compose.ui.test.v2.runComposeUiTest import androidx.compose.ui.test.v2.runComposeUiTest
import androidx.compose.runtime.mutableStateOf 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.ApprovalState
import com.vnidrop.app.feature.approvals.PendingApproval import com.vnidrop.app.feature.approvals.PendingApproval
import com.vnidrop.app.feature.settings.SettingsScreen import com.vnidrop.app.feature.settings.SettingsScreen
import com.vnidrop.app.feature.settings.SettingsSection import com.vnidrop.app.feature.settings.SettingsSection
import com.vnidrop.app.feature.settings.SettingsState import com.vnidrop.app.feature.settings.SettingsState
import com.vnidrop.app.feature.send.SendScreen
import com.vnidrop.app.feature.send.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.notifications.NotificationPermission
import com.vnidrop.app.ui.feedback.UiMessage import com.vnidrop.app.ui.feedback.UiMessage
import com.vnidrop.app.ui.feedback.UiMessageController import com.vnidrop.app.ui.feedback.UiMessageController
@@ -43,7 +53,7 @@ class FoundationComposeTest {
var accepted: String? = null var accepted: String? = null
setContent { setContent {
VniDropTheme(isDarkTheme = false) { VniDropTheme(isDarkTheme = false) {
ApprovalBannerHost( ApprovalModalHost(
state = ApprovalState(pending = listOf(approval())), state = ApprovalState(pending = listOf(approval())),
onAccept = { accepted = it }, onAccept = { accepted = it },
onRefuse = {}, onRefuse = {},
@@ -131,6 +141,30 @@ class FoundationComposeTest {
VniDropTheme(isDarkTheme = false) { VniDropSnackbarHost(controller) } VniDropTheme(isDarkTheme = false) { VniDropSnackbarHost(controller) }
} }
onNodeWithText("Saved successfully").assertIsDisplayed() 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 @Test
@@ -144,6 +178,9 @@ class FoundationComposeTest {
overlay = { overlay = {
Box(Modifier.align(Alignment.BottomCenter).size(20.dp).testTag("snackbar-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") Text("Content")
} }
@@ -151,10 +188,103 @@ class FoundationComposeTest {
} }
val overlayBottom = onNodeWithTag("snackbar-overlay").getUnclippedBoundsInRoot().bottom val overlayBottom = onNodeWithTag("snackbar-overlay").getUnclippedBoundsInRoot().bottom
val floatingActionTop = onNodeWithTag("floating-action").getUnclippedBoundsInRoot().top
val navigationLabelTop = onNodeWithText("Send").getUnclippedBoundsInRoot().top val navigationLabelTop = onNodeWithText("Send").getUnclippedBoundsInRoot().top
assertTrue(overlayBottom <= floatingActionTop)
assertTrue(overlayBottom <= navigationLabelTop) 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 @Test
fun snackbarActionAndCancellationAreForwarded() = runComposeUiTest { fun snackbarActionAndCancellationAreForwarded() = runComposeUiTest {
val controller = UiMessageController() val controller = UiMessageController()
@@ -184,4 +314,20 @@ class FoundationComposeTest {
receiverDeviceName = "Phone", receiverDeviceName = "Phone",
requestedAt = 1L, 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,
)
} }