Merge pull request #7 from vnidrop/fix/ui-cleanup-username

fix(ui): username input and transfer details cleanup
This commit is contained in:
Hammed Abass
2026-07-12 17:00:29 +02:00
committed by GitHub
7 changed files with 51 additions and 57 deletions

View File

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

View File

@@ -25,7 +25,6 @@ import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.send_transfer_created
import vnidrop.shared.generated.resources.transfer_deleted
import vnidrop.shared.generated.resources.transfer_event_stopped
import vnidrop.shared.generated.resources.transfer_nfc_written
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) {
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.DestructiveButton
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.state.TransferProgress
import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.theme.LocalVniDropColors
@@ -69,16 +67,13 @@ enum class InvitationAction { Export, Share, Nfc }
internal fun TransferDetails(
transfer: Transfer,
events: List<CoreEventModel>,
progress: TransferProgress? = null,
pendingReceivers: Int = 0,
completedReceivers: Int,
canCancel: Boolean = false,
onBack: () -> Unit,
onActivity: () -> Unit,
onReceivers: () -> Unit,
onShare: () -> Unit,
onDelete: () -> Unit,
onCancel: () -> Unit = {},
) {
LazyColumn(
modifier = Modifier.fillMaxSize().statusBarsPadding(),
@@ -106,24 +101,6 @@ internal fun TransferDetails(
DetailValue(stringResource(Res.string.metadata_size), formatBytes(transfer.totalSize))
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
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 {
@@ -163,11 +140,6 @@ private fun receiversDescription(pending: Int, completed: Int): String = when {
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
private fun DetailDestination(title: String, description: String, count: Int? = null, onClick: () -> Unit) {
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.UiText
import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -71,19 +73,26 @@ class SettingsViewModel(
private val effects = Channel<SettingsEffect>(Channel.BUFFERED)
val effectFlow = effects.receiveAsFlow()
private var enableNotificationsAfterSettings = false
private var usernamePersistJob: Job? = null
init {
viewModelScope.launch {
preferencesRepository.preferences.collect { preferences ->
_state.update {
it.copy(
username = preferences.username,
val previousFolder = _state.value.receiveFolder
// While the user is typing, keep the in-progress value. DataStore
// 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,
themeMode = preferences.themeMode,
notificationsEnabled = preferences.notificationsEnabled,
)
}
validateFolder(preferences.receiveFolder)
if (preferences.receiveFolder != previousFolder) {
validateFolder(preferences.receiveFolder)
}
}
}
refreshNotificationPermission()
@@ -96,7 +105,12 @@ class SettingsViewModel(
}
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) {
@@ -203,4 +217,8 @@ class SettingsViewModel(
val status = fileSystemService.validateReceiveFolder(folder)
_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)
}
@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
fun settingsKeepsNotificationsDisabledWhenPermissionIsDenied() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))

View File

@@ -169,7 +169,9 @@ class FakePreferencesRepository(
) : PreferencesRepository {
val mutablePreferences = MutableStateFlow(initial)
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 resetReceiveFolder() = Unit
override suspend fun setThemeMode(mode: ThemeMode) { mutablePreferences.value = mutablePreferences.value.copy(themeMode = mode) }