feat(shared): polish beta UX copy and friendly errors

Map snackbars to stable user-facing strings, rewrite transfer-oriented
empty states and settings copy, and resource-back progress/status labels.
This commit is contained in:
2026-07-15 03:53:24 +02:00
parent bf81661667
commit 7cd125053c
21 changed files with 562 additions and 245 deletions

View File

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

View File

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

View File

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

View File

@@ -53,6 +53,7 @@ import com.vnidrop.app.ui.components.Field
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.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,
)
}
}
}

View File

@@ -16,6 +16,8 @@ import com.vnidrop.app.ui.feedback.UiMessage
import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.feedback.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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,122 @@
package com.vnidrop.app.ui.feedback
import uniffi.vnidrop.VnidropException
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.error_device_info
import vnidrop.shared.generated.resources.error_filesystem
import vnidrop.shared.generated.resources.error_generic
import vnidrop.shared.generated.resources.error_initialization
import vnidrop.shared.generated.resources.error_invalid_ticket
import vnidrop.shared.generated.resources.error_invitation_empty
import vnidrop.shared.generated.resources.error_missing_native_library
import vnidrop.shared.generated.resources.error_permission
import vnidrop.shared.generated.resources.error_repository
import vnidrop.shared.generated.resources.error_selection_failed
import vnidrop.shared.generated.resources.error_socket_bind
import vnidrop.shared.generated.resources.error_camera
import vnidrop.shared.generated.resources.error_nfc
import vnidrop.shared.generated.resources.error_share_empty
import vnidrop.shared.generated.resources.error_starting_up
import vnidrop.shared.generated.resources.error_transfer
/**
* Maps technical failures to stable, user-facing copy.
*
* Never expose raw exception messages (especially UniFFI `reason=…` blobs) in snackbars.
*/
fun Throwable.toUiText(): UiText =
when (this) {
is VnidropException.Ticket -> UiText.Resource(Res.string.error_invalid_ticket)
is VnidropException.Permission -> UiText.Resource(Res.string.error_permission)
is VnidropException.Filesystem -> UiText.Resource(Res.string.error_filesystem)
is VnidropException.Transfer -> transferUiText(reason)
is VnidropException.Repository -> UiText.Resource(Res.string.error_repository)
is VnidropException.Initialization -> initializationUiText(reason)
is VnidropException.Internal -> reasonHints(reason) ?: UiText.Resource(Res.string.error_generic)
else -> reasonHints(technicalDetail()) ?: UiText.Resource(Res.string.error_generic)
}
/** User intentionally backed out of a flow — do not treat as a failure snackbar. */
fun Throwable.isUserCancellation(): Boolean {
val haystack = technicalDetail().lowercase()
if (haystack.isBlank()) return false
return haystack.contains("cancelled") ||
haystack.contains("canceled") ||
haystack.contains("user cancelled") ||
haystack.contains("user canceled")
}
/** Prefer [VnidropException.reason] when present; else [Throwable.message]. */
fun Throwable.technicalDetail(): String =
when (this) {
is VnidropException.Initialization -> reason
is VnidropException.Ticket -> reason
is VnidropException.Filesystem -> reason
is VnidropException.Transfer -> reason
is VnidropException.Permission -> reason
is VnidropException.Repository -> reason
is VnidropException.Internal -> reason
else -> message.orEmpty()
}
private fun transferUiText(reason: String): UiText {
val detail = reason.lowercase()
return when {
detail.contains("refused") || detail.contains("denied") || detail.contains("not approved") ->
UiText.Resource(Res.string.error_permission)
else -> UiText.Resource(Res.string.error_transfer)
}
}
private fun initializationUiText(reason: String): UiText {
val detail = reason.lowercase()
return when {
detail.contains("native") && detail.contains("library") ->
UiText.Resource(Res.string.error_missing_native_library)
detail.contains("socket") || detail.contains("bind") ->
UiText.Resource(Res.string.error_socket_bind)
else -> UiText.Resource(Res.string.error_initialization)
}
}
private fun reasonHints(detailRaw: String): UiText? {
val detail = detailRaw.lowercase()
if (detail.isBlank()) return null
return when {
detail.contains("still starting") || detail.contains("starting up") ->
UiText.Resource(Res.string.error_starting_up)
detail.contains("empty") && (detail.contains("invitation") || detail.contains("ticket") || detail.contains("qr")) ->
UiText.Resource(Res.string.error_invitation_empty)
detail.contains("select at least one") || detail.contains("no files found") ->
UiText.Resource(Res.string.error_share_empty)
detail.contains("camera") ->
UiText.Resource(Res.string.error_camera)
detail.contains("nfc") || detail.contains("ndef") ||
(detail.contains("read-only") && detail.contains("tag")) ||
detail.contains("tag is too small") || detail.contains("no nfc tag") ->
UiText.Resource(Res.string.error_nfc)
detail.contains("native") && detail.contains("library") ->
UiText.Resource(Res.string.error_missing_native_library)
detail.contains("socket") || detail.contains("bind") ->
UiText.Resource(Res.string.error_socket_bind)
detail.contains("device information") || detail.contains("device info") ->
UiText.Resource(Res.string.error_device_info)
detail.contains("refused") || detail.contains("denied") || detail.contains("permission") ||
detail.contains("not approved") || detail.contains("waiting for approval") ->
UiText.Resource(Res.string.error_permission)
detail.contains("invalid ticket") || detail.contains("ticket error") ||
detail.contains("could not be read") || detail.contains("malformed") ||
detail.contains("invitation could not be opened") ->
UiText.Resource(Res.string.error_invalid_ticket)
detail.contains("selected") &&
(detail.contains("file") || detail.contains("folder") || detail.contains("document") || detail.contains("open")) ->
UiText.Resource(Res.string.error_selection_failed)
detail.contains("could not open the selected") || detail.contains("could not open selected") ->
UiText.Resource(Res.string.error_selection_failed)
detail.contains("document picker") || detail.contains("folder picker") || detail.contains("file descriptor") ||
detail.contains("view controller") ->
UiText.Resource(Res.string.error_selection_failed)
else -> null
}
}

View File

@@ -1,83 +0,0 @@
package com.vnidrop.app.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.TicketInspectionModel
import com.vnidrop.app.ui.components.AppCard
import com.vnidrop.app.ui.components.MetadataRow
import com.vnidrop.app.ui.components.ProgressRow
import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.state.summarizeProgress
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.metadata_files
import vnidrop.shared.generated.resources.metadata_hash
import vnidrop.shared.generated.resources.metadata_kind
import vnidrop.shared.generated.resources.metadata_sender
import vnidrop.shared.generated.resources.metadata_size
import vnidrop.shared.generated.resources.metadata_transfer
import vnidrop.shared.generated.resources.progress_title
import vnidrop.shared.generated.resources.ticket_details_title
import vnidrop.shared.generated.resources.unknown_sender
@Composable
fun ScreenHeader(title: String, subtitle: String) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text(subtitle, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
fun EmptyText(text: String) {
Text(text, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodyMedium)
}
@Composable
fun ProgressSection(coreState: CoreState) {
val progress = summarizeProgress(coreState.events)
if (progress.isNotEmpty()) {
AppCard(title = stringResource(Res.string.progress_title)) {
progress.forEach { item -> ProgressRow(label = item.label, progress = item.progress) }
}
}
}
@Composable
fun TicketInspectionCard(inspection: TicketInspectionModel) {
val metadata = inspection.metadata
AppCard(title = stringResource(Res.string.ticket_details_title)) {
MetadataRow(stringResource(Res.string.metadata_kind), inspection.kind)
MetadataRow(stringResource(Res.string.metadata_transfer), metadata.transferName)
MetadataRow(stringResource(Res.string.metadata_sender), metadata.senderName ?: stringResource(Res.string.unknown_sender))
MetadataRow(stringResource(Res.string.metadata_files), metadata.fileCount.toString())
MetadataRow(stringResource(Res.string.metadata_size), formatBytes(metadata.totalSize))
MetadataRow(stringResource(Res.string.metadata_hash), metadata.contentHash)
}
}
@Composable
fun TicketText(ticket: String) {
SelectionContainer {
Text(
text = ticket,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp))
.background(LocalVniDropColors.current.backgroundSurface200).padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
}
}

View File

@@ -1,9 +1,37 @@
package com.vnidrop.app.ui.state
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? {