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.
This commit is contained in:
2026-07-15 03:53:24 +02:00
parent bf81661667
commit 7cd125053c
21 changed files with 562 additions and 245 deletions

View File

@@ -55,8 +55,9 @@ private class AndroidLocalNotificationService(
init { init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = context.getSystemService(NotificationManager::class.java) val manager = context.getSystemService(NotificationManager::class.java)
// Shown under system notification settings for this app.
manager.createNotificationChannel( manager.createNotificationChannel(
NotificationChannel(ChannelId, "Connection requests", NotificationManager.IMPORTANCE_HIGH), NotificationChannel(ChannelId, "Transfer requests", NotificationManager.IMPORTANCE_HIGH),
) )
} }
} }

View File

@@ -3,9 +3,9 @@
<string name="nav_receive">Receive</string> <string name="nav_receive">Receive</string>
<string name="nav_settings">Settings</string> <string name="nav_settings">Settings</string>
<string name="send_title">Send</string> <string name="send_title">Send</string>
<string name="send_subtitle">Files you have made available from this device.</string> <string name="send_subtitle">Transfers youre sharing from this device.</string>
<string name="send_empty_title">Share your first file</string> <string name="send_empty_title">Nothing shared yet</string>
<string name="send_empty_body">Choose a file, decide who can receive it, then share it directly from this device.</string> <string name="send_empty_body">Create a transfer, decide who can receive it, then invite them with a QR code, NFC tag, or invitation file.</string>
<string name="button_create_new_transfer">New transfer</string> <string name="button_create_new_transfer">New transfer</string>
<string name="send_transfers_title">Your transfers</string> <string name="send_transfers_title">Your transfers</string>
<string name="send_new_transfer_title">New transfer</string> <string name="send_new_transfer_title">New transfer</string>
@@ -21,8 +21,8 @@
<string name="send_access_approval">Ask before each download</string> <string name="send_access_approval">Ask before each download</string>
<string name="send_access_approval_description">You approve or refuse every new receiver.</string> <string name="send_access_approval_description">You approve or refuse every new receiver.</string>
<string name="send_access_anyone">Anyone with this transfer</string> <string name="send_access_anyone">Anyone with this transfer</string>
<string name="send_access_anyone_description">No approval is required. Only use this for files you are comfortable sharing.</string> <string name="send_access_anyone_description">No approval is required. Only use this for items you are comfortable sharing.</string>
<string name="send_access_anyone_warning">Anyone who has the ticket can download until you stop the share. Do not use this for private or sensitive files.</string> <string name="send_access_anyone_warning">Anyone with the invitation can download until you stop sharing. Do not use this for private or sensitive items.</string>
<string name="send_file_size_unknown">Size unavailable</string> <string name="send_file_size_unknown">Size unavailable</string>
<string name="send_transfer_created">Transfer created.</string> <string name="send_transfer_created">Transfer created.</string>
<string name="send_transfer_details_title">Transfer details</string> <string name="send_transfer_details_title">Transfer details</string>
@@ -53,8 +53,8 @@
<string name="transfer_nfc_waiting">Hold your device near a writable NFC tag.</string> <string name="transfer_nfc_waiting">Hold your device near a writable NFC tag.</string>
<string name="transfer_invitation_saved">Invitation saved.</string> <string name="transfer_invitation_saved">Invitation saved.</string>
<string name="transfer_nfc_written">Invitation written to the NFC tag.</string> <string name="transfer_nfc_written">Invitation written to the NFC tag.</string>
<string name="transfer_event_preparing">Preparing the selected files</string> <string name="transfer_event_preparing">Preparing your transfer</string>
<string name="transfer_event_ready">Transfer ready to share</string> <string name="transfer_event_ready">Ready to share</string>
<string name="transfer_event_requested">A receiver requested access</string> <string name="transfer_event_requested">A receiver requested access</string>
<string name="transfer_event_approved">Receiver access approved</string> <string name="transfer_event_approved">Receiver access approved</string>
<string name="transfer_event_refused">Receiver access refused</string> <string name="transfer_event_refused">Receiver access refused</string>
@@ -62,34 +62,21 @@
<string name="transfer_event_stopped">Sharing stopped</string> <string name="transfer_event_stopped">Sharing stopped</string>
<string name="transfer_event_failed">The transfer encountered a problem</string> <string name="transfer_event_failed">The transfer encountered a problem</string>
<string name="transfer_event_updated">Transfer updated</string> <string name="transfer_event_updated">Transfer updated</string>
<string name="button_choose_file">Choose file</string> <string name="button_share_file">Start sharing</string>
<string name="button_change_file">Change file</string>
<string name="button_share_file">Share files</string>
<string name="button_sharing_file">Preparing transfer…</string> <string name="button_sharing_file">Preparing transfer…</string>
<string name="button_copy_ticket">Copy transfer link</string>
<string name="send_new_transfer_description">Create a new transfer</string> <string name="send_new_transfer_description">Create a new transfer</string>
<string name="source_title">Source</string>
<string name="send_source_empty">Select a file to start a share. The app keeps bytes in Rust and platform file handles.</string>
<string name="button_select_file">Select file</string>
<string name="button_clear">Clear</string> <string name="button_clear">Clear</string>
<string name="transfer_details_title">Transfer details</string> <string name="transfer_details_title">Transfer details</string>
<string name="field_transfer_name">Transfer name</string> <string name="field_transfer_name">Transfer name</string>
<string name="field_sender_name">Sender name</string> <string name="field_sender_name">Sender name</string>
<string name="button_create_share">Create share</string>
<string name="button_creating_share">Creating share...</string>
<string name="share_details_title">Share details</string>
<string name="receiver_requests_title">Receiver requests</string>
<string name="button_copy">Copy</string>
<string name="button_use_locally">Use locally</string>
<string name="button_refresh">Refresh</string>
<string name="button_refuse">Refuse</string> <string name="button_refuse">Refuse</string>
<string name="button_approve">Approve</string> <string name="button_approve">Approve</string>
<string name="receive_title">Receive</string> <string name="receive_title">Receive</string>
<string name="receive_new_subtitle">Files received directly on this device.</string> <string name="receive_new_subtitle">Transfers youve received on this device.</string>
<string name="receive_empty_title">Receive your first file</string> <string name="receive_empty_title">Nothing received yet</string>
<string name="receive_empty_body">Open a VniDrop invitation, scan its QR code, or read a nearby NFC tag.</string> <string name="receive_empty_body">Open a VniDrop invitation, scan a QR code, or hold near an NFC tag.</string>
<string name="button_receive_files">Receive files</string> <string name="button_receive_files">Start receiving</string>
<string name="receive_history_title">Received files</string> <string name="receive_history_title">History</string>
<string name="receive_clear_history">Clear history</string> <string name="receive_clear_history">Clear history</string>
<string name="receive_delete_history_item">Delete from receive history</string> <string name="receive_delete_history_item">Delete from receive history</string>
<string name="receive_delete_history_title">Remove from history?</string> <string name="receive_delete_history_title">Remove from history?</string>
@@ -109,54 +96,66 @@
<string name="receive_review_title">Review transfer</string> <string name="receive_review_title">Review transfer</string>
<string name="receive_unknown_transfer">VniDrop transfer</string> <string name="receive_unknown_transfer">VniDrop transfer</string>
<string name="receive_completed">Transfer received.</string> <string name="receive_completed">Transfer received.</string>
<string name="receive_subtitle">Inspect a ticket, request access, and stream files into the output directory.</string>
<string name="ticket_card_title">Ticket</string>
<string name="field_ticket">Ticket</string>
<string name="field_output_directory">Output directory</string>
<string name="field_receiver_name">Receiver name</string> <string name="field_receiver_name">Receiver name</string>
<string name="button_inspect_ticket">Inspect ticket</string>
<string name="button_receive">Receive</string> <string name="button_receive">Receive</string>
<string name="button_receiving">Receiving...</string>
<string name="button_retry">Retry</string> <string name="button_retry">Retry</string>
<string name="button_stop_sharing">Stop sharing</string> <string name="button_cancel_receive">Cancel</string>
<string name="button_cancel_receive">Cancel receive</string> <string name="progress_receiving">Receiving</string>
<string name="progress_receiving">Receiving files</string> <string name="progress_preparing">Preparing</string>
<string name="progress_preparing">Preparing transfer</string> <string name="progress_ready">Ready</string>
<string name="progress_share_ready">Ready to share</string>
<string name="progress_connecting">Connecting</string>
<string name="progress_connected">Connected</string>
<string name="progress_requesting_access">Requesting access</string>
<string name="progress_getting_ready">Getting ready</string>
<string name="progress_downloading">Downloading</string>
<string name="progress_saving">Saving</string>
<string name="progress_sending">Sending</string>
<string name="progress_completed">Completed</string>
<string name="progress_cancelled">Cancelled</string>
<string name="progress_failed">Failed</string>
<string name="progress_interrupted">Transfer interrupted</string>
<string name="progress_working">Working…</string>
<string name="status_preparing">Preparing</string>
<string name="status_available">Available</string>
<string name="status_receiving">Receiving</string>
<string name="status_completed">Completed</string>
<string name="status_cancelled">Cancelled</string>
<string name="status_stopped">Stopped</string>
<string name="status_failed">Failed</string>
<string name="transfer_receivers_pending">%1$d waiting</string> <string name="transfer_receivers_pending">%1$d waiting</string>
<string name="transfer_receivers_completed_count">%1$d completed</string> <string name="transfer_receivers_completed_count">%1$d completed</string>
<string name="transfer_receiver_sending">Sending</string> <string name="transfer_event_downloading">Downloading</string>
<string name="transfer_event_downloading">Downloading files</string> <string name="transfer_event_saving">Saving</string>
<string name="transfer_event_saving">Saving files</string>
<string name="transfer_event_connecting">Connecting to sender</string> <string name="transfer_event_connecting">Connecting to sender</string>
<string name="ticket_details_title">Ticket details</string> <string name="transfer_file_count_one">%1$d file</string>
<string name="transfer_file_count_other">%1$d files</string>
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
<string name="settings_subtitle">Configure the local node and app appearance.</string> <string name="settings_subtitle">Your name, where transfers are saved, appearance, and notifications.</string>
<string name="node_title">Node</string>
<string name="appearance_title">Appearance</string> <string name="appearance_title">Appearance</string>
<string name="preferences_title">Preferences</string> <string name="preferences_title">Preferences</string>
<string name="field_username">Username</string> <string name="field_username">Display name</string>
<string name="preferences_receive_folder_title">Receive folder</string> <string name="preferences_receive_folder_title">Save received transfers to</string>
<string name="button_choose_folder">Choose folder</string> <string name="button_choose_folder">Choose folder</string>
<string name="button_reset_default">Reset default</string> <string name="button_reset_default">Use default</string>
<string name="button_back">Back</string> <string name="button_back">Back</string>
<string name="button_close">Close</string> <string name="button_close">Close</string>
<string name="button_cancel">Cancel</string> <string name="button_cancel">Cancel</string>
<string name="button_delete_transfer">Delete transfer</string> <string name="button_delete_transfer">Delete transfer</string>
<string name="folder_status_writable">Writable</string> <string name="folder_status_writable">Ready</string>
<string name="folder_status_permission_required">Permission required</string> <string name="folder_status_permission_required">Permission needed</string>
<string name="folder_status_unavailable">Unavailable</string> <string name="folder_status_unavailable">Unavailable</string>
<string name="folder_status_validating">Checking folder...</string> <string name="folder_status_validating">Checking folder</string>
<string name="appearance_mode_title">Display mode</string>
<string name="appearance_system_mode">System</string> <string name="appearance_system_mode">System</string>
<string name="appearance_dark_mode">Dark mode</string> <string name="appearance_dark_mode">Dark mode</string>
<string name="appearance_light_mode">Light mode</string> <string name="appearance_light_mode">Light mode</string>
<string name="appearance_auto_description">VniDrop follows the theme selected on this device.</string> <string name="appearance_auto_description">Match this devices light or dark appearance.</string>
<string name="about_title">About</string> <string name="about_title">About</string>
<string name="about_privacy">Privacy policy</string> <string name="about_privacy">Privacy policy</string>
<string name="about_bug_report">Report a bug</string> <string name="about_bug_report">Report a bug</string>
<string name="diagnostics_title">Share diagnostics</string> <string name="diagnostics_title">Share diagnostics</string>
<string name="diagnostics_description">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.</string> <string name="diagnostics_description">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.</string>
<string name="diagnostics_enabled_message">Diagnostics sharing is on.</string> <string name="diagnostics_enabled_message">Diagnostics sharing is on.</string>
<string name="diagnostics_disabled_message">Diagnostics sharing is off.</string> <string name="diagnostics_disabled_message">Diagnostics sharing is off.</string>
<string name="bug_report_description">Tell us what went wrong. We attach device info and optional recent logs (with sensitive values redacted).</string> <string name="bug_report_description">Tell us what went wrong. We attach device info and optional recent logs (with sensitive values redacted).</string>
@@ -183,36 +182,35 @@
<string name="value_unavailable">Not available</string> <string name="value_unavailable">Not available</string>
<string name="notifications_title">Notifications</string> <string name="notifications_title">Notifications</string>
<string name="notifications_local_title">Allow notifications</string> <string name="notifications_local_title">Allow notifications</string>
<string name="notifications_description">Let VniDrop notify you about new connection requests while the app is running in the background.</string> <string name="notifications_description">Get notified about new receive requests while VniDrop is in the background.</string>
<string name="notifications_permission_denied">Notifications are turned off for VniDrop. You can enable them in Settings.</string> <string name="notifications_permission_denied">Notifications are turned off for VniDrop. You can enable them in Settings.</string>
<string name="notifications_unsupported">Notifications are not available on this device.</string> <string name="notifications_unsupported">Notifications are not available on this device.</string>
<string name="notifications_enabled_message">Notifications enabled.</string> <string name="notifications_enabled_message">Notifications enabled.</string>
<string name="notifications_settings_open_failed">Could not open notification settings.</string> <string name="notifications_settings_open_failed">Could not open notification settings.</string>
<string name="button_open_settings">Open Settings</string> <string name="button_open_settings">Open Settings</string>
<string name="snackbar_dismiss">Dismiss</string> <string name="snackbar_dismiss">Dismiss</string>
<string name="value_on">On</string> <string name="approval_connection_request">Receive request</string>
<string name="value_off">Off</string> <string name="approval_request_body">%1$s wants to receive “%2$s”.</string>
<string name="approval_connection_request">Connection request</string> <string name="approval_nearby_device">A nearby device</string>
<string name="approval_endpoint_id">Endpoint ID: %1$s</string> <string name="approval_endpoint_id">Device ID: %1$s</string>
<string name="approval_pending_count">%1$d requests are waiting</string> <string name="approval_pending_count">%1$d requests waiting</string>
<string name="core_status_ready">Ready</string>
<string name="event_log_title">Event log</string>
<string name="no_events">No events have been emitted yet.</string>
<string name="progress_title">Progress</string>
<string name="not_initialized">Not initialized</string>
<string name="unknown_sender">Unknown</string>
<string name="metadata_name">Name</string>
<string name="metadata_source">Source</string>
<string name="metadata_transfer">Transfer</string>
<string name="metadata_size">Size</string> <string name="metadata_size">Size</string>
<string name="metadata_kind">Kind</string>
<string name="metadata_sender">Sender</string>
<string name="metadata_files">Files</string> <string name="metadata_files">Files</string>
<string name="metadata_hash">Hash</string>
<string name="metadata_platform">Platform</string>
<string name="metadata_status">Status</string> <string name="metadata_status">Status</string>
<string name="error_invalid_ticket">The ticket could not be read. Check that the full ticket was copied.</string> <string name="error_generic">Something went wrong. Try again.</string>
<string name="error_permission">The transfer is waiting for approval or was refused by the sender.</string> <string name="error_starting_up">VniDrop is still starting. Open the invitation again in a moment.</string>
<string name="error_invalid_ticket">This invitation could not be read. Ask the sender for a new one.</string>
<string name="error_invitation_empty">That invitation is empty. Try opening it again.</string>
<string name="error_permission">The sender has not approved this transfer, or it was refused.</string>
<string name="error_filesystem">VniDrop could not access the selected files or folder. Check permissions and try again.</string>
<string name="error_transfer">The transfer could not be completed. Check your connection and try again.</string>
<string name="error_repository">VniDrop could not save transfer data on this device.</string>
<string name="error_initialization">VniDrop could not finish starting up. Close the app and try again.</string>
<string name="error_socket_bind">VniDrop could not open its network sockets on this device.</string> <string name="error_socket_bind">VniDrop could not open its network sockets on this device.</string>
<string name="error_missing_native_library">The native VniDrop library is missing from this build.</string> <string name="error_missing_native_library">The native VniDrop library is missing from this build.</string>
<string name="error_selection_failed">Could not open the selected item. Try choosing it again.</string>
<string name="error_device_info">Could not load device information.</string>
<string name="error_nfc">This NFC tag could not be used. Try another tag.</string>
<string name="error_camera">Camera access is required to scan a QR code.</string>
<string name="error_share_empty">Select at least one item to share.</string>
</resources> </resources>

View File

@@ -109,7 +109,7 @@ fun App(
if (ready == null) { if (ready == null) {
receiveViewModel.onInvitationResult( receiveViewModel.onInvitationResult(
ReceiveMethod.InvitationFile, 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 return@collect
} }

View File

@@ -20,6 +20,11 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch 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( data class PendingApproval(
val id: String, val id: String,
@@ -137,12 +142,14 @@ class ApprovalCoordinator(
return return
} }
context.pending.filterNot { it.id in publishedNotificationIds }.forEach { request -> 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( notifications.publish(
LocalNotification( LocalNotification(
id = notificationId(request.id), id = notificationId(request.id),
title = "Connection request", title = getString(Res.string.approval_connection_request),
body = "$receiver wants to receive ${request.transferName}", body = getString(Res.string.approval_request_body, receiver, request.transferName),
), ),
).onSuccess { ).onSuccess {
publishedNotificationIds += request.id publishedNotificationIds += request.id

View File

@@ -32,7 +32,9 @@ import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.approval_connection_request import vnidrop.shared.generated.resources.approval_connection_request
import vnidrop.shared.generated.resources.approval_endpoint_id 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_pending_count
import vnidrop.shared.generated.resources.approval_request_body
import vnidrop.shared.generated.resources.button_approve import vnidrop.shared.generated.resources.button_approve
import vnidrop.shared.generated.resources.button_refuse import vnidrop.shared.generated.resources.button_refuse
@@ -44,7 +46,9 @@ fun ApprovalModalHost(
) { ) {
val request = state.current ?: return val request = state.current ?: return
val busy = request.id in state.respondingIds 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 val colors = LocalVniDropColors.current
Dialog( Dialog(
onDismissRequest = {}, onDismissRequest = {},
@@ -75,7 +79,7 @@ fun ApprovalModalHost(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
Text( Text(
"$receiver wants to receive ${request.transferName}.", stringResource(Res.string.approval_request_body, receiver, request.transferName),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = colors.foregroundLight, color = colors.foregroundLight,
) )

View File

@@ -53,6 +53,7 @@ import com.vnidrop.app.ui.components.Field
import com.vnidrop.app.ui.components.PrimaryButton import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.ProgressRow import com.vnidrop.app.ui.components.ProgressRow
import com.vnidrop.app.ui.components.SecondaryButton 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.WindowClass
import com.vnidrop.app.ui.state.displayNameForStatus import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes import com.vnidrop.app.ui.state.formatBytes
@@ -270,7 +271,7 @@ private fun InvitationReviewPanel(
?: events.firstOrNull { it.direction == "receive" && it.transferId != null }?.transferId ?: events.firstOrNull { it.direction == "receive" && it.transferId != null }?.transferId
val progress = progressId?.let { progressForTransfer(events, it) } val progress = progressId?.let { progressForTransfer(events, it) }
ProgressRow( ProgressRow(
label = progress?.label ?: stringResource(Res.string.progress_receiving), label = progress?.label ?: Res.string.progress_receiving,
progress = progress?.progress, progress = progress?.progress,
detail = progress?.detail, detail = progress?.detail,
) )
@@ -288,7 +289,14 @@ private fun InvitationReviewPanel(
) )
} }
state.lastReceiveError?.let { error -> 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,
)
} }
} }
} }

View File

@@ -16,6 +16,8 @@ import com.vnidrop.app.ui.feedback.UiMessage
import com.vnidrop.app.ui.feedback.UiMessageController import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.feedback.UiMessageTone import com.vnidrop.app.ui.feedback.UiMessageTone
import com.vnidrop.app.ui.feedback.UiText 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -23,6 +25,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_retry 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_completed
import vnidrop.shared.generated.resources.receive_history_cleared import vnidrop.shared.generated.resources.receive_history_cleared
import vnidrop.shared.generated.resources.transfer_deleted import vnidrop.shared.generated.resources.transfer_deleted
@@ -43,7 +46,7 @@ data class ReceiveState(
val isInspecting: Boolean = false, val isInspecting: Boolean = false,
val isReceiving: Boolean = false, val isReceiving: Boolean = false,
val activeReceiveTransferId: ULong? = null, val activeReceiveTransferId: ULong? = null,
val lastReceiveError: String? = null, val lastReceiveError: UiText? = null,
val isWaitingForNfc: Boolean = false, val isWaitingForNfc: Boolean = false,
val historyDeleteTarget: ReceiveHistoryDeleteTarget? = null, val historyDeleteTarget: ReceiveHistoryDeleteTarget? = null,
val isDeletingHistory: Boolean = false, val isDeletingHistory: Boolean = false,
@@ -170,17 +173,27 @@ class ReceiveViewModel(
messages.tryShow(UiMessage(UiText.Resource(Res.string.receive_completed), UiMessageTone.Success)) messages.tryShow(UiMessage(UiText.Resource(Res.string.receive_completed), UiMessageTone.Success))
}, },
onFailure = { error -> 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 { _state.update {
it.copy( it.copy(
isReceiving = false, isReceiving = false,
activeReceiveTransferId = null, activeReceiveTransferId = null,
lastReceiveError = message, lastReceiveError = uiText,
) )
} }
messages.tryShow( messages.tryShow(
UiMessage( UiMessage(
text = UiText.Dynamic(message), text = uiText,
tone = UiMessageTone.Error, tone = UiMessageTone.Error,
actionLabel = UiText.Resource(Res.string.button_retry), actionLabel = UiText.Resource(Res.string.button_retry),
onAction = { receive() }, onAction = { receive() },
@@ -215,7 +228,7 @@ class ReceiveViewModel(
private fun inspectInvitation(method: ReceiveMethod, raw: String) { private fun inspectInvitation(method: ReceiveMethod, raw: String) {
val ticket = raw.trim() 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 { viewModelScope.launch {
_state.update { _state.update {
it.copy( it.copy(

View File

@@ -25,6 +25,7 @@ import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.send_transfer_created import vnidrop.shared.generated.resources.send_transfer_created
import vnidrop.shared.generated.resources.transfer_deleted import vnidrop.shared.generated.resources.transfer_deleted
import vnidrop.shared.generated.resources.transfer_invitation_saved
import vnidrop.shared.generated.resources.transfer_nfc_written import vnidrop.shared.generated.resources.transfer_nfc_written
data class SendState( 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() { fun clearSelectedSource() {
_state.update { it.copy(selectedFiles = emptyList(), transferName = "") } _state.update { it.copy(selectedFiles = emptyList(), transferName = "") }
@@ -227,8 +228,9 @@ class SendViewModel(
result.fold( result.fold(
onSuccess = { onSuccess = {
val message = when (action) { val message = when (action) {
InvitationAction.Export -> null InvitationAction.Export -> Res.string.transfer_invitation_saved
InvitationAction.Nfc -> Res.string.transfer_nfc_written InvitationAction.Nfc -> Res.string.transfer_nfc_written
// System share sheet already confirms the action on most platforms.
InvitationAction.Share -> null InvitationAction.Share -> null
} }
message?.let { messages.tryShow(UiMessage(UiText.Resource(it), UiMessageTone.Success)) } message?.let { messages.tryShow(UiMessage(UiText.Resource(it), UiMessageTone.Success)) }

View File

@@ -198,7 +198,7 @@ private fun ReceiverRow(receiver: ReceiverRequestModel, sendProgress: TransferPr
} }
if (showLiveSend) { if (showLiveSend) {
ProgressRow( ProgressRow(
label = stringResource(Res.string.transfer_receiver_sending), label = sendProgress.label,
progress = sendProgress.progress, progress = sendProgress.progress,
detail = sendProgress.detail, detail = sendProgress.detail,
) )

View File

@@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector 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 com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res 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_dark_mode
import vnidrop.shared.generated.resources.appearance_light_mode import vnidrop.shared.generated.resources.appearance_light_mode
import vnidrop.shared.generated.resources.appearance_system_mode import vnidrop.shared.generated.resources.appearance_system_mode
@@ -39,6 +42,13 @@ internal fun AppearanceSettings(
onModeChanged(ThemeMode.Light) onModeChanged(ThemeMode.Light)
} }
} }
if (mode == ThemeMode.System) {
Text(
stringResource(Res.string.appearance_auto_description),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodySmall,
)
}
} }
} }

View File

@@ -9,6 +9,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.ThemeMode
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import com.vnidrop.app.ui.theme.LocalVniDropColors
import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.about_title import vnidrop.shared.generated.resources.about_title
import vnidrop.shared.generated.resources.appearance_dark_mode 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.appearance_title
import vnidrop.shared.generated.resources.notifications_title import vnidrop.shared.generated.resources.notifications_title
import vnidrop.shared.generated.resources.preferences_title import vnidrop.shared.generated.resources.preferences_title
import vnidrop.shared.generated.resources.settings_subtitle
import vnidrop.shared.generated.resources.settings_title import vnidrop.shared.generated.resources.settings_title
@Composable @Composable
@@ -26,11 +28,18 @@ internal fun SettingsOverview(
largeTitle: Boolean, largeTitle: Boolean,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text( Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
stringResource(Res.string.settings_title), Text(
style = if (largeTitle) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium, stringResource(Res.string.settings_title),
fontWeight = FontWeight.Bold, 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 { SettingsGroup {
SettingsRow( SettingsRow(
icon = SettingsIcons.Device, icon = SettingsIcons.Device,

View File

@@ -319,7 +319,13 @@ class SettingsViewModel(
} catch (error: Throwable) { } catch (error: Throwable) {
if (error is CancellationException) throw error if (error is CancellationException) throw error
_state.update { it.copy(isLoadingDeviceInfo = false) } _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
},
)
} }
} }
} }

View File

@@ -13,14 +13,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
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.StringResource
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun ProgressRow( fun ProgressRow(
label: String, label: StringResource,
progress: Float?, progress: Float?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
detail: String? = null, detail: String? = null,
) { ) {
val labelText = stringResource(label)
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row( Row(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
@@ -28,7 +31,7 @@ fun ProgressRow(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
label, labelText,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -55,15 +58,3 @@ fun ProgressRow(
else LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth()) 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)
}
}

View File

@@ -1,5 +1,6 @@
package com.vnidrop.app.ui.feedback package com.vnidrop.app.ui.feedback
import com.vnidrop.app.logging.AppLogger
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@@ -43,12 +44,29 @@ class UiMessageController {
_dismissals.tryEmit(Unit) _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( tryShow(
UiMessage( UiMessage(
text = UiText.Dynamic(error.message?.takeIf(String::isNotBlank) ?: fallback), text = error.toUiText(),
tone = UiMessageTone.Error, tone = UiMessageTone.Error,
), ),
) )
} }
fun error(text: UiText) {
tryShow(UiMessage(text = text, tone = UiMessageTone.Error))
}
} }

View File

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

View File

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

View File

@@ -1,9 +1,37 @@
package com.vnidrop.app.ui.state package com.vnidrop.app.ui.state
import androidx.compose.runtime.Composable
import com.vnidrop.app.core.CoreEventModel import com.vnidrop.app.core.CoreEventModel
import com.vnidrop.app.core.Transfer import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferStatus import com.vnidrop.app.core.TransferStatus
import kotlin.math.roundToInt 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 { enum class WindowClass {
Phone, Phone,
@@ -25,22 +53,26 @@ data class TransferProgress(
val transferId: ULong?, val transferId: ULong?,
val phase: String, val phase: String,
val kind: String, val kind: String,
val label: String, val label: StringResource,
val progress: Float?, val progress: Float?,
val detail: String? = null, val detail: String? = null,
) )
fun displayNameForStatus(status: TransferStatus): String = fun statusLabelResource(status: TransferStatus): StringResource =
when (status) { when (status) {
TransferStatus.Importing -> "Preparing" TransferStatus.Importing -> Res.string.status_preparing
TransferStatus.Sharing -> "Available" TransferStatus.Sharing -> Res.string.status_available
TransferStatus.Receiving -> "Receiving" TransferStatus.Receiving -> Res.string.status_receiving
TransferStatus.Done -> "Completed" TransferStatus.Done -> Res.string.status_completed
TransferStatus.Cancelled -> "Cancelled" TransferStatus.Cancelled -> Res.string.status_cancelled
TransferStatus.Stopped -> "Stopped" TransferStatus.Stopped -> Res.string.status_stopped
TransferStatus.Failed -> "Failed" TransferStatus.Failed -> Res.string.status_failed
} }
@Composable
fun displayNameForStatus(status: TransferStatus): String =
stringResource(statusLabelResource(status))
fun Transfer.isActiveTransfer(): Boolean = fun Transfer.isActiveTransfer(): Boolean =
status in activeTransferStatuses status in activeTransferStatuses
@@ -101,7 +133,7 @@ fun progressForReceiver(
transferId = transferId, transferId = transferId,
phase = "transfer", phase = "transfer",
kind = "aborted", kind = "aborted",
label = "Send interrupted", label = Res.string.progress_interrupted,
progress = null, progress = null,
detail = null, detail = null,
) )
@@ -111,7 +143,7 @@ fun progressForReceiver(
transferId = transferId, transferId = transferId,
phase = "transfer", phase = "transfer",
kind = "completed", kind = "completed",
label = "Send completed", label = Res.string.progress_completed,
progress = 1f, progress = 1f,
detail = null, detail = null,
) )
@@ -122,7 +154,7 @@ fun progressForReceiver(
transferId = transferId, transferId = transferId,
phase = "transfer", phase = "transfer",
kind = latest.kind, kind = latest.kind,
label = "Sending", label = Res.string.progress_sending,
progress = progress, progress = progress,
detail = progressDetail(latest), detail = progressDetail(latest),
) )
@@ -156,7 +188,7 @@ fun activeSendProgress(
transferId = transferId, transferId = transferId,
phase = "transfer", phase = "transfer",
kind = relevant.first().kind, kind = relevant.first().kind,
label = "Sending to receiver", label = Res.string.progress_sending,
progress = aggregateReceiverProgress(relevant, totalSizeHint), progress = aggregateReceiverProgress(relevant, totalSizeHint),
detail = progressDetail(relevant.first()), detail = progressDetail(relevant.first()),
) )
@@ -174,14 +206,9 @@ fun summarizeProgress(events: List<CoreEventModel>): List<TransferProgress> =
.take(6) .take(6)
.mapNotNull { progressForTransfer(events, it) } .mapNotNull { progressForTransfer(events, it) }
fun transferSubtitle(transfer: Transfer): String { /** File-count string resource for transfer subtitles (resolve with [stringResource]). */
val pieces = listOfNotNull( fun transferFileCountResource(fileCount: ULong): StringResource =
transfer.transferName, if (fileCount == 1UL) Res.string.transfer_file_count_one else Res.string.transfer_file_count_other
"${transfer.fileCount} file${if (transfer.fileCount == 1UL) "" else "s"}",
formatBytes(transfer.totalSize),
)
return pieces.joinToString(" | ")
}
fun formatBytes(size: ULong): String { fun formatBytes(size: ULong): String {
val value = size.toDouble() val value = size.toDouble()
@@ -217,29 +244,26 @@ private val activeTransferStatuses = setOf(
TransferStatus.Receiving, TransferStatus.Receiving,
) )
private fun humanProgressLabel(event: CoreEventModel): String = when { private fun humanProgressLabel(event: CoreEventModel): StringResource = when {
event.phase == "import" && event.kind == "copy-progress" -> "Preparing files" event.phase == "import" && event.kind == "copy-progress" -> Res.string.progress_preparing
event.phase == "import" && event.kind == "outboard-progress" -> "Indexing files" event.phase == "import" && event.kind == "outboard-progress" -> Res.string.progress_preparing
event.phase == "import" && event.kind == "started" -> "Preparing transfer" event.phase == "import" && event.kind == "started" -> Res.string.progress_preparing
event.phase == "import" && event.kind == "done" -> "Files ready" event.phase == "import" && event.kind == "done" -> Res.string.progress_ready
event.phase == "ticket" && event.kind == "created" -> "Share ready" event.phase == "ticket" && event.kind == "created" -> Res.string.progress_share_ready
event.phase == "network" && event.kind == "connecting" -> "Connecting to sender" event.phase == "network" && event.kind == "connecting" -> Res.string.progress_connecting
event.phase == "network" && event.kind == "connected" -> "Connected" event.phase == "network" && event.kind == "connected" -> Res.string.progress_connected
event.phase == "handshake" -> "Requesting access" event.phase == "handshake" -> Res.string.progress_requesting_access
event.phase == "download" && event.kind == "found-collection" -> "Found files" event.phase == "download" && event.kind == "found-collection" -> Res.string.progress_getting_ready
event.phase == "download" && event.kind == "progress" -> "Downloading" event.phase == "download" && event.kind == "progress" -> Res.string.progress_downloading
event.phase == "export" && event.kind == "progress" -> "Saving files" event.phase == "export" && event.kind == "progress" -> Res.string.progress_saving
event.phase == "transfer" && event.kind == "progress" -> "Sending to receiver" event.phase == "transfer" && event.kind == "progress" -> Res.string.progress_sending
event.phase == "transfer" && event.kind == "started" -> "Receiver connected" event.phase == "transfer" && event.kind == "started" -> Res.string.progress_connected
event.phase == "transfer" && event.kind == "completed" -> "Send completed" event.phase == "transfer" && event.kind == "completed" -> Res.string.progress_completed
event.phase == "lifecycle" && event.kind == "done" -> "Completed" event.phase == "lifecycle" && event.kind == "done" -> Res.string.progress_completed
event.phase == "lifecycle" && event.kind == "cancelled" -> "Cancelled" event.phase == "lifecycle" && event.kind == "cancelled" -> Res.string.progress_cancelled
event.kind == "failed" -> "Failed" event.kind == "failed" -> Res.string.progress_failed
else -> listOfNotNull( // Never dump raw phase/kind tokens into the UI.
event.direction?.replaceFirstChar { it.uppercase() }, else -> Res.string.progress_working
event.phase.replaceFirstChar { it.uppercase() },
event.kind.replace('-', ' '),
).joinToString(" · ")
} }
private fun progressDetail(event: CoreEventModel): String? { private fun progressDetail(event: CoreEventModel): String? {

View File

@@ -30,6 +30,7 @@ import com.vnidrop.app.support.FakeFilePreviewRepository
import com.vnidrop.app.support.FakeNotificationService import com.vnidrop.app.support.FakeNotificationService
import com.vnidrop.app.support.FakePreferencesRepository import com.vnidrop.app.support.FakePreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessageController 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.navigation.AppDestination
import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -45,6 +46,8 @@ import kotlin.test.assertEquals
import kotlin.test.assertContentEquals import kotlin.test.assertContentEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.error_permission
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class ViewModelsTest { class ViewModelsTest {
@@ -431,7 +434,10 @@ class ViewModelsTest {
assertEquals("ticket-xyz", viewModel.state.value.ticket) assertEquals("ticket-xyz", viewModel.state.value.ticket)
assertFalse(viewModel.state.value.isReceiving) assertFalse(viewModel.state.value.isReceiving)
assertTrue(viewModel.state.value.inspection != null) 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 @Test

View File

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

View File

@@ -14,6 +14,14 @@ import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertTrue 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 { class AppUiModelsTest {
@Test @Test
@@ -102,7 +110,7 @@ class AppUiModelsTest {
) )
val progress = progressForTransfer(events, 7UL) val progress = progressForTransfer(events, 7UL)
assertEquals(0.75f, progress?.progress) assertEquals(0.75f, progress?.progress)
assertEquals("Saving files", progress?.label) assertEquals(Res.string.progress_saving, progress?.label)
assertEquals("a.bin", progress?.detail) assertEquals("a.bin", progress?.detail)
} }
@@ -150,7 +158,7 @@ class AppUiModelsTest {
// blob1 complete 50 + blob2 40 = 90 / total hint 100 // blob1 complete 50 + blob2 40 = 90 / total hint 100
val progress = progressForReceiver(events, 7UL, "peer-a", totalSizeHint = 100UL) val progress = progressForReceiver(events, 7UL, "peer-a", totalSizeHint = 100UL)
assertEquals(0.9f, progress?.progress) assertEquals(0.9f, progress?.progress)
assertEquals("Sending", progress?.label) assertEquals(Res.string.progress_sending, progress?.label)
assertEquals(null, progressForReceiver(events, 7UL, "missing")) assertEquals(null, progressForReceiver(events, 7UL, "missing"))
} }
@@ -206,7 +214,7 @@ class AppUiModelsTest {
) )
val progress = activeSendProgress(events, 7UL, totalSizeHint = 100UL) val progress = activeSendProgress(events, 7UL, totalSizeHint = 100UL)
assertEquals(0.3f, progress?.progress) assertEquals(0.3f, progress?.progress)
assertEquals("Sending", progress?.label) assertEquals(Res.string.progress_sending, progress?.label)
} }
@Test @Test
@@ -216,6 +224,55 @@ class AppUiModelsTest {
assertFalse(storedTransfer(status = TransferStatus.Done).canCancelTransfer()) 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 = private fun storedTransfer(status: TransferStatus): Transfer =
Transfer( Transfer(
localId = "local-1", localId = "local-1",

View File

@@ -97,7 +97,7 @@ class FoundationComposeTest {
} }
} }
onNodeWithText("Notifications").performClick() 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 @Test
@@ -375,8 +375,8 @@ class FoundationComposeTest {
} }
} }
onNodeWithText("Receive your first file").assertIsDisplayed() onNodeWithText("Nothing received yet").assertIsDisplayed()
onNodeWithText("Receive files").performClick() onNodeWithText("Start receiving").performClick()
onNodeWithText("How would you like to connect?").assertIsDisplayed() onNodeWithText("How would you like to connect?").assertIsDisplayed()
onNodeWithText("Open a .vnd invitation").assertIsDisplayed() onNodeWithText("Open a .vnd invitation").assertIsDisplayed()
} }