From b4306d6694fcd774416f375120846ed1d74a3160 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Fri, 10 Jul 2026 22:06:31 +0200 Subject: [PATCH] feat(send): build adaptive transfer creation flow --- .../vnidrop/app/core/FilePicker.android.kt | 35 ++- .../composeResources/values/strings.xml | 28 ++- .../kotlin/com/vnidrop/app/core/FilePicker.kt | 2 + .../vnidrop/app/feature/send/SendCatalog.kt | 217 +++++++++++++++++ .../com/vnidrop/app/feature/send/SendIcons.kt | 75 ++++++ .../com/vnidrop/app/feature/send/SendRoute.kt | 8 +- .../vnidrop/app/feature/send/SendScreen.kt | 137 +++-------- .../vnidrop/app/feature/send/SendViewModel.kt | 101 +++++--- .../app/feature/send/TransferComposer.kt | 220 ++++++++++++++++++ .../app/feature/send/TransferDetails.kt | 94 ++++++++ .../app/ui/components/AdaptiveDrawer.kt | 53 +++++ .../vnidrop/app/ui/screens/SendEmptyState.kt | 77 ------ .../com/vnidrop/app/ui/state/AppUiModels.kt | 23 +- .../com/vnidrop/app/feature/ViewModelsTest.kt | 47 +++- .../kotlin/com/vnidrop/app/support/Fakes.kt | 51 +++- .../vnidrop/app/ui/state/AppUiModelsTest.kt | 29 ++- .../com/vnidrop/app/core/FilePicker.ios.kt | 31 ++- .../com/vnidrop/app/core/FilePicker.jvm.kt | 26 ++- 18 files changed, 1009 insertions(+), 245 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendIcons.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt delete mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendEmptyState.kt diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt index e3f101d..f8116b9 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt @@ -2,7 +2,11 @@ package com.vnidrop.app.core import android.content.Context import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Point import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract import android.provider.OpenableColumns import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -18,7 +22,7 @@ actual fun rememberShareFilePicker( val context = LocalContext.current val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> if (uri != null) { - onFilePicked(PickedShareFile(uri.toString(), context.displayName(uri))) + onFilePicked(context.pickedShareFile(uri)) } } return remember(launcher) { @@ -62,12 +66,33 @@ actual fun rememberReceiveFolderPicker( } } -private fun Context.displayName(uri: Uri): String { +private fun Context.pickedShareFile(uri: Uri): PickedShareFile { + var displayName: String? = null + var sizeBytes: ULong? = null contentResolver.query(uri, null, null, null, null)?.use { cursor -> val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (nameIndex >= 0 && cursor.moveToFirst()) { - return cursor.getString(nameIndex) + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (cursor.moveToFirst()) { + if (nameIndex >= 0 && !cursor.isNull(nameIndex)) displayName = cursor.getString(nameIndex) + if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) { + sizeBytes = cursor.getLong(sizeIndex).takeIf { it >= 0L }?.toULong() + } } } - return uri.lastPathSegment ?: "transfer" + return PickedShareFile( + value = uri.toString(), + displayName = displayName ?: uri.lastPathSegment ?: "transfer", + sizeBytes = sizeBytes, + thumbnailBytes = runCatching { + val bitmap = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + contentResolver.loadThumbnail(uri, android.util.Size(192, 192), null) + } else { + DocumentsContract.getDocumentThumbnail(contentResolver, uri, Point(192, 192), null) + }) ?: error("The document provider did not return a thumbnail") + java.io.ByteArrayOutputStream().use { output -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, output) + output.toByteArray() + } + }.getOrNull(), + ) } diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 41f495b..9318337 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -3,10 +3,29 @@ Receive Settings Send - Send files to another device when you are ready. - No active transfers - Create a transfer when you are ready. You will pick what to send, review the details, and share it with another device. - Create new transfer + Files you have made available from this device. + Share your first file + Choose a file, decide who can receive it, then share it directly from this device. + New transfer + Your transfers + New transfer + Choose what to share + Select one file from this device. You can review its details before creating the transfer. + Review transfer + Who can receive it? + Ask before each download + You approve or refuse every new receiver. + Anyone with this transfer + No approval is required. Only use this for files you are comfortable sharing. + Size unavailable + Transfer created. + Transfer details + Choose file + Change file + Share file + Preparing transfer… + Copy transfer link + Create a new transfer Source Select a file to start a share. The app keeps bytes in Rust and platform file handles. Select file @@ -71,6 +90,7 @@ Notifications enabled. Could not open notification settings. Open Settings + Dismiss On Off Connection request diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt index 81a17d3..4d0b710 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt @@ -5,6 +5,8 @@ import androidx.compose.runtime.Composable data class PickedShareFile( val value: String, val displayName: String, + val sizeBytes: ULong? = null, + val thumbnailBytes: ByteArray? = null, ) interface ShareFilePicker { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt new file mode 100644 index 0000000..72c191d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt @@ -0,0 +1,217 @@ +package com.vnidrop.app.feature.send + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.heightIn +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.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +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.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vnidrop.app.core.Transfer +import com.vnidrop.app.core.TransferStatus +import com.vnidrop.app.ui.components.PillTone +import com.vnidrop.app.ui.components.PrimaryButton +import com.vnidrop.app.ui.components.StatusPill +import com.vnidrop.app.ui.state.WindowClass +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.stringResource +import org.jetbrains.compose.resources.decodeToImageBitmap +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.button_create_new_transfer +import vnidrop.shared.generated.resources.send_empty_body +import vnidrop.shared.generated.resources.send_empty_title +import vnidrop.shared.generated.resources.send_new_transfer_description +import vnidrop.shared.generated.resources.send_new_transfer_title +import vnidrop.shared.generated.resources.send_subtitle +import vnidrop.shared.generated.resources.send_title +import vnidrop.shared.generated.resources.send_transfers_title + +@Composable +internal fun SendFloatingAction(onClick: () -> Unit, modifier: Modifier = Modifier) { + FloatingActionButton( + onClick = onClick, + modifier = modifier, + containerColor = LocalVniDropColors.current.brandButton, + contentColor = Color.White, + ) { + Icon(SendIcons.Plus, contentDescription = stringResource(Res.string.send_new_transfer_description)) + } +} + +@Composable +internal fun TransferCatalog( + transfers: List, + transferThumbnails: Map, + windowClass: WindowClass, + onOpenComposer: () -> Unit, + onTransferSelected: (ULong) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize().statusBarsPadding(), + contentPadding = PaddingValues( + start = 16.dp, + top = 16.dp, + end = 16.dp, + bottom = if (windowClass == WindowClass.Phone && transfers.isNotEmpty()) 96.dp else 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { CatalogHeader(showAction = windowClass != WindowClass.Phone && transfers.isNotEmpty(), onOpenComposer) } + if (transfers.isEmpty()) { + item { SendEmptyState(onOpenComposer) } + } else { + item { + Text( + stringResource(Res.string.send_transfers_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + items(transfers, key = Transfer::localId) { transfer -> + TransferListItem(transfer, transferThumbnails[transfer.transferId]) { onTransferSelected(transfer.transferId) } + } + } + } +} + +@Composable +private fun CatalogHeader(showAction: Boolean, onOpenComposer: () -> Unit) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(Res.string.send_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text( + stringResource(Res.string.send_subtitle), + color = LocalVniDropColors.current.foregroundLighter, + style = MaterialTheme.typography.bodyMedium, + ) + } + if (showAction) { + Spacer(Modifier.width(16.dp)) + PrimaryButton(stringResource(Res.string.button_create_new_transfer), onClick = onOpenComposer) + } + } +} + +@Composable +private fun SendEmptyState(onOpenComposer: () -> Unit) { + val colors = LocalVniDropColors.current + Column( + modifier = Modifier.fillMaxWidth().heightIn(min = 430.dp).padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Box( + modifier = Modifier.size(68.dp).clip(RoundedCornerShape(22.dp)).background(colors.brandLink.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, + ) { + Icon(SendIcons.File, contentDescription = null, tint = colors.brandLink, modifier = Modifier.size(30.dp)) + } + Text( + stringResource(Res.string.send_empty_title), + modifier = Modifier.padding(top = 22.dp), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + Text( + stringResource(Res.string.send_empty_body), + modifier = Modifier.padding(top = 8.dp).widthIn(max = 480.dp), + color = colors.foregroundLighter, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + PrimaryButton( + stringResource(Res.string.button_create_new_transfer), + onClick = onOpenComposer, + modifier = Modifier.padding(top = 22.dp), + ) + } +} + +@Composable +private fun TransferListItem(transfer: Transfer, thumbnailBytes: ByteArray?, onClick: () -> Unit) { + val colors = LocalVniDropColors.current + Surface(onClick = onClick, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = colors.backgroundSurface200) { + Row(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier.size(44.dp).background(colors.backgroundSurface300, RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center, + ) { + FileArtwork(thumbnailBytes, Modifier.fillMaxSize()) + } + 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, + ) + Text( + "${formatBytes(transfer.totalSize)} · ${accessPolicyLabel(transfer.accessPolicy)}", + color = colors.foregroundLighter, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + 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)) + } + } +} + +@Composable +internal fun FileArtwork(thumbnailBytes: ByteArray?, modifier: Modifier = Modifier) { + val bitmap = remember(thumbnailBytes) { thumbnailBytes?.let { runCatching { it.decodeToImageBitmap() }.getOrNull() } } + if (bitmap != null) { + androidx.compose.foundation.Image( + bitmap = bitmap, + contentDescription = null, + modifier = modifier.clip(RoundedCornerShape(10.dp)), + contentScale = ContentScale.Crop, + ) + } else { + Box(modifier, contentAlignment = Alignment.Center) { + Icon(SendIcons.File, contentDescription = null, tint = LocalVniDropColors.current.foregroundLight, modifier = Modifier.size(22.dp)) + } + } +} + +private fun TransferStatus.pillTone(): PillTone = when (this) { + TransferStatus.Sharing, TransferStatus.Done -> PillTone.Brand + TransferStatus.Importing, TransferStatus.Receiving -> PillTone.Warning + TransferStatus.Failed, TransferStatus.Cancelled -> PillTone.Destructive + TransferStatus.Stopped -> PillTone.Neutral +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendIcons.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendIcons.kt new file mode 100644 index 0000000..ce88401 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendIcons.kt @@ -0,0 +1,75 @@ +package com.vnidrop.app.feature.send + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathBuilder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +internal object SendIcons { + val Plus = lineIcon("Plus") { + moveTo(12f, 5f) + lineTo(12f, 19f) + moveTo(5f, 12f) + lineTo(19f, 12f) + } + val File = lineIcon("File") { + moveTo(14f, 2f) + lineTo(6f, 2f) + lineTo(6f, 22f) + lineTo(18f, 22f) + lineTo(18f, 6f) + close() + moveTo(14f, 2f) + lineTo(14f, 6f) + lineTo(18f, 6f) + } + val Back = lineIcon("Back") { + moveTo(19f, 12f) + lineTo(5f, 12f) + moveTo(12f, 19f) + lineTo(5f, 12f) + lineTo(12f, 5f) + } + val ChevronRight = lineIcon("ChevronRight") { + moveTo(9f, 18f) + lineTo(15f, 12f) + lineTo(9f, 6f) + } + val Shield = lineIcon("Shield") { + moveTo(12f, 2f) + lineTo(20f, 6f) + lineTo(20f, 12f) + arcTo(9f, 9f, 0f, false, true, 12f, 22f) + arcTo(9f, 9f, 0f, false, true, 4f, 12f) + lineTo(4f, 6f) + close() + } + val Globe = lineIcon("Globe") { + moveTo(21f, 12f) + arcTo(9f, 9f, 0f, true, true, 3f, 12f) + arcTo(9f, 9f, 0f, true, true, 21f, 12f) + moveTo(3f, 12f) + lineTo(21f, 12f) + moveTo(12f, 3f) + arcTo(14f, 14f, 0f, false, true, 12f, 21f) + arcTo(14f, 14f, 0f, false, true, 12f, 3f) + } +} + +private fun lineIcon(name: String, block: PathBuilder.() -> Unit): ImageVector = + ImageVector.Builder(name, 24.dp, 24.dp, 24f, 24f).apply { + path( + fill = SolidColor(Color.Transparent), + stroke = SolidColor(Color.Black), + strokeLineWidth = 2f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + pathFillType = PathFillType.NonZero, + pathBuilder = block, + ) + }.build() diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt index 8d3537e..6fdb0d4 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt @@ -13,7 +13,6 @@ import com.vnidrop.app.ui.state.WindowClass fun SendRoute( viewModel: SendViewModel, windowClass: WindowClass, - onUseTicket: (String) -> Unit, ) { val state by viewModel.state.collectAsStateWithLifecycle() val coreState by viewModel.coreState.collectAsStateWithLifecycle() @@ -25,7 +24,6 @@ fun SendRoute( when (effect) { SendEffect.OpenFilePicker -> picker.pickFile() is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket)) - is SendEffect.UseTicket -> onUseTicket(effect.ticket) } } } @@ -34,12 +32,16 @@ fun SendRoute( coreState = coreState, state = state, windowClass = windowClass, + onOpenComposer = viewModel::openComposer, + onDismissComposer = viewModel::dismissComposer, onSelectFile = viewModel::selectFile, onClearFile = viewModel::clearSelectedSource, onTransferNameChanged = viewModel::setTransferName, onSenderNameChanged = viewModel::setSenderName, + onAccessPolicyChanged = viewModel::setAccessPolicy, onCreateShare = viewModel::createShare, + onTransferSelected = viewModel::openTransfer, + onCloseTransferDetails = viewModel::closeTransferDetails, onCopyTicket = viewModel::copyTicket, - onUseTicket = viewModel::useTicket, ) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt index 12f4334..d6bfdc5 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt @@ -1,125 +1,62 @@ package com.vnidrop.app.feature.send -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.dp +import androidx.compose.ui.Modifier import com.vnidrop.app.core.CoreState -import com.vnidrop.app.core.Share -import com.vnidrop.app.ui.components.AppCard -import com.vnidrop.app.ui.components.Field -import com.vnidrop.app.ui.components.MetadataRow -import com.vnidrop.app.ui.components.PillTone -import com.vnidrop.app.ui.components.PrimaryButton -import com.vnidrop.app.ui.components.SecondaryButton -import com.vnidrop.app.ui.components.StatusPill -import com.vnidrop.app.ui.screens.EmptyText -import com.vnidrop.app.ui.screens.ProgressSection -import com.vnidrop.app.ui.screens.ScreenHeader -import com.vnidrop.app.ui.screens.SendEmptyState -import com.vnidrop.app.ui.screens.TicketText -import com.vnidrop.app.ui.screens.shouldShowEmptyState +import com.vnidrop.app.core.ShareAccessPolicy +import com.vnidrop.app.core.TransferDirection +import com.vnidrop.app.ui.components.AdaptiveDrawer import com.vnidrop.app.ui.state.WindowClass -import com.vnidrop.app.ui.state.formatBytes -import org.jetbrains.compose.resources.stringResource -import vnidrop.shared.generated.resources.Res -import vnidrop.shared.generated.resources.button_clear -import vnidrop.shared.generated.resources.button_copy -import vnidrop.shared.generated.resources.button_create_share -import vnidrop.shared.generated.resources.button_creating_share -import vnidrop.shared.generated.resources.button_select_file -import vnidrop.shared.generated.resources.button_use_locally -import vnidrop.shared.generated.resources.field_sender_name -import vnidrop.shared.generated.resources.field_transfer_name -import vnidrop.shared.generated.resources.metadata_name -import vnidrop.shared.generated.resources.metadata_size -import vnidrop.shared.generated.resources.metadata_source -import vnidrop.shared.generated.resources.metadata_transfer -import vnidrop.shared.generated.resources.send_source_empty -import vnidrop.shared.generated.resources.send_subtitle -import vnidrop.shared.generated.resources.send_title -import vnidrop.shared.generated.resources.share_details_title -import vnidrop.shared.generated.resources.source_title -import vnidrop.shared.generated.resources.transfer_details_title @Composable fun SendScreen( coreState: CoreState, state: SendState, windowClass: WindowClass, + onOpenComposer: () -> Unit, + onDismissComposer: () -> Unit, onSelectFile: () -> Unit, onClearFile: () -> Unit, onTransferNameChanged: (String) -> Unit, onSenderNameChanged: (String) -> Unit, + onAccessPolicyChanged: (ShareAccessPolicy) -> Unit, onCreateShare: () -> Unit, + onTransferSelected: (ULong) -> Unit, + onCloseTransferDetails: () -> Unit, onCopyTicket: (String) -> Unit, - onUseTicket: (String) -> Unit, ) { - Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { - if (!state.shouldShowEmptyState(coreState)) { - ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle)) - } - if (state.shouldShowEmptyState(coreState)) { - SendEmptyState(windowClass = windowClass, onCreateNewTransfer = onSelectFile) + val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send } + val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } } + + Box(Modifier.fillMaxSize()) { + if (selectedTransfer != null) { + TransferDetails(selectedTransfer, onCloseTransferDetails, onCopyTicket) } else { - SourceCard(state, onSelectFile, onClearFile) - DetailsCard(coreState, state, onTransferNameChanged, onSenderNameChanged, onCreateShare) - coreState.lastShare?.let { ShareCard(it, onCopyTicket, onUseTicket) } - ProgressSection(coreState) + TransferCatalog( + transfers = outgoingTransfers, + transferThumbnails = state.transferThumbnails, + windowClass = windowClass, + onOpenComposer = onOpenComposer, + onTransferSelected = onTransferSelected, + ) } } -} -@Composable -private fun SourceCard(state: SendState, onSelectFile: () -> Unit, onClearFile: () -> Unit) { - AppCard(title = stringResource(Res.string.source_title)) { - if (state.selectedSource.isBlank()) { - EmptyText(stringResource(Res.string.send_source_empty)) - } else { - MetadataRow(stringResource(Res.string.metadata_name), state.selectedDisplayName.ifBlank { state.selectedSource.substringAfterLast('/') }) - MetadataRow(stringResource(Res.string.metadata_source), state.selectedSource) - } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - PrimaryButton(stringResource(Res.string.button_select_file), onClick = onSelectFile) - SecondaryButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = state.hasSelectedSource) - } - } -} - -@Composable -private fun DetailsCard( - coreState: CoreState, - state: SendState, - onTransferNameChanged: (String) -> Unit, - onSenderNameChanged: (String) -> Unit, - onCreateShare: () -> Unit, -) { - AppCard(title = stringResource(Res.string.transfer_details_title)) { - Field(state.transferName, onTransferNameChanged, stringResource(Res.string.field_transfer_name)) - Field(state.senderName, onSenderNameChanged, stringResource(Res.string.field_sender_name)) - PrimaryButton( - text = if (state.isSharing) stringResource(Res.string.button_creating_share) else stringResource(Res.string.button_create_share), - onClick = onCreateShare, - enabled = state.canCreateShare(coreState.isInitialized), - ) - } -} - -@Composable -private fun ShareCard(share: Share, onCopyTicket: (String) -> Unit, onUseTicket: (String) -> Unit) { - AppCard( - title = stringResource(Res.string.share_details_title), - trailing = { - StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand) - }, - ) { - MetadataRow(stringResource(Res.string.metadata_transfer), share.transferName) - MetadataRow(stringResource(Res.string.metadata_size), formatBytes(share.totalSize)) - TicketText(share.ticket) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - PrimaryButton(stringResource(Res.string.button_copy), onClick = { onCopyTicket(share.ticket) }) - SecondaryButton(stringResource(Res.string.button_use_locally), onClick = { onUseTicket(share.ticket) }) + if (state.isComposerOpen) { + AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissComposer) { + TransferComposer( + coreInitialized = coreState.isInitialized, + state = state, + windowClass = windowClass, + onSelectFile = onSelectFile, + onClearFile = onClearFile, + onTransferNameChanged = onTransferNameChanged, + onSenderNameChanged = onSenderNameChanged, + onAccessPolicyChanged = onAccessPolicyChanged, + onCreateShare = onCreateShare, + ) } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt index ef6e5ad..065ac15 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt @@ -5,8 +5,12 @@ import androidx.lifecycle.viewModelScope import com.vnidrop.app.core.CoreGateway import com.vnidrop.app.core.FileSystemService import com.vnidrop.app.core.PickedShareFile +import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.preferences.PreferencesRepository +import com.vnidrop.app.ui.feedback.UiMessage import com.vnidrop.app.ui.feedback.UiMessageController +import com.vnidrop.app.ui.feedback.UiMessageTone +import com.vnidrop.app.ui.feedback.UiText import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -14,26 +18,26 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch - -private const val DefaultTransferName = "VniDrop transfer" +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.send_transfer_created data class SendState( - val selectedSource: String = "", - val selectedDisplayName: String = "", - val transferName: String = DefaultTransferName, + val isComposerOpen: Boolean = false, + val selectedFile: PickedShareFile? = null, + val transferName: String = "", val senderName: String = "", + val accessPolicy: ShareAccessPolicy = ShareAccessPolicy.RequireApproval, val isSharing: Boolean = false, + val selectedTransferId: ULong? = null, + val transferThumbnails: Map = emptyMap(), ) { - val hasSelectedSource: Boolean - get() = selectedSource.isNotBlank() - - fun canCreateShare(coreInitialized: Boolean): Boolean = coreInitialized && hasSelectedSource && !isSharing + fun canCreateShare(coreInitialized: Boolean): Boolean = + coreInitialized && selectedFile != null && transferName.isNotBlank() && !isSharing } sealed interface SendEffect { data object OpenFilePicker : SendEffect data class CopyTicket(val ticket: String) : SendEffect - data class UseTicket(val ticket: String) : SendEffect } class SendViewModel( @@ -48,7 +52,6 @@ class SendViewModel( private val effects = Channel(Channel.BUFFERED) val effectFlow = effects.receiveAsFlow() - private var selectedFile: PickedShareFile? = null init { viewModelScope.launch { @@ -60,15 +63,38 @@ class SendViewModel( } } + fun openComposer() { + if (_state.value.isSharing) return + _state.update { + it.copy( + isComposerOpen = true, + selectedFile = null, + transferName = "", + accessPolicy = ShareAccessPolicy.RequireApproval, + ) + } + } + + fun dismissComposer() { + if (_state.value.isSharing) return + _state.update { + it.copy( + isComposerOpen = false, + selectedFile = null, + transferName = "", + accessPolicy = ShareAccessPolicy.RequireApproval, + ) + } + } + fun selectFile() = sendEffect(SendEffect.OpenFilePicker) fun onFilePicked(file: PickedShareFile) { - selectedFile = file _state.update { it.copy( - selectedSource = file.value, - selectedDisplayName = file.displayName, - transferName = if (it.transferName.isBlank() || it.transferName == DefaultTransferName) file.displayName else it.transferName, + isComposerOpen = true, + selectedFile = file, + transferName = file.displayName, ) } } @@ -76,33 +102,54 @@ class SendViewModel( fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason)) fun clearSelectedSource() { - selectedFile = null - _state.update { it.copy(selectedSource = "", selectedDisplayName = "") } + _state.update { it.copy(selectedFile = null, transferName = "") } } 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 copyTicket(ticket: String) = sendEffect(SendEffect.CopyTicket(ticket)) - fun useTicket(ticket: String) = sendEffect(SendEffect.UseTicket(ticket)) fun createShare() { val current = state.value + val file = current.selectedFile ?: return if (!current.canCreateShare(coreState.value.isInitialized)) return viewModelScope.launch { _state.update { it.copy(isSharing = true) } - try { - val result = selectedFile?.let { file -> - fileSystemService.sharePickedFile(repository, file, current.transferName, current.senderName) - } ?: repository.sharePath(current.selectedSource, current.transferName, current.senderName) - result.onFailure(messages::error) - } finally { - _state.update { it.copy(isSharing = false) } - } + val result = fileSystemService.sharePickedFile( + repository = repository, + file = file, + transferName = current.transferName.trim(), + senderName = current.senderName.trim(), + accessPolicy = current.accessPolicy, + ) + result.fold( + onSuccess = { share -> + _state.update { + it.copy( + isComposerOpen = false, + selectedFile = null, + 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)) + }, + onFailure = { error -> + _state.update { it.copy(isSharing = false) } + messages.error(error) + }, + ) } } private fun sendEffect(effect: SendEffect) { viewModelScope.launch { effects.send(effect) } } - } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt new file mode 100644 index 0000000..8ad4281 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt @@ -0,0 +1,220 @@ +package com.vnidrop.app.feature.send + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +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.PickedShareFile +import com.vnidrop.app.core.ShareAccessPolicy +import com.vnidrop.app.ui.components.Field +import com.vnidrop.app.ui.components.PrimaryButton +import com.vnidrop.app.ui.components.QuietButton +import com.vnidrop.app.ui.state.WindowClass +import com.vnidrop.app.ui.state.formatBytes +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_change_file +import vnidrop.shared.generated.resources.button_choose_file +import vnidrop.shared.generated.resources.button_clear +import vnidrop.shared.generated.resources.button_share_file +import vnidrop.shared.generated.resources.button_sharing_file +import vnidrop.shared.generated.resources.field_sender_name +import vnidrop.shared.generated.resources.field_transfer_name +import vnidrop.shared.generated.resources.send_access_anyone +import vnidrop.shared.generated.resources.send_access_anyone_description +import vnidrop.shared.generated.resources.send_access_approval +import vnidrop.shared.generated.resources.send_access_approval_description +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 +internal fun TransferComposer( + coreInitialized: Boolean, + state: SendState, + windowClass: WindowClass, + onSelectFile: () -> Unit, + onClearFile: () -> Unit, + onTransferNameChanged: (String) -> Unit, + onSenderNameChanged: (String) -> Unit, + onAccessPolicyChanged: (ShareAccessPolicy) -> Unit, + onCreateShare: () -> Unit, +) { + Column( + 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) + } else { + ReviewFileStep( + file = file, + state = state, + windowClass = windowClass, + onSelectFile = onSelectFile, + onClearFile = onClearFile, + onTransferNameChanged = onTransferNameChanged, + onSenderNameChanged = onSenderNameChanged, + onAccessPolicyChanged = onAccessPolicyChanged, + onCreateShare = onCreateShare, + coreInitialized = coreInitialized, + ) + } + } +} + +@Composable +private fun ChooseFileStep(onSelectFile: () -> Unit) { + Text(stringResource(Res.string.send_choose_file_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Text( + stringResource(Res.string.send_choose_file_body), + color = LocalVniDropColors.current.foregroundLighter, + style = MaterialTheme.typography.bodyMedium, + ) + Surface(shape = RoundedCornerShape(16.dp), color = LocalVniDropColors.current.backgroundSurface200) { + Column( + modifier = Modifier.fillMaxWidth().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Icon(SendIcons.File, contentDescription = null, tint = LocalVniDropColors.current.brandLink, modifier = Modifier.size(32.dp)) + PrimaryButton(stringResource(Res.string.button_choose_file), onClick = onSelectFile) + } + } +} + +@Composable +private fun ReviewFileStep( + file: PickedShareFile, + state: SendState, + windowClass: WindowClass, + onSelectFile: () -> Unit, + onClearFile: () -> Unit, + onTransferNameChanged: (String) -> Unit, + onSenderNameChanged: (String) -> Unit, + onAccessPolicyChanged: (ShareAccessPolicy) -> Unit, + onCreateShare: () -> Unit, + coreInitialized: Boolean, +) { + Text(stringResource(Res.string.send_review_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + SelectedFileCard(file) + Field(state.transferName, onTransferNameChanged, stringResource(Res.string.field_transfer_name)) + Field(state.senderName, onSenderNameChanged, stringResource(Res.string.field_sender_name)) + Text(stringResource(Res.string.send_access_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + PolicyOption( + icon = SendIcons.Shield, + title = stringResource(Res.string.send_access_approval), + description = stringResource(Res.string.send_access_approval_description), + selected = state.accessPolicy == ShareAccessPolicy.RequireApproval, + onClick = { onAccessPolicyChanged(ShareAccessPolicy.RequireApproval) }, + ) + PolicyOption( + icon = SendIcons.Globe, + title = stringResource(Res.string.send_access_anyone), + description = stringResource(Res.string.send_access_anyone_description), + selected = state.accessPolicy == ShareAccessPolicy.AnyoneWithTransfer, + onClick = { onAccessPolicyChanged(ShareAccessPolicy.AnyoneWithTransfer) }, + ) + if (windowClass == WindowClass.Phone) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth()) + QuietButton(stringResource(Res.string.button_change_file), onClick = onSelectFile, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing) + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ShareButton(state, coreInitialized, onCreateShare) + QuietButton(stringResource(Res.string.button_change_file), onClick = onSelectFile, enabled = !state.isSharing) + QuietButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = !state.isSharing) + } + } +} + +@Composable +private fun ShareButton(state: SendState, coreInitialized: Boolean, onCreateShare: () -> Unit, modifier: Modifier = Modifier) { + PrimaryButton( + if (state.isSharing) stringResource(Res.string.button_sharing_file) else stringResource(Res.string.button_share_file), + onClick = onCreateShare, + modifier = modifier, + enabled = state.canCreateShare(coreInitialized), + ) +} + +@Composable +private fun SelectedFileCard(file: PickedShareFile) { + Surface(shape = RoundedCornerShape(14.dp), color = LocalVniDropColors.current.backgroundSurface200) { + Row(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(44.dp).background(LocalVniDropColors.current.backgroundSurface300, RoundedCornerShape(11.dp))) { + FileArtwork(file.thumbnailBytes, Modifier.fillMaxSize()) + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(file.displayName, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + file.sizeBytes?.let(::formatBytes) ?: stringResource(Res.string.send_file_size_unknown), + color = LocalVniDropColors.current.foregroundLighter, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} + +@Composable +private fun PolicyOption( + icon: ImageVector, + title: String, + description: String, + selected: Boolean, + onClick: () -> Unit, +) { + val colors = LocalVniDropColors.current + val shape = RoundedCornerShape(14.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .background(if (selected) colors.backgroundSelection else colors.backgroundSurface200) + .selectable(selected = selected, role = Role.RadioButton, onClick = onClick) + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = if (selected) colors.brandLink else colors.foregroundLight, modifier = Modifier.size(22.dp)) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, fontWeight = FontWeight.SemiBold) + Text(description, color = colors.foregroundLighter, style = MaterialTheme.typography.bodySmall) + } + RadioButton(selected = selected, onClick = null) + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt new file mode 100644 index 0000000..a2b6ca0 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt @@ -0,0 +1,94 @@ +package com.vnidrop.app.feature.send + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +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.PrimaryButton +import com.vnidrop.app.ui.screens.TicketText +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.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 + +@Composable +internal fun TransferDetails(transfer: Transfer, onBack: () -> Unit, onCopyTicket: (String) -> Unit) { + LazyColumn( + modifier = Modifier.fillMaxSize().statusBarsPadding(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { + Icon(SendIcons.Back, contentDescription = stringResource(Res.string.button_back)) + } + Text( + stringResource(Res.string.send_transfer_details_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + } + } + item { + AppCard(title = transfer.transferName ?: stringResource(Res.string.send_new_transfer_title)) { + DetailValue(stringResource(Res.string.metadata_status), displayNameForStatus(transfer.status)) + 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) }) + } + } + } + } +} + +@Composable +private fun DetailValue(label: String, value: String) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) + Text(value, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +internal fun accessPolicyLabel(policy: ShareAccessPolicy): String = when (policy) { + ShareAccessPolicy.RequireApproval -> stringResource(Res.string.send_access_approval) + ShareAccessPolicy.AnyoneWithTransfer -> stringResource(Res.string.send_access_anyone) +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt new file mode 100644 index 0000000..a62f6f9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt @@ -0,0 +1,53 @@ +package com.vnidrop.app.ui.components + +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.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +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.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 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdaptiveDrawer( + windowClass: WindowClass, + onDismissRequest: () -> Unit, + content: @Composable () -> Unit, +) { + if (windowClass == WindowClass.Phone) { + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = LocalVniDropColors.current.backgroundDialog, + ) { + Box(Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 12.dp)) { + content() + } + } + } else { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = Modifier.fillMaxWidth(0.86f).widthIn(max = 560.dp), + shape = RoundedCornerShape(20.dp), + color = LocalVniDropColors.current.backgroundDialog, + shadowElevation = 12.dp, + ) { + content() + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendEmptyState.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendEmptyState.kt deleted file mode 100644 index f7c0d30..0000000 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendEmptyState.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.vnidrop.app.ui.screens - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.vnidrop.app.core.CoreState -import com.vnidrop.app.feature.send.SendState -import com.vnidrop.app.ui.components.PrimaryButton -import com.vnidrop.app.ui.navigation.VniDropIcons -import com.vnidrop.app.ui.state.WindowClass -import com.vnidrop.app.ui.state.isActiveTransfer -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_create_new_transfer -import vnidrop.shared.generated.resources.send_empty_body -import vnidrop.shared.generated.resources.send_empty_title - -@Composable -internal fun SendEmptyState(windowClass: WindowClass, onCreateNewTransfer: () -> Unit) { - val colors = LocalVniDropColors.current - Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = if (windowClass == WindowClass.Phone) 540.dp else 420.dp) - .padding(horizontal = 24.dp, vertical = 40.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = VniDropIcons.Send, - contentDescription = null, - modifier = Modifier.size(96.dp), - tint = colors.brandDefault, - ) - Text( - text = stringResource(Res.string.send_empty_title), - modifier = Modifier.padding(top = 24.dp), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - ) - Text( - text = stringResource(Res.string.send_empty_body), - modifier = Modifier - .padding(top = 10.dp) - .widthIn(max = 520.dp), - color = colors.foregroundLighter, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - ) - PrimaryButton( - text = stringResource(Res.string.button_create_new_transfer), - onClick = onCreateNewTransfer, - modifier = Modifier.padding(top = 24.dp), - ) - } -} - -internal fun SendState.shouldShowEmptyState(coreState: CoreState): Boolean = - !hasSelectedSource && - !isSharing && - coreState.lastShare == null && - coreState.transfers.none { it.direction == "send" && it.isActiveTransfer() } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt index a639fca..6f946c8 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt @@ -2,6 +2,7 @@ package com.vnidrop.app.ui.state import com.vnidrop.app.core.CoreEventModel import com.vnidrop.app.core.Transfer +import com.vnidrop.app.core.TransferStatus import kotlin.math.roundToInt enum class WindowClass { @@ -27,19 +28,19 @@ data class TransferProgress( val progress: Float?, ) -fun displayNameForStatus(status: String): String = - when (status.lowercase()) { - "sharing" -> "Sharing" - "receiving" -> "Receiving" - "done" -> "Done" - "cancelled" -> "Cancelled" - "stopped" -> "Stopped" - "failed" -> "Failed" - else -> status.replaceFirstChar { it.uppercase() } +fun displayNameForStatus(status: TransferStatus): String = + when (status) { + TransferStatus.Importing -> "Preparing" + TransferStatus.Sharing -> "Available" + TransferStatus.Receiving -> "Receiving" + TransferStatus.Done -> "Completed" + TransferStatus.Cancelled -> "Cancelled" + TransferStatus.Stopped -> "Stopped" + TransferStatus.Failed -> "Failed" } fun Transfer.isActiveTransfer(): Boolean = - status.lowercase() in activeTransferStatuses + status in activeTransferStatuses fun summarizeProgress(events: List): List = events @@ -81,7 +82,7 @@ fun formatBytes(size: ULong): String { } private val progressPhases = setOf("import", "ticket", "access", "transfer", "download", "export", "lifecycle") -private val activeTransferStatuses = setOf("importing", "sharing", "receiving") +private val activeTransferStatuses = setOf(TransferStatus.Importing, TransferStatus.Sharing, TransferStatus.Receiving) private fun eventLabel(event: CoreEventModel): String { val direction = event.direction?.replaceFirstChar { it.uppercase() } diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index 80ab3dc..cefa3cb 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -2,8 +2,11 @@ package com.vnidrop.app.feature import com.vnidrop.app.DeviceInfo import com.vnidrop.app.PlatformEnvironment +import com.vnidrop.app.core.CoreState import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolderKind +import com.vnidrop.app.core.Share +import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.feature.app.AppViewModel import com.vnidrop.app.feature.receive.ReceiveViewModel import com.vnidrop.app.feature.send.SendViewModel @@ -139,11 +142,49 @@ class ViewModelsTest { fun sendViewModelOwnsSelectedFileState() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), UiMessageController()) - viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg")) + viewModel.openComposer() + viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL)) assertEquals("photo.jpg", viewModel.state.value.transferName) - assertTrue(viewModel.state.value.hasSelectedSource) + assertEquals(42UL, viewModel.state.value.selectedFile?.sizeBytes) viewModel.clearSelectedSource() - assertFalse(viewModel.state.value.hasSelectedSource) + assertEquals(null, viewModel.state.value.selectedFile) + } + + @Test + fun sendComposerClosesAfterSuccessfulAtomicShareCreation() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + mutableState.value = CoreState(isInitialized = true) + shareResult = Result.success(Share(7UL, "ticket", "photo.jpg", "hash", 1UL, 42UL)) + } + val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController()) + advanceUntilIdle() + viewModel.openComposer() + viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL)) + viewModel.setAccessPolicy(ShareAccessPolicy.AnyoneWithTransfer) + viewModel.createShare() + advanceUntilIdle() + + assertFalse(viewModel.state.value.isComposerOpen) + assertEquals(null, viewModel.state.value.selectedFile) + assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy) + assertEquals(7UL, core.state.value.transfers.first().transferId) + } + + @Test + fun sendComposerStaysOpenWhenShareCreationFails() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { mutableState.value = CoreState(isInitialized = true) } + val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController()) + advanceUntilIdle() + viewModel.openComposer() + viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL)) + viewModel.createShare() + advanceUntilIdle() + + assertTrue(viewModel.state.value.isComposerOpen) + assertEquals("photo.jpg", viewModel.state.value.selectedFile?.displayName) + assertFalse(viewModel.state.value.isSharing) } @Test diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt index 92926f6..f0d9bec 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -9,7 +9,11 @@ import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiverRequestModel import com.vnidrop.app.core.Share +import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.TicketInspectionModel +import com.vnidrop.app.core.Transfer +import com.vnidrop.app.core.TransferDirection +import com.vnidrop.app.core.TransferStatus import com.vnidrop.app.notifications.LocalNotification import com.vnidrop.app.notifications.LocalNotificationService import com.vnidrop.app.notifications.NotificationPermission @@ -30,15 +34,53 @@ class FakeCoreGateway : CoreGateway { val requests = mutableMapOf>() var responseResult: Result = Result.success(Unit) val responses = mutableListOf>() + var shareResult: Result = Result.failure(UnsupportedOperationException()) + var lastShareAccessPolicy: ShareAccessPolicy? = null override suspend fun initialize(appDataDir: String): Result { mutableState.value = mutableState.value.copy(isInitialized = true) return Result.success(Unit) } override fun shutdown() = Unit - override suspend fun sharePath(path: String, transferName: String, senderName: String) = Result.failure(UnsupportedOperationException()) - override suspend fun shareFileDescriptor(fd: Int, displayName: String, transferName: String, senderName: String) = Result.failure(UnsupportedOperationException()) - override suspend fun shareSecurityScopedFileUrl(fileUrl: String, displayName: String, transferName: String, senderName: String) = Result.failure(UnsupportedOperationException()) + override suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result { + lastShareAccessPolicy = accessPolicy + shareResult.onSuccess { share -> + mutableState.value = mutableState.value.copy( + transfers = listOf( + Transfer( + localId = "local-${share.transferId}", + transferId = share.transferId, + direction = TransferDirection.Send, + status = TransferStatus.Sharing, + peerId = null, + transferName = share.transferName, + contentHash = share.contentHash, + fileCount = share.fileCount, + totalSize = share.totalSize, + ticket = share.ticket, + accessPolicy = accessPolicy, + createdAt = 1L, + updatedAt = 1L, + ), + ) + mutableState.value.transfers, + ) + } + return shareResult + } + override suspend fun shareFileDescriptor( + fd: Int, + displayName: String, + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy, + ) = Result.failure(UnsupportedOperationException()) + override suspend fun shareSecurityScopedFileUrl( + fileUrl: String, + displayName: String, + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy, + ) = Result.failure(UnsupportedOperationException()) override suspend fun inspectTicket(ticket: String) = Result.failure(UnsupportedOperationException()) override suspend fun receive(ticket: String, outputDir: String, receiverName: String) = Result.success(Unit) override suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = Result.success(Unit) @@ -93,5 +135,6 @@ class FakeFileSystemService( file: PickedShareFile, transferName: String, senderName: String, - ) = repository.sharePath(file.value, transferName, senderName) + accessPolicy: ShareAccessPolicy, + ) = repository.sharePath(file.value, transferName, senderName, accessPolicy) } diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt index 0dfb119..3cefe44 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt @@ -3,6 +3,10 @@ package com.vnidrop.app.ui.state import com.vnidrop.app.feature.receive.ReceiveState import com.vnidrop.app.feature.send.SendState import com.vnidrop.app.core.Transfer +import com.vnidrop.app.core.ShareAccessPolicy +import com.vnidrop.app.core.PickedShareFile +import com.vnidrop.app.core.TransferDirection +import com.vnidrop.app.core.TransferStatus import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.resolveDarkTheme import kotlin.test.Test @@ -41,7 +45,10 @@ class AppUiModelsTest { @Test fun sendStateExposesShareEligibility() { - val ready = SendState(selectedSource = "/tmp/payload.txt") + val ready = SendState( + selectedFile = PickedShareFile("/tmp/payload.txt", "payload.txt", 128UL), + transferName = "payload.txt", + ) assertTrue(ready.canCreateShare(coreInitialized = true)) assertFalse(ready.canCreateShare(coreInitialized = false)) @@ -67,24 +74,28 @@ class AppUiModelsTest { @Test fun transferActivityOnlyIncludesRunningStatuses() { - assertTrue(storedTransfer(status = "importing").isActiveTransfer()) - assertTrue(storedTransfer(status = "sharing").isActiveTransfer()) - assertTrue(storedTransfer(status = "receiving").isActiveTransfer()) - assertFalse(storedTransfer(status = "done").isActiveTransfer()) - assertFalse(storedTransfer(status = "failed").isActiveTransfer()) - assertFalse(storedTransfer(status = "cancelled").isActiveTransfer()) + assertTrue(storedTransfer(status = TransferStatus.Importing).isActiveTransfer()) + assertTrue(storedTransfer(status = TransferStatus.Sharing).isActiveTransfer()) + assertTrue(storedTransfer(status = TransferStatus.Receiving).isActiveTransfer()) + assertFalse(storedTransfer(status = TransferStatus.Done).isActiveTransfer()) + assertFalse(storedTransfer(status = TransferStatus.Failed).isActiveTransfer()) + assertFalse(storedTransfer(status = TransferStatus.Cancelled).isActiveTransfer()) } - private fun storedTransfer(status: String): Transfer = + private fun storedTransfer(status: TransferStatus): Transfer = Transfer( localId = "local-1", transferId = 1UL, peerId = null, - direction = "send", + direction = TransferDirection.Send, status = status, transferName = "Demo", + contentHash = "hash", ticket = null, fileCount = 1UL, totalSize = 128UL, + accessPolicy = ShareAccessPolicy.RequireApproval, + createdAt = 1L, + updatedAt = 1L, ) } diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt index ff72d58..52499cb 100644 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt @@ -3,10 +3,17 @@ package com.vnidrop.app.core import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.readBytes import platform.Foundation.NSURL +import platform.Foundation.NSFileManager +import platform.Foundation.NSFileSize +import platform.Foundation.NSNumber import platform.UIKit.UIApplication import platform.UIKit.UIDocumentPickerDelegateProtocol import platform.UIKit.UIDocumentPickerViewController +import platform.UIKit.UIDocumentInteractionController +import platform.UIKit.UIImage +import platform.UIKit.UIImagePNGRepresentation import platform.UIKit.UIModalPresentationFormSheet import platform.UniformTypeIdentifiers.UTTypeFolder import platform.UniformTypeIdentifiers.UTTypeItem @@ -83,7 +90,21 @@ private class DocumentPickerDelegate( onError("The selected iOS document URL was invalid") } else { val displayName = url.lastPathComponent ?: "transfer" - onFilePicked(PickedShareFile(url.absoluteString ?: url.path.orEmpty(), displayName)) + val didStartAccess = url.startAccessingSecurityScopedResource() + val sizeBytes = try { + val attributes = url.path?.let { NSFileManager.defaultManager.attributesOfItemAtPath(it, null) } + (attributes?.get(NSFileSize) as? NSNumber)?.unsignedLongLongValue + } finally { + if (didStartAccess) url.stopAccessingSecurityScopedResource() + } + onFilePicked( + PickedShareFile( + url.absoluteString ?: url.path.orEmpty(), + displayName, + sizeBytes, + nativeFileIcon(url), + ), + ) } retainedPickerDelegate = null } @@ -92,3 +113,11 @@ private class DocumentPickerDelegate( retainedPickerDelegate = null } } + +@OptIn(ExperimentalForeignApi::class) +private fun nativeFileIcon(url: NSURL): ByteArray? = runCatching { + val controller = UIDocumentInteractionController.interactionControllerWithURL(url) + val icon = controller.icons.lastOrNull() as? UIImage ?: return null + val data = UIImagePNGRepresentation(icon) ?: return null + data.bytes?.readBytes(data.length.toInt()) +}.getOrNull() diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt index e784821..c7b5198 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt @@ -6,8 +6,12 @@ import java.awt.EventQueue import java.awt.FileDialog import java.awt.Frame import java.awt.KeyboardFocusManager +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream import java.io.File +import javax.imageio.ImageIO import javax.swing.JFileChooser +import javax.swing.filechooser.FileSystemView @Composable actual fun rememberShareFilePicker( @@ -75,7 +79,12 @@ private fun pickShareFile(): PickedShareFile? { val file = dialog.file if (directory != null && file != null) { val selected = File(directory, file) - PickedShareFile(selected.absolutePath, selected.name) + PickedShareFile( + selected.absolutePath, + selected.name, + selected.length().takeIf { it >= 0L }?.toULong(), + selected.systemIconPng(), + ) } else { null } @@ -84,6 +93,21 @@ private fun pickShareFile(): PickedShareFile? { } } +private fun File.systemIconPng(): ByteArray? = runCatching { + val icon = FileSystemView.getFileSystemView().getSystemIcon(this, 128, 128) + val image = BufferedImage(icon.iconWidth, icon.iconHeight, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + try { + icon.paintIcon(null, graphics, 0, 0) + } finally { + graphics.dispose() + } + ByteArrayOutputStream().use { output -> + ImageIO.write(image, "png", output) + output.toByteArray() + } +}.getOrNull() + private fun pickDirectory(): File? = if (isMacOs()) { val dialog = withMacDirectoryDialog {