mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
feat: add preferences and send empty state
This commit is contained in:
@@ -10,8 +10,13 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vnidrop.app.core.rememberFileSystemService
|
||||
import com.vnidrop.app.core.rememberReceiveFolderPicker
|
||||
import com.vnidrop.app.core.rememberShareFilePicker
|
||||
import com.vnidrop.app.logging.AppLogger
|
||||
import com.vnidrop.app.preferences.AppPreferencesDefaults
|
||||
import com.vnidrop.app.preferences.AppPreferencesRepository
|
||||
import com.vnidrop.app.preferences.createAppPreferencesDataStore
|
||||
import com.vnidrop.app.platform.PlatformSystemAppearance
|
||||
import com.vnidrop.app.ui.navigation.AppDestination
|
||||
import com.vnidrop.app.ui.screens.ReceiveScreen
|
||||
@@ -19,6 +24,7 @@ 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.windowClassFor
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import com.vnidrop.app.ui.theme.VniDropTheme
|
||||
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
|
||||
|
||||
@@ -26,11 +32,23 @@ import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
|
||||
@Preview
|
||||
fun App() {
|
||||
val platform = remember { getPlatform() }
|
||||
val fileSystemService = rememberFileSystemService()
|
||||
val preferencesRepository = remember(platform.defaultCoreDataDir) {
|
||||
AppPreferencesRepository(
|
||||
dataStore = createAppPreferencesDataStore(platform.defaultCoreDataDir),
|
||||
defaults = AppPreferencesDefaults(
|
||||
username = platform.deviceInfo.deviceName?.takeIf { it.isNotBlank() } ?: "Receiver",
|
||||
receiveFolder = fileSystemService.defaultReceiveFolder(),
|
||||
themeMode = ThemeMode.System,
|
||||
),
|
||||
)
|
||||
}
|
||||
val viewModel = viewModel {
|
||||
VniDropAppViewModel(
|
||||
appDataDir = platform.defaultCoreDataDir,
|
||||
defaultReceiveDir = platform.defaultReceiveDir,
|
||||
platformName = platform.name,
|
||||
preferencesRepository = preferencesRepository,
|
||||
fileSystemService = fileSystemService,
|
||||
)
|
||||
}
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
@@ -44,6 +62,14 @@ fun App() {
|
||||
viewModel.onEvent(VniDropAppEvent.ShareFilePickFailed(error))
|
||||
},
|
||||
)
|
||||
val receiveFolderPicker = rememberReceiveFolderPicker(
|
||||
onFolderPicked = { folder ->
|
||||
viewModel.onEvent(VniDropAppEvent.ReceiveFolderPicked(folder))
|
||||
},
|
||||
onError = { error ->
|
||||
viewModel.onEvent(VniDropAppEvent.ReceiveFolderPickFailed(error))
|
||||
},
|
||||
)
|
||||
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.effectFlow.collect { effect ->
|
||||
@@ -52,6 +78,10 @@ fun App() {
|
||||
AppLogger.info("file-picker", "open share file picker")
|
||||
picker.pickFile()
|
||||
}
|
||||
VniDropAppEffect.OpenReceiveFolderPicker -> {
|
||||
AppLogger.info("file-picker", "open receive folder picker")
|
||||
receiveFolderPicker.pickFolder()
|
||||
}
|
||||
is VniDropAppEffect.CopyTicket -> {
|
||||
AppLogger.info("send", "ticket copied")
|
||||
clipboard.setText(AnnotatedString(effect.ticket))
|
||||
@@ -78,19 +108,22 @@ fun App() {
|
||||
AppDestination.Send -> SendScreen(
|
||||
coreState = coreState,
|
||||
sendState = state.send,
|
||||
windowClass = windowClass,
|
||||
onEvent = viewModel::onEvent,
|
||||
)
|
||||
AppDestination.Receive -> ReceiveScreen(
|
||||
coreState = coreState,
|
||||
receiveState = state.receive,
|
||||
preferencesState = state.preferences,
|
||||
onEvent = viewModel::onEvent,
|
||||
)
|
||||
AppDestination.Settings -> SettingsScreen(
|
||||
deviceInfo = platform.deviceInfo,
|
||||
coreState = coreState,
|
||||
themeMode = state.app.themeMode,
|
||||
preferencesState = state.preferences,
|
||||
windowClass = windowClass,
|
||||
onThemeModeChange = { viewModel.onEvent(VniDropAppEvent.ThemeModeChanged(it)) },
|
||||
onEvent = viewModel::onEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,18 @@ 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.FileSystemService
|
||||
import com.vnidrop.app.core.FolderAccessStatus
|
||||
import com.vnidrop.app.core.PickedShareFile
|
||||
import com.vnidrop.app.core.ReceiveFolder
|
||||
import com.vnidrop.app.core.ReceiveFolderKind
|
||||
import com.vnidrop.app.core.sharePickedFile
|
||||
import com.vnidrop.app.logging.AppLogger
|
||||
import com.vnidrop.app.preferences.AppPreferences
|
||||
import com.vnidrop.app.preferences.AppPreferencesRepository
|
||||
import com.vnidrop.app.ui.navigation.AppDestination
|
||||
import com.vnidrop.app.ui.state.AppUiState
|
||||
import com.vnidrop.app.ui.state.PreferencesUiState
|
||||
import com.vnidrop.app.ui.state.ReceiveUiState
|
||||
import com.vnidrop.app.ui.state.SendUiState
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
@@ -21,6 +28,7 @@ import kotlinx.coroutines.launch
|
||||
|
||||
data class VniDropAppState(
|
||||
val app: AppUiState = AppUiState(),
|
||||
val preferences: PreferencesUiState = PreferencesUiState(),
|
||||
val send: SendUiState = SendUiState(),
|
||||
val receive: ReceiveUiState = ReceiveUiState(),
|
||||
)
|
||||
@@ -28,6 +36,11 @@ data class VniDropAppState(
|
||||
sealed interface VniDropAppEvent {
|
||||
data class DestinationSelected(val destination: AppDestination) : VniDropAppEvent
|
||||
data class ThemeModeChanged(val mode: ThemeMode) : VniDropAppEvent
|
||||
data class UsernameChanged(val value: String) : VniDropAppEvent
|
||||
data object ChooseReceiveFolderClicked : VniDropAppEvent
|
||||
data class ReceiveFolderPicked(val folder: ReceiveFolder) : VniDropAppEvent
|
||||
data class ReceiveFolderPickFailed(val reason: String) : VniDropAppEvent
|
||||
data object ResetReceiveFolderClicked : VniDropAppEvent
|
||||
data object SelectFileClicked : VniDropAppEvent
|
||||
data class ShareFilePicked(val file: PickedShareFile) : VniDropAppEvent
|
||||
data class ShareFilePickFailed(val reason: String) : VniDropAppEvent
|
||||
@@ -48,16 +61,18 @@ sealed interface VniDropAppEvent {
|
||||
|
||||
sealed interface VniDropAppEffect {
|
||||
data object OpenShareFilePicker : VniDropAppEffect
|
||||
data object OpenReceiveFolderPicker : VniDropAppEffect
|
||||
data class CopyTicket(val ticket: String) : VniDropAppEffect
|
||||
}
|
||||
|
||||
class VniDropAppViewModel(
|
||||
appDataDir: String,
|
||||
defaultReceiveDir: String,
|
||||
platformName: String,
|
||||
private val preferencesRepository: AppPreferencesRepository,
|
||||
private val fileSystemService: FileSystemService,
|
||||
private val repository: CoreRepository = CoreRepository(),
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(VniDropAppState(receive = ReceiveUiState(outputDirectory = defaultReceiveDir)))
|
||||
private val _state = MutableStateFlow(VniDropAppState())
|
||||
val state: StateFlow<VniDropAppState> = _state
|
||||
val coreState: StateFlow<CoreUiState> = repository.state
|
||||
|
||||
@@ -73,12 +88,23 @@ class VniDropAppViewModel(
|
||||
viewModelScope.launch {
|
||||
repository.initialize(appDataDir)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.preferences.collect { preferences ->
|
||||
applyPreferences(preferences)
|
||||
validateReceiveFolder(preferences.receiveFolder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onEvent(event: VniDropAppEvent) {
|
||||
when (event) {
|
||||
is VniDropAppEvent.DestinationSelected -> updateAppState { copy(destination = event.destination) }
|
||||
is VniDropAppEvent.ThemeModeChanged -> setThemeMode(event.mode)
|
||||
is VniDropAppEvent.UsernameChanged -> setUsername(event.value)
|
||||
VniDropAppEvent.ChooseReceiveFolderClicked -> sendEffect(VniDropAppEffect.OpenReceiveFolderPicker)
|
||||
is VniDropAppEvent.ReceiveFolderPicked -> setReceiveFolder(event.folder)
|
||||
is VniDropAppEvent.ReceiveFolderPickFailed -> setFilePickerError(event.reason)
|
||||
VniDropAppEvent.ResetReceiveFolderClicked -> resetReceiveFolder()
|
||||
VniDropAppEvent.SelectFileClicked -> sendEffect(VniDropAppEffect.OpenShareFilePicker)
|
||||
is VniDropAppEvent.ShareFilePicked -> setSelectedFile(event.file)
|
||||
is VniDropAppEvent.ShareFilePickFailed -> setFilePickerError(event.reason)
|
||||
@@ -101,6 +127,27 @@ class VniDropAppViewModel(
|
||||
private fun setThemeMode(mode: ThemeMode) {
|
||||
AppLogger.info("appearance", "theme mode changed", mapOf("mode" to mode.name))
|
||||
updateAppState { copy(themeMode = mode) }
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setThemeMode(mode)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setUsername(username: String) {
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setUsername(username)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setReceiveFolder(folder: ReceiveFolder) {
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setReceiveFolder(folder)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetReceiveFolder() {
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.resetReceiveFolder()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setSelectedFile(file: PickedShareFile) {
|
||||
@@ -179,19 +226,72 @@ class VniDropAppViewModel(
|
||||
|
||||
private fun receive() {
|
||||
val receiveState = state.value.receive
|
||||
if (!receiveState.canReceive(coreState.value.isInitialized)) return
|
||||
val preferences = state.value.preferences
|
||||
if (!receiveState.canReceive(coreState.value.isInitialized) || !preferences.canReceiveIntoFolder) return
|
||||
|
||||
viewModelScope.launch {
|
||||
AppLogger.info("receive", "receive requested")
|
||||
updateReceiveState { copy(isReceiving = true) }
|
||||
try {
|
||||
repository.receive(receiveState.ticket, receiveState.outputDirectory, receiveState.receiverName)
|
||||
receiveIntoFolder(receiveState, preferences.receiveFolder)
|
||||
} finally {
|
||||
updateReceiveState { copy(isReceiving = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun receiveIntoFolder(receiveState: ReceiveUiState, folder: ReceiveFolder) {
|
||||
val outputSink = fileSystemService.createReceiveOutputSink(folder)
|
||||
when {
|
||||
outputSink != null -> repository.receiveWithOutputSink(receiveState.ticket, outputSink, receiveState.receiverName)
|
||||
folder.kind == ReceiveFolderKind.IosSecurityScopedUrl -> {
|
||||
repository.receiveIntoSecurityScopedDirectory(receiveState.ticket, folder.value, receiveState.receiverName)
|
||||
}
|
||||
else -> repository.receive(receiveState.ticket, folder.value, receiveState.receiverName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyPreferences(preferences: AppPreferences) {
|
||||
_state.update { current ->
|
||||
val previousUsername = current.preferences.username
|
||||
current.copy(
|
||||
app = current.app.copy(themeMode = preferences.themeMode),
|
||||
preferences = current.preferences.copy(
|
||||
username = preferences.username,
|
||||
receiveFolder = preferences.receiveFolder,
|
||||
),
|
||||
send = current.send.withDefaultName(
|
||||
currentValue = current.send.senderName,
|
||||
previousDefault = previousUsername,
|
||||
nextDefault = preferences.username,
|
||||
update = { copy(senderName = it) },
|
||||
),
|
||||
receive = current.receive
|
||||
.withDefaultName(
|
||||
currentValue = current.receive.receiverName,
|
||||
previousDefault = previousUsername,
|
||||
nextDefault = preferences.username,
|
||||
update = { copy(receiverName = it) },
|
||||
)
|
||||
.copy(outputDirectory = preferences.receiveFolder.value),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun validateReceiveFolder(folder: ReceiveFolder) {
|
||||
updatePreferencesState { copy(isValidatingFolder = true) }
|
||||
val status = fileSystemService.validateReceiveFolder(folder)
|
||||
updatePreferencesState {
|
||||
copy(
|
||||
folderAccessStatus = status,
|
||||
isValidatingFolder = false,
|
||||
)
|
||||
}
|
||||
if (status != FolderAccessStatus.Writable) {
|
||||
repository.setError("Receive folder is not writable.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendEffect(effect: VniDropAppEffect) {
|
||||
viewModelScope.launch {
|
||||
effects.send(effect)
|
||||
@@ -218,8 +318,27 @@ class VniDropAppViewModel(
|
||||
if (next == current.receive) current else current.copy(receive = next)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePreferencesState(reducer: PreferencesUiState.() -> PreferencesUiState) {
|
||||
_state.update { current ->
|
||||
val next = current.preferences.reducer()
|
||||
if (next == current.preferences) current else current.copy(preferences = next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> T.withDefaultName(
|
||||
currentValue: String,
|
||||
previousDefault: String,
|
||||
nextDefault: String,
|
||||
update: T.(String) -> T,
|
||||
): T =
|
||||
if (currentValue.isBlank() || currentValue == previousDefault) {
|
||||
update(nextDefault)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
private fun SendUiState.withSelectedFile(file: PickedShareFile): SendUiState =
|
||||
copy(
|
||||
selectedSource = file.value,
|
||||
|
||||
@@ -11,6 +11,7 @@ import kotlin.random.Random
|
||||
import uniffi.vnidrop.CoreEvent
|
||||
import uniffi.vnidrop.CoreEventSink
|
||||
import uniffi.vnidrop.ReceiverRequest
|
||||
import uniffi.vnidrop.ReceiveOutputSink
|
||||
import uniffi.vnidrop.ShareMetadataInput
|
||||
import uniffi.vnidrop.ShareResult
|
||||
import uniffi.vnidrop.ShareSource
|
||||
@@ -126,6 +127,12 @@ class CoreRepository(
|
||||
loadTransfers()
|
||||
}
|
||||
|
||||
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = runCore {
|
||||
requireCore().receiveWithOutputSink(ticket, outputSink, receiverName.ifBlank { null })
|
||||
refreshStatus()
|
||||
loadTransfers()
|
||||
}
|
||||
|
||||
suspend fun receiveIntoSecurityScopedDirectory(
|
||||
ticket: String,
|
||||
outputDirectoryUrl: String,
|
||||
|
||||
@@ -11,12 +11,22 @@ interface ShareFilePicker {
|
||||
fun pickFile()
|
||||
}
|
||||
|
||||
interface ReceiveFolderPicker {
|
||||
fun pickFolder()
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun rememberShareFilePicker(
|
||||
onFilePicked: (PickedShareFile) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
): ShareFilePicker
|
||||
|
||||
@Composable
|
||||
expect fun rememberReceiveFolderPicker(
|
||||
onFolderPicked: (ReceiveFolder) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
): ReceiveFolderPicker
|
||||
|
||||
expect suspend fun sharePickedFile(
|
||||
repository: CoreRepository,
|
||||
file: PickedShareFile,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.vnidrop.app.core
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import uniffi.vnidrop.ReceiveOutputSink
|
||||
|
||||
enum class ReceiveFolderKind {
|
||||
FileSystemPath,
|
||||
AndroidTreeUri,
|
||||
IosSecurityScopedUrl,
|
||||
}
|
||||
|
||||
data class ReceiveFolder(
|
||||
val kind: ReceiveFolderKind,
|
||||
val value: String,
|
||||
val displayName: String,
|
||||
)
|
||||
|
||||
enum class FolderAccessStatus {
|
||||
Writable,
|
||||
PermissionRequired,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
interface FileSystemService {
|
||||
fun defaultReceiveFolder(): ReceiveFolder
|
||||
suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus
|
||||
fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink?
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun rememberFileSystemService(): FileSystemService
|
||||
|
||||
fun ReceiveFolder.isFileSystemPath(): Boolean =
|
||||
kind == ReceiveFolderKind.FileSystemPath
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.vnidrop.app.preferences
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.vnidrop.app.core.ReceiveFolder
|
||||
import com.vnidrop.app.core.ReceiveFolderKind
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
import okio.Path.Companion.toPath
|
||||
|
||||
data class AppPreferences(
|
||||
val username: String,
|
||||
val receiveFolder: ReceiveFolder,
|
||||
val themeMode: ThemeMode,
|
||||
)
|
||||
|
||||
class AppPreferencesDefaults(
|
||||
val username: String,
|
||||
val receiveFolder: ReceiveFolder,
|
||||
val themeMode: ThemeMode,
|
||||
)
|
||||
|
||||
class AppPreferencesRepository(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val defaults: AppPreferencesDefaults,
|
||||
) {
|
||||
val preferences: Flow<AppPreferences> = dataStore.data
|
||||
.catch { emit(emptyPreferences()) }
|
||||
.map { prefs ->
|
||||
AppPreferences(
|
||||
username = prefs[PreferenceKeys.Username]?.takeIf { it.isNotBlank() } ?: defaults.username,
|
||||
receiveFolder = ReceiveFolder(
|
||||
kind = prefs[PreferenceKeys.ReceiveFolderKind]?.let { receiveFolderKindOrNull(it) }
|
||||
?: defaults.receiveFolder.kind,
|
||||
value = prefs[PreferenceKeys.ReceiveFolderValue]?.takeIf { it.isNotBlank() }
|
||||
?: defaults.receiveFolder.value,
|
||||
displayName = prefs[PreferenceKeys.ReceiveFolderDisplayName]?.takeIf { it.isNotBlank() }
|
||||
?: defaults.receiveFolder.displayName,
|
||||
),
|
||||
themeMode = prefs[PreferenceKeys.ThemeMode]?.let { themeModeOrNull(it) } ?: defaults.themeMode,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setUsername(username: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[PreferenceKeys.Username] = username.trim()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setReceiveFolder(folder: ReceiveFolder) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[PreferenceKeys.ReceiveFolderKind] = folder.kind.name
|
||||
prefs[PreferenceKeys.ReceiveFolderValue] = folder.value
|
||||
prefs[PreferenceKeys.ReceiveFolderDisplayName] = folder.displayName
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resetReceiveFolder() {
|
||||
setReceiveFolder(defaults.receiveFolder)
|
||||
}
|
||||
|
||||
suspend fun setThemeMode(mode: ThemeMode) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[PreferenceKeys.ThemeMode] = mode.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createAppPreferencesDataStore(appDataDir: String): DataStore<Preferences> =
|
||||
PreferenceDataStoreFactory.createWithPath(
|
||||
produceFile = { "$appDataDir/$AppPreferencesFileName".toPath() },
|
||||
)
|
||||
|
||||
private object PreferenceKeys {
|
||||
val Username = stringPreferencesKey("username")
|
||||
val ReceiveFolderKind = stringPreferencesKey("receive_folder_kind")
|
||||
val ReceiveFolderValue = stringPreferencesKey("receive_folder_value")
|
||||
val ReceiveFolderDisplayName = stringPreferencesKey("receive_folder_display_name")
|
||||
val ThemeMode = stringPreferencesKey("theme_mode")
|
||||
}
|
||||
|
||||
private fun receiveFolderKindOrNull(raw: String): ReceiveFolderKind? =
|
||||
runCatching { ReceiveFolderKind.valueOf(raw) }.getOrNull()
|
||||
|
||||
private fun themeModeOrNull(raw: String): ThemeMode? =
|
||||
runCatching { ThemeMode.valueOf(raw) }.getOrNull()
|
||||
|
||||
private const val AppPreferencesFileName = "app_preferences.preferences_pb"
|
||||
@@ -7,10 +7,13 @@ 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.core.FolderAccessStatus
|
||||
import com.vnidrop.app.ui.components.AppCard
|
||||
import com.vnidrop.app.ui.components.Field
|
||||
import com.vnidrop.app.ui.components.MetadataRow
|
||||
import com.vnidrop.app.ui.components.PrimaryButton
|
||||
import com.vnidrop.app.ui.components.SecondaryButton
|
||||
import com.vnidrop.app.ui.state.PreferencesUiState
|
||||
import com.vnidrop.app.ui.state.ReceiveUiState
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
@@ -20,6 +23,11 @@ import vnidrop.shared.generated.resources.button_receiving
|
||||
import vnidrop.shared.generated.resources.field_output_directory
|
||||
import vnidrop.shared.generated.resources.field_receiver_name
|
||||
import vnidrop.shared.generated.resources.field_ticket
|
||||
import vnidrop.shared.generated.resources.folder_status_permission_required
|
||||
import vnidrop.shared.generated.resources.folder_status_unavailable
|
||||
import vnidrop.shared.generated.resources.folder_status_validating
|
||||
import vnidrop.shared.generated.resources.folder_status_writable
|
||||
import vnidrop.shared.generated.resources.metadata_status
|
||||
import vnidrop.shared.generated.resources.receive_subtitle
|
||||
import vnidrop.shared.generated.resources.receive_title
|
||||
import vnidrop.shared.generated.resources.ticket_card_title
|
||||
@@ -28,6 +36,7 @@ import vnidrop.shared.generated.resources.ticket_card_title
|
||||
fun ReceiveScreen(
|
||||
coreState: CoreUiState,
|
||||
receiveState: ReceiveUiState,
|
||||
preferencesState: PreferencesUiState,
|
||||
onEvent: (VniDropAppEvent) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
@@ -40,10 +49,13 @@ fun ReceiveScreen(
|
||||
label = stringResource(Res.string.field_ticket),
|
||||
minLines = 4,
|
||||
)
|
||||
Field(
|
||||
value = receiveState.outputDirectory,
|
||||
onValueChange = { onEvent(VniDropAppEvent.OutputDirectoryChanged(it)) },
|
||||
MetadataRow(
|
||||
label = stringResource(Res.string.field_output_directory),
|
||||
value = preferencesState.receiveFolder.displayName.ifBlank { preferencesState.receiveFolder.value },
|
||||
)
|
||||
MetadataRow(
|
||||
label = stringResource(Res.string.metadata_status),
|
||||
value = preferencesState.folderAccessStatus.displayName(preferencesState.isValidatingFolder),
|
||||
)
|
||||
Field(
|
||||
value = receiveState.receiverName,
|
||||
@@ -59,7 +71,7 @@ fun ReceiveScreen(
|
||||
PrimaryButton(
|
||||
text = if (receiveState.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive),
|
||||
onClick = { onEvent(VniDropAppEvent.ReceiveClicked) },
|
||||
enabled = receiveState.canReceive(coreState.isInitialized),
|
||||
enabled = receiveState.canReceive(coreState.isInitialized) && preferencesState.canReceiveIntoFolder,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -67,3 +79,15 @@ fun ReceiveScreen(
|
||||
ProgressSection(coreState)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FolderAccessStatus.displayName(isValidating: Boolean): String =
|
||||
if (isValidating) {
|
||||
stringResource(Res.string.folder_status_validating)
|
||||
} else {
|
||||
when (this) {
|
||||
FolderAccessStatus.Writable -> stringResource(Res.string.folder_status_writable)
|
||||
FolderAccessStatus.PermissionRequired -> stringResource(Res.string.folder_status_permission_required)
|
||||
FolderAccessStatus.Unavailable -> stringResource(Res.string.folder_status_unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.vnidrop.app.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.core.CoreUiState
|
||||
import com.vnidrop.app.ui.components.PrimaryButton
|
||||
import com.vnidrop.app.ui.navigation.VniDropIcons
|
||||
import com.vnidrop.app.ui.state.SendUiState
|
||||
import com.vnidrop.app.ui.state.WindowClass
|
||||
import com.vnidrop.app.ui.state.isActiveTransfer
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.button_create_new_transfer
|
||||
import vnidrop.shared.generated.resources.send_empty_body
|
||||
import vnidrop.shared.generated.resources.send_empty_title
|
||||
|
||||
@Composable
|
||||
internal fun SendEmptyState(windowClass: WindowClass) {
|
||||
val colors = LocalVniDropColors.current
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = if (windowClass == WindowClass.Phone) 540.dp else 420.dp)
|
||||
.padding(horizontal = 24.dp, vertical = 40.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = VniDropIcons.Send,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(96.dp),
|
||||
tint = colors.brandDefault,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.send_empty_title),
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.send_empty_body),
|
||||
modifier = Modifier
|
||||
.padding(top = 10.dp)
|
||||
.widthIn(max = 520.dp),
|
||||
color = colors.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
PrimaryButton(
|
||||
text = stringResource(Res.string.button_create_new_transfer),
|
||||
onClick = {},
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun SendUiState.shouldShowEmptyState(coreState: CoreUiState): Boolean =
|
||||
!hasSelectedSource &&
|
||||
!isSharing &&
|
||||
coreState.lastShare == null &&
|
||||
coreState.transfers.none { it.direction == "send" && it.isActiveTransfer() }
|
||||
@@ -17,6 +17,7 @@ import com.vnidrop.app.ui.components.PrimaryButton
|
||||
import com.vnidrop.app.ui.components.SecondaryButton
|
||||
import com.vnidrop.app.ui.components.StatusPill
|
||||
import com.vnidrop.app.ui.state.SendUiState
|
||||
import com.vnidrop.app.ui.state.WindowClass
|
||||
import com.vnidrop.app.ui.state.formatBytes
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import uniffi.vnidrop.ReceiverRequest
|
||||
@@ -25,8 +26,8 @@ import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.button_approve
|
||||
import vnidrop.shared.generated.resources.button_clear
|
||||
import vnidrop.shared.generated.resources.button_copy
|
||||
import vnidrop.shared.generated.resources.button_create_share_ticket
|
||||
import vnidrop.shared.generated.resources.button_creating_ticket
|
||||
import vnidrop.shared.generated.resources.button_create_share
|
||||
import vnidrop.shared.generated.resources.button_creating_share
|
||||
import vnidrop.shared.generated.resources.button_refresh
|
||||
import vnidrop.shared.generated.resources.button_refuse
|
||||
import vnidrop.shared.generated.resources.button_select_file
|
||||
@@ -41,7 +42,7 @@ import vnidrop.shared.generated.resources.receiver_requests_title
|
||||
import vnidrop.shared.generated.resources.send_source_empty
|
||||
import vnidrop.shared.generated.resources.send_subtitle
|
||||
import vnidrop.shared.generated.resources.send_title
|
||||
import vnidrop.shared.generated.resources.share_ticket_title
|
||||
import vnidrop.shared.generated.resources.share_details_title
|
||||
import vnidrop.shared.generated.resources.source_title
|
||||
import vnidrop.shared.generated.resources.transfer_details_title
|
||||
|
||||
@@ -49,28 +50,35 @@ import vnidrop.shared.generated.resources.transfer_details_title
|
||||
fun SendScreen(
|
||||
coreState: CoreUiState,
|
||||
sendState: SendUiState,
|
||||
windowClass: WindowClass,
|
||||
onEvent: (VniDropAppEvent) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle))
|
||||
if (!sendState.shouldShowEmptyState(coreState)) {
|
||||
ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle))
|
||||
}
|
||||
ErrorSection(coreState)
|
||||
SendSourceCard(
|
||||
sendState = sendState,
|
||||
onEvent = onEvent,
|
||||
)
|
||||
SendDetailsCard(
|
||||
coreState = coreState,
|
||||
sendState = sendState,
|
||||
onEvent = onEvent,
|
||||
)
|
||||
coreState.lastShare?.let { share ->
|
||||
ShareResultCard(
|
||||
share = share,
|
||||
requests = coreState.receiverRequests,
|
||||
if (sendState.shouldShowEmptyState(coreState)) {
|
||||
SendEmptyState(windowClass = windowClass)
|
||||
} else {
|
||||
SendSourceCard(
|
||||
sendState = sendState,
|
||||
onEvent = onEvent,
|
||||
)
|
||||
SendDetailsCard(
|
||||
coreState = coreState,
|
||||
sendState = sendState,
|
||||
onEvent = onEvent,
|
||||
)
|
||||
coreState.lastShare?.let { share ->
|
||||
ShareResultCard(
|
||||
share = share,
|
||||
requests = coreState.receiverRequests,
|
||||
onEvent = onEvent,
|
||||
)
|
||||
}
|
||||
ProgressSection(coreState)
|
||||
}
|
||||
ProgressSection(coreState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +126,7 @@ private fun SendDetailsCard(
|
||||
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),
|
||||
text = if (sendState.isSharing) stringResource(Res.string.button_creating_share) else stringResource(Res.string.button_create_share),
|
||||
onClick = { onEvent(VniDropAppEvent.CreateShareClicked) },
|
||||
enabled = sendState.canCreateShare(coreState.isInitialized),
|
||||
)
|
||||
@@ -131,7 +139,7 @@ private fun ShareResultCard(
|
||||
requests: List<ReceiverRequest>,
|
||||
onEvent: (VniDropAppEvent) -> Unit,
|
||||
) {
|
||||
AppCard(title = stringResource(Res.string.share_ticket_title), trailing = {
|
||||
AppCard(title = stringResource(Res.string.share_details_title), trailing = {
|
||||
StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand)
|
||||
}) {
|
||||
MetadataRow(stringResource(Res.string.metadata_transfer), share.transferName)
|
||||
|
||||
@@ -39,7 +39,13 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.DeviceInfo
|
||||
import com.vnidrop.app.VniDropAppEvent
|
||||
import com.vnidrop.app.core.CoreUiState
|
||||
import com.vnidrop.app.core.FolderAccessStatus
|
||||
import com.vnidrop.app.ui.components.Field
|
||||
import com.vnidrop.app.ui.components.PrimaryButton
|
||||
import com.vnidrop.app.ui.components.SecondaryButton
|
||||
import com.vnidrop.app.ui.state.PreferencesUiState
|
||||
import com.vnidrop.app.ui.state.WindowClass
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
@@ -55,9 +61,18 @@ import vnidrop.shared.generated.resources.appearance_mode_title
|
||||
import vnidrop.shared.generated.resources.appearance_system_mode
|
||||
import vnidrop.shared.generated.resources.appearance_title
|
||||
import vnidrop.shared.generated.resources.battery_level_title
|
||||
import vnidrop.shared.generated.resources.button_choose_folder
|
||||
import vnidrop.shared.generated.resources.core_status_ready
|
||||
import vnidrop.shared.generated.resources.device_model_title
|
||||
import vnidrop.shared.generated.resources.device_name_title
|
||||
import vnidrop.shared.generated.resources.folder_status_permission_required
|
||||
import vnidrop.shared.generated.resources.folder_status_unavailable
|
||||
import vnidrop.shared.generated.resources.folder_status_validating
|
||||
import vnidrop.shared.generated.resources.folder_status_writable
|
||||
import vnidrop.shared.generated.resources.preferences_receive_folder_title
|
||||
import vnidrop.shared.generated.resources.preferences_title
|
||||
import vnidrop.shared.generated.resources.button_reset_default
|
||||
import vnidrop.shared.generated.resources.field_username
|
||||
import vnidrop.shared.generated.resources.network_title
|
||||
import vnidrop.shared.generated.resources.node_title
|
||||
import vnidrop.shared.generated.resources.not_initialized
|
||||
@@ -68,6 +83,7 @@ import vnidrop.shared.generated.resources.version_title
|
||||
|
||||
private enum class SettingsPane {
|
||||
Overview,
|
||||
Preferences,
|
||||
Appearance,
|
||||
About,
|
||||
}
|
||||
@@ -77,8 +93,9 @@ fun SettingsScreen(
|
||||
deviceInfo: DeviceInfo,
|
||||
coreState: CoreUiState,
|
||||
themeMode: ThemeMode,
|
||||
preferencesState: PreferencesUiState,
|
||||
windowClass: WindowClass,
|
||||
onThemeModeChange: (ThemeMode) -> Unit,
|
||||
onEvent: (VniDropAppEvent) -> Unit,
|
||||
) {
|
||||
var pane by remember { mutableStateOf(SettingsPane.Overview) }
|
||||
|
||||
@@ -89,7 +106,8 @@ fun SettingsScreen(
|
||||
deviceInfo = deviceInfo,
|
||||
coreState = coreState,
|
||||
themeMode = themeMode,
|
||||
onThemeModeChange = onThemeModeChange,
|
||||
preferencesState = preferencesState,
|
||||
onEvent = onEvent,
|
||||
)
|
||||
else -> MobileSettings(
|
||||
pane = pane,
|
||||
@@ -97,7 +115,8 @@ fun SettingsScreen(
|
||||
deviceInfo = deviceInfo,
|
||||
coreState = coreState,
|
||||
themeMode = themeMode,
|
||||
onThemeModeChange = onThemeModeChange,
|
||||
preferencesState = preferencesState,
|
||||
onEvent = onEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -109,7 +128,8 @@ private fun MobileSettings(
|
||||
deviceInfo: DeviceInfo,
|
||||
coreState: CoreUiState,
|
||||
themeMode: ThemeMode,
|
||||
onThemeModeChange: (ThemeMode) -> Unit,
|
||||
preferencesState: PreferencesUiState,
|
||||
onEvent: (VniDropAppEvent) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
ErrorSection(coreState)
|
||||
@@ -117,13 +137,21 @@ private fun MobileSettings(
|
||||
SettingsPane.Overview -> SettingsOverview(
|
||||
coreState = coreState,
|
||||
themeMode = themeMode,
|
||||
preferencesState = preferencesState,
|
||||
onOpenPreferences = { onPaneSelected(SettingsPane.Preferences) },
|
||||
onOpenAppearance = { onPaneSelected(SettingsPane.Appearance) },
|
||||
onOpenAbout = { onPaneSelected(SettingsPane.About) },
|
||||
largeTitle = true,
|
||||
)
|
||||
SettingsPane.Preferences -> PreferencesSettings(
|
||||
preferencesState = preferencesState,
|
||||
onEvent = onEvent,
|
||||
onBack = { onPaneSelected(SettingsPane.Overview) },
|
||||
showBack = true,
|
||||
)
|
||||
SettingsPane.Appearance -> AppearanceSettings(
|
||||
themeMode = themeMode,
|
||||
onThemeModeChange = onThemeModeChange,
|
||||
onThemeModeChange = { onEvent(VniDropAppEvent.ThemeModeChanged(it)) },
|
||||
onBack = { onPaneSelected(SettingsPane.Overview) },
|
||||
showBack = true,
|
||||
)
|
||||
@@ -144,7 +172,8 @@ private fun DesktopSettings(
|
||||
deviceInfo: DeviceInfo,
|
||||
coreState: CoreUiState,
|
||||
themeMode: ThemeMode,
|
||||
onThemeModeChange: (ThemeMode) -> Unit,
|
||||
preferencesState: PreferencesUiState,
|
||||
onEvent: (VniDropAppEvent) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -158,6 +187,8 @@ private fun DesktopSettings(
|
||||
SettingsOverview(
|
||||
coreState = coreState,
|
||||
themeMode = themeMode,
|
||||
preferencesState = preferencesState,
|
||||
onOpenPreferences = { onPaneSelected(SettingsPane.Preferences) },
|
||||
onOpenAppearance = { onPaneSelected(SettingsPane.Appearance) },
|
||||
onOpenAbout = { onPaneSelected(SettingsPane.About) },
|
||||
largeTitle = false,
|
||||
@@ -170,9 +201,15 @@ private fun DesktopSettings(
|
||||
) {
|
||||
when (selectedPane) {
|
||||
SettingsPane.Overview,
|
||||
SettingsPane.Preferences -> PreferencesSettings(
|
||||
preferencesState = preferencesState,
|
||||
onEvent = onEvent,
|
||||
onBack = {},
|
||||
showBack = false,
|
||||
)
|
||||
SettingsPane.Appearance -> AppearanceSettings(
|
||||
themeMode = themeMode,
|
||||
onThemeModeChange = onThemeModeChange,
|
||||
onThemeModeChange = { onEvent(VniDropAppEvent.ThemeModeChanged(it)) },
|
||||
onBack = {},
|
||||
showBack = false,
|
||||
)
|
||||
@@ -191,6 +228,8 @@ private fun DesktopSettings(
|
||||
private fun SettingsOverview(
|
||||
coreState: CoreUiState,
|
||||
themeMode: ThemeMode,
|
||||
preferencesState: PreferencesUiState,
|
||||
onOpenPreferences: () -> Unit,
|
||||
onOpenAppearance: () -> Unit,
|
||||
onOpenAbout: () -> Unit,
|
||||
largeTitle: Boolean,
|
||||
@@ -203,6 +242,13 @@ private fun SettingsOverview(
|
||||
Text(stringResource(Res.string.settings_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
SettingsGroup {
|
||||
SettingsRow(
|
||||
icon = SettingsIcons.Device,
|
||||
title = stringResource(Res.string.preferences_title),
|
||||
value = preferencesState.username,
|
||||
selected = selectedPane == SettingsPane.Preferences,
|
||||
onClick = onOpenPreferences,
|
||||
)
|
||||
SettingsRow(
|
||||
icon = SettingsIcons.Sun,
|
||||
title = stringResource(Res.string.appearance_title),
|
||||
@@ -228,6 +274,46 @@ private fun SettingsOverview(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreferencesSettings(
|
||||
preferencesState: PreferencesUiState,
|
||||
onEvent: (VniDropAppEvent) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
showBack: Boolean,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
SettingsTopBar(title = stringResource(Res.string.preferences_title), onBack = onBack, showBack = showBack)
|
||||
Field(
|
||||
value = preferencesState.username,
|
||||
onValueChange = { onEvent(VniDropAppEvent.UsernameChanged(it)) },
|
||||
label = stringResource(Res.string.field_username),
|
||||
)
|
||||
SettingsGroup {
|
||||
SettingsRow(
|
||||
icon = SettingsIcons.Folder,
|
||||
title = stringResource(Res.string.preferences_receive_folder_title),
|
||||
value = preferencesState.receiveFolder.displayName.ifBlank { preferencesState.receiveFolder.value },
|
||||
iconTone = IconTone.Neutral,
|
||||
)
|
||||
SettingsRow(
|
||||
icon = SettingsIcons.Check,
|
||||
title = preferencesState.folderAccessStatus.displayName(isValidating = preferencesState.isValidatingFolder),
|
||||
iconTone = IconTone.Neutral,
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
PrimaryButton(
|
||||
text = stringResource(Res.string.button_choose_folder),
|
||||
onClick = { onEvent(VniDropAppEvent.ChooseReceiveFolderClicked) },
|
||||
)
|
||||
SecondaryButton(
|
||||
text = stringResource(Res.string.button_reset_default),
|
||||
onClick = { onEvent(VniDropAppEvent.ResetReceiveFolderClicked) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppearanceSettings(
|
||||
themeMode: ThemeMode,
|
||||
@@ -479,6 +565,18 @@ private fun ThemeMode.displayName(): String =
|
||||
ThemeMode.Dark -> stringResource(Res.string.appearance_dark_mode)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FolderAccessStatus.displayName(isValidating: Boolean): String =
|
||||
if (isValidating) {
|
||||
stringResource(Res.string.folder_status_validating)
|
||||
} else {
|
||||
when (this) {
|
||||
FolderAccessStatus.Writable -> stringResource(Res.string.folder_status_writable)
|
||||
FolderAccessStatus.PermissionRequired -> stringResource(Res.string.folder_status_permission_required)
|
||||
FolderAccessStatus.Unavailable -> stringResource(Res.string.folder_status_unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
private enum class IconTone {
|
||||
Brand,
|
||||
Neutral,
|
||||
@@ -533,6 +631,15 @@ private object SettingsIcons {
|
||||
moveTo(11f, 18f)
|
||||
lineTo(13f, 18f)
|
||||
}
|
||||
val Folder = lineIcon("Folder") {
|
||||
moveTo(3f, 7f)
|
||||
lineTo(9f, 7f)
|
||||
lineTo(11f, 9f)
|
||||
lineTo(21f, 9f)
|
||||
lineTo(21f, 19f)
|
||||
lineTo(3f, 19f)
|
||||
close()
|
||||
}
|
||||
val Info = lineIcon("Info") {
|
||||
moveTo(12f, 16f)
|
||||
lineTo(12f, 12f)
|
||||
|
||||
@@ -2,6 +2,9 @@ package com.vnidrop.app.ui.state
|
||||
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import com.vnidrop.app.ui.navigation.AppDestination
|
||||
import com.vnidrop.app.core.FolderAccessStatus
|
||||
import com.vnidrop.app.core.ReceiveFolder
|
||||
import com.vnidrop.app.core.ReceiveFolderKind
|
||||
import uniffi.vnidrop.CoreEvent
|
||||
import uniffi.vnidrop.StoredTransfer
|
||||
import kotlin.math.roundToInt
|
||||
@@ -27,6 +30,20 @@ data class AppUiState(
|
||||
val themeMode: ThemeMode = ThemeMode.System,
|
||||
)
|
||||
|
||||
data class PreferencesUiState(
|
||||
val username: String = "",
|
||||
val receiveFolder: ReceiveFolder = ReceiveFolder(
|
||||
kind = ReceiveFolderKind.FileSystemPath,
|
||||
value = "",
|
||||
displayName = "",
|
||||
),
|
||||
val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable,
|
||||
val isValidatingFolder: Boolean = false,
|
||||
) {
|
||||
val canReceiveIntoFolder: Boolean
|
||||
get() = folderAccessStatus == FolderAccessStatus.Writable
|
||||
}
|
||||
|
||||
data class SendUiState(
|
||||
val selectedSource: String = "",
|
||||
val selectedDisplayName: String = "",
|
||||
@@ -72,6 +89,9 @@ fun displayNameForStatus(status: String): String =
|
||||
else -> status.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
fun StoredTransfer.isActiveTransfer(): Boolean =
|
||||
status.lowercase() in activeTransferStatuses
|
||||
|
||||
fun summarizeProgress(events: List<CoreEvent>): List<TransferProgress> =
|
||||
events
|
||||
.filter { event -> event.transferId != null && event.phase in progressPhases }
|
||||
@@ -123,6 +143,7 @@ fun friendlyCoreError(raw: String?): String? {
|
||||
}
|
||||
|
||||
private val progressPhases = setOf("import", "ticket", "access", "transfer", "download", "export", "lifecycle")
|
||||
private val activeTransferStatuses = setOf("sharing", "receiving")
|
||||
|
||||
private fun eventLabel(event: CoreEvent): String {
|
||||
val direction = event.direction?.replaceFirstChar { it.uppercase() }
|
||||
|
||||
Reference in New Issue
Block a user