From 5b5126c6b4a4567e7e7c7efe0ae84cd3db9fc949 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Tue, 7 Jul 2026 17:53:01 +0200 Subject: [PATCH] Refactor app state handling --- .../commonMain/kotlin/com/vnidrop/app/App.kt | 133 +++------- .../com/vnidrop/app/VniDropAppViewModel.kt | 230 ++++++++++++++++++ .../vnidrop/app/ui/screens/ReceiveScreen.kt | 22 +- .../com/vnidrop/app/ui/screens/SendScreen.kt | 72 +++--- .../com/vnidrop/app/ui/state/AppUiModels.kt | 16 +- .../vnidrop/app/ui/state/AppUiModelsTest.kt | 22 ++ 6 files changed, 348 insertions(+), 147 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/VniDropAppViewModel.kt diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index 71af790..36b5770 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -3,19 +3,14 @@ package com.vnidrop.app import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview -import com.vnidrop.app.core.CoreRepository -import com.vnidrop.app.core.PickedShareFile +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import com.vnidrop.app.core.rememberShareFilePicker -import com.vnidrop.app.core.sharePickedFile import com.vnidrop.app.logging.AppLogger import com.vnidrop.app.platform.PlatformSystemAppearance import com.vnidrop.app.ui.navigation.AppDestination @@ -23,51 +18,49 @@ import com.vnidrop.app.ui.screens.ReceiveScreen import com.vnidrop.app.ui.screens.SendScreen import com.vnidrop.app.ui.screens.SettingsScreen import com.vnidrop.app.ui.shell.AppShell -import com.vnidrop.app.ui.state.AppUiState -import com.vnidrop.app.ui.state.ReceiveUiState -import com.vnidrop.app.ui.state.SendUiState import com.vnidrop.app.ui.state.windowClassFor import com.vnidrop.app.ui.theme.VniDropTheme import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme -import kotlinx.coroutines.launch @Composable @Preview fun App() { val platform = remember { getPlatform() } - val repository = remember { CoreRepository() } - val coreState by repository.state.collectAsState() - var appState by remember { mutableStateOf(AppUiState()) } - val appDataDir = platform.defaultCoreDataDir - var sendState by remember { mutableStateOf(SendUiState()) } - var receiveState by remember { mutableStateOf(ReceiveUiState(outputDirectory = platform.defaultReceiveDir)) } - var selectedFile by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() + val viewModel = viewModel { + VniDropAppViewModel( + appDataDir = platform.defaultCoreDataDir, + defaultReceiveDir = platform.defaultReceiveDir, + platformName = platform.name, + ) + } + val state by viewModel.state.collectAsStateWithLifecycle() + val coreState by viewModel.coreState.collectAsStateWithLifecycle() val clipboard = LocalClipboardManager.current val picker = rememberShareFilePicker( onFilePicked = { file -> - AppLogger.info("file-picker", "file selected", mapOf("name" to file.displayName)) - selectedFile = file - sendState = sendState.withSelectedFile(file) + viewModel.onEvent(VniDropAppEvent.ShareFilePicked(file)) }, onError = { error -> - AppLogger.warn("file-picker", "file picker error", mapOf("reason" to error)) - scope.launch { repository.setError(error) } + viewModel.onEvent(VniDropAppEvent.ShareFilePickFailed(error)) }, ) - LaunchedEffect(Unit) { - AppLogger.initialize(appDataDir) - AppLogger.info("lifecycle", "app started", mapOf("platform" to platform.name)) - AppLogger.info("core", "automatic initialize requested", mapOf("appDataDir" to appDataDir)) - repository.initialize(appDataDir) + LaunchedEffect(viewModel) { + viewModel.effectFlow.collect { effect -> + when (effect) { + VniDropAppEffect.OpenShareFilePicker -> { + AppLogger.info("file-picker", "open share file picker") + picker.pickFile() + } + is VniDropAppEffect.CopyTicket -> { + AppLogger.info("send", "ticket copied") + clipboard.setText(AnnotatedString(effect.ticket)) + } + } + } } - LaunchedEffect(coreState.lastShare?.transferId) { - coreState.lastShare?.let { share -> repository.refreshReceiverRequests(share.transferId) } - } - - val isDarkTheme = rememberResolvedDarkTheme(appState.themeMode) + val isDarkTheme = rememberResolvedDarkTheme(state.app.themeMode) PlatformSystemAppearance(isDarkTheme) LaunchedEffect(isDarkTheme) { AppLogger.info("appearance", "system appearance synchronized", mapOf("dark" to isDarkTheme.toString())) @@ -77,84 +70,30 @@ fun App() { BoxWithConstraints { val windowClass = windowClassFor(maxWidth.value) AppShell( - selectedDestination = appState.destination, + selectedDestination = state.app.destination, windowClass = windowClass, - onDestinationSelected = { appState = appState.copy(destination = it) }, + onDestinationSelected = { viewModel.onEvent(VniDropAppEvent.DestinationSelected(it)) }, ) { - when (appState.destination) { + when (state.app.destination) { AppDestination.Send -> SendScreen( coreState = coreState, - sendState = sendState, - onSendStateChange = { sendState = it }, - onSelectFile = { - AppLogger.info("file-picker", "open share file picker") - picker.pickFile() - }, - onCreateShare = { - scope.launch { - AppLogger.info("send", "create share requested", mapOf("source" to sendState.selectedSource)) - sendState = sendState.copy(isSharing = true) - val file = selectedFile - if (file == null) { - repository.sharePath(sendState.selectedSource, sendState.transferName, sendState.senderName) - } else { - sharePickedFile(repository, file, sendState.transferName, sendState.senderName) - } - sendState = sendState.copy(isSharing = false) - } - }, - onCopyTicket = { ticket -> - AppLogger.info("send", "ticket copied") - clipboard.setText(AnnotatedString(ticket)) - }, - onUseLocally = { ticket -> - receiveState = receiveState.copy(ticket = ticket) - appState = appState.copy(destination = AppDestination.Receive) - }, - onRefreshRequests = { transferId -> scope.launch { repository.refreshReceiverRequests(transferId) } }, - onRespondRequest = { requestId, accepted -> - scope.launch { - repository.respondReceiverRequest( - requestId = requestId, - accepted = accepted, - reason = if (accepted) null else "sender-refused", - ) - } - }, + sendState = state.send, + onEvent = viewModel::onEvent, ) AppDestination.Receive -> ReceiveScreen( coreState = coreState, - receiveState = receiveState, - onReceiveStateChange = { receiveState = it }, - onInspect = { scope.launch { repository.inspectTicket(receiveState.ticket) } }, - onReceive = { - scope.launch { - AppLogger.info("receive", "receive requested") - receiveState = receiveState.copy(isReceiving = true) - repository.receive(receiveState.ticket, receiveState.outputDirectory, receiveState.receiverName) - receiveState = receiveState.copy(isReceiving = false) - } - }, + receiveState = state.receive, + onEvent = viewModel::onEvent, ) AppDestination.Settings -> SettingsScreen( deviceInfo = platform.deviceInfo, coreState = coreState, - themeMode = appState.themeMode, + themeMode = state.app.themeMode, windowClass = windowClass, - onThemeModeChange = { - AppLogger.info("appearance", "theme mode changed", mapOf("mode" to it.name)) - appState = appState.copy(themeMode = it) - }, + onThemeModeChange = { viewModel.onEvent(VniDropAppEvent.ThemeModeChanged(it)) }, ) } } } } } - -private fun SendUiState.withSelectedFile(file: PickedShareFile): SendUiState = - copy( - selectedSource = file.value, - selectedDisplayName = file.displayName, - transferName = if (transferName == "VniDrop transfer" || transferName.isBlank()) file.displayName else transferName, - ) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/VniDropAppViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/VniDropAppViewModel.kt new file mode 100644 index 0000000..84746e9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/VniDropAppViewModel.kt @@ -0,0 +1,230 @@ +package com.vnidrop.app + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vnidrop.app.core.CoreRepository +import com.vnidrop.app.core.CoreUiState +import com.vnidrop.app.core.PickedShareFile +import com.vnidrop.app.core.sharePickedFile +import com.vnidrop.app.logging.AppLogger +import com.vnidrop.app.ui.navigation.AppDestination +import com.vnidrop.app.ui.state.AppUiState +import com.vnidrop.app.ui.state.ReceiveUiState +import com.vnidrop.app.ui.state.SendUiState +import com.vnidrop.app.ui.theme.ThemeMode +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class VniDropAppState( + val app: AppUiState = AppUiState(), + val send: SendUiState = SendUiState(), + val receive: ReceiveUiState = ReceiveUiState(), +) + +sealed interface VniDropAppEvent { + data class DestinationSelected(val destination: AppDestination) : VniDropAppEvent + data class ThemeModeChanged(val mode: ThemeMode) : VniDropAppEvent + data object SelectFileClicked : VniDropAppEvent + data class ShareFilePicked(val file: PickedShareFile) : VniDropAppEvent + data class ShareFilePickFailed(val reason: String) : VniDropAppEvent + data object ClearSelectedSourceClicked : VniDropAppEvent + data class TransferNameChanged(val value: String) : VniDropAppEvent + data class SenderNameChanged(val value: String) : VniDropAppEvent + data object CreateShareClicked : VniDropAppEvent + data class CopyTicketClicked(val ticket: String) : VniDropAppEvent + data class UseTicketLocallyClicked(val ticket: String) : VniDropAppEvent + data class RefreshReceiverRequestsClicked(val transferId: ULong) : VniDropAppEvent + data class RespondReceiverRequestClicked(val requestId: String, val accepted: Boolean) : VniDropAppEvent + data class ReceiveTicketChanged(val value: String) : VniDropAppEvent + data class OutputDirectoryChanged(val value: String) : VniDropAppEvent + data class ReceiverNameChanged(val value: String) : VniDropAppEvent + data object InspectTicketClicked : VniDropAppEvent + data object ReceiveClicked : VniDropAppEvent +} + +sealed interface VniDropAppEffect { + data object OpenShareFilePicker : VniDropAppEffect + data class CopyTicket(val ticket: String) : VniDropAppEffect +} + +class VniDropAppViewModel( + appDataDir: String, + defaultReceiveDir: String, + platformName: String, + private val repository: CoreRepository = CoreRepository(), +) : ViewModel() { + private val _state = MutableStateFlow(VniDropAppState(receive = ReceiveUiState(outputDirectory = defaultReceiveDir))) + val state: StateFlow = _state + val coreState: StateFlow = repository.state + + private val effects = Channel(Channel.BUFFERED) + val effectFlow = effects.receiveAsFlow() + + private var selectedFile: PickedShareFile? = null + + init { + AppLogger.initialize(appDataDir) + AppLogger.info("lifecycle", "app started", mapOf("platform" to platformName)) + AppLogger.info("core", "automatic initialize requested", mapOf("appDataDir" to appDataDir)) + viewModelScope.launch { + repository.initialize(appDataDir) + } + } + + fun onEvent(event: VniDropAppEvent) { + when (event) { + is VniDropAppEvent.DestinationSelected -> updateAppState { copy(destination = event.destination) } + is VniDropAppEvent.ThemeModeChanged -> setThemeMode(event.mode) + VniDropAppEvent.SelectFileClicked -> sendEffect(VniDropAppEffect.OpenShareFilePicker) + is VniDropAppEvent.ShareFilePicked -> setSelectedFile(event.file) + is VniDropAppEvent.ShareFilePickFailed -> setFilePickerError(event.reason) + VniDropAppEvent.ClearSelectedSourceClicked -> clearSelectedSource() + is VniDropAppEvent.TransferNameChanged -> updateSendState { copy(transferName = event.value) } + is VniDropAppEvent.SenderNameChanged -> updateSendState { copy(senderName = event.value) } + VniDropAppEvent.CreateShareClicked -> createShare() + is VniDropAppEvent.CopyTicketClicked -> sendEffect(VniDropAppEffect.CopyTicket(event.ticket)) + is VniDropAppEvent.UseTicketLocallyClicked -> useTicketLocally(event.ticket) + is VniDropAppEvent.RefreshReceiverRequestsClicked -> refreshReceiverRequests(event.transferId) + is VniDropAppEvent.RespondReceiverRequestClicked -> respondReceiverRequest(event.requestId, event.accepted) + is VniDropAppEvent.ReceiveTicketChanged -> updateReceiveState { copy(ticket = event.value) } + is VniDropAppEvent.OutputDirectoryChanged -> updateReceiveState { copy(outputDirectory = event.value) } + is VniDropAppEvent.ReceiverNameChanged -> updateReceiveState { copy(receiverName = event.value) } + VniDropAppEvent.InspectTicketClicked -> inspectTicket() + VniDropAppEvent.ReceiveClicked -> receive() + } + } + + private fun setThemeMode(mode: ThemeMode) { + AppLogger.info("appearance", "theme mode changed", mapOf("mode" to mode.name)) + updateAppState { copy(themeMode = mode) } + } + + private fun setSelectedFile(file: PickedShareFile) { + AppLogger.info("file-picker", "file selected", mapOf("name" to file.displayName)) + selectedFile = file + updateSendState { withSelectedFile(file) } + } + + private fun setFilePickerError(reason: String) { + AppLogger.warn("file-picker", "file picker error", mapOf("reason" to reason)) + viewModelScope.launch { repository.setError(reason) } + } + + private fun clearSelectedSource() { + selectedFile = null + updateSendState { + copy( + selectedSource = "", + selectedDisplayName = "", + ) + } + } + + private fun createShare() { + val sendState = state.value.send + if (!sendState.canCreateShare(coreState.value.isInitialized)) return + + viewModelScope.launch { + AppLogger.info("send", "create share requested", mapOf("source" to sendState.selectedSource)) + updateSendState { copy(isSharing = true) } + try { + val file = selectedFile + if (file == null) { + repository.sharePath(sendState.selectedSource, sendState.transferName, sendState.senderName) + } else { + sharePickedFile(repository, file, sendState.transferName, sendState.senderName) + } + repository.state.value.lastShare?.let { share -> + repository.refreshReceiverRequests(share.transferId) + } + } finally { + updateSendState { copy(isSharing = false) } + } + } + } + + private fun useTicketLocally(ticket: String) { + updateReceiveState { copy(ticket = ticket) } + updateAppState { copy(destination = AppDestination.Receive) } + } + + private fun refreshReceiverRequests(transferId: ULong) { + viewModelScope.launch { + repository.refreshReceiverRequests(transferId) + } + } + + private fun respondReceiverRequest(requestId: String, accepted: Boolean) { + viewModelScope.launch { + repository.respondReceiverRequest( + requestId = requestId, + accepted = accepted, + reason = if (accepted) null else "sender-refused", + ) + } + } + + private fun inspectTicket() { + val receiveState = state.value.receive + if (!receiveState.canInspect(coreState.value.isInitialized)) return + + viewModelScope.launch { + repository.inspectTicket(receiveState.ticket) + } + } + + private fun receive() { + val receiveState = state.value.receive + if (!receiveState.canReceive(coreState.value.isInitialized)) return + + viewModelScope.launch { + AppLogger.info("receive", "receive requested") + updateReceiveState { copy(isReceiving = true) } + try { + repository.receive(receiveState.ticket, receiveState.outputDirectory, receiveState.receiverName) + } finally { + updateReceiveState { copy(isReceiving = false) } + } + } + } + + private fun sendEffect(effect: VniDropAppEffect) { + viewModelScope.launch { + effects.send(effect) + } + } + + private fun updateAppState(reducer: AppUiState.() -> AppUiState) { + _state.update { current -> + val next = current.app.reducer() + if (next == current.app) current else current.copy(app = next) + } + } + + private fun updateSendState(reducer: SendUiState.() -> SendUiState) { + _state.update { current -> + val next = current.send.reducer() + if (next == current.send) current else current.copy(send = next) + } + } + + private fun updateReceiveState(reducer: ReceiveUiState.() -> ReceiveUiState) { + _state.update { current -> + val next = current.receive.reducer() + if (next == current.receive) current else current.copy(receive = next) + } + } +} + +private fun SendUiState.withSelectedFile(file: PickedShareFile): SendUiState = + copy( + selectedSource = file.value, + selectedDisplayName = file.displayName, + transferName = if (transferName == DefaultTransferName || transferName.isBlank()) file.displayName else transferName, + ) + +private const val DefaultTransferName = "VniDrop transfer" diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ReceiveScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ReceiveScreen.kt index d0b4beb..eb2adef 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ReceiveScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/ReceiveScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp +import com.vnidrop.app.VniDropAppEvent import com.vnidrop.app.core.CoreUiState import com.vnidrop.app.ui.components.AppCard import com.vnidrop.app.ui.components.Field @@ -27,9 +28,7 @@ import vnidrop.shared.generated.resources.ticket_card_title fun ReceiveScreen( coreState: CoreUiState, receiveState: ReceiveUiState, - onReceiveStateChange: (ReceiveUiState) -> Unit, - onInspect: () -> Unit, - onReceive: () -> Unit, + onEvent: (VniDropAppEvent) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { ScreenHeader(stringResource(Res.string.receive_title), stringResource(Res.string.receive_subtitle)) @@ -37,33 +36,30 @@ fun ReceiveScreen( AppCard(title = stringResource(Res.string.ticket_card_title)) { Field( value = receiveState.ticket, - onValueChange = { onReceiveStateChange(receiveState.copy(ticket = it)) }, + onValueChange = { onEvent(VniDropAppEvent.ReceiveTicketChanged(it)) }, label = stringResource(Res.string.field_ticket), minLines = 4, ) Field( value = receiveState.outputDirectory, - onValueChange = { onReceiveStateChange(receiveState.copy(outputDirectory = it)) }, + onValueChange = { onEvent(VniDropAppEvent.OutputDirectoryChanged(it)) }, label = stringResource(Res.string.field_output_directory), ) Field( value = receiveState.receiverName, - onValueChange = { onReceiveStateChange(receiveState.copy(receiverName = it)) }, + onValueChange = { onEvent(VniDropAppEvent.ReceiverNameChanged(it)) }, label = stringResource(Res.string.field_receiver_name), ) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { SecondaryButton( text = stringResource(Res.string.button_inspect_ticket), - onClick = onInspect, - enabled = coreState.isInitialized && receiveState.ticket.isNotBlank(), + onClick = { onEvent(VniDropAppEvent.InspectTicketClicked) }, + enabled = receiveState.canInspect(coreState.isInitialized), ) PrimaryButton( text = if (receiveState.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive), - onClick = onReceive, - enabled = coreState.isInitialized && - receiveState.ticket.isNotBlank() && - receiveState.outputDirectory.isNotBlank() && - !receiveState.isReceiving, + onClick = { onEvent(VniDropAppEvent.ReceiveClicked) }, + enabled = receiveState.canReceive(coreState.isInitialized), ) } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendScreen.kt index a5c0ee5..9c41ccf 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.vnidrop.app.VniDropAppEvent import com.vnidrop.app.core.CoreUiState import com.vnidrop.app.ui.components.AppCard import com.vnidrop.app.ui.components.Field @@ -48,36 +49,25 @@ import vnidrop.shared.generated.resources.transfer_details_title fun SendScreen( coreState: CoreUiState, sendState: SendUiState, - onSendStateChange: (SendUiState) -> Unit, - onSelectFile: () -> Unit, - onCreateShare: () -> Unit, - onCopyTicket: (String) -> Unit, - onUseLocally: (String) -> Unit, - onRefreshRequests: (ULong) -> Unit, - onRespondRequest: (String, Boolean) -> Unit, + onEvent: (VniDropAppEvent) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle)) ErrorSection(coreState) SendSourceCard( sendState = sendState, - onSendStateChange = onSendStateChange, - onSelectFile = onSelectFile, + onEvent = onEvent, ) SendDetailsCard( coreState = coreState, sendState = sendState, - onSendStateChange = onSendStateChange, - onCreateShare = onCreateShare, + onEvent = onEvent, ) coreState.lastShare?.let { share -> ShareResultCard( share = share, requests = coreState.receiverRequests, - onCopyTicket = onCopyTicket, - onUseLocally = onUseLocally, - onRefreshRequests = onRefreshRequests, - onRespondRequest = onRespondRequest, + onEvent = onEvent, ) } ProgressSection(coreState) @@ -87,10 +77,9 @@ fun SendScreen( @Composable private fun SendSourceCard( sendState: SendUiState, - onSendStateChange: (SendUiState) -> Unit, - onSelectFile: () -> Unit, + onEvent: (VniDropAppEvent) -> Unit, ) { - AppCard(title = stringResource(Res.string.source_title)) { + AppCard(title = stringResource(Res.string.source_title)) { if (sendState.selectedSource.isBlank()) { EmptyText(stringResource(Res.string.send_source_empty)) } else { @@ -98,11 +87,14 @@ private fun SendSourceCard( MetadataRow(stringResource(Res.string.metadata_source), sendState.selectedSource) } Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - PrimaryButton(stringResource(Res.string.button_select_file), onClick = onSelectFile) + PrimaryButton( + text = stringResource(Res.string.button_select_file), + onClick = { onEvent(VniDropAppEvent.SelectFileClicked) }, + ) SecondaryButton( text = stringResource(Res.string.button_clear), - onClick = { onSendStateChange(sendState.copy(selectedSource = "", selectedDisplayName = "")) }, - enabled = sendState.selectedSource.isNotBlank(), + onClick = { onEvent(VniDropAppEvent.ClearSelectedSourceClicked) }, + enabled = sendState.hasSelectedSource, ) } } @@ -112,24 +104,23 @@ private fun SendSourceCard( private fun SendDetailsCard( coreState: CoreUiState, sendState: SendUiState, - onSendStateChange: (SendUiState) -> Unit, - onCreateShare: () -> Unit, + onEvent: (VniDropAppEvent) -> Unit, ) { AppCard(title = stringResource(Res.string.transfer_details_title)) { Field( value = sendState.transferName, - onValueChange = { onSendStateChange(sendState.copy(transferName = it)) }, + onValueChange = { onEvent(VniDropAppEvent.TransferNameChanged(it)) }, label = stringResource(Res.string.field_transfer_name), ) Field( value = sendState.senderName, - onValueChange = { onSendStateChange(sendState.copy(senderName = it)) }, + onValueChange = { onEvent(VniDropAppEvent.SenderNameChanged(it)) }, label = stringResource(Res.string.field_sender_name), ) PrimaryButton( text = if (sendState.isSharing) stringResource(Res.string.button_creating_ticket) else stringResource(Res.string.button_create_share_ticket), - onClick = onCreateShare, - enabled = coreState.isInitialized && sendState.selectedSource.isNotBlank() && !sendState.isSharing, + onClick = { onEvent(VniDropAppEvent.CreateShareClicked) }, + enabled = sendState.canCreateShare(coreState.isInitialized), ) } } @@ -138,10 +129,7 @@ private fun SendDetailsCard( private fun ShareResultCard( share: ShareResult, requests: List, - onCopyTicket: (String) -> Unit, - onUseLocally: (String) -> Unit, - onRefreshRequests: (ULong) -> Unit, - onRespondRequest: (String, Boolean) -> Unit, + onEvent: (VniDropAppEvent) -> Unit, ) { AppCard(title = stringResource(Res.string.share_ticket_title), trailing = { StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand) @@ -150,14 +138,28 @@ private fun ShareResultCard( 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 = { onUseLocally(share.ticket) }) - SecondaryButton(stringResource(Res.string.button_refresh), onClick = { onRefreshRequests(share.transferId) }) + PrimaryButton( + text = stringResource(Res.string.button_copy), + onClick = { onEvent(VniDropAppEvent.CopyTicketClicked(share.ticket)) }, + ) + SecondaryButton( + text = stringResource(Res.string.button_use_locally), + onClick = { onEvent(VniDropAppEvent.UseTicketLocallyClicked(share.ticket)) }, + ) + SecondaryButton( + text = stringResource(Res.string.button_refresh), + onClick = { onEvent(VniDropAppEvent.RefreshReceiverRequestsClicked(share.transferId)) }, + ) } if (requests.isNotEmpty()) { SectionDivider() Text(stringResource(Res.string.receiver_requests_title), fontWeight = FontWeight.SemiBold) - ReceiverRequestList(requests = requests, onRespondRequest = onRespondRequest) + ReceiverRequestList( + requests = requests, + onRespondRequest = { requestId, accepted -> + onEvent(VniDropAppEvent.RespondReceiverRequestClicked(requestId, accepted)) + }, + ) } } } 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 8f4bcaa..6e34ad8 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 @@ -33,14 +33,26 @@ data class SendUiState( val transferName: String = "VniDrop transfer", val senderName: String = "", val isSharing: Boolean = false, -) +) { + val hasSelectedSource: Boolean + get() = selectedSource.isNotBlank() + + fun canCreateShare(isCoreInitialized: Boolean): Boolean = + isCoreInitialized && hasSelectedSource && !isSharing +} data class ReceiveUiState( val ticket: String = "", val outputDirectory: String = "", val receiverName: String = "", val isReceiving: Boolean = false, -) +) { + fun canInspect(isCoreInitialized: Boolean): Boolean = + isCoreInitialized && ticket.isNotBlank() + + fun canReceive(isCoreInitialized: Boolean): Boolean = + isCoreInitialized && ticket.isNotBlank() && outputDirectory.isNotBlank() && !isReceiving +} data class TransferProgress( val transferId: ULong?, 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 639c111..d991a3a 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 @@ -47,4 +47,26 @@ class AppUiModelsTest { assertEquals("58 B", formatBytes(58UL)) assertEquals("1.5 KB", formatBytes(1536UL)) } + + @Test + fun sendStateExposesShareEligibility() { + val ready = SendUiState(selectedSource = "/tmp/payload.txt") + + assertTrue(ready.canCreateShare(isCoreInitialized = true)) + assertFalse(ready.canCreateShare(isCoreInitialized = false)) + assertFalse(SendUiState().canCreateShare(isCoreInitialized = true)) + assertFalse(ready.copy(isSharing = true).canCreateShare(isCoreInitialized = true)) + } + + @Test + fun receiveStateExposesInspectAndReceiveEligibility() { + val ready = ReceiveUiState(ticket = "ticket", outputDirectory = "/tmp/out") + + assertTrue(ready.canInspect(isCoreInitialized = true)) + assertTrue(ready.canReceive(isCoreInitialized = true)) + assertFalse(ready.canInspect(isCoreInitialized = false)) + assertFalse(ready.copy(ticket = "").canReceive(isCoreInitialized = true)) + assertFalse(ready.copy(outputDirectory = "").canReceive(isCoreInitialized = true)) + assertFalse(ready.copy(isReceiving = true).canReceive(isCoreInitialized = true)) + } }