feat(send): build durable transfer details and sharing flow

This commit is contained in:
2026-07-11 09:31:19 +02:00
parent dc862bc01c
commit 3da91be771
36 changed files with 1340 additions and 60 deletions

View File

@@ -45,7 +45,13 @@ fun App(dependencies: AppDependencies) {
AppViewModel(dependencies.environment, graph.coreRepository, graph.preferencesRepository, graph.messages)
}
val sendViewModel = viewModel {
SendViewModel(graph.coreRepository, dependencies.fileSystemService, graph.preferencesRepository, graph.messages)
SendViewModel(
graph.coreRepository,
dependencies.fileSystemService,
graph.preferencesRepository,
graph.filePreviewRepository,
graph.messages,
)
}
val receiveViewModel = viewModel {
ReceiveViewModel(graph.coreRepository, dependencies.fileSystemService, graph.preferencesRepository, graph.messages)

View File

@@ -3,6 +3,8 @@ package com.vnidrop.app
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreRepository
import com.vnidrop.app.feature.approvals.ApprovalCoordinator
import com.vnidrop.app.feature.send.AppFilePreviewRepository
import com.vnidrop.app.feature.send.createPlatformPreviewStore
import com.vnidrop.app.logging.AppLogger
import com.vnidrop.app.platform.AppVisibility
import com.vnidrop.app.preferences.AppPreferencesDefaults
@@ -22,6 +24,9 @@ class AppGraph(
) {
val visibility = AppVisibility()
val messages = UiMessageController()
val filePreviewRepository = AppFilePreviewRepository(
createPlatformPreviewStore(dependencies.environment.defaultCoreDataDir),
)
val preferencesRepository = AppPreferencesRepository(
dataStore = createAppPreferencesDataStore(dependencies.environment.defaultCoreDataDir),
defaults = AppPreferencesDefaults(

View File

@@ -89,12 +89,22 @@ data class ReceiverRequestModel(
val receiverName: String?,
val receiverDeviceName: String?,
val appVersion: String,
val status: String,
val status: ReceiverDeliveryStatus,
val reason: String?,
val requestedAt: Long,
val respondedAt: Long?,
val completedAt: Long?,
)
enum class ReceiverDeliveryStatus {
Requested,
Accepted,
Refused,
Expired,
Completed,
Unknown,
}
data class CoreState(
val isInitialized: Boolean = false,
val status: CoreStatus? = null,
@@ -106,6 +116,7 @@ data class CoreState(
sealed interface CoreSignal {
data class ApprovalChanged(val transferId: ULong) : CoreSignal
data class ReceiverHistoryChanged(val transferId: ULong) : CoreSignal
}
interface CoreGateway {
@@ -134,6 +145,7 @@ interface CoreGateway {
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit>
suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result<Unit>
suspend fun cancel(transferId: ULong): Result<Unit>
suspend fun delete(transferId: ULong): Result<Unit>
suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>>
suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result<Unit>
suspend fun refresh(): Result<Unit>

View File

@@ -49,6 +49,9 @@ class CoreRepository(
if (model.phase == "approval" && model.transferId != null) {
_signals.tryEmit(CoreSignal.ApprovalChanged(model.transferId))
}
if (model.phase == "delivery" && model.transferId != null) {
_signals.tryEmit(CoreSignal.ReceiverHistoryChanged(model.transferId))
}
}
}
@@ -163,6 +166,13 @@ class CoreRepository(
refreshSnapshot()
}
override suspend fun delete(transferId: ULong): Result<Unit> = runCore {
requireCore().deleteTransfer(transferId)
refreshSnapshot()
_signals.tryEmit(CoreSignal.ApprovalChanged(transferId))
_signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId))
}
override suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>> = runCore {
requireCore().listReceiverRequests(transferId).map(ReceiverRequest::toModel)
}
@@ -328,8 +338,16 @@ private fun ReceiverRequest.toModel(): ReceiverRequestModel = ReceiverRequestMod
receiverName = receiverName,
receiverDeviceName = receiverDeviceName,
appVersion = appVersion,
status = status,
status = when (status) {
"requested" -> ReceiverDeliveryStatus.Requested
"accepted" -> ReceiverDeliveryStatus.Accepted
"refused" -> ReceiverDeliveryStatus.Refused
"expired" -> ReceiverDeliveryStatus.Expired
"completed" -> ReceiverDeliveryStatus.Completed
else -> ReceiverDeliveryStatus.Unknown
},
reason = reason,
requestedAt = requestedAt,
respondedAt = respondedAt,
completedAt = completedAt,
)

View File

@@ -5,6 +5,7 @@ import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.notifications.LocalNotification
import com.vnidrop.app.notifications.LocalNotificationService
import com.vnidrop.app.notifications.NotificationPermission
@@ -55,6 +56,7 @@ class ApprovalCoordinator(
repository.signals.collect { signal ->
when (signal) {
is CoreSignal.ApprovalChanged -> refresh(signal.transferId)
is CoreSignal.ReceiverHistoryChanged -> Unit
}
}
}
@@ -108,7 +110,7 @@ class ApprovalCoordinator(
private suspend fun refresh(transferId: ULong) {
repository.receiverRequests(transferId).fold(
onSuccess = { requests ->
val refreshed = requests.filter { it.status == "requested" }.map(ReceiverRequestModel::toPending)
val refreshed = requests.filter { it.status == ReceiverDeliveryStatus.Requested }.map(ReceiverRequestModel::toPending)
val removed = _state.value.pending.filter { it.transferId == transferId }.map { it.id }.toSet() - refreshed.map { it.id }.toSet()
removed.forEach { id ->
notifications.cancel(notificationId(id))

View File

@@ -0,0 +1,111 @@
package com.vnidrop.app.feature.send
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
data class PreviewFileInfo(
val transferId: ULong,
val byteSize: Long,
val modifiedAtMillis: Long,
)
interface PlatformPreviewStore {
fun list(): List<PreviewFileInfo>
fun read(transferId: ULong): ByteArray?
fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean
fun delete(transferId: ULong)
}
expect fun createPlatformPreviewStore(appDataDir: String): PlatformPreviewStore
interface FilePreviewRepository {
val previews: StateFlow<Map<ULong, ByteArray>>
suspend fun restore(activeTransferIds: Set<ULong>)
suspend fun save(transferId: ULong, bytes: ByteArray)
suspend fun remove(transferId: ULong)
}
data class PreviewStoragePolicy(
val maxEntryBytes: Int = 512 * 1024,
val maxTotalBytes: Long = 20L * 1024L * 1024L,
) {
init {
require(maxEntryBytes > 0)
require(maxTotalBytes >= maxEntryBytes)
}
}
class AppFilePreviewRepository(
private val store: PlatformPreviewStore,
private val policy: PreviewStoragePolicy = PreviewStoragePolicy(),
) : FilePreviewRepository {
private val mutex = Mutex()
private val _previews = MutableStateFlow<Map<ULong, ByteArray>>(emptyMap())
override val previews: StateFlow<Map<ULong, ByteArray>> = _previews.asStateFlow()
override suspend fun restore(activeTransferIds: Set<ULong>) = withContext(Dispatchers.Default) {
mutex.withLock {
val files = store.list()
for (file in files) {
if (file.transferId !in activeTransferIds || file.byteSize !in 1..policy.maxEntryBytes.toLong()) {
store.delete(file.transferId)
}
}
enforceQuota()
_previews.value = store.list()
.filter { it.transferId in activeTransferIds }
.mapNotNull { file ->
store.read(file.transferId)
?.takeIf { it.isSupportedPreview() && it.size <= policy.maxEntryBytes }
?.let { file.transferId to it }
?: run {
store.delete(file.transferId)
null
}
}
.toMap()
}
}
override suspend fun save(transferId: ULong, bytes: ByteArray) = withContext(Dispatchers.Default) {
mutex.withLock {
if (bytes.size !in 1..policy.maxEntryBytes || !bytes.isSupportedPreview()) return@withLock
if (!store.writeAtomically(transferId, bytes)) return@withLock
enforceQuota(protectedTransferId = transferId)
if (store.read(transferId) != null) {
_previews.value = _previews.value + (transferId to bytes.copyOf())
}
}
}
override suspend fun remove(transferId: ULong) = withContext(Dispatchers.Default) {
mutex.withLock {
store.delete(transferId)
_previews.value = _previews.value - transferId
}
}
private fun enforceQuota(protectedTransferId: ULong? = null) {
val files = store.list().sortedBy { it.modifiedAtMillis }
var total = files.sumOf(PreviewFileInfo::byteSize)
for (file in files) {
if (total <= policy.maxTotalBytes) break
if (file.transferId == protectedTransferId) continue
store.delete(file.transferId)
total -= file.byteSize
_previews.value = _previews.value - file.transferId
}
}
}
private fun ByteArray.isSupportedPreview(): Boolean {
val png = size >= 8 && this[0] == 0x89.toByte() && decodeToString(1, 4) == "PNG"
val jpeg = size >= 3 && this[0] == 0xff.toByte() && this[1] == 0xd8.toByte() && this[2] == 0xff.toByte()
val webp = size >= 12 && decodeToString(0, 4) == "RIFF" && decodeToString(8, 12) == "WEBP"
return png || jpeg || webp
}

View File

@@ -170,13 +170,18 @@ private fun TransferListItem(transfer: Transfer, thumbnailBytes: ByteArray?, onC
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
transfer.transferName ?: stringResource(Res.string.send_new_transfer_title),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
transfer.transferName ?: stringResource(Res.string.send_new_transfer_title),
modifier = Modifier.weight(1f, fill = false),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.width(8.dp))
StatusPill(displayNameForStatus(transfer.status), tone = transfer.status.pillTone())
}
Text(
"${formatBytes(transfer.totalSize)} · ${accessPolicyLabel(transfer.accessPolicy)}",
color = colors.foregroundLighter,
@@ -185,7 +190,6 @@ private fun TransferListItem(transfer: Transfer, thumbnailBytes: ByteArray?, onC
overflow = TextOverflow.Ellipsis,
)
}
StatusPill(displayNameForStatus(transfer.status), tone = transfer.status.pillTone())
Spacer(Modifier.width(8.dp))
Icon(SendIcons.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(18.dp))
}

View File

@@ -35,6 +35,13 @@ internal object SendIcons {
lineTo(5f, 12f)
lineTo(12f, 5f)
}
val Delete = lineIcon("Delete") {
moveTo(4f, 7f); lineTo(20f, 7f)
moveTo(9f, 7f); lineTo(9f, 4f); lineTo(15f, 4f); lineTo(15f, 7f)
moveTo(6f, 7f); lineTo(7f, 21f); lineTo(17f, 21f); lineTo(18f, 7f)
moveTo(10f, 11f); lineTo(10f, 17f)
moveTo(14f, 11f); lineTo(14f, 17f)
}
val ChevronRight = lineIcon("ChevronRight") {
moveTo(9f, 18f)
lineTo(15f, 12f)

View File

@@ -18,6 +18,7 @@ fun SendRoute(
val coreState by viewModel.coreState.collectAsStateWithLifecycle()
val clipboard = LocalClipboardManager.current
val picker = rememberShareFilePicker(viewModel::onFilePicked, viewModel::onFilePickFailed)
val shareActions = rememberTransferShareActions()
LaunchedEffect(viewModel) {
viewModel.effectFlow.collect { effect ->
@@ -32,6 +33,7 @@ fun SendRoute(
coreState = coreState,
state = state,
windowClass = windowClass,
shareActions = shareActions,
onOpenComposer = viewModel::openComposer,
onDismissComposer = viewModel::dismissComposer,
onSelectFile = viewModel::selectFile,
@@ -43,5 +45,13 @@ fun SendRoute(
onTransferSelected = viewModel::openTransfer,
onCloseTransferDetails = viewModel::closeTransferDetails,
onCopyTicket = viewModel::copyTicket,
onActivity = viewModel::openActivity,
onReceivers = viewModel::openReceivers,
onShare = viewModel::openShare,
onCloseDetailPanel = viewModel::closeDetailPanel,
onInvitationResult = viewModel::onInvitationResult,
onRequestDelete = viewModel::requestDeleteTransfer,
onDismissDelete = viewModel::dismissDeleteTransfer,
onConfirmDelete = viewModel::confirmDeleteTransfer,
)
}

View File

@@ -3,7 +3,11 @@ package com.vnidrop.app.feature.send
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.TransferDirection
@@ -15,6 +19,7 @@ fun SendScreen(
coreState: CoreState,
state: SendState,
windowClass: WindowClass,
shareActions: TransferShareActions = UnavailableTransferShareActions,
onOpenComposer: () -> Unit,
onDismissComposer: () -> Unit,
onSelectFile: () -> Unit,
@@ -26,13 +31,34 @@ fun SendScreen(
onTransferSelected: (ULong) -> Unit,
onCloseTransferDetails: () -> Unit,
onCopyTicket: (String) -> Unit,
onActivity: () -> Unit = {},
onReceivers: () -> Unit = {},
onShare: () -> Unit = {},
onCloseDetailPanel: () -> Unit = {},
onInvitationResult: (InvitationAction, Result<Unit>) -> Unit = { _, _ -> },
onRequestDelete: () -> Unit = {},
onDismissDelete: () -> Unit = {},
onConfirmDelete: () -> Unit = {},
) {
val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
val qrCache = remember { mutableStateMapOf<String, ImageBitmap>() }
LaunchedEffect(outgoingTransfers.mapNotNull { it.ticket }) {
qrCache.keys.retainAll(outgoingTransfers.mapNotNull { it.ticket }.toSet())
}
Box(Modifier.fillMaxSize()) {
if (selectedTransfer != null) {
TransferDetails(selectedTransfer, onCloseTransferDetails, onCopyTicket)
TransferDetails(
transfer = selectedTransfer,
events = coreState.events,
completedReceivers = state.receiverHistory.count { it.status == com.vnidrop.app.core.ReceiverDeliveryStatus.Completed },
onBack = onCloseTransferDetails,
onActivity = onActivity,
onReceivers = onReceivers,
onShare = onShare,
onDelete = onRequestDelete,
)
} else {
TransferCatalog(
transfers = outgoingTransfers,
@@ -59,4 +85,31 @@ fun SendScreen(
)
}
}
if (selectedTransfer != null && state.detailPanel != null) {
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onCloseDetailPanel) {
when (state.detailPanel) {
TransferDetailPanel.Activity -> TransferActivityPanel(coreState.events, selectedTransfer.transferId)
TransferDetailPanel.Receivers -> ReceiverHistoryPanel(state.receiverHistory, state.isLoadingReceivers)
TransferDetailPanel.Share -> TransferSharePanel(
selectedTransfer,
shareActions,
qrBitmap = selectedTransfer.ticket?.let(qrCache::get),
onQrRendered = { ticket, bitmap -> qrCache[ticket] = bitmap },
onResult = onInvitationResult,
)
}
}
}
if (selectedTransfer != null && state.isDeleteConfirmationOpen) {
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissDelete) {
DeleteTransferPanel(
transferName = selectedTransfer.transferName,
isDeleting = state.isDeleting,
onCancel = onDismissDelete,
onConfirm = onConfirmDelete,
)
}
}
}

View File

@@ -3,9 +3,11 @@ package com.vnidrop.app.feature.send
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessage
import com.vnidrop.app.ui.feedback.UiMessageController
@@ -17,9 +19,13 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.send_transfer_created
import vnidrop.shared.generated.resources.transfer_nfc_written
import vnidrop.shared.generated.resources.transfer_deleted
data class SendState(
val isComposerOpen: Boolean = false,
@@ -30,11 +36,18 @@ data class SendState(
val isSharing: Boolean = false,
val selectedTransferId: ULong? = null,
val transferThumbnails: Map<ULong, ByteArray> = emptyMap(),
val detailPanel: TransferDetailPanel? = null,
val receiverHistory: List<ReceiverRequestModel> = emptyList(),
val isLoadingReceivers: Boolean = false,
val isDeleteConfirmationOpen: Boolean = false,
val isDeleting: Boolean = false,
) {
fun canCreateShare(coreInitialized: Boolean): Boolean =
coreInitialized && selectedFile != null && transferName.isNotBlank() && !isSharing
}
enum class TransferDetailPanel { Activity, Receivers, Share }
sealed interface SendEffect {
data object OpenFilePicker : SendEffect
data class CopyTicket(val ticket: String) : SendEffect
@@ -44,6 +57,7 @@ class SendViewModel(
private val repository: CoreGateway,
private val fileSystemService: FileSystemService,
preferencesRepository: PreferencesRepository,
private val filePreviewRepository: FilePreviewRepository,
private val messages: UiMessageController,
) : ViewModel() {
private val _state = MutableStateFlow(SendState())
@@ -54,6 +68,29 @@ class SendViewModel(
val effectFlow = effects.receiveAsFlow()
init {
viewModelScope.launch {
repository.signals.collect { signal ->
val transferId = when (signal) {
is CoreSignal.ReceiverHistoryChanged -> signal.transferId
is CoreSignal.ApprovalChanged -> signal.transferId
}
if (transferId == _state.value.selectedTransferId &&
_state.value.detailPanel == TransferDetailPanel.Receivers
) refreshReceivers(transferId)
}
}
viewModelScope.launch {
filePreviewRepository.previews.collect { previews ->
_state.update { it.copy(transferThumbnails = previews) }
}
}
viewModelScope.launch {
coreState.map { core ->
core.takeIf { it.isInitialized }?.transfers?.map { it.transferId }?.toSet()
}.distinctUntilChanged().collect { activeIds ->
if (activeIds != null) filePreviewRepository.restore(activeIds)
}
}
viewModelScope.launch {
preferencesRepository.preferences.collect { preferences ->
_state.update { current ->
@@ -108,9 +145,70 @@ class SendViewModel(
fun setTransferName(value: String) = _state.update { it.copy(transferName = value) }
fun setSenderName(value: String) = _state.update { it.copy(senderName = value) }
fun setAccessPolicy(value: ShareAccessPolicy) = _state.update { it.copy(accessPolicy = value) }
fun openTransfer(transferId: ULong) = _state.update { it.copy(selectedTransferId = transferId) }
fun closeTransferDetails() = _state.update { it.copy(selectedTransferId = null) }
fun openTransfer(transferId: ULong) {
_state.update { it.copy(selectedTransferId = transferId, detailPanel = null) }
refreshReceivers(transferId)
}
fun closeTransferDetails() = _state.update {
it.copy(
selectedTransferId = null,
detailPanel = null,
receiverHistory = emptyList(),
isDeleteConfirmationOpen = false,
)
}
fun openActivity() = _state.update { it.copy(detailPanel = TransferDetailPanel.Activity) }
fun openShare() = _state.update { it.copy(detailPanel = TransferDetailPanel.Share) }
fun openReceivers() {
val transferId = _state.value.selectedTransferId ?: return
_state.update { it.copy(detailPanel = TransferDetailPanel.Receivers) }
refreshReceivers(transferId)
}
fun closeDetailPanel() = _state.update { it.copy(detailPanel = null) }
fun requestDeleteTransfer() = _state.update { it.copy(isDeleteConfirmationOpen = true) }
fun dismissDeleteTransfer() {
if (!_state.value.isDeleting) _state.update { it.copy(isDeleteConfirmationOpen = false) }
}
fun confirmDeleteTransfer() {
val transferId = _state.value.selectedTransferId ?: return
if (_state.value.isDeleting) return
viewModelScope.launch {
_state.update { it.copy(isDeleting = true) }
repository.delete(transferId).fold(
onSuccess = {
filePreviewRepository.remove(transferId)
_state.update {
it.copy(
selectedTransferId = null,
detailPanel = null,
receiverHistory = emptyList(),
isDeleteConfirmationOpen = false,
isDeleting = false,
)
}
messages.tryShow(UiMessage(UiText.Resource(Res.string.transfer_deleted), UiMessageTone.Success))
},
onFailure = { error ->
_state.update { it.copy(isDeleting = false) }
messages.error(error)
},
)
}
}
fun copyTicket(ticket: String) = sendEffect(SendEffect.CopyTicket(ticket))
fun onInvitationResult(action: InvitationAction, result: Result<Unit>) {
result.fold(
onSuccess = {
val message = when (action) {
InvitationAction.Export -> null
InvitationAction.Nfc -> Res.string.transfer_nfc_written
InvitationAction.Share -> null
}
message?.let { messages.tryShow(UiMessage(UiText.Resource(it), UiMessageTone.Success)) }
},
onFailure = messages::error,
)
}
fun createShare() {
val current = state.value
@@ -127,6 +225,7 @@ class SendViewModel(
)
result.fold(
onSuccess = { share ->
file.thumbnailBytes?.let { filePreviewRepository.save(share.transferId, it) }
_state.update {
it.copy(
isComposerOpen = false,
@@ -134,9 +233,6 @@ class SendViewModel(
transferName = "",
accessPolicy = ShareAccessPolicy.RequireApproval,
isSharing = false,
transferThumbnails = file.thumbnailBytes?.let { bytes ->
it.transferThumbnails + (share.transferId to bytes)
} ?: it.transferThumbnails,
)
}
messages.show(UiMessage(UiText.Resource(Res.string.send_transfer_created), UiMessageTone.Success))
@@ -152,4 +248,17 @@ class SendViewModel(
private fun sendEffect(effect: SendEffect) {
viewModelScope.launch { effects.send(effect) }
}
private fun refreshReceivers(transferId: ULong) {
viewModelScope.launch {
_state.update { it.copy(isLoadingReceivers = true) }
repository.receiverRequests(transferId).fold(
onSuccess = { requests -> _state.update { it.copy(receiverHistory = requests, isLoadingReceivers = false) } },
onFailure = { error ->
_state.update { it.copy(isLoadingReceivers = false) }
messages.error(error)
},
)
}
}
}

View File

@@ -54,7 +54,6 @@ import vnidrop.shared.generated.resources.send_access_title
import vnidrop.shared.generated.resources.send_choose_file_body
import vnidrop.shared.generated.resources.send_choose_file_title
import vnidrop.shared.generated.resources.send_file_size_unknown
import vnidrop.shared.generated.resources.send_new_transfer_title
import vnidrop.shared.generated.resources.send_review_title
@Composable
@@ -73,7 +72,6 @@ internal fun TransferComposer(
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(stringResource(Res.string.send_new_transfer_title), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
val file = state.selectedFile
if (file == null) {
ChooseFileStep(onSelectFile)

View File

@@ -1,45 +1,79 @@
package com.vnidrop.app.feature.send
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.CoreEventModel
import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.ui.components.AppCard
import com.vnidrop.app.ui.components.DestructiveButton
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.screens.TicketText
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.decodeToImageBitmap
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_back
import vnidrop.shared.generated.resources.button_copy_ticket
import vnidrop.shared.generated.resources.metadata_files
import vnidrop.shared.generated.resources.metadata_size
import vnidrop.shared.generated.resources.metadata_status
import vnidrop.shared.generated.resources.send_access_anyone
import vnidrop.shared.generated.resources.send_access_approval
import vnidrop.shared.generated.resources.send_access_title
import vnidrop.shared.generated.resources.send_new_transfer_title
import vnidrop.shared.generated.resources.send_transfer_details_title
import qrcode.QRCode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import vnidrop.shared.generated.resources.*
enum class InvitationAction { Export, Share, Nfc }
@Composable
internal fun TransferDetails(transfer: Transfer, onBack: () -> Unit, onCopyTicket: (String) -> Unit) {
internal fun TransferDetails(
transfer: Transfer,
events: List<CoreEventModel>,
completedReceivers: Int,
onBack: () -> Unit,
onActivity: () -> Unit,
onReceivers: () -> Unit,
onShare: () -> Unit,
onDelete: () -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().statusBarsPadding(),
contentPadding = PaddingValues(16.dp),
@@ -47,14 +81,16 @@ internal fun TransferDetails(transfer: Transfer, onBack: () -> Unit, onCopyTicke
) {
item {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) {
Icon(SendIcons.Back, contentDescription = stringResource(Res.string.button_back))
}
IconButton(onClick = onBack) { Icon(SendIcons.Back, stringResource(Res.string.button_back)) }
Text(
stringResource(Res.string.send_transfer_details_title),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
IconButton(onClick = onDelete) {
Icon(SendIcons.Delete, stringResource(Res.string.button_delete_transfer), tint = LocalVniDropColors.current.destructiveDefault)
}
}
}
item {
@@ -63,25 +99,242 @@ internal fun TransferDetails(transfer: Transfer, onBack: () -> Unit, onCopyTicke
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailValue(stringResource(Res.string.metadata_size), formatBytes(transfer.totalSize))
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailValue(stringResource(Res.string.metadata_files), transfer.fileCount.toString())
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailValue(stringResource(Res.string.send_access_title), accessPolicyLabel(transfer.accessPolicy))
}
}
transfer.ticket?.let { ticket ->
item {
AppCard(title = stringResource(Res.string.button_copy_ticket)) {
TicketText(ticket)
PrimaryButton(stringResource(Res.string.button_copy_ticket), onClick = { onCopyTicket(ticket) })
item {
Surface(shape = RoundedCornerShape(16.dp), color = LocalVniDropColors.current.backgroundSurface200) {
Column {
DetailDestination(
title = stringResource(Res.string.transfer_activity_title),
description = stringResource(Res.string.transfer_activity_description),
count = events.count { it.transferId == transfer.transferId && it.isMeaningfulActivity() },
onClick = onActivity,
)
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailDestination(
title = stringResource(Res.string.transfer_receivers_title),
description = stringResource(Res.string.transfer_receivers_description),
count = completedReceivers,
onClick = onReceivers,
)
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailDestination(
title = stringResource(Res.string.transfer_share_title),
description = stringResource(Res.string.transfer_share_description),
onClick = onShare,
)
}
}
}
}
}
@Composable
private fun DetailDestination(title: String, description: String, count: Int? = null, onClick: () -> Unit) {
Row(
Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(title, fontWeight = FontWeight.SemiBold)
Text(description, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
}
if (count != null && count > 0) {
Text(count.toString(), modifier = Modifier.background(LocalVniDropColors.current.backgroundSelection, RoundedCornerShape(20.dp)).padding(horizontal = 9.dp, vertical = 3.dp))
Spacer(Modifier.width(8.dp))
}
Icon(SendIcons.ChevronRight, null, tint = LocalVniDropColors.current.foregroundLighter, modifier = Modifier.size(18.dp))
}
}
@Composable
internal fun ReceiverHistoryPanel(receivers: List<ReceiverRequestModel>, loading: Boolean) {
PanelContainer(stringResource(Res.string.transfer_receivers_title)) {
when {
loading -> Box(Modifier.fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
receivers.isEmpty() -> Text(stringResource(Res.string.transfer_no_receivers), color = LocalVniDropColors.current.foregroundLighter)
else -> receivers.forEachIndexed { index, receiver ->
if (index > 0) HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
ReceiverRow(receiver)
}
}
}
}
@Composable
private fun ReceiverRow(receiver: ReceiverRequestModel) {
val name = receiver.receiverName ?: receiver.receiverDeviceName ?: stringResource(Res.string.transfer_nearby_device)
Column(Modifier.fillMaxWidth().padding(vertical = 13.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(name, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
receiver.receiverDeviceName?.takeIf { it != name }?.let {
Text(it, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
}
Text(receiverStatusText(receiver.status), color = receiverStatusColor(receiver.status), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
}
}
@Composable
internal fun TransferActivityPanel(events: List<CoreEventModel>, transferId: ULong) {
val visible = events.filter { it.transferId == transferId && it.isMeaningfulActivity() }.sortedByDescending(CoreEventModel::timestamp)
PanelContainer(stringResource(Res.string.transfer_activity_title)) {
if (visible.isEmpty()) Text(stringResource(Res.string.transfer_no_activity), color = LocalVniDropColors.current.foregroundLighter)
else visible.forEachIndexed { index, event ->
if (index > 0) HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
Text(eventTitle(event), modifier = Modifier.padding(vertical = 14.dp), fontWeight = FontWeight.Medium)
}
}
}
@Composable
internal fun TransferSharePanel(
transfer: Transfer,
actions: TransferShareActions,
qrBitmap: androidx.compose.ui.graphics.ImageBitmap?,
onQrRendered: (String, androidx.compose.ui.graphics.ImageBitmap) -> Unit,
onResult: (InvitationAction, Result<Unit>) -> Unit,
) {
DisposableEffect(actions) { onDispose(actions::cancelNfcWrite) }
val ticket = transfer.ticket
PanelContainer(stringResource(Res.string.transfer_share_title)) {
if (ticket == null) {
Text(stringResource(Res.string.transfer_event_preparing), color = LocalVniDropColors.current.foregroundLighter)
return@PanelContainer
}
val renderedBitmap by produceState(qrBitmap, ticket, qrBitmap) {
if (value == null) {
value = withContext(Dispatchers.Default) {
runCatching { QRCode.ofSquares().withSize(8).build(ticket).renderToBytes().decodeToImageBitmap() }.getOrNull()
}
value?.let { onQrRendered(ticket, it) }
}
}
val renderedQr = renderedBitmap
Surface(
modifier = Modifier.align(Alignment.CenterHorizontally).size(268.dp),
shape = RoundedCornerShape(18.dp),
color = Color.White,
) {
if (renderedQr != null) {
Image(renderedQr, null, Modifier.padding(14.dp).fillMaxSize().clip(RoundedCornerShape(8.dp)))
} else {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
}
}
Text(
stringResource(Res.string.transfer_scan_qr),
modifier = Modifier.align(Alignment.CenterHorizontally),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodySmall,
)
if (actions.nfcAvailability != NfcShareAvailability.Hidden) {
var writingNfc by remember(ticket) { mutableStateOf(false) }
SecondaryButton(
if (writingNfc) stringResource(Res.string.transfer_nfc_waiting) else stringResource(Res.string.button_write_nfc),
onClick = {
writingNfc = true
actions.writeInvitationToNfc(ticket) {
writingNfc = false
onResult(InvitationAction.Nfc, it)
}
},
modifier = Modifier.fillMaxWidth(),
enabled = actions.nfcAvailability == NfcShareAvailability.Available && !writingNfc,
)
if (actions.nfcAvailability == NfcShareAvailability.Unavailable) {
Text(stringResource(Res.string.transfer_nfc_unavailable), color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
}
}
SecondaryButton(
stringResource(Res.string.button_download_invitation),
onClick = { actions.exportInvitation(ticket, transfer.transferName.orEmpty()) { onResult(InvitationAction.Export, it) } },
modifier = Modifier.fillMaxWidth(),
)
PrimaryButton(
stringResource(Res.string.button_native_share),
onClick = { actions.shareInvitation(ticket, transfer.transferName.orEmpty()) { onResult(InvitationAction.Share, it) } },
modifier = Modifier.fillMaxWidth(),
enabled = actions.canUseNativeShare,
)
}
}
@Composable
internal fun DeleteTransferPanel(
transferName: String?,
isDeleting: Boolean,
onCancel: () -> Unit,
onConfirm: () -> Unit,
) {
PanelContainer(stringResource(Res.string.transfer_delete_title)) {
Text(
stringResource(Res.string.transfer_delete_description, transferName ?: stringResource(Res.string.send_new_transfer_title)),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodyMedium,
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)) {
SecondaryButton(stringResource(Res.string.button_cancel), onClick = onCancel, enabled = !isDeleting)
DestructiveButton(
if (isDeleting) stringResource(Res.string.transfer_deleting) else stringResource(Res.string.button_delete_transfer),
onClick = onConfirm,
enabled = !isDeleting,
)
}
}
}
@Composable
private fun PanelContainer(title: String, content: @Composable ColumnScope.() -> Unit) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
content()
}
}
@Composable
private fun receiverStatusText(status: ReceiverDeliveryStatus) = stringResource(when (status) {
ReceiverDeliveryStatus.Requested -> Res.string.transfer_receiver_requested
ReceiverDeliveryStatus.Accepted -> Res.string.transfer_receiver_accepted
ReceiverDeliveryStatus.Refused -> Res.string.transfer_receiver_refused
ReceiverDeliveryStatus.Expired -> Res.string.transfer_receiver_expired
ReceiverDeliveryStatus.Completed -> Res.string.transfer_receiver_completed
ReceiverDeliveryStatus.Unknown -> Res.string.transfer_receiver_unknown
})
@Composable
private fun receiverStatusColor(status: ReceiverDeliveryStatus) = when (status) {
ReceiverDeliveryStatus.Completed -> LocalVniDropColors.current.brandDefault
ReceiverDeliveryStatus.Refused, ReceiverDeliveryStatus.Expired -> LocalVniDropColors.current.destructiveDefault
else -> LocalVniDropColors.current.foregroundLighter
}
private fun CoreEventModel.isMeaningfulActivity() =
(phase == "import" && kind == "started") ||
(phase == "ticket" && kind == "created") ||
kind in setOf(
"receiver-requested", "receiver-accepted", "receiver-auto-approved",
"receiver-refused", "receiver-completed", "share-stopped", "failed",
)
@Composable
private fun eventTitle(event: CoreEventModel) = stringResource(when {
event.phase == "import" && event.kind == "started" -> Res.string.transfer_event_preparing
event.phase == "ticket" && event.kind == "created" -> Res.string.transfer_event_ready
event.kind == "receiver-requested" -> Res.string.transfer_event_requested
event.kind == "receiver-accepted" || event.kind == "receiver-auto-approved" -> Res.string.transfer_event_approved
event.kind == "receiver-refused" -> Res.string.transfer_event_refused
event.kind == "receiver-completed" -> Res.string.transfer_event_completed
event.kind == "share-stopped" -> Res.string.transfer_event_stopped
event.kind == "failed" -> Res.string.transfer_event_failed
else -> Res.string.transfer_event_updated
})
@Composable
private fun DetailValue(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
Text(value, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyMedium)
}

View File

@@ -0,0 +1,38 @@
package com.vnidrop.app.feature.send
import androidx.compose.runtime.Composable
enum class NfcShareAvailability { Available, Unavailable, Hidden }
interface TransferShareActions {
val canUseNativeShare: Boolean
val nfcAvailability: NfcShareAvailability
fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit)
fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit)
fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit)
fun cancelNfcWrite()
}
object UnavailableTransferShareActions : TransferShareActions {
override val canUseNativeShare = false
override val nfcAvailability = NfcShareAvailability.Hidden
override fun exportInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) =
onResult(Result.failure(UnsupportedOperationException("Invitation export is unavailable")))
override fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> Unit) =
onResult(Result.failure(UnsupportedOperationException("System sharing is unavailable")))
override fun writeInvitationToNfc(ticket: String, onResult: (Result<Unit>) -> Unit) =
onResult(Result.failure(UnsupportedOperationException("NFC is unavailable")))
override fun cancelNfcWrite() = Unit
}
@Composable
expect fun rememberTransferShareActions(): TransferShareActions
internal fun invitationFileName(transferName: String): String {
val safe = transferName.trim()
.map { character -> if (character.isLetterOrDigit() || character in "-_. ") character else '_' }
.joinToString("")
.trim('.', ' ')
.ifBlank { "VniDrop transfer" }
return if (safe.endsWith(".vnd", ignoreCase = true)) safe else "$safe.vnd"
}

View File

@@ -4,19 +4,30 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_close
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -31,9 +42,7 @@ fun AdaptiveDrawer(
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = LocalVniDropColors.current.backgroundDialog,
) {
Box(Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 12.dp)) {
content()
}
ClosableModalContent(onDismissRequest, Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 12.dp), content)
}
} else {
Dialog(
@@ -45,9 +54,31 @@ fun AdaptiveDrawer(
shape = RoundedCornerShape(20.dp),
color = LocalVniDropColors.current.backgroundDialog,
shadowElevation = 12.dp,
) {
content()
}
) { ClosableModalContent(onDismissRequest, content = content) }
}
}
}
@Composable
private fun ClosableModalContent(
onClose: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Box(modifier) {
content()
IconButton(
onClick = onClose,
modifier = Modifier.align(androidx.compose.ui.Alignment.TopEnd).padding(8.dp).size(40.dp),
) {
Icon(CloseIcon, stringResource(Res.string.button_close), tint = LocalVniDropColors.current.foregroundLight)
}
}
}
private val CloseIcon = ImageVector.Builder("Close", 24.dp, 24.dp, 24f, 24f).apply {
path(fill = SolidColor(Color.Transparent), stroke = SolidColor(Color.Black), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round) {
moveTo(6f, 6f); lineTo(18f, 18f)
moveTo(18f, 6f); lineTo(6f, 18f)
}
}.build()

View File

@@ -40,3 +40,19 @@ fun QuietButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
fun DestructiveButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 44.dp),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = LocalVniDropColors.current.destructiveDefault,
contentColor = Color.White,
),
) {
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}

View File

@@ -28,9 +28,10 @@ fun StatusPill(label: String, modifier: Modifier = Modifier, tone: PillTone = Pi
PillTone.Warning -> colors.warningDefault
PillTone.Destructive -> colors.destructiveDefault
}
val shape = RoundedCornerShape(7.dp)
Row(
modifier = modifier.clip(RoundedCornerShape(999.dp)).background(color.copy(alpha = 0.12f))
.border(1.dp, color.copy(alpha = 0.32f), RoundedCornerShape(999.dp)).padding(horizontal = 10.dp, vertical = 5.dp),
modifier = modifier.clip(shape).background(color.copy(alpha = 0.12f))
.border(1.dp, color.copy(alpha = 0.32f), shape).padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(7.dp).clip(CircleShape).background(color))