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,
|
onRequestDelete = viewModel::requestDeleteTransfer,
|
||||||
onDismissDelete = viewModel::dismissDeleteTransfer,
|
onDismissDelete = viewModel::dismissDeleteTransfer,
|
||||||
onConfirmDelete = viewModel::confirmDeleteTransfer,
|
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.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(
|
||||||
|
|||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
Reference in New Issue
Block a user