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:
@@ -3,9 +3,9 @@
|
||||
<string name="nav_receive">Receive</string>
|
||||
<string name="nav_settings">Settings</string>
|
||||
<string name="send_title">Send</string>
|
||||
<string name="send_subtitle">Files you have made available from this device.</string>
|
||||
<string name="send_empty_title">Share your first file</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_subtitle">Transfers you’re sharing from this device.</string>
|
||||
<string name="send_empty_title">Nothing shared yet</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="send_transfers_title">Your transfers</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_description">You approve or refuse every new receiver.</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_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_description">No approval is required. Only use this for items you are comfortable sharing.</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_transfer_created">Transfer created.</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_invitation_saved">Invitation saved.</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_ready">Transfer ready to share</string>
|
||||
<string name="transfer_event_preparing">Preparing your transfer</string>
|
||||
<string name="transfer_event_ready">Ready to share</string>
|
||||
<string name="transfer_event_requested">A receiver requested access</string>
|
||||
<string name="transfer_event_approved">Receiver access approved</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_failed">The transfer encountered a problem</string>
|
||||
<string name="transfer_event_updated">Transfer updated</string>
|
||||
<string name="button_choose_file">Choose file</string>
|
||||
<string name="button_change_file">Change file</string>
|
||||
<string name="button_share_file">Share files</string>
|
||||
<string name="button_share_file">Start sharing</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="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="transfer_details_title">Transfer details</string>
|
||||
<string name="field_transfer_name">Transfer 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_approve">Approve</string>
|
||||
<string name="receive_title">Receive</string>
|
||||
<string name="receive_new_subtitle">Files received directly on this device.</string>
|
||||
<string name="receive_empty_title">Receive your first file</string>
|
||||
<string name="receive_empty_body">Open a VniDrop invitation, scan its QR code, or read a nearby NFC tag.</string>
|
||||
<string name="button_receive_files">Receive files</string>
|
||||
<string name="receive_history_title">Received files</string>
|
||||
<string name="receive_new_subtitle">Transfers you’ve received on this device.</string>
|
||||
<string name="receive_empty_title">Nothing received yet</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">Start receiving</string>
|
||||
<string name="receive_history_title">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_title">Remove from history?</string>
|
||||
@@ -109,54 +96,66 @@
|
||||
<string name="receive_review_title">Review transfer</string>
|
||||
<string name="receive_unknown_transfer">VniDrop transfer</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="button_inspect_ticket">Inspect ticket</string>
|
||||
<string name="button_receive">Receive</string>
|
||||
<string name="button_receiving">Receiving...</string>
|
||||
<string name="button_retry">Retry</string>
|
||||
<string name="button_stop_sharing">Stop sharing</string>
|
||||
<string name="button_cancel_receive">Cancel receive</string>
|
||||
<string name="progress_receiving">Receiving files</string>
|
||||
<string name="progress_preparing">Preparing transfer</string>
|
||||
<string name="button_cancel_receive">Cancel</string>
|
||||
<string name="progress_receiving">Receiving</string>
|
||||
<string name="progress_preparing">Preparing</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_completed_count">%1$d completed</string>
|
||||
<string name="transfer_receiver_sending">Sending</string>
|
||||
<string name="transfer_event_downloading">Downloading files</string>
|
||||
<string name="transfer_event_saving">Saving files</string>
|
||||
<string name="transfer_event_downloading">Downloading</string>
|
||||
<string name="transfer_event_saving">Saving</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_subtitle">Configure the local node and app appearance.</string>
|
||||
<string name="node_title">Node</string>
|
||||
<string name="settings_subtitle">Your name, where transfers are saved, appearance, and notifications.</string>
|
||||
<string name="appearance_title">Appearance</string>
|
||||
<string name="preferences_title">Preferences</string>
|
||||
<string name="field_username">Username</string>
|
||||
<string name="preferences_receive_folder_title">Receive folder</string>
|
||||
<string name="field_username">Display name</string>
|
||||
<string name="preferences_receive_folder_title">Save received transfers to</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_close">Close</string>
|
||||
<string name="button_cancel">Cancel</string>
|
||||
<string name="button_delete_transfer">Delete transfer</string>
|
||||
<string name="folder_status_writable">Writable</string>
|
||||
<string name="folder_status_permission_required">Permission required</string>
|
||||
<string name="folder_status_writable">Ready</string>
|
||||
<string name="folder_status_permission_required">Permission needed</string>
|
||||
<string name="folder_status_unavailable">Unavailable</string>
|
||||
<string name="folder_status_validating">Checking folder...</string>
|
||||
<string name="appearance_mode_title">Display mode</string>
|
||||
<string name="folder_status_validating">Checking folder…</string>
|
||||
<string name="appearance_system_mode">System</string>
|
||||
<string name="appearance_dark_mode">Dark 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_privacy">Privacy policy</string>
|
||||
<string name="about_bug_report">Report a bug</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_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>
|
||||
@@ -183,36 +182,35 @@
|
||||
<string name="value_unavailable">Not available</string>
|
||||
<string name="notifications_title">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_unsupported">Notifications are not available on this device.</string>
|
||||
<string name="notifications_enabled_message">Notifications enabled.</string>
|
||||
<string name="notifications_settings_open_failed">Could not open notification settings.</string>
|
||||
<string name="button_open_settings">Open Settings</string>
|
||||
<string name="snackbar_dismiss">Dismiss</string>
|
||||
<string name="value_on">On</string>
|
||||
<string name="value_off">Off</string>
|
||||
<string name="approval_connection_request">Connection request</string>
|
||||
<string name="approval_endpoint_id">Endpoint ID: %1$s</string>
|
||||
<string name="approval_pending_count">%1$d requests are 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="approval_connection_request">Receive request</string>
|
||||
<string name="approval_request_body">%1$s wants to receive “%2$s”.</string>
|
||||
<string name="approval_nearby_device">A nearby device</string>
|
||||
<string name="approval_endpoint_id">Device ID: %1$s</string>
|
||||
<string name="approval_pending_count">%1$d requests waiting</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_hash">Hash</string>
|
||||
<string name="metadata_platform">Platform</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_permission">The transfer is waiting for approval or was refused by the sender.</string>
|
||||
<string name="error_generic">Something went wrong. Try again.</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_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>
|
||||
|
||||
@@ -109,7 +109,7 @@ fun App(
|
||||
if (ready == null) {
|
||||
receiveViewModel.onInvitationResult(
|
||||
ReceiveMethod.InvitationFile,
|
||||
Result.failure(IllegalStateException("VniDrop is still starting up. Open the invitation again in a moment.")),
|
||||
Result.failure(IllegalStateException("VniDrop is still starting up")),
|
||||
)
|
||||
return@collect
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.approval_connection_request
|
||||
import vnidrop.shared.generated.resources.approval_nearby_device
|
||||
import vnidrop.shared.generated.resources.approval_request_body
|
||||
|
||||
data class PendingApproval(
|
||||
val id: String,
|
||||
@@ -137,12 +142,14 @@ class ApprovalCoordinator(
|
||||
return
|
||||
}
|
||||
context.pending.filterNot { it.id in publishedNotificationIds }.forEach { request ->
|
||||
val receiver = request.receiverName ?: request.receiverDeviceName ?: "A nearby device"
|
||||
val receiver = request.receiverName
|
||||
?: request.receiverDeviceName
|
||||
?: getString(Res.string.approval_nearby_device)
|
||||
notifications.publish(
|
||||
LocalNotification(
|
||||
id = notificationId(request.id),
|
||||
title = "Connection request",
|
||||
body = "$receiver wants to receive ${request.transferName}",
|
||||
title = getString(Res.string.approval_connection_request),
|
||||
body = getString(Res.string.approval_request_body, receiver, request.transferName),
|
||||
),
|
||||
).onSuccess {
|
||||
publishedNotificationIds += request.id
|
||||
|
||||
@@ -32,7 +32,9 @@ import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.approval_connection_request
|
||||
import vnidrop.shared.generated.resources.approval_endpoint_id
|
||||
import vnidrop.shared.generated.resources.approval_nearby_device
|
||||
import vnidrop.shared.generated.resources.approval_pending_count
|
||||
import vnidrop.shared.generated.resources.approval_request_body
|
||||
import vnidrop.shared.generated.resources.button_approve
|
||||
import vnidrop.shared.generated.resources.button_refuse
|
||||
|
||||
@@ -44,7 +46,9 @@ fun ApprovalModalHost(
|
||||
) {
|
||||
val request = state.current ?: return
|
||||
val busy = request.id in state.respondingIds
|
||||
val receiver = request.receiverName ?: request.receiverDeviceName ?: "A nearby device"
|
||||
val receiver = request.receiverName
|
||||
?: request.receiverDeviceName
|
||||
?: stringResource(Res.string.approval_nearby_device)
|
||||
val colors = LocalVniDropColors.current
|
||||
Dialog(
|
||||
onDismissRequest = {},
|
||||
@@ -75,7 +79,7 @@ fun ApprovalModalHost(
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
"$receiver wants to receive ${request.transferName}.",
|
||||
stringResource(Res.string.approval_request_body, receiver, request.transferName),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = colors.foregroundLight,
|
||||
)
|
||||
|
||||
@@ -53,6 +53,7 @@ import com.vnidrop.app.ui.components.Field
|
||||
import com.vnidrop.app.ui.components.PrimaryButton
|
||||
import com.vnidrop.app.ui.components.ProgressRow
|
||||
import com.vnidrop.app.ui.components.SecondaryButton
|
||||
import com.vnidrop.app.ui.feedback.UiText
|
||||
import com.vnidrop.app.ui.state.WindowClass
|
||||
import com.vnidrop.app.ui.state.displayNameForStatus
|
||||
import com.vnidrop.app.ui.state.formatBytes
|
||||
@@ -270,7 +271,7 @@ private fun InvitationReviewPanel(
|
||||
?: events.firstOrNull { it.direction == "receive" && it.transferId != null }?.transferId
|
||||
val progress = progressId?.let { progressForTransfer(events, it) }
|
||||
ProgressRow(
|
||||
label = progress?.label ?: stringResource(Res.string.progress_receiving),
|
||||
label = progress?.label ?: Res.string.progress_receiving,
|
||||
progress = progress?.progress,
|
||||
detail = progress?.detail,
|
||||
)
|
||||
@@ -288,7 +289,14 @@ private fun InvitationReviewPanel(
|
||||
)
|
||||
}
|
||||
state.lastReceiveError?.let { error ->
|
||||
Text(error, color = LocalVniDropColors.current.destructiveDefault, style = MaterialTheme.typography.bodySmall)
|
||||
Text(
|
||||
when (error) {
|
||||
is UiText.Dynamic -> error.value
|
||||
is UiText.Resource -> stringResource(error.resource)
|
||||
},
|
||||
color = LocalVniDropColors.current.destructiveDefault,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import com.vnidrop.app.ui.feedback.UiMessage
|
||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||
import com.vnidrop.app.ui.feedback.UiMessageTone
|
||||
import com.vnidrop.app.ui.feedback.UiText
|
||||
import com.vnidrop.app.ui.feedback.isUserCancellation
|
||||
import com.vnidrop.app.ui.feedback.toUiText
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -23,6 +25,7 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.button_retry
|
||||
import vnidrop.shared.generated.resources.error_invitation_empty
|
||||
import vnidrop.shared.generated.resources.receive_completed
|
||||
import vnidrop.shared.generated.resources.receive_history_cleared
|
||||
import vnidrop.shared.generated.resources.transfer_deleted
|
||||
@@ -43,7 +46,7 @@ data class ReceiveState(
|
||||
val isInspecting: Boolean = false,
|
||||
val isReceiving: Boolean = false,
|
||||
val activeReceiveTransferId: ULong? = null,
|
||||
val lastReceiveError: String? = null,
|
||||
val lastReceiveError: UiText? = null,
|
||||
val isWaitingForNfc: Boolean = false,
|
||||
val historyDeleteTarget: ReceiveHistoryDeleteTarget? = null,
|
||||
val isDeletingHistory: Boolean = false,
|
||||
@@ -170,17 +173,27 @@ class ReceiveViewModel(
|
||||
messages.tryShow(UiMessage(UiText.Resource(Res.string.receive_completed), UiMessageTone.Success))
|
||||
},
|
||||
onFailure = { error ->
|
||||
val message = error.message?.takeIf(String::isNotBlank) ?: "Something went wrong."
|
||||
if (error.isUserCancellation()) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isReceiving = false,
|
||||
activeReceiveTransferId = null,
|
||||
lastReceiveError = null,
|
||||
)
|
||||
}
|
||||
return@fold
|
||||
}
|
||||
val uiText = error.toUiText()
|
||||
_state.update {
|
||||
it.copy(
|
||||
isReceiving = false,
|
||||
activeReceiveTransferId = null,
|
||||
lastReceiveError = message,
|
||||
lastReceiveError = uiText,
|
||||
)
|
||||
}
|
||||
messages.tryShow(
|
||||
UiMessage(
|
||||
text = UiText.Dynamic(message),
|
||||
text = uiText,
|
||||
tone = UiMessageTone.Error,
|
||||
actionLabel = UiText.Resource(Res.string.button_retry),
|
||||
onAction = { receive() },
|
||||
@@ -215,7 +228,7 @@ class ReceiveViewModel(
|
||||
|
||||
private fun inspectInvitation(method: ReceiveMethod, raw: String) {
|
||||
val ticket = raw.trim()
|
||||
if (ticket.isBlank()) return messages.error(IllegalArgumentException("The invitation is empty"))
|
||||
if (ticket.isBlank()) return messages.error(UiText.Resource(Res.string.error_invitation_empty))
|
||||
viewModelScope.launch {
|
||||
_state.update {
|
||||
it.copy(
|
||||
|
||||
@@ -25,6 +25,7 @@ import kotlinx.coroutines.launch
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.send_transfer_created
|
||||
import vnidrop.shared.generated.resources.transfer_deleted
|
||||
import vnidrop.shared.generated.resources.transfer_invitation_saved
|
||||
import vnidrop.shared.generated.resources.transfer_nfc_written
|
||||
|
||||
data class SendState(
|
||||
@@ -149,7 +150,7 @@ class SendViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason))
|
||||
fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason.takeIf(String::isNotBlank) ?: "selection failed"))
|
||||
|
||||
fun clearSelectedSource() {
|
||||
_state.update { it.copy(selectedFiles = emptyList(), transferName = "") }
|
||||
@@ -227,8 +228,9 @@ class SendViewModel(
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
val message = when (action) {
|
||||
InvitationAction.Export -> null
|
||||
InvitationAction.Export -> Res.string.transfer_invitation_saved
|
||||
InvitationAction.Nfc -> Res.string.transfer_nfc_written
|
||||
// System share sheet already confirms the action on most platforms.
|
||||
InvitationAction.Share -> null
|
||||
}
|
||||
message?.let { messages.tryShow(UiMessage(UiText.Resource(it), UiMessageTone.Success)) }
|
||||
|
||||
@@ -198,7 +198,7 @@ private fun ReceiverRow(receiver: ReceiverRequestModel, sendProgress: TransferPr
|
||||
}
|
||||
if (showLiveSend) {
|
||||
ProgressRow(
|
||||
label = stringResource(Res.string.transfer_receiver_sending),
|
||||
label = sendProgress.label,
|
||||
progress = sendProgress.progress,
|
||||
detail = sendProgress.detail,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
@@ -12,6 +14,7 @@ import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.appearance_auto_description
|
||||
import vnidrop.shared.generated.resources.appearance_dark_mode
|
||||
import vnidrop.shared.generated.resources.appearance_light_mode
|
||||
import vnidrop.shared.generated.resources.appearance_system_mode
|
||||
@@ -39,6 +42,13 @@ internal fun AppearanceSettings(
|
||||
onModeChanged(ThemeMode.Light)
|
||||
}
|
||||
}
|
||||
if (mode == ThemeMode.System) {
|
||||
Text(
|
||||
stringResource(Res.string.appearance_auto_description),
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.about_title
|
||||
import vnidrop.shared.generated.resources.appearance_dark_mode
|
||||
@@ -17,6 +18,7 @@ import vnidrop.shared.generated.resources.appearance_system_mode
|
||||
import vnidrop.shared.generated.resources.appearance_title
|
||||
import vnidrop.shared.generated.resources.notifications_title
|
||||
import vnidrop.shared.generated.resources.preferences_title
|
||||
import vnidrop.shared.generated.resources.settings_subtitle
|
||||
import vnidrop.shared.generated.resources.settings_title
|
||||
|
||||
@Composable
|
||||
@@ -26,11 +28,18 @@ internal fun SettingsOverview(
|
||||
largeTitle: Boolean,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(
|
||||
stringResource(Res.string.settings_title),
|
||||
style = if (largeTitle) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
stringResource(Res.string.settings_title),
|
||||
style = if (largeTitle) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.settings_subtitle),
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
SettingsGroup {
|
||||
SettingsRow(
|
||||
icon = SettingsIcons.Device,
|
||||
|
||||
@@ -319,7 +319,13 @@ class SettingsViewModel(
|
||||
} catch (error: Throwable) {
|
||||
if (error is CancellationException) throw error
|
||||
_state.update { it.copy(isLoadingDeviceInfo = false) }
|
||||
messages.error(error, "Could not load device information.")
|
||||
messages.error(
|
||||
if (error.message.isNullOrBlank()) {
|
||||
IllegalStateException("Could not load device information.")
|
||||
} else {
|
||||
error
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,14 +13,17 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun ProgressRow(
|
||||
label: String,
|
||||
label: StringResource,
|
||||
progress: Float?,
|
||||
modifier: Modifier = Modifier,
|
||||
detail: String? = null,
|
||||
) {
|
||||
val labelText = stringResource(label)
|
||||
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
@@ -28,7 +31,7 @@ fun ProgressRow(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
labelText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -55,15 +58,3 @@ fun ProgressRow(
|
||||
else LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MetadataRow(label: String, value: String, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Text(label, Modifier.weight(0.35f), color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
||||
Text(value, Modifier.weight(0.65f), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vnidrop.app.ui.feedback
|
||||
|
||||
import com.vnidrop.app.logging.AppLogger
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -43,12 +44,29 @@ class UiMessageController {
|
||||
_dismissals.tryEmit(Unit)
|
||||
}
|
||||
|
||||
fun error(error: Throwable, fallback: String = "Something went wrong.") {
|
||||
/**
|
||||
* Surfaces a user-facing error snackbar. Logs the full technical error.
|
||||
* User cancellations are logged and suppressed.
|
||||
*/
|
||||
fun error(error: Throwable) {
|
||||
if (error.isUserCancellation()) {
|
||||
AppLogger.info(
|
||||
"ui",
|
||||
"suppressed user cancellation",
|
||||
mapOf("detail" to error.technicalDetail().ifBlank { error::class.simpleName.orEmpty() }),
|
||||
)
|
||||
return
|
||||
}
|
||||
AppLogger.error("ui", "user-facing error", error)
|
||||
tryShow(
|
||||
UiMessage(
|
||||
text = UiText.Dynamic(error.message?.takeIf(String::isNotBlank) ?: fallback),
|
||||
text = error.toUiText(),
|
||||
tone = UiMessageTone.Error,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun error(text: UiText) {
|
||||
tryShow(UiMessage(text = text, tone = UiMessageTone.Error))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.vnidrop.app.core.CoreEventModel
|
||||
import com.vnidrop.app.core.Transfer
|
||||
import com.vnidrop.app.core.TransferStatus
|
||||
import kotlin.math.roundToInt
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.progress_cancelled
|
||||
import vnidrop.shared.generated.resources.progress_completed
|
||||
import vnidrop.shared.generated.resources.progress_connected
|
||||
import vnidrop.shared.generated.resources.progress_connecting
|
||||
import vnidrop.shared.generated.resources.progress_downloading
|
||||
import vnidrop.shared.generated.resources.progress_failed
|
||||
import vnidrop.shared.generated.resources.progress_getting_ready
|
||||
import vnidrop.shared.generated.resources.progress_interrupted
|
||||
import vnidrop.shared.generated.resources.progress_preparing
|
||||
import vnidrop.shared.generated.resources.progress_ready
|
||||
import vnidrop.shared.generated.resources.progress_requesting_access
|
||||
import vnidrop.shared.generated.resources.progress_saving
|
||||
import vnidrop.shared.generated.resources.progress_sending
|
||||
import vnidrop.shared.generated.resources.progress_share_ready
|
||||
import vnidrop.shared.generated.resources.progress_working
|
||||
import vnidrop.shared.generated.resources.status_available
|
||||
import vnidrop.shared.generated.resources.status_cancelled
|
||||
import vnidrop.shared.generated.resources.status_completed
|
||||
import vnidrop.shared.generated.resources.status_failed
|
||||
import vnidrop.shared.generated.resources.status_preparing
|
||||
import vnidrop.shared.generated.resources.status_receiving
|
||||
import vnidrop.shared.generated.resources.status_stopped
|
||||
import vnidrop.shared.generated.resources.transfer_file_count_one
|
||||
import vnidrop.shared.generated.resources.transfer_file_count_other
|
||||
|
||||
enum class WindowClass {
|
||||
Phone,
|
||||
@@ -25,22 +53,26 @@ data class TransferProgress(
|
||||
val transferId: ULong?,
|
||||
val phase: String,
|
||||
val kind: String,
|
||||
val label: String,
|
||||
val label: StringResource,
|
||||
val progress: Float?,
|
||||
val detail: String? = null,
|
||||
)
|
||||
|
||||
fun displayNameForStatus(status: TransferStatus): String =
|
||||
fun statusLabelResource(status: TransferStatus): StringResource =
|
||||
when (status) {
|
||||
TransferStatus.Importing -> "Preparing"
|
||||
TransferStatus.Sharing -> "Available"
|
||||
TransferStatus.Receiving -> "Receiving"
|
||||
TransferStatus.Done -> "Completed"
|
||||
TransferStatus.Cancelled -> "Cancelled"
|
||||
TransferStatus.Stopped -> "Stopped"
|
||||
TransferStatus.Failed -> "Failed"
|
||||
TransferStatus.Importing -> Res.string.status_preparing
|
||||
TransferStatus.Sharing -> Res.string.status_available
|
||||
TransferStatus.Receiving -> Res.string.status_receiving
|
||||
TransferStatus.Done -> Res.string.status_completed
|
||||
TransferStatus.Cancelled -> Res.string.status_cancelled
|
||||
TransferStatus.Stopped -> Res.string.status_stopped
|
||||
TransferStatus.Failed -> Res.string.status_failed
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun displayNameForStatus(status: TransferStatus): String =
|
||||
stringResource(statusLabelResource(status))
|
||||
|
||||
fun Transfer.isActiveTransfer(): Boolean =
|
||||
status in activeTransferStatuses
|
||||
|
||||
@@ -101,7 +133,7 @@ fun progressForReceiver(
|
||||
transferId = transferId,
|
||||
phase = "transfer",
|
||||
kind = "aborted",
|
||||
label = "Send interrupted",
|
||||
label = Res.string.progress_interrupted,
|
||||
progress = null,
|
||||
detail = null,
|
||||
)
|
||||
@@ -111,7 +143,7 @@ fun progressForReceiver(
|
||||
transferId = transferId,
|
||||
phase = "transfer",
|
||||
kind = "completed",
|
||||
label = "Send completed",
|
||||
label = Res.string.progress_completed,
|
||||
progress = 1f,
|
||||
detail = null,
|
||||
)
|
||||
@@ -122,7 +154,7 @@ fun progressForReceiver(
|
||||
transferId = transferId,
|
||||
phase = "transfer",
|
||||
kind = latest.kind,
|
||||
label = "Sending",
|
||||
label = Res.string.progress_sending,
|
||||
progress = progress,
|
||||
detail = progressDetail(latest),
|
||||
)
|
||||
@@ -156,7 +188,7 @@ fun activeSendProgress(
|
||||
transferId = transferId,
|
||||
phase = "transfer",
|
||||
kind = relevant.first().kind,
|
||||
label = "Sending to receiver",
|
||||
label = Res.string.progress_sending,
|
||||
progress = aggregateReceiverProgress(relevant, totalSizeHint),
|
||||
detail = progressDetail(relevant.first()),
|
||||
)
|
||||
@@ -174,14 +206,9 @@ fun summarizeProgress(events: List<CoreEventModel>): List<TransferProgress> =
|
||||
.take(6)
|
||||
.mapNotNull { progressForTransfer(events, it) }
|
||||
|
||||
fun transferSubtitle(transfer: Transfer): String {
|
||||
val pieces = listOfNotNull(
|
||||
transfer.transferName,
|
||||
"${transfer.fileCount} file${if (transfer.fileCount == 1UL) "" else "s"}",
|
||||
formatBytes(transfer.totalSize),
|
||||
)
|
||||
return pieces.joinToString(" | ")
|
||||
}
|
||||
/** File-count string resource for transfer subtitles (resolve with [stringResource]). */
|
||||
fun transferFileCountResource(fileCount: ULong): StringResource =
|
||||
if (fileCount == 1UL) Res.string.transfer_file_count_one else Res.string.transfer_file_count_other
|
||||
|
||||
fun formatBytes(size: ULong): String {
|
||||
val value = size.toDouble()
|
||||
@@ -217,29 +244,26 @@ private val activeTransferStatuses = setOf(
|
||||
TransferStatus.Receiving,
|
||||
)
|
||||
|
||||
private fun humanProgressLabel(event: CoreEventModel): String = when {
|
||||
event.phase == "import" && event.kind == "copy-progress" -> "Preparing files"
|
||||
event.phase == "import" && event.kind == "outboard-progress" -> "Indexing files"
|
||||
event.phase == "import" && event.kind == "started" -> "Preparing transfer"
|
||||
event.phase == "import" && event.kind == "done" -> "Files ready"
|
||||
event.phase == "ticket" && event.kind == "created" -> "Share ready"
|
||||
event.phase == "network" && event.kind == "connecting" -> "Connecting to sender"
|
||||
event.phase == "network" && event.kind == "connected" -> "Connected"
|
||||
event.phase == "handshake" -> "Requesting access"
|
||||
event.phase == "download" && event.kind == "found-collection" -> "Found files"
|
||||
event.phase == "download" && event.kind == "progress" -> "Downloading"
|
||||
event.phase == "export" && event.kind == "progress" -> "Saving files"
|
||||
event.phase == "transfer" && event.kind == "progress" -> "Sending to receiver"
|
||||
event.phase == "transfer" && event.kind == "started" -> "Receiver connected"
|
||||
event.phase == "transfer" && event.kind == "completed" -> "Send completed"
|
||||
event.phase == "lifecycle" && event.kind == "done" -> "Completed"
|
||||
event.phase == "lifecycle" && event.kind == "cancelled" -> "Cancelled"
|
||||
event.kind == "failed" -> "Failed"
|
||||
else -> listOfNotNull(
|
||||
event.direction?.replaceFirstChar { it.uppercase() },
|
||||
event.phase.replaceFirstChar { it.uppercase() },
|
||||
event.kind.replace('-', ' '),
|
||||
).joinToString(" · ")
|
||||
private fun humanProgressLabel(event: CoreEventModel): StringResource = when {
|
||||
event.phase == "import" && event.kind == "copy-progress" -> Res.string.progress_preparing
|
||||
event.phase == "import" && event.kind == "outboard-progress" -> Res.string.progress_preparing
|
||||
event.phase == "import" && event.kind == "started" -> Res.string.progress_preparing
|
||||
event.phase == "import" && event.kind == "done" -> Res.string.progress_ready
|
||||
event.phase == "ticket" && event.kind == "created" -> Res.string.progress_share_ready
|
||||
event.phase == "network" && event.kind == "connecting" -> Res.string.progress_connecting
|
||||
event.phase == "network" && event.kind == "connected" -> Res.string.progress_connected
|
||||
event.phase == "handshake" -> Res.string.progress_requesting_access
|
||||
event.phase == "download" && event.kind == "found-collection" -> Res.string.progress_getting_ready
|
||||
event.phase == "download" && event.kind == "progress" -> Res.string.progress_downloading
|
||||
event.phase == "export" && event.kind == "progress" -> Res.string.progress_saving
|
||||
event.phase == "transfer" && event.kind == "progress" -> Res.string.progress_sending
|
||||
event.phase == "transfer" && event.kind == "started" -> Res.string.progress_connected
|
||||
event.phase == "transfer" && event.kind == "completed" -> Res.string.progress_completed
|
||||
event.phase == "lifecycle" && event.kind == "done" -> Res.string.progress_completed
|
||||
event.phase == "lifecycle" && event.kind == "cancelled" -> Res.string.progress_cancelled
|
||||
event.kind == "failed" -> Res.string.progress_failed
|
||||
// Never dump raw phase/kind tokens into the UI.
|
||||
else -> Res.string.progress_working
|
||||
}
|
||||
|
||||
private fun progressDetail(event: CoreEventModel): String? {
|
||||
|
||||
Reference in New Issue
Block a user