mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
Merge pull request #7 from vnidrop/fix/ui-cleanup-username
fix(ui): username input and transfer details cleanup
This commit is contained in:
@@ -53,6 +53,5 @@ fun SendRoute(
|
||||
onRequestDelete = viewModel::requestDeleteTransfer,
|
||||
onDismissDelete = viewModel::dismissDeleteTransfer,
|
||||
onConfirmDelete = viewModel::confirmDeleteTransfer,
|
||||
onCancelTransfer = viewModel::cancelSelectedTransfer,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user