mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
feat(send): build durable transfer details and sharing flow
This commit is contained in:
@@ -20,6 +20,42 @@
|
||||
<string name="send_file_size_unknown">Size unavailable</string>
|
||||
<string name="send_transfer_created">Transfer created.</string>
|
||||
<string name="send_transfer_details_title">Transfer details</string>
|
||||
<string name="transfer_activity_title">Activity</string>
|
||||
<string name="transfer_activity_description">See important updates for this transfer</string>
|
||||
<string name="transfer_receivers_title">Receivers</string>
|
||||
<string name="transfer_receivers_description">Requests, approvals, and completed deliveries</string>
|
||||
<string name="transfer_share_title">Share</string>
|
||||
<string name="transfer_share_description">QR code, invitation file, and nearby options</string>
|
||||
<string name="transfer_delete_title">Delete transfer?</string>
|
||||
<string name="transfer_delete_description">“%1$s” will stop being shared and its transfer history will be removed from this device.</string>
|
||||
<string name="transfer_deleting">Deleting…</string>
|
||||
<string name="transfer_deleted">Transfer deleted.</string>
|
||||
<string name="transfer_no_activity">There is no activity to show yet.</string>
|
||||
<string name="transfer_no_receivers">Nobody has requested this transfer yet.</string>
|
||||
<string name="transfer_receiver_requested">Waiting for your approval</string>
|
||||
<string name="transfer_receiver_accepted">Approved — waiting for completion</string>
|
||||
<string name="transfer_receiver_refused">Request refused</string>
|
||||
<string name="transfer_receiver_expired">Request expired</string>
|
||||
<string name="transfer_receiver_completed">Received successfully</string>
|
||||
<string name="transfer_receiver_unknown">Status unavailable</string>
|
||||
<string name="transfer_nearby_device">Nearby device</string>
|
||||
<string name="transfer_scan_qr">Scan with VniDrop to receive this transfer</string>
|
||||
<string name="button_write_nfc">Write to NFC tag</string>
|
||||
<string name="button_download_invitation">Save .vnd file</string>
|
||||
<string name="button_native_share">Share invitation</string>
|
||||
<string name="transfer_nfc_unavailable">NFC tag writing is not available on this device.</string>
|
||||
<string name="transfer_nfc_waiting">Hold your device near a writable NFC tag.</string>
|
||||
<string name="transfer_invitation_saved">Invitation saved.</string>
|
||||
<string name="transfer_nfc_written">Invitation written to the NFC tag.</string>
|
||||
<string name="transfer_event_preparing">Preparing the selected files</string>
|
||||
<string name="transfer_event_ready">Transfer ready to share</string>
|
||||
<string name="transfer_event_requested">A receiver requested access</string>
|
||||
<string name="transfer_event_approved">Receiver access approved</string>
|
||||
<string name="transfer_event_refused">Receiver access refused</string>
|
||||
<string name="transfer_event_completed">A receiver completed the transfer</string>
|
||||
<string name="transfer_event_stopped">Sharing stopped</string>
|
||||
<string name="transfer_event_failed">The transfer encountered a problem</string>
|
||||
<string name="transfer_event_updated">Transfer updated</string>
|
||||
<string name="button_choose_file">Choose file</string>
|
||||
<string name="button_change_file">Change file</string>
|
||||
<string name="button_share_file">Share file</string>
|
||||
@@ -63,6 +99,9 @@
|
||||
<string name="button_choose_folder">Choose folder</string>
|
||||
<string name="button_reset_default">Reset default</string>
|
||||
<string name="button_back">Back</string>
|
||||
<string name="button_close">Close</string>
|
||||
<string name="button_cancel">Cancel</string>
|
||||
<string name="button_delete_transfer">Delete transfer</string>
|
||||
<string name="folder_status_writable">Writable</string>
|
||||
<string name="folder_status_permission_required">Permission required</string>
|
||||
<string name="folder_status_unavailable">Unavailable</string>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user