mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
Refactor app state handling
This commit is contained in:
@@ -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<PickedShareFile?>(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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ReceiverRequest>,
|
||||
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))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
|
||||
Reference in New Issue
Block a user