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