Refactor app state handling

This commit is contained in:
2026-07-07 17:53:01 +02:00
parent fd4fff2c86
commit 5b5126c6b4
6 changed files with 348 additions and 147 deletions

View File

@@ -3,19 +3,14 @@ package com.vnidrop.app
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import com.vnidrop.app.core.CoreRepository import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vnidrop.app.core.PickedShareFile import androidx.lifecycle.viewmodel.compose.viewModel
import com.vnidrop.app.core.rememberShareFilePicker import com.vnidrop.app.core.rememberShareFilePicker
import com.vnidrop.app.core.sharePickedFile
import com.vnidrop.app.logging.AppLogger import com.vnidrop.app.logging.AppLogger
import com.vnidrop.app.platform.PlatformSystemAppearance import com.vnidrop.app.platform.PlatformSystemAppearance
import com.vnidrop.app.ui.navigation.AppDestination 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.SendScreen
import com.vnidrop.app.ui.screens.SettingsScreen import com.vnidrop.app.ui.screens.SettingsScreen
import com.vnidrop.app.ui.shell.AppShell 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.state.windowClassFor
import com.vnidrop.app.ui.theme.VniDropTheme import com.vnidrop.app.ui.theme.VniDropTheme
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
import kotlinx.coroutines.launch
@Composable @Composable
@Preview @Preview
fun App() { fun App() {
val platform = remember { getPlatform() } val platform = remember { getPlatform() }
val repository = remember { CoreRepository() } val viewModel = viewModel {
val coreState by repository.state.collectAsState() VniDropAppViewModel(
var appState by remember { mutableStateOf(AppUiState()) } appDataDir = platform.defaultCoreDataDir,
val appDataDir = platform.defaultCoreDataDir defaultReceiveDir = platform.defaultReceiveDir,
var sendState by remember { mutableStateOf(SendUiState()) } platformName = platform.name,
var receiveState by remember { mutableStateOf(ReceiveUiState(outputDirectory = platform.defaultReceiveDir)) } )
var selectedFile by remember { mutableStateOf<PickedShareFile?>(null) } }
val scope = rememberCoroutineScope() val state by viewModel.state.collectAsStateWithLifecycle()
val coreState by viewModel.coreState.collectAsStateWithLifecycle()
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
val picker = rememberShareFilePicker( val picker = rememberShareFilePicker(
onFilePicked = { file -> onFilePicked = { file ->
AppLogger.info("file-picker", "file selected", mapOf("name" to file.displayName)) viewModel.onEvent(VniDropAppEvent.ShareFilePicked(file))
selectedFile = file
sendState = sendState.withSelectedFile(file)
}, },
onError = { error -> onError = { error ->
AppLogger.warn("file-picker", "file picker error", mapOf("reason" to error)) viewModel.onEvent(VniDropAppEvent.ShareFilePickFailed(error))
scope.launch { repository.setError(error) }
}, },
) )
LaunchedEffect(Unit) { LaunchedEffect(viewModel) {
AppLogger.initialize(appDataDir) viewModel.effectFlow.collect { effect ->
AppLogger.info("lifecycle", "app started", mapOf("platform" to platform.name)) when (effect) {
AppLogger.info("core", "automatic initialize requested", mapOf("appDataDir" to appDataDir)) VniDropAppEffect.OpenShareFilePicker -> {
repository.initialize(appDataDir) 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) { val isDarkTheme = rememberResolvedDarkTheme(state.app.themeMode)
coreState.lastShare?.let { share -> repository.refreshReceiverRequests(share.transferId) }
}
val isDarkTheme = rememberResolvedDarkTheme(appState.themeMode)
PlatformSystemAppearance(isDarkTheme) PlatformSystemAppearance(isDarkTheme)
LaunchedEffect(isDarkTheme) { LaunchedEffect(isDarkTheme) {
AppLogger.info("appearance", "system appearance synchronized", mapOf("dark" to isDarkTheme.toString())) AppLogger.info("appearance", "system appearance synchronized", mapOf("dark" to isDarkTheme.toString()))
@@ -77,84 +70,30 @@ fun App() {
BoxWithConstraints { BoxWithConstraints {
val windowClass = windowClassFor(maxWidth.value) val windowClass = windowClassFor(maxWidth.value)
AppShell( AppShell(
selectedDestination = appState.destination, selectedDestination = state.app.destination,
windowClass = windowClass, windowClass = windowClass,
onDestinationSelected = { appState = appState.copy(destination = it) }, onDestinationSelected = { viewModel.onEvent(VniDropAppEvent.DestinationSelected(it)) },
) { ) {
when (appState.destination) { when (state.app.destination) {
AppDestination.Send -> SendScreen( AppDestination.Send -> SendScreen(
coreState = coreState, coreState = coreState,
sendState = sendState, sendState = state.send,
onSendStateChange = { sendState = it }, onEvent = viewModel::onEvent,
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",
)
}
},
) )
AppDestination.Receive -> ReceiveScreen( AppDestination.Receive -> ReceiveScreen(
coreState = coreState, coreState = coreState,
receiveState = receiveState, receiveState = state.receive,
onReceiveStateChange = { receiveState = it }, onEvent = viewModel::onEvent,
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)
}
},
) )
AppDestination.Settings -> SettingsScreen( AppDestination.Settings -> SettingsScreen(
deviceInfo = platform.deviceInfo, deviceInfo = platform.deviceInfo,
coreState = coreState, coreState = coreState,
themeMode = appState.themeMode, themeMode = state.app.themeMode,
windowClass = windowClass, windowClass = windowClass,
onThemeModeChange = { onThemeModeChange = { viewModel.onEvent(VniDropAppEvent.ThemeModeChanged(it)) },
AppLogger.info("appearance", "theme mode changed", mapOf("mode" to it.name))
appState = appState.copy(themeMode = 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,
)

View File

@@ -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<VniDropAppState> = _state
val coreState: StateFlow<CoreUiState> = repository.state
private val effects = Channel<VniDropAppEffect>(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"

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vnidrop.app.VniDropAppEvent
import com.vnidrop.app.core.CoreUiState import com.vnidrop.app.core.CoreUiState
import com.vnidrop.app.ui.components.AppCard import com.vnidrop.app.ui.components.AppCard
import com.vnidrop.app.ui.components.Field import com.vnidrop.app.ui.components.Field
@@ -27,9 +28,7 @@ import vnidrop.shared.generated.resources.ticket_card_title
fun ReceiveScreen( fun ReceiveScreen(
coreState: CoreUiState, coreState: CoreUiState,
receiveState: ReceiveUiState, receiveState: ReceiveUiState,
onReceiveStateChange: (ReceiveUiState) -> Unit, onEvent: (VniDropAppEvent) -> Unit,
onInspect: () -> Unit,
onReceive: () -> Unit,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
ScreenHeader(stringResource(Res.string.receive_title), stringResource(Res.string.receive_subtitle)) 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)) { AppCard(title = stringResource(Res.string.ticket_card_title)) {
Field( Field(
value = receiveState.ticket, value = receiveState.ticket,
onValueChange = { onReceiveStateChange(receiveState.copy(ticket = it)) }, onValueChange = { onEvent(VniDropAppEvent.ReceiveTicketChanged(it)) },
label = stringResource(Res.string.field_ticket), label = stringResource(Res.string.field_ticket),
minLines = 4, minLines = 4,
) )
Field( Field(
value = receiveState.outputDirectory, value = receiveState.outputDirectory,
onValueChange = { onReceiveStateChange(receiveState.copy(outputDirectory = it)) }, onValueChange = { onEvent(VniDropAppEvent.OutputDirectoryChanged(it)) },
label = stringResource(Res.string.field_output_directory), label = stringResource(Res.string.field_output_directory),
) )
Field( Field(
value = receiveState.receiverName, value = receiveState.receiverName,
onValueChange = { onReceiveStateChange(receiveState.copy(receiverName = it)) }, onValueChange = { onEvent(VniDropAppEvent.ReceiverNameChanged(it)) },
label = stringResource(Res.string.field_receiver_name), label = stringResource(Res.string.field_receiver_name),
) )
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SecondaryButton( SecondaryButton(
text = stringResource(Res.string.button_inspect_ticket), text = stringResource(Res.string.button_inspect_ticket),
onClick = onInspect, onClick = { onEvent(VniDropAppEvent.InspectTicketClicked) },
enabled = coreState.isInitialized && receiveState.ticket.isNotBlank(), enabled = receiveState.canInspect(coreState.isInitialized),
) )
PrimaryButton( PrimaryButton(
text = if (receiveState.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive), text = if (receiveState.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive),
onClick = onReceive, onClick = { onEvent(VniDropAppEvent.ReceiveClicked) },
enabled = coreState.isInitialized && enabled = receiveState.canReceive(coreState.isInitialized),
receiveState.ticket.isNotBlank() &&
receiveState.outputDirectory.isNotBlank() &&
!receiveState.isReceiving,
) )
} }
} }

View File

@@ -7,6 +7,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vnidrop.app.VniDropAppEvent
import com.vnidrop.app.core.CoreUiState import com.vnidrop.app.core.CoreUiState
import com.vnidrop.app.ui.components.AppCard import com.vnidrop.app.ui.components.AppCard
import com.vnidrop.app.ui.components.Field import com.vnidrop.app.ui.components.Field
@@ -48,36 +49,25 @@ import vnidrop.shared.generated.resources.transfer_details_title
fun SendScreen( fun SendScreen(
coreState: CoreUiState, coreState: CoreUiState,
sendState: SendUiState, sendState: SendUiState,
onSendStateChange: (SendUiState) -> Unit, onEvent: (VniDropAppEvent) -> Unit,
onSelectFile: () -> Unit,
onCreateShare: () -> Unit,
onCopyTicket: (String) -> Unit,
onUseLocally: (String) -> Unit,
onRefreshRequests: (ULong) -> Unit,
onRespondRequest: (String, Boolean) -> Unit,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle)) ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle))
ErrorSection(coreState) ErrorSection(coreState)
SendSourceCard( SendSourceCard(
sendState = sendState, sendState = sendState,
onSendStateChange = onSendStateChange, onEvent = onEvent,
onSelectFile = onSelectFile,
) )
SendDetailsCard( SendDetailsCard(
coreState = coreState, coreState = coreState,
sendState = sendState, sendState = sendState,
onSendStateChange = onSendStateChange, onEvent = onEvent,
onCreateShare = onCreateShare,
) )
coreState.lastShare?.let { share -> coreState.lastShare?.let { share ->
ShareResultCard( ShareResultCard(
share = share, share = share,
requests = coreState.receiverRequests, requests = coreState.receiverRequests,
onCopyTicket = onCopyTicket, onEvent = onEvent,
onUseLocally = onUseLocally,
onRefreshRequests = onRefreshRequests,
onRespondRequest = onRespondRequest,
) )
} }
ProgressSection(coreState) ProgressSection(coreState)
@@ -87,10 +77,9 @@ fun SendScreen(
@Composable @Composable
private fun SendSourceCard( private fun SendSourceCard(
sendState: SendUiState, sendState: SendUiState,
onSendStateChange: (SendUiState) -> Unit, onEvent: (VniDropAppEvent) -> Unit,
onSelectFile: () -> Unit,
) { ) {
AppCard(title = stringResource(Res.string.source_title)) { AppCard(title = stringResource(Res.string.source_title)) {
if (sendState.selectedSource.isBlank()) { if (sendState.selectedSource.isBlank()) {
EmptyText(stringResource(Res.string.send_source_empty)) EmptyText(stringResource(Res.string.send_source_empty))
} else { } else {
@@ -98,11 +87,14 @@ private fun SendSourceCard(
MetadataRow(stringResource(Res.string.metadata_source), sendState.selectedSource) MetadataRow(stringResource(Res.string.metadata_source), sendState.selectedSource)
} }
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { 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( SecondaryButton(
text = stringResource(Res.string.button_clear), text = stringResource(Res.string.button_clear),
onClick = { onSendStateChange(sendState.copy(selectedSource = "", selectedDisplayName = "")) }, onClick = { onEvent(VniDropAppEvent.ClearSelectedSourceClicked) },
enabled = sendState.selectedSource.isNotBlank(), enabled = sendState.hasSelectedSource,
) )
} }
} }
@@ -112,24 +104,23 @@ private fun SendSourceCard(
private fun SendDetailsCard( private fun SendDetailsCard(
coreState: CoreUiState, coreState: CoreUiState,
sendState: SendUiState, sendState: SendUiState,
onSendStateChange: (SendUiState) -> Unit, onEvent: (VniDropAppEvent) -> Unit,
onCreateShare: () -> Unit,
) { ) {
AppCard(title = stringResource(Res.string.transfer_details_title)) { AppCard(title = stringResource(Res.string.transfer_details_title)) {
Field( Field(
value = sendState.transferName, value = sendState.transferName,
onValueChange = { onSendStateChange(sendState.copy(transferName = it)) }, onValueChange = { onEvent(VniDropAppEvent.TransferNameChanged(it)) },
label = stringResource(Res.string.field_transfer_name), label = stringResource(Res.string.field_transfer_name),
) )
Field( Field(
value = sendState.senderName, value = sendState.senderName,
onValueChange = { onSendStateChange(sendState.copy(senderName = it)) }, onValueChange = { onEvent(VniDropAppEvent.SenderNameChanged(it)) },
label = stringResource(Res.string.field_sender_name), label = stringResource(Res.string.field_sender_name),
) )
PrimaryButton( PrimaryButton(
text = if (sendState.isSharing) stringResource(Res.string.button_creating_ticket) else stringResource(Res.string.button_create_share_ticket), text = if (sendState.isSharing) stringResource(Res.string.button_creating_ticket) else stringResource(Res.string.button_create_share_ticket),
onClick = onCreateShare, onClick = { onEvent(VniDropAppEvent.CreateShareClicked) },
enabled = coreState.isInitialized && sendState.selectedSource.isNotBlank() && !sendState.isSharing, enabled = sendState.canCreateShare(coreState.isInitialized),
) )
} }
} }
@@ -138,10 +129,7 @@ private fun SendDetailsCard(
private fun ShareResultCard( private fun ShareResultCard(
share: ShareResult, share: ShareResult,
requests: List<ReceiverRequest>, requests: List<ReceiverRequest>,
onCopyTicket: (String) -> Unit, onEvent: (VniDropAppEvent) -> Unit,
onUseLocally: (String) -> Unit,
onRefreshRequests: (ULong) -> Unit,
onRespondRequest: (String, Boolean) -> Unit,
) { ) {
AppCard(title = stringResource(Res.string.share_ticket_title), trailing = { AppCard(title = stringResource(Res.string.share_ticket_title), trailing = {
StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand) 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)) MetadataRow(stringResource(Res.string.metadata_size), formatBytes(share.totalSize))
TicketText(share.ticket) TicketText(share.ticket)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
PrimaryButton(stringResource(Res.string.button_copy), onClick = { onCopyTicket(share.ticket) }) PrimaryButton(
SecondaryButton(stringResource(Res.string.button_use_locally), onClick = { onUseLocally(share.ticket) }) text = stringResource(Res.string.button_copy),
SecondaryButton(stringResource(Res.string.button_refresh), onClick = { onRefreshRequests(share.transferId) }) 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()) { if (requests.isNotEmpty()) {
SectionDivider() SectionDivider()
Text(stringResource(Res.string.receiver_requests_title), fontWeight = FontWeight.SemiBold) 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))
},
)
} }
} }
} }

View File

@@ -33,14 +33,26 @@ data class SendUiState(
val transferName: String = "VniDrop transfer", val transferName: String = "VniDrop transfer",
val senderName: String = "", val senderName: String = "",
val isSharing: Boolean = false, val isSharing: Boolean = false,
) ) {
val hasSelectedSource: Boolean
get() = selectedSource.isNotBlank()
fun canCreateShare(isCoreInitialized: Boolean): Boolean =
isCoreInitialized && hasSelectedSource && !isSharing
}
data class ReceiveUiState( data class ReceiveUiState(
val ticket: String = "", val ticket: String = "",
val outputDirectory: String = "", val outputDirectory: String = "",
val receiverName: String = "", val receiverName: String = "",
val isReceiving: Boolean = false, 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( data class TransferProgress(
val transferId: ULong?, val transferId: ULong?,

View File

@@ -47,4 +47,26 @@ class AppUiModelsTest {
assertEquals("58 B", formatBytes(58UL)) assertEquals("58 B", formatBytes(58UL))
assertEquals("1.5 KB", formatBytes(1536UL)) 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))
}
} }