mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
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:
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 you’re 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 you’ve 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 device’s 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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)) }
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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? {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user