From 7cd125053c0466d1c92a982866980e8b70d14e3f Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Wed, 15 Jul 2026 03:53:24 +0200 Subject: [PATCH] feat(shared): polish beta UX copy and friendly errors Map snackbars to stable user-facing strings, rewrite transfer-oriented empty states and settings copy, and resource-back progress/status labels. --- .../LocalNotificationService.android.kt | 3 +- .../composeResources/values/strings.xml | 142 +++++++++--------- .../commonMain/kotlin/com/vnidrop/app/App.kt | 2 +- .../feature/approvals/ApprovalCoordinator.kt | 13 +- .../feature/approvals/ApprovalModalHost.kt | 8 +- .../app/feature/receive/ReceiveScreen.kt | 12 +- .../app/feature/receive/ReceiveViewModel.kt | 23 ++- .../vnidrop/app/feature/send/SendViewModel.kt | 6 +- .../app/feature/send/TransferDetails.kt | 2 +- .../feature/settings/AppearanceSettings.kt | 10 ++ .../app/feature/settings/SettingsOverview.kt | 19 ++- .../app/feature/settings/SettingsViewModel.kt | 8 +- .../app/ui/components/TransferComponents.kt | 19 +-- .../app/ui/feedback/UiMessageController.kt | 22 ++- .../app/ui/feedback/UserFacingError.kt | 122 +++++++++++++++ .../vnidrop/app/ui/screens/ScreenSections.kt | 83 ---------- .../com/vnidrop/app/ui/state/AppUiModels.kt | 112 ++++++++------ .../com/vnidrop/app/feature/ViewModelsTest.kt | 8 +- .../app/ui/feedback/UserFacingErrorTest.kt | 124 +++++++++++++++ .../vnidrop/app/ui/state/AppUiModelsTest.kt | 63 +++++++- .../vnidrop/app/ui/FoundationComposeTest.kt | 6 +- 21 files changed, 562 insertions(+), 245 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UserFacingError.kt delete mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ScreenSections.kt create mode 100644 shared/src/commonTest/kotlin/com/vnidrop/app/ui/feedback/UserFacingErrorTest.kt diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.android.kt index 8cc39ca..b7b8b1f 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.android.kt @@ -55,8 +55,9 @@ private class AndroidLocalNotificationService( init { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val manager = context.getSystemService(NotificationManager::class.java) + // Shown under system notification settings for this app. manager.createNotificationChannel( - NotificationChannel(ChannelId, "Connection requests", NotificationManager.IMPORTANCE_HIGH), + NotificationChannel(ChannelId, "Transfer requests", NotificationManager.IMPORTANCE_HIGH), ) } } diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 707d235..325aef4 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -3,9 +3,9 @@ Receive Settings Send - Files you have made available from this device. - Share your first file - Choose a file, decide who can receive it, then share it directly from this device. + Transfers you’re sharing from this device. + Nothing shared yet + Create a transfer, decide who can receive it, then invite them with a QR code, NFC tag, or invitation file. New transfer Your transfers New transfer @@ -21,8 +21,8 @@ Ask before each download You approve or refuse every new receiver. Anyone with this transfer - No approval is required. Only use this for files you are comfortable sharing. - Anyone who has the ticket can download until you stop the share. Do not use this for private or sensitive files. + No approval is required. Only use this for items you are comfortable sharing. + Anyone with the invitation can download until you stop sharing. Do not use this for private or sensitive items. Size unavailable Transfer created. Transfer details @@ -53,8 +53,8 @@ Hold your device near a writable NFC tag. Invitation saved. Invitation written to the NFC tag. - Preparing the selected files - Transfer ready to share + Preparing your transfer + Ready to share A receiver requested access Receiver access approved Receiver access refused @@ -62,34 +62,21 @@ Sharing stopped The transfer encountered a problem Transfer updated - Choose file - Change file - Share files + Start sharing Preparing transfer… - Copy transfer link Create a new transfer - Source - Select a file to start a share. The app keeps bytes in Rust and platform file handles. - Select file Clear Transfer details Transfer name Sender name - Create share - Creating share... - Share details - Receiver requests - Copy - Use locally - Refresh Refuse Approve Receive - Files received directly on this device. - Receive your first file - Open a VniDrop invitation, scan its QR code, or read a nearby NFC tag. - Receive files - Received files + Transfers you’ve received on this device. + Nothing received yet + Open a VniDrop invitation, scan a QR code, or hold near an NFC tag. + Start receiving + History Clear history Delete from receive history Remove from history? @@ -109,54 +96,66 @@ Review transfer VniDrop transfer Transfer received. - Inspect a ticket, request access, and stream files into the output directory. - Ticket - Ticket - Output directory Receiver name - Inspect ticket Receive - Receiving... Retry - Stop sharing - Cancel receive - Receiving files - Preparing transfer + Cancel + Receiving + Preparing + Ready + Ready to share + Connecting + Connected + Requesting access + Getting ready + Downloading + Saving + Sending + Completed + Cancelled + Failed + Transfer interrupted + Working… + Preparing + Available + Receiving + Completed + Cancelled + Stopped + Failed %1$d waiting %1$d completed - Sending - Downloading files - Saving files + Downloading + Saving Connecting to sender - Ticket details + %1$d file + %1$d files Settings - Configure the local node and app appearance. - Node + Your name, where transfers are saved, appearance, and notifications. Appearance Preferences - Username - Receive folder + Display name + Save received transfers to Choose folder - Reset default + Use default Back Close Cancel Delete transfer - Writable - Permission required + Ready + Permission needed Unavailable - Checking folder... - Display mode + Checking folder… System Dark mode Light mode - VniDrop follows the theme selected on this device. + Match this device’s light or dark appearance. About Privacy policy Report a bug Share diagnostics - Send anonymous crash reports and usage events so we can improve VniDrop. You can turn this off anytime. Tickets, file paths, and transfer contents are never included. + Send anonymous crash reports and usage events so we can improve VniDrop. You can turn this off anytime. Invitations, file paths, and transfer contents are never included. Diagnostics sharing is on. Diagnostics sharing is off. Tell us what went wrong. We attach device info and optional recent logs (with sensitive values redacted). @@ -183,36 +182,35 @@ Not available Notifications Allow notifications - Let VniDrop notify you about new connection requests while the app is running in the background. + Get notified about new receive requests while VniDrop is in the background. Notifications are turned off for VniDrop. You can enable them in Settings. Notifications are not available on this device. Notifications enabled. Could not open notification settings. Open Settings Dismiss - On - Off - Connection request - Endpoint ID: %1$s - %1$d requests are waiting - Ready - Event log - No events have been emitted yet. - Progress - Not initialized - Unknown - Name - Source - Transfer + Receive request + %1$s wants to receive “%2$s”. + A nearby device + Device ID: %1$s + %1$d requests waiting Size - Kind - Sender Files - Hash - Platform Status - The ticket could not be read. Check that the full ticket was copied. - The transfer is waiting for approval or was refused by the sender. + Something went wrong. Try again. + VniDrop is still starting. Open the invitation again in a moment. + This invitation could not be read. Ask the sender for a new one. + That invitation is empty. Try opening it again. + The sender has not approved this transfer, or it was refused. + VniDrop could not access the selected files or folder. Check permissions and try again. + The transfer could not be completed. Check your connection and try again. + VniDrop could not save transfer data on this device. + VniDrop could not finish starting up. Close the app and try again. VniDrop could not open its network sockets on this device. The native VniDrop library is missing from this build. + Could not open the selected item. Try choosing it again. + Could not load device information. + This NFC tag could not be used. Try another tag. + Camera access is required to scan a QR code. + Select at least one item to share. diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index 1bc72cf..5c6547b 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -109,7 +109,7 @@ fun App( if (ready == null) { receiveViewModel.onInvitationResult( ReceiveMethod.InvitationFile, - Result.failure(IllegalStateException("VniDrop is still starting up. Open the invitation again in a moment.")), + Result.failure(IllegalStateException("VniDrop is still starting up")), ) return@collect } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt index 82832f4..22b9aa5 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt @@ -20,6 +20,11 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.getString +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.approval_connection_request +import vnidrop.shared.generated.resources.approval_nearby_device +import vnidrop.shared.generated.resources.approval_request_body data class PendingApproval( val id: String, @@ -137,12 +142,14 @@ class ApprovalCoordinator( return } context.pending.filterNot { it.id in publishedNotificationIds }.forEach { request -> - val receiver = request.receiverName ?: request.receiverDeviceName ?: "A nearby device" + val receiver = request.receiverName + ?: request.receiverDeviceName + ?: getString(Res.string.approval_nearby_device) notifications.publish( LocalNotification( id = notificationId(request.id), - title = "Connection request", - body = "$receiver wants to receive ${request.transferName}", + title = getString(Res.string.approval_connection_request), + body = getString(Res.string.approval_request_body, receiver, request.transferName), ), ).onSuccess { publishedNotificationIds += request.id diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalModalHost.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalModalHost.kt index bb3ecc8..4d097f1 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalModalHost.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalModalHost.kt @@ -32,7 +32,9 @@ 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_endpoint_id +import vnidrop.shared.generated.resources.approval_nearby_device import vnidrop.shared.generated.resources.approval_pending_count +import vnidrop.shared.generated.resources.approval_request_body import vnidrop.shared.generated.resources.button_approve import vnidrop.shared.generated.resources.button_refuse @@ -44,7 +46,9 @@ fun ApprovalModalHost( ) { val request = state.current ?: return val busy = request.id in state.respondingIds - val receiver = request.receiverName ?: request.receiverDeviceName ?: "A nearby device" + val receiver = request.receiverName + ?: request.receiverDeviceName + ?: stringResource(Res.string.approval_nearby_device) val colors = LocalVniDropColors.current Dialog( onDismissRequest = {}, @@ -75,7 +79,7 @@ fun ApprovalModalHost( fontWeight = FontWeight.Bold, ) Text( - "$receiver wants to receive ${request.transferName}.", + stringResource(Res.string.approval_request_body, receiver, request.transferName), style = MaterialTheme.typography.bodyLarge, color = colors.foregroundLight, ) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt index e3d772d..c99987e 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt @@ -53,6 +53,7 @@ import com.vnidrop.app.ui.components.Field import com.vnidrop.app.ui.components.PrimaryButton import com.vnidrop.app.ui.components.ProgressRow import com.vnidrop.app.ui.components.SecondaryButton +import com.vnidrop.app.ui.feedback.UiText import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.state.displayNameForStatus import com.vnidrop.app.ui.state.formatBytes @@ -270,7 +271,7 @@ private fun InvitationReviewPanel( ?: events.firstOrNull { it.direction == "receive" && it.transferId != null }?.transferId val progress = progressId?.let { progressForTransfer(events, it) } ProgressRow( - label = progress?.label ?: stringResource(Res.string.progress_receiving), + label = progress?.label ?: Res.string.progress_receiving, progress = progress?.progress, detail = progress?.detail, ) @@ -288,7 +289,14 @@ private fun InvitationReviewPanel( ) } state.lastReceiveError?.let { error -> - Text(error, color = LocalVniDropColors.current.destructiveDefault, style = MaterialTheme.typography.bodySmall) + Text( + when (error) { + is UiText.Dynamic -> error.value + is UiText.Resource -> stringResource(error.resource) + }, + color = LocalVniDropColors.current.destructiveDefault, + style = MaterialTheme.typography.bodySmall, + ) } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt index c693195..006026a 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt @@ -16,6 +16,8 @@ import com.vnidrop.app.ui.feedback.UiMessage import com.vnidrop.app.ui.feedback.UiMessageController import com.vnidrop.app.ui.feedback.UiMessageTone import com.vnidrop.app.ui.feedback.UiText +import com.vnidrop.app.ui.feedback.isUserCancellation +import com.vnidrop.app.ui.feedback.toUiText import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -23,6 +25,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.button_retry +import vnidrop.shared.generated.resources.error_invitation_empty import vnidrop.shared.generated.resources.receive_completed import vnidrop.shared.generated.resources.receive_history_cleared import vnidrop.shared.generated.resources.transfer_deleted @@ -43,7 +46,7 @@ data class ReceiveState( val isInspecting: Boolean = false, val isReceiving: Boolean = false, val activeReceiveTransferId: ULong? = null, - val lastReceiveError: String? = null, + val lastReceiveError: UiText? = null, val isWaitingForNfc: Boolean = false, val historyDeleteTarget: ReceiveHistoryDeleteTarget? = null, val isDeletingHistory: Boolean = false, @@ -170,17 +173,27 @@ class ReceiveViewModel( messages.tryShow(UiMessage(UiText.Resource(Res.string.receive_completed), UiMessageTone.Success)) }, onFailure = { error -> - val message = error.message?.takeIf(String::isNotBlank) ?: "Something went wrong." + if (error.isUserCancellation()) { + _state.update { + it.copy( + isReceiving = false, + activeReceiveTransferId = null, + lastReceiveError = null, + ) + } + return@fold + } + val uiText = error.toUiText() _state.update { it.copy( isReceiving = false, activeReceiveTransferId = null, - lastReceiveError = message, + lastReceiveError = uiText, ) } messages.tryShow( UiMessage( - text = UiText.Dynamic(message), + text = uiText, tone = UiMessageTone.Error, actionLabel = UiText.Resource(Res.string.button_retry), onAction = { receive() }, @@ -215,7 +228,7 @@ class ReceiveViewModel( private fun inspectInvitation(method: ReceiveMethod, raw: String) { val ticket = raw.trim() - if (ticket.isBlank()) return messages.error(IllegalArgumentException("The invitation is empty")) + if (ticket.isBlank()) return messages.error(UiText.Resource(Res.string.error_invitation_empty)) viewModelScope.launch { _state.update { it.copy( diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt index 2918f59..63f1a09 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.launch import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.send_transfer_created import vnidrop.shared.generated.resources.transfer_deleted +import vnidrop.shared.generated.resources.transfer_invitation_saved import vnidrop.shared.generated.resources.transfer_nfc_written data class SendState( @@ -149,7 +150,7 @@ class SendViewModel( } } - fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason)) + fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason.takeIf(String::isNotBlank) ?: "selection failed")) fun clearSelectedSource() { _state.update { it.copy(selectedFiles = emptyList(), transferName = "") } @@ -227,8 +228,9 @@ class SendViewModel( result.fold( onSuccess = { val message = when (action) { - InvitationAction.Export -> null + InvitationAction.Export -> Res.string.transfer_invitation_saved InvitationAction.Nfc -> Res.string.transfer_nfc_written + // System share sheet already confirms the action on most platforms. InvitationAction.Share -> null } message?.let { messages.tryShow(UiMessage(UiText.Resource(it), UiMessageTone.Success)) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt index 1cbaeee..0d772b5 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt @@ -198,7 +198,7 @@ private fun ReceiverRow(receiver: ReceiverRequestModel, sendProgress: TransferPr } if (showLiveSend) { ProgressRow( - label = stringResource(Res.string.transfer_receiver_sending), + label = sendProgress.label, progress = sendProgress.progress, detail = sendProgress.detail, ) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AppearanceSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AppearanceSettings.kt index 9349518..8144651 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AppearanceSettings.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/AppearanceSettings.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -12,6 +14,7 @@ import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.LocalVniDropColors import org.jetbrains.compose.resources.stringResource import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.appearance_auto_description import vnidrop.shared.generated.resources.appearance_dark_mode import vnidrop.shared.generated.resources.appearance_light_mode import vnidrop.shared.generated.resources.appearance_system_mode @@ -39,6 +42,13 @@ internal fun AppearanceSettings( onModeChanged(ThemeMode.Light) } } + if (mode == ThemeMode.System) { + Text( + stringResource(Res.string.appearance_auto_description), + color = LocalVniDropColors.current.foregroundLighter, + style = MaterialTheme.typography.bodySmall, + ) + } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt index 583b7e8..258a4d9 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vnidrop.app.ui.theme.ThemeMode import org.jetbrains.compose.resources.stringResource +import com.vnidrop.app.ui.theme.LocalVniDropColors import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.about_title import vnidrop.shared.generated.resources.appearance_dark_mode @@ -17,6 +18,7 @@ import vnidrop.shared.generated.resources.appearance_system_mode import vnidrop.shared.generated.resources.appearance_title import vnidrop.shared.generated.resources.notifications_title import vnidrop.shared.generated.resources.preferences_title +import vnidrop.shared.generated.resources.settings_subtitle import vnidrop.shared.generated.resources.settings_title @Composable @@ -26,11 +28,18 @@ internal fun SettingsOverview( largeTitle: Boolean, ) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text( - stringResource(Res.string.settings_title), - style = if (largeTitle) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - ) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + stringResource(Res.string.settings_title), + style = if (largeTitle) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + Text( + stringResource(Res.string.settings_subtitle), + color = LocalVniDropColors.current.foregroundLighter, + style = MaterialTheme.typography.bodyMedium, + ) + } SettingsGroup { SettingsRow( icon = SettingsIcons.Device, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt index c9ad186..8de22bd 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt @@ -319,7 +319,13 @@ class SettingsViewModel( } catch (error: Throwable) { if (error is CancellationException) throw error _state.update { it.copy(isLoadingDeviceInfo = false) } - messages.error(error, "Could not load device information.") + messages.error( + if (error.message.isNullOrBlank()) { + IllegalStateException("Could not load device information.") + } else { + error + }, + ) } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/TransferComponents.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/TransferComponents.kt index 98b8196..4638d5c 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/TransferComponents.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/TransferComponents.kt @@ -13,14 +13,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vnidrop.app.ui.theme.LocalVniDropColors +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource @Composable fun ProgressRow( - label: String, + label: StringResource, progress: Float?, modifier: Modifier = Modifier, detail: String? = null, ) { + val labelText = stringResource(label) Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { Row( Modifier.fillMaxWidth(), @@ -28,7 +31,7 @@ fun ProgressRow( verticalAlignment = Alignment.CenterVertically, ) { Text( - label, + labelText, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -55,15 +58,3 @@ fun ProgressRow( else LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth()) } } - -@Composable -fun MetadataRow(label: String, value: String, modifier: Modifier = Modifier) { - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - ) { - Text(label, Modifier.weight(0.35f), color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) - Text(value, Modifier.weight(0.65f), style = MaterialTheme.typography.bodySmall) - } -} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt index 902f384..b77dfa8 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt @@ -1,5 +1,6 @@ package com.vnidrop.app.ui.feedback +import com.vnidrop.app.logging.AppLogger import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -43,12 +44,29 @@ class UiMessageController { _dismissals.tryEmit(Unit) } - fun error(error: Throwable, fallback: String = "Something went wrong.") { + /** + * Surfaces a user-facing error snackbar. Logs the full technical error. + * User cancellations are logged and suppressed. + */ + fun error(error: Throwable) { + if (error.isUserCancellation()) { + AppLogger.info( + "ui", + "suppressed user cancellation", + mapOf("detail" to error.technicalDetail().ifBlank { error::class.simpleName.orEmpty() }), + ) + return + } + AppLogger.error("ui", "user-facing error", error) tryShow( UiMessage( - text = UiText.Dynamic(error.message?.takeIf(String::isNotBlank) ?: fallback), + text = error.toUiText(), tone = UiMessageTone.Error, ), ) } + + fun error(text: UiText) { + tryShow(UiMessage(text = text, tone = UiMessageTone.Error)) + } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UserFacingError.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UserFacingError.kt new file mode 100644 index 0000000..8a2cf98 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UserFacingError.kt @@ -0,0 +1,122 @@ +package com.vnidrop.app.ui.feedback + +import uniffi.vnidrop.VnidropException +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.error_device_info +import vnidrop.shared.generated.resources.error_filesystem +import vnidrop.shared.generated.resources.error_generic +import vnidrop.shared.generated.resources.error_initialization +import vnidrop.shared.generated.resources.error_invalid_ticket +import vnidrop.shared.generated.resources.error_invitation_empty +import vnidrop.shared.generated.resources.error_missing_native_library +import vnidrop.shared.generated.resources.error_permission +import vnidrop.shared.generated.resources.error_repository +import vnidrop.shared.generated.resources.error_selection_failed +import vnidrop.shared.generated.resources.error_socket_bind +import vnidrop.shared.generated.resources.error_camera +import vnidrop.shared.generated.resources.error_nfc +import vnidrop.shared.generated.resources.error_share_empty +import vnidrop.shared.generated.resources.error_starting_up +import vnidrop.shared.generated.resources.error_transfer + +/** + * Maps technical failures to stable, user-facing copy. + * + * Never expose raw exception messages (especially UniFFI `reason=…` blobs) in snackbars. + */ +fun Throwable.toUiText(): UiText = + when (this) { + is VnidropException.Ticket -> UiText.Resource(Res.string.error_invalid_ticket) + is VnidropException.Permission -> UiText.Resource(Res.string.error_permission) + is VnidropException.Filesystem -> UiText.Resource(Res.string.error_filesystem) + is VnidropException.Transfer -> transferUiText(reason) + is VnidropException.Repository -> UiText.Resource(Res.string.error_repository) + is VnidropException.Initialization -> initializationUiText(reason) + is VnidropException.Internal -> reasonHints(reason) ?: UiText.Resource(Res.string.error_generic) + else -> reasonHints(technicalDetail()) ?: UiText.Resource(Res.string.error_generic) + } + +/** User intentionally backed out of a flow — do not treat as a failure snackbar. */ +fun Throwable.isUserCancellation(): Boolean { + val haystack = technicalDetail().lowercase() + if (haystack.isBlank()) return false + return haystack.contains("cancelled") || + haystack.contains("canceled") || + haystack.contains("user cancelled") || + haystack.contains("user canceled") +} + +/** Prefer [VnidropException.reason] when present; else [Throwable.message]. */ +fun Throwable.technicalDetail(): String = + when (this) { + is VnidropException.Initialization -> reason + is VnidropException.Ticket -> reason + is VnidropException.Filesystem -> reason + is VnidropException.Transfer -> reason + is VnidropException.Permission -> reason + is VnidropException.Repository -> reason + is VnidropException.Internal -> reason + else -> message.orEmpty() + } + +private fun transferUiText(reason: String): UiText { + val detail = reason.lowercase() + return when { + detail.contains("refused") || detail.contains("denied") || detail.contains("not approved") -> + UiText.Resource(Res.string.error_permission) + else -> UiText.Resource(Res.string.error_transfer) + } +} + +private fun initializationUiText(reason: String): UiText { + val detail = reason.lowercase() + return when { + detail.contains("native") && detail.contains("library") -> + UiText.Resource(Res.string.error_missing_native_library) + detail.contains("socket") || detail.contains("bind") -> + UiText.Resource(Res.string.error_socket_bind) + else -> UiText.Resource(Res.string.error_initialization) + } +} + +private fun reasonHints(detailRaw: String): UiText? { + val detail = detailRaw.lowercase() + if (detail.isBlank()) return null + + return when { + detail.contains("still starting") || detail.contains("starting up") -> + UiText.Resource(Res.string.error_starting_up) + detail.contains("empty") && (detail.contains("invitation") || detail.contains("ticket") || detail.contains("qr")) -> + UiText.Resource(Res.string.error_invitation_empty) + detail.contains("select at least one") || detail.contains("no files found") -> + UiText.Resource(Res.string.error_share_empty) + detail.contains("camera") -> + UiText.Resource(Res.string.error_camera) + detail.contains("nfc") || detail.contains("ndef") || + (detail.contains("read-only") && detail.contains("tag")) || + detail.contains("tag is too small") || detail.contains("no nfc tag") -> + UiText.Resource(Res.string.error_nfc) + detail.contains("native") && detail.contains("library") -> + UiText.Resource(Res.string.error_missing_native_library) + detail.contains("socket") || detail.contains("bind") -> + UiText.Resource(Res.string.error_socket_bind) + detail.contains("device information") || detail.contains("device info") -> + UiText.Resource(Res.string.error_device_info) + detail.contains("refused") || detail.contains("denied") || detail.contains("permission") || + detail.contains("not approved") || detail.contains("waiting for approval") -> + UiText.Resource(Res.string.error_permission) + detail.contains("invalid ticket") || detail.contains("ticket error") || + detail.contains("could not be read") || detail.contains("malformed") || + detail.contains("invitation could not be opened") -> + UiText.Resource(Res.string.error_invalid_ticket) + detail.contains("selected") && + (detail.contains("file") || detail.contains("folder") || detail.contains("document") || detail.contains("open")) -> + UiText.Resource(Res.string.error_selection_failed) + detail.contains("could not open the selected") || detail.contains("could not open selected") -> + UiText.Resource(Res.string.error_selection_failed) + detail.contains("document picker") || detail.contains("folder picker") || detail.contains("file descriptor") || + detail.contains("view controller") -> + UiText.Resource(Res.string.error_selection_failed) + else -> null + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ScreenSections.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ScreenSections.kt deleted file mode 100644 index 92db048..0000000 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ScreenSections.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.vnidrop.app.ui.screens - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.vnidrop.app.core.CoreState -import com.vnidrop.app.core.TicketInspectionModel -import com.vnidrop.app.ui.components.AppCard -import com.vnidrop.app.ui.components.MetadataRow -import com.vnidrop.app.ui.components.ProgressRow -import com.vnidrop.app.ui.state.formatBytes -import com.vnidrop.app.ui.state.summarizeProgress -import com.vnidrop.app.ui.theme.LocalVniDropColors -import org.jetbrains.compose.resources.stringResource -import vnidrop.shared.generated.resources.Res -import vnidrop.shared.generated.resources.metadata_files -import vnidrop.shared.generated.resources.metadata_hash -import vnidrop.shared.generated.resources.metadata_kind -import vnidrop.shared.generated.resources.metadata_sender -import vnidrop.shared.generated.resources.metadata_size -import vnidrop.shared.generated.resources.metadata_transfer -import vnidrop.shared.generated.resources.progress_title -import vnidrop.shared.generated.resources.ticket_details_title -import vnidrop.shared.generated.resources.unknown_sender - -@Composable -fun ScreenHeader(title: String, subtitle: String) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) - Text(subtitle, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodyMedium) - } -} - -@Composable -fun EmptyText(text: String) { - Text(text, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodyMedium) -} - -@Composable -fun ProgressSection(coreState: CoreState) { - val progress = summarizeProgress(coreState.events) - if (progress.isNotEmpty()) { - AppCard(title = stringResource(Res.string.progress_title)) { - progress.forEach { item -> ProgressRow(label = item.label, progress = item.progress) } - } - } -} - -@Composable -fun TicketInspectionCard(inspection: TicketInspectionModel) { - val metadata = inspection.metadata - AppCard(title = stringResource(Res.string.ticket_details_title)) { - MetadataRow(stringResource(Res.string.metadata_kind), inspection.kind) - MetadataRow(stringResource(Res.string.metadata_transfer), metadata.transferName) - MetadataRow(stringResource(Res.string.metadata_sender), metadata.senderName ?: stringResource(Res.string.unknown_sender)) - MetadataRow(stringResource(Res.string.metadata_files), metadata.fileCount.toString()) - MetadataRow(stringResource(Res.string.metadata_size), formatBytes(metadata.totalSize)) - MetadataRow(stringResource(Res.string.metadata_hash), metadata.contentHash) - } -} - -@Composable -fun TicketText(ticket: String) { - SelectionContainer { - Text( - text = ticket, - modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) - .background(LocalVniDropColors.current.backgroundSurface200).padding(12.dp), - style = MaterialTheme.typography.bodySmall, - ) - } -} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt index b068ea0..3adbf74 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt @@ -1,9 +1,37 @@ package com.vnidrop.app.ui.state +import androidx.compose.runtime.Composable import com.vnidrop.app.core.CoreEventModel import com.vnidrop.app.core.Transfer import com.vnidrop.app.core.TransferStatus import kotlin.math.roundToInt +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.progress_cancelled +import vnidrop.shared.generated.resources.progress_completed +import vnidrop.shared.generated.resources.progress_connected +import vnidrop.shared.generated.resources.progress_connecting +import vnidrop.shared.generated.resources.progress_downloading +import vnidrop.shared.generated.resources.progress_failed +import vnidrop.shared.generated.resources.progress_getting_ready +import vnidrop.shared.generated.resources.progress_interrupted +import vnidrop.shared.generated.resources.progress_preparing +import vnidrop.shared.generated.resources.progress_ready +import vnidrop.shared.generated.resources.progress_requesting_access +import vnidrop.shared.generated.resources.progress_saving +import vnidrop.shared.generated.resources.progress_sending +import vnidrop.shared.generated.resources.progress_share_ready +import vnidrop.shared.generated.resources.progress_working +import vnidrop.shared.generated.resources.status_available +import vnidrop.shared.generated.resources.status_cancelled +import vnidrop.shared.generated.resources.status_completed +import vnidrop.shared.generated.resources.status_failed +import vnidrop.shared.generated.resources.status_preparing +import vnidrop.shared.generated.resources.status_receiving +import vnidrop.shared.generated.resources.status_stopped +import vnidrop.shared.generated.resources.transfer_file_count_one +import vnidrop.shared.generated.resources.transfer_file_count_other enum class WindowClass { Phone, @@ -25,22 +53,26 @@ data class TransferProgress( val transferId: ULong?, val phase: String, val kind: String, - val label: String, + val label: StringResource, val progress: Float?, val detail: String? = null, ) -fun displayNameForStatus(status: TransferStatus): String = +fun statusLabelResource(status: TransferStatus): StringResource = when (status) { - TransferStatus.Importing -> "Preparing" - TransferStatus.Sharing -> "Available" - TransferStatus.Receiving -> "Receiving" - TransferStatus.Done -> "Completed" - TransferStatus.Cancelled -> "Cancelled" - TransferStatus.Stopped -> "Stopped" - TransferStatus.Failed -> "Failed" + TransferStatus.Importing -> Res.string.status_preparing + TransferStatus.Sharing -> Res.string.status_available + TransferStatus.Receiving -> Res.string.status_receiving + TransferStatus.Done -> Res.string.status_completed + TransferStatus.Cancelled -> Res.string.status_cancelled + TransferStatus.Stopped -> Res.string.status_stopped + TransferStatus.Failed -> Res.string.status_failed } +@Composable +fun displayNameForStatus(status: TransferStatus): String = + stringResource(statusLabelResource(status)) + fun Transfer.isActiveTransfer(): Boolean = status in activeTransferStatuses @@ -101,7 +133,7 @@ fun progressForReceiver( transferId = transferId, phase = "transfer", kind = "aborted", - label = "Send interrupted", + label = Res.string.progress_interrupted, progress = null, detail = null, ) @@ -111,7 +143,7 @@ fun progressForReceiver( transferId = transferId, phase = "transfer", kind = "completed", - label = "Send completed", + label = Res.string.progress_completed, progress = 1f, detail = null, ) @@ -122,7 +154,7 @@ fun progressForReceiver( transferId = transferId, phase = "transfer", kind = latest.kind, - label = "Sending", + label = Res.string.progress_sending, progress = progress, detail = progressDetail(latest), ) @@ -156,7 +188,7 @@ fun activeSendProgress( transferId = transferId, phase = "transfer", kind = relevant.first().kind, - label = "Sending to receiver", + label = Res.string.progress_sending, progress = aggregateReceiverProgress(relevant, totalSizeHint), detail = progressDetail(relevant.first()), ) @@ -174,14 +206,9 @@ fun summarizeProgress(events: List): List = .take(6) .mapNotNull { progressForTransfer(events, it) } -fun transferSubtitle(transfer: Transfer): String { - val pieces = listOfNotNull( - transfer.transferName, - "${transfer.fileCount} file${if (transfer.fileCount == 1UL) "" else "s"}", - formatBytes(transfer.totalSize), - ) - return pieces.joinToString(" | ") -} +/** File-count string resource for transfer subtitles (resolve with [stringResource]). */ +fun transferFileCountResource(fileCount: ULong): StringResource = + if (fileCount == 1UL) Res.string.transfer_file_count_one else Res.string.transfer_file_count_other fun formatBytes(size: ULong): String { val value = size.toDouble() @@ -217,29 +244,26 @@ private val activeTransferStatuses = setOf( TransferStatus.Receiving, ) -private fun humanProgressLabel(event: CoreEventModel): String = when { - event.phase == "import" && event.kind == "copy-progress" -> "Preparing files" - event.phase == "import" && event.kind == "outboard-progress" -> "Indexing files" - event.phase == "import" && event.kind == "started" -> "Preparing transfer" - event.phase == "import" && event.kind == "done" -> "Files ready" - event.phase == "ticket" && event.kind == "created" -> "Share ready" - event.phase == "network" && event.kind == "connecting" -> "Connecting to sender" - event.phase == "network" && event.kind == "connected" -> "Connected" - event.phase == "handshake" -> "Requesting access" - event.phase == "download" && event.kind == "found-collection" -> "Found files" - event.phase == "download" && event.kind == "progress" -> "Downloading" - event.phase == "export" && event.kind == "progress" -> "Saving files" - event.phase == "transfer" && event.kind == "progress" -> "Sending to receiver" - event.phase == "transfer" && event.kind == "started" -> "Receiver connected" - event.phase == "transfer" && event.kind == "completed" -> "Send completed" - event.phase == "lifecycle" && event.kind == "done" -> "Completed" - event.phase == "lifecycle" && event.kind == "cancelled" -> "Cancelled" - event.kind == "failed" -> "Failed" - else -> listOfNotNull( - event.direction?.replaceFirstChar { it.uppercase() }, - event.phase.replaceFirstChar { it.uppercase() }, - event.kind.replace('-', ' '), - ).joinToString(" · ") +private fun humanProgressLabel(event: CoreEventModel): StringResource = when { + event.phase == "import" && event.kind == "copy-progress" -> Res.string.progress_preparing + event.phase == "import" && event.kind == "outboard-progress" -> Res.string.progress_preparing + event.phase == "import" && event.kind == "started" -> Res.string.progress_preparing + event.phase == "import" && event.kind == "done" -> Res.string.progress_ready + event.phase == "ticket" && event.kind == "created" -> Res.string.progress_share_ready + event.phase == "network" && event.kind == "connecting" -> Res.string.progress_connecting + event.phase == "network" && event.kind == "connected" -> Res.string.progress_connected + event.phase == "handshake" -> Res.string.progress_requesting_access + event.phase == "download" && event.kind == "found-collection" -> Res.string.progress_getting_ready + event.phase == "download" && event.kind == "progress" -> Res.string.progress_downloading + event.phase == "export" && event.kind == "progress" -> Res.string.progress_saving + event.phase == "transfer" && event.kind == "progress" -> Res.string.progress_sending + event.phase == "transfer" && event.kind == "started" -> Res.string.progress_connected + event.phase == "transfer" && event.kind == "completed" -> Res.string.progress_completed + event.phase == "lifecycle" && event.kind == "done" -> Res.string.progress_completed + event.phase == "lifecycle" && event.kind == "cancelled" -> Res.string.progress_cancelled + event.kind == "failed" -> Res.string.progress_failed + // Never dump raw phase/kind tokens into the UI. + else -> Res.string.progress_working } private fun progressDetail(event: CoreEventModel): String? { diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index 9ca228c..6db3377 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -30,6 +30,7 @@ import com.vnidrop.app.support.FakeFilePreviewRepository import com.vnidrop.app.support.FakeNotificationService import com.vnidrop.app.support.FakePreferencesRepository import com.vnidrop.app.ui.feedback.UiMessageController +import com.vnidrop.app.ui.feedback.UiText import com.vnidrop.app.ui.navigation.AppDestination import com.vnidrop.app.ui.theme.ThemeMode import kotlinx.coroutines.Dispatchers @@ -45,6 +46,8 @@ import kotlin.test.assertEquals import kotlin.test.assertContentEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.error_permission @OptIn(ExperimentalCoroutinesApi::class) class ViewModelsTest { @@ -431,7 +434,10 @@ class ViewModelsTest { assertEquals("ticket-xyz", viewModel.state.value.ticket) assertFalse(viewModel.state.value.isReceiving) assertTrue(viewModel.state.value.inspection != null) - assertEquals("sender refused", viewModel.state.value.lastReceiveError) + assertEquals( + UiText.Resource(Res.string.error_permission), + viewModel.state.value.lastReceiveError, + ) } @Test diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/feedback/UserFacingErrorTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/feedback/UserFacingErrorTest.kt new file mode 100644 index 0000000..59e96f3 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/feedback/UserFacingErrorTest.kt @@ -0,0 +1,124 @@ +package com.vnidrop.app.ui.feedback + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import uniffi.vnidrop.VnidropException +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.error_filesystem +import vnidrop.shared.generated.resources.error_generic +import vnidrop.shared.generated.resources.error_initialization +import vnidrop.shared.generated.resources.error_invalid_ticket +import vnidrop.shared.generated.resources.error_invitation_empty +import vnidrop.shared.generated.resources.error_missing_native_library +import vnidrop.shared.generated.resources.error_permission +import vnidrop.shared.generated.resources.error_repository +import vnidrop.shared.generated.resources.error_selection_failed +import vnidrop.shared.generated.resources.error_socket_bind +import vnidrop.shared.generated.resources.error_camera +import vnidrop.shared.generated.resources.error_nfc +import vnidrop.shared.generated.resources.error_share_empty +import vnidrop.shared.generated.resources.error_starting_up +import vnidrop.shared.generated.resources.error_transfer + +class UserFacingErrorTest { + @Test + fun mapsVnidropExceptionVariantsToStableCopy() { + assertEquals( + UiText.Resource(Res.string.error_invalid_ticket), + VnidropException.Ticket("decode failed: bad base32").toUiText(), + ) + assertEquals( + UiText.Resource(Res.string.error_permission), + VnidropException.Permission("receiver refused").toUiText(), + ) + assertEquals( + UiText.Resource(Res.string.error_filesystem), + VnidropException.Filesystem("permission denied opening path").toUiText(), + ) + assertEquals( + UiText.Resource(Res.string.error_transfer), + VnidropException.Transfer("connection reset").toUiText(), + ) + assertEquals( + UiText.Resource(Res.string.error_repository), + VnidropException.Repository("database locked").toUiText(), + ) + assertEquals( + UiText.Resource(Res.string.error_initialization), + VnidropException.Initialization("endpoint bootstrap failed").toUiText(), + ) + assertEquals( + UiText.Resource(Res.string.error_generic), + VnidropException.Internal("unexpected").toUiText(), + ) + } + + @Test + fun neverSurfacesRawReasonPrefixFromUniffi() { + val text = VnidropException.Transfer("blob fetch failed").toUiText() + assertTrue(text is UiText.Resource) + assertFalse(text.toString().contains("reason=")) + } + + @Test + fun mapsCommonPlatformReasons() { + assertEquals( + UiText.Resource(Res.string.error_permission), + IllegalStateException("sender refused").toUiText(), + ) + assertEquals( + UiText.Resource(Res.string.error_invalid_ticket), + IllegalArgumentException("invalid ticket").toUiText(), + ) + assertEquals( + UiText.Resource(Res.string.error_invitation_empty), + IllegalArgumentException("The invitation is empty").toUiText(), + ) + assertEquals( + UiText.Resource(Res.string.error_selection_failed), + IllegalStateException("Could not open the selected files").toUiText(), + ) + assertEquals( + UiText.Resource(Res.string.error_socket_bind), + IllegalStateException("socket bind failed on port").toUiText(), + ) + assertEquals( + UiText.Resource(Res.string.error_missing_native_library), + IllegalStateException("native library missing from this build").toUiText(), + ) + assertEquals( + UiText.Resource(Res.string.error_starting_up), + IllegalStateException("VniDrop is still starting up").toUiText(), + ) + assertEquals( + UiText.Resource(Res.string.error_nfc), + IllegalStateException("This NFC tag is read-only").toUiText(), + ) + assertEquals( + UiText.Resource(Res.string.error_camera), + IllegalStateException("Camera access is required to scan QR codes").toUiText(), + ) + assertEquals( + UiText.Resource(Res.string.error_share_empty), + IllegalStateException("Select at least one file to share").toUiText(), + ) + } + + @Test + fun unknownErrorsUseGenericCopy() { + assertEquals( + UiText.Resource(Res.string.error_generic), + RuntimeException("opaque backend code 0xdead").toUiText(), + ) + } + + @Test + fun detectsUserCancellations() { + assertTrue(IllegalStateException("QR scanning was cancelled").isUserCancellation()) + assertTrue(IllegalStateException("NFC writing was cancelled").isUserCancellation()) + assertTrue(VnidropException.Transfer("transfer cancelled by user").isUserCancellation()) + assertFalse(IllegalStateException("sender refused").isUserCancellation()) + } +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt index 705056a..2a4ffcb 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt @@ -14,6 +14,14 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.progress_completed +import vnidrop.shared.generated.resources.progress_interrupted +import vnidrop.shared.generated.resources.progress_saving +import vnidrop.shared.generated.resources.progress_sending +import vnidrop.shared.generated.resources.progress_working +import vnidrop.shared.generated.resources.transfer_file_count_one +import vnidrop.shared.generated.resources.transfer_file_count_other class AppUiModelsTest { @Test @@ -102,7 +110,7 @@ class AppUiModelsTest { ) val progress = progressForTransfer(events, 7UL) assertEquals(0.75f, progress?.progress) - assertEquals("Saving files", progress?.label) + assertEquals(Res.string.progress_saving, progress?.label) assertEquals("a.bin", progress?.detail) } @@ -150,7 +158,7 @@ class AppUiModelsTest { // blob1 complete 50 + blob2 40 = 90 / total hint 100 val progress = progressForReceiver(events, 7UL, "peer-a", totalSizeHint = 100UL) assertEquals(0.9f, progress?.progress) - assertEquals("Sending", progress?.label) + assertEquals(Res.string.progress_sending, progress?.label) assertEquals(null, progressForReceiver(events, 7UL, "missing")) } @@ -206,7 +214,7 @@ class AppUiModelsTest { ) val progress = activeSendProgress(events, 7UL, totalSizeHint = 100UL) assertEquals(0.3f, progress?.progress) - assertEquals("Sending", progress?.label) + assertEquals(Res.string.progress_sending, progress?.label) } @Test @@ -216,6 +224,55 @@ class AppUiModelsTest { assertFalse(storedTransfer(status = TransferStatus.Done).canCancelTransfer()) } + @Test + fun unknownProgressEventsUseGenericWorkingLabel() { + // Tracked phase/kind that has no dedicated product copy must not dump tokens. + val progress = progressForTransfer( + listOf(event(id = "x", phase = "lifecycle", kind = "progress", data = "{}")), + 7UL, + ) + assertEquals(Res.string.progress_working, progress?.label) + } + + @Test + fun transferFileCountPicksSingularAndPluralResources() { + assertEquals(Res.string.transfer_file_count_one, transferFileCountResource(1UL)) + assertEquals(Res.string.transfer_file_count_other, transferFileCountResource(2UL)) + assertEquals(Res.string.transfer_file_count_other, transferFileCountResource(0UL)) + } + + @Test + fun progressForReceiverInterruptedUsesInterruptedLabel() { + val events = listOf( + event( + id = "aborted", + phase = "transfer", + kind = "aborted", + data = """{"connection_id":1,"request_id":1,"endpoint_id":"peer-a"}""", + direction = "send", + ), + ) + val progress = progressForReceiver(events, 7UL, "peer-a") + assertEquals(Res.string.progress_interrupted, progress?.label) + assertEquals(null, progress?.progress) + } + + @Test + fun progressForReceiverCompletedWithoutProgressUsesCompletedLabel() { + val events = listOf( + event( + id = "done", + phase = "transfer", + kind = "completed", + data = """{"connection_id":1,"request_id":1,"endpoint_id":"peer-a"}""", + direction = "send", + ), + ) + val progress = progressForReceiver(events, 7UL, "peer-a") + assertEquals(Res.string.progress_completed, progress?.label) + assertEquals(1f, progress?.progress) + } + private fun storedTransfer(status: TransferStatus): Transfer = Transfer( localId = "local-1", diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt index 0f2e2e0..e13b944 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -97,7 +97,7 @@ class FoundationComposeTest { } } onNodeWithText("Notifications").performClick() - onNodeWithText("Let VniDrop notify you about new connection requests while the app is running in the background.").assertIsDisplayed() + onNodeWithText("Get notified about new receive requests while VniDrop is in the background.").assertIsDisplayed() } @Test @@ -375,8 +375,8 @@ class FoundationComposeTest { } } - onNodeWithText("Receive your first file").assertIsDisplayed() - onNodeWithText("Receive files").performClick() + onNodeWithText("Nothing received yet").assertIsDisplayed() + onNodeWithText("Start receiving").performClick() onNodeWithText("How would you like to connect?").assertIsDisplayed() onNodeWithText("Open a .vnd invitation").assertIsDisplayed() }