fix(ui): drop redundant stop-sharing UI and fix username field

Remove the transfer-details progress bar and Stop sharing control now that
delete already revokes a share. Fix preferences username typing by keeping
local draft state and debouncing DataStore writes so trim/races no longer
fight the caret.
This commit is contained in:
2026-07-12 16:33:48 +02:00
parent e114396738
commit 9ffdaeaa50
7 changed files with 51 additions and 57 deletions

View File

@@ -53,6 +53,5 @@ fun SendRoute(
onRequestDelete = viewModel::requestDeleteTransfer, onRequestDelete = viewModel::requestDeleteTransfer,
onDismissDelete = viewModel::dismissDeleteTransfer, onDismissDelete = viewModel::dismissDeleteTransfer,
onConfirmDelete = viewModel::confirmDeleteTransfer, onConfirmDelete = viewModel::confirmDeleteTransfer,
onCancelTransfer = viewModel::cancelSelectedTransfer,
) )
} }

View File

@@ -14,8 +14,6 @@ import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.TransferDirection import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.ui.components.AdaptiveDrawer import com.vnidrop.app.ui.components.AdaptiveDrawer
import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.canCancelTransfer
import com.vnidrop.app.ui.state.progressForTransfer
@Composable @Composable
fun SendScreen( fun SendScreen(
@@ -42,7 +40,6 @@ fun SendScreen(
onRequestDelete: () -> Unit = {}, onRequestDelete: () -> Unit = {},
onDismissDelete: () -> Unit = {}, onDismissDelete: () -> Unit = {},
onConfirmDelete: () -> Unit = {}, onConfirmDelete: () -> Unit = {},
onCancelTransfer: () -> Unit = {},
) { ) {
val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send } val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } } val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
@@ -56,18 +53,15 @@ fun SendScreen(
TransferDetails( TransferDetails(
transfer = selectedTransfer, transfer = selectedTransfer,
events = coreState.events, events = coreState.events,
progress = progressForTransfer(coreState.events, selectedTransfer.transferId),
pendingReceivers = state.receiverHistory.count { pendingReceivers = state.receiverHistory.count {
it.status == ReceiverDeliveryStatus.Requested || it.status == ReceiverDeliveryStatus.Accepted it.status == ReceiverDeliveryStatus.Requested || it.status == ReceiverDeliveryStatus.Accepted
}, },
completedReceivers = state.receiverHistory.count { it.status == ReceiverDeliveryStatus.Completed }, completedReceivers = state.receiverHistory.count { it.status == ReceiverDeliveryStatus.Completed },
canCancel = selectedTransfer.canCancelTransfer(),
onBack = onCloseTransferDetails, onBack = onCloseTransferDetails,
onActivity = onActivity, onActivity = onActivity,
onReceivers = onReceivers, onReceivers = onReceivers,
onShare = onShare, onShare = onShare,
onDelete = onRequestDelete, onDelete = onRequestDelete,
onCancel = onCancelTransfer,
) )
} else { } else {
TransferCatalog( TransferCatalog(

View File

@@ -25,7 +25,6 @@ import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.send_transfer_created import vnidrop.shared.generated.resources.send_transfer_created
import vnidrop.shared.generated.resources.transfer_deleted import vnidrop.shared.generated.resources.transfer_deleted
import vnidrop.shared.generated.resources.transfer_event_stopped
import vnidrop.shared.generated.resources.transfer_nfc_written import vnidrop.shared.generated.resources.transfer_nfc_written
data class SendState( data class SendState(
@@ -252,21 +251,6 @@ class SendViewModel(
} }
} }
fun cancelSelectedTransfer() {
val transferId = _state.value.selectedTransferId ?: return
viewModelScope.launch {
repository.cancel(transferId).fold(
onSuccess = {
repository.refresh()
messages.tryShow(
UiMessage(UiText.Resource(Res.string.transfer_event_stopped), UiMessageTone.Info),
)
},
onFailure = messages::error,
)
}
}
private fun sendEffect(effect: SendEffect) { private fun sendEffect(effect: SendEffect) {
viewModelScope.launch { effects.send(effect) } viewModelScope.launch { effects.send(effect) }
} }

View File

@@ -50,9 +50,7 @@ import com.vnidrop.app.core.Transfer
import com.vnidrop.app.ui.components.AppCard import com.vnidrop.app.ui.components.AppCard
import com.vnidrop.app.ui.components.DestructiveButton import com.vnidrop.app.ui.components.DestructiveButton
import com.vnidrop.app.ui.components.PrimaryButton import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.ProgressRow
import com.vnidrop.app.ui.components.SecondaryButton import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.state.TransferProgress
import com.vnidrop.app.ui.state.displayNameForStatus import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.theme.LocalVniDropColors import com.vnidrop.app.ui.theme.LocalVniDropColors
@@ -69,16 +67,13 @@ enum class InvitationAction { Export, Share, Nfc }
internal fun TransferDetails( internal fun TransferDetails(
transfer: Transfer, transfer: Transfer,
events: List<CoreEventModel>, events: List<CoreEventModel>,
progress: TransferProgress? = null,
pendingReceivers: Int = 0, pendingReceivers: Int = 0,
completedReceivers: Int, completedReceivers: Int,
canCancel: Boolean = false,
onBack: () -> Unit, onBack: () -> Unit,
onActivity: () -> Unit, onActivity: () -> Unit,
onReceivers: () -> Unit, onReceivers: () -> Unit,
onShare: () -> Unit, onShare: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onCancel: () -> Unit = {},
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().statusBarsPadding(), modifier = Modifier.fillMaxSize().statusBarsPadding(),
@@ -106,24 +101,6 @@ internal fun TransferDetails(
DetailValue(stringResource(Res.string.metadata_size), formatBytes(transfer.totalSize)) DetailValue(stringResource(Res.string.metadata_size), formatBytes(transfer.totalSize))
HorizontalDivider(color = LocalVniDropColors.current.borderDefault) HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailValue(stringResource(Res.string.send_access_title), accessPolicyLabel(transfer.accessPolicy)) DetailValue(stringResource(Res.string.send_access_title), accessPolicyLabel(transfer.accessPolicy))
if (progress != null && transfer.status.isLiveProgressStatus()) {
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
ProgressRow(
label = progress.label,
progress = progress.progress,
detail = progress.detail,
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
if (canCancel) {
item {
SecondaryButton(
stringResource(Res.string.button_stop_sharing),
onClick = onCancel,
modifier = Modifier.fillMaxWidth(),
)
} }
} }
item { item {
@@ -163,11 +140,6 @@ private fun receiversDescription(pending: Int, completed: Int): String = when {
else -> stringResource(Res.string.transfer_receivers_description) else -> stringResource(Res.string.transfer_receivers_description)
} }
private fun com.vnidrop.app.core.TransferStatus.isLiveProgressStatus(): Boolean =
this == com.vnidrop.app.core.TransferStatus.Importing ||
this == com.vnidrop.app.core.TransferStatus.Sharing ||
this == com.vnidrop.app.core.TransferStatus.Receiving
@Composable @Composable
private fun DetailDestination(title: String, description: String, count: Int? = null, onClick: () -> Unit) { private fun DetailDestination(title: String, description: String, count: Int? = null, onClick: () -> Unit) {
Row( Row(

View File

@@ -16,8 +16,10 @@ import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.feedback.UiMessageTone import com.vnidrop.app.ui.feedback.UiMessageTone
import com.vnidrop.app.ui.feedback.UiText import com.vnidrop.app.ui.feedback.UiText
import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -71,21 +73,28 @@ class SettingsViewModel(
private val effects = Channel<SettingsEffect>(Channel.BUFFERED) private val effects = Channel<SettingsEffect>(Channel.BUFFERED)
val effectFlow = effects.receiveAsFlow() val effectFlow = effects.receiveAsFlow()
private var enableNotificationsAfterSettings = false private var enableNotificationsAfterSettings = false
private var usernamePersistJob: Job? = null
init { init {
viewModelScope.launch { viewModelScope.launch {
preferencesRepository.preferences.collect { preferences -> preferencesRepository.preferences.collect { preferences ->
_state.update { val previousFolder = _state.value.receiveFolder
it.copy( // While the user is typing, keep the in-progress value. DataStore
username = preferences.username, // echoes can race keystrokes and trim trailing spaces mid-edit.
val editingUsername = usernamePersistJob?.isActive == true
_state.update { current ->
current.copy(
username = if (editingUsername) current.username else preferences.username,
receiveFolder = preferences.receiveFolder, receiveFolder = preferences.receiveFolder,
themeMode = preferences.themeMode, themeMode = preferences.themeMode,
notificationsEnabled = preferences.notificationsEnabled, notificationsEnabled = preferences.notificationsEnabled,
) )
} }
if (preferences.receiveFolder != previousFolder) {
validateFolder(preferences.receiveFolder) validateFolder(preferences.receiveFolder)
} }
} }
}
refreshNotificationPermission() refreshNotificationPermission()
loadDeviceInfo() loadDeviceInfo()
} }
@@ -96,7 +105,12 @@ class SettingsViewModel(
} }
fun setUsername(value: String) { fun setUsername(value: String) {
viewModelScope.launch { preferencesRepository.setUsername(value) } _state.update { it.copy(username = value) }
usernamePersistJob?.cancel()
usernamePersistJob = viewModelScope.launch {
delay(UsernamePersistDebounceMs)
preferencesRepository.setUsername(value)
}
} }
fun setThemeMode(mode: ThemeMode) { fun setThemeMode(mode: ThemeMode) {
@@ -203,4 +217,8 @@ class SettingsViewModel(
val status = fileSystemService.validateReceiveFolder(folder) val status = fileSystemService.validateReceiveFolder(folder)
_state.update { it.copy(folderAccessStatus = status, isValidatingFolder = false) } _state.update { it.copy(folderAccessStatus = status, isValidatingFolder = false) }
} }
private companion object {
const val UsernamePersistDebounceMs = 350L
}
} }

View File

@@ -80,6 +80,31 @@ class ViewModelsTest {
assertEquals(1, notifications.cancelAllCount) assertEquals(1, notifications.cancelAllCount)
} }
@Test
fun settingsUsernameKeepsSpacesWhileTypingAndPersistsAfterDebounce() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val preferences = preferences()
val viewModel = SettingsViewModel(
environment(),
{ DeviceInfo("Device", null, "OS", null, null) },
FakeFileSystemService(folder),
preferences,
FakeNotificationService(),
UiMessageController(),
)
advanceUntilIdle()
viewModel.setUsername("Ada ")
// Immediate local state must keep the trailing space so multi-word names can be typed.
assertEquals("Ada ", viewModel.state.value.username)
assertEquals("Receiver", preferences.mutablePreferences.value.username)
testScheduler.advanceTimeBy(400)
advanceUntilIdle()
assertEquals("Ada", preferences.mutablePreferences.value.username)
assertEquals("Ada", viewModel.state.value.username)
}
@Test @Test
fun settingsKeepsNotificationsDisabledWhenPermissionIsDenied() = runTest { fun settingsKeepsNotificationsDisabledWhenPermissionIsDenied() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))

View File

@@ -169,7 +169,9 @@ class FakePreferencesRepository(
) : PreferencesRepository { ) : PreferencesRepository {
val mutablePreferences = MutableStateFlow(initial) val mutablePreferences = MutableStateFlow(initial)
override val preferences = mutablePreferences override val preferences = mutablePreferences
override suspend fun setUsername(username: String) { mutablePreferences.value = mutablePreferences.value.copy(username = username) } override suspend fun setUsername(username: String) {
mutablePreferences.value = mutablePreferences.value.copy(username = username.trim())
}
override suspend fun setReceiveFolder(folder: ReceiveFolder) { mutablePreferences.value = mutablePreferences.value.copy(receiveFolder = folder) } override suspend fun setReceiveFolder(folder: ReceiveFolder) { mutablePreferences.value = mutablePreferences.value.copy(receiveFolder = folder) }
override suspend fun resetReceiveFolder() = Unit override suspend fun resetReceiveFolder() = Unit
override suspend fun setThemeMode(mode: ThemeMode) { mutablePreferences.value = mutablePreferences.value.copy(themeMode = mode) } override suspend fun setThemeMode(mode: ThemeMode) { mutablePreferences.value = mutablePreferences.value.copy(themeMode = mode) }