mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
Merge pull request #2 from vnidrop/feat/compose-app-state-refactor
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.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,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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.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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,8 +77,7 @@ 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()) {
|
||||||
@@ -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))
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user