mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
fix(ui): present approvals modally and package fresh native builds
This commit is contained in:
@@ -58,6 +58,6 @@ android {
|
||||
|
||||
tasks.configureEach {
|
||||
if (name == "mergeDebugJniLibFolders" || name == "mergeDebugNativeLibs") {
|
||||
dependsOn(":shared:cargoBuildAndroidArm64Debug")
|
||||
dependsOn(":shared:copyAndroidAndroidArm64Debug")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
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().weight(1f),
|
||||
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) { ticket ->
|
||||
receiveViewModel.setTicket(ticket)
|
||||
appViewModel.selectDestination(AppDestination.Receive)
|
||||
}
|
||||
AppDestination.Receive -> ReceiveRoute(receiveViewModel)
|
||||
AppDestination.Settings -> SettingsRoute(settingsViewModel, windowClass)
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user