Улучшение UI/UX
This commit is contained in:
@@ -67,17 +67,6 @@ fun TaskPipelineScreen(
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Button(onClick = { showTestDialog = true }) {
|
||||
Text(stringResource(R.string.task_pipeline_run_test))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.orchestrator.cancelAll() },
|
||||
enabled = hasAnyTask,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.task_pipeline_cancel_all))
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.task_pipeline_jobs),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
@@ -113,6 +102,20 @@ fun TaskPipelineScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = { showTestDialog = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.task_pipeline_run_test))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.orchestrator.cancelAll() },
|
||||
enabled = hasAnyTask,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.task_pipeline_cancel_all))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,9 +143,18 @@ abstract class AbstractVaultBrowserViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyUser(@StringRes messageRes: Int) {
|
||||
viewModelScope.launch {
|
||||
_userNotifications.emit(UserNotification.TextRes(messageRes))
|
||||
}
|
||||
}
|
||||
|
||||
fun printStorageInfoToLog(storage: IStorageInfo) {
|
||||
val id = storage.uuid
|
||||
if (isStorageTaskActive(id)) return
|
||||
if (isStorageTaskActive(id)) {
|
||||
notifyUser(R.string.vault_msg_storage_pipeline_busy)
|
||||
return
|
||||
}
|
||||
taskOrchestrator.enqueue(
|
||||
title = uiStrings(R.string.task_title_dump_storage_log),
|
||||
dispatcher = Dispatchers.IO,
|
||||
@@ -182,6 +191,7 @@ abstract class AbstractVaultBrowserViewModel(
|
||||
}
|
||||
if (isVaultListMutationActive()) {
|
||||
logger.debug(TAG, "createStorage ignored (vault list mutation already running)")
|
||||
notifyUser(R.string.vault_msg_vault_list_mutation_busy)
|
||||
return
|
||||
}
|
||||
logger.debug(TAG, "createStorage: enqueue task")
|
||||
@@ -209,7 +219,10 @@ abstract class AbstractVaultBrowserViewModel(
|
||||
|
||||
fun enableEncryption(storage: IStorageInfo, password: String, encryptPath: Boolean) {
|
||||
val id = storage.uuid
|
||||
if (isStorageTaskActive(id)) return
|
||||
if (isStorageTaskActive(id)) {
|
||||
notifyUser(R.string.vault_msg_storage_pipeline_busy)
|
||||
return
|
||||
}
|
||||
val key = EncryptKey(password)
|
||||
taskOrchestrator.enqueue(
|
||||
title = uiStrings(R.string.task_title_enable_encryption),
|
||||
@@ -258,7 +271,10 @@ abstract class AbstractVaultBrowserViewModel(
|
||||
|
||||
fun openEncryptedStorage(storage: IStorageInfo, password: String, rememberPassword: Boolean) {
|
||||
val id = storage.uuid
|
||||
if (isStorageTaskActive(id)) return
|
||||
if (isStorageTaskActive(id)) {
|
||||
notifyUser(R.string.vault_msg_storage_pipeline_busy)
|
||||
return
|
||||
}
|
||||
val key = EncryptKey(password)
|
||||
taskOrchestrator.enqueue(
|
||||
title = uiStrings(R.string.task_title_open_encrypted_storage),
|
||||
@@ -285,7 +301,10 @@ abstract class AbstractVaultBrowserViewModel(
|
||||
|
||||
fun closeEncryptedStorage(storage: IStorageInfo) {
|
||||
val id = storage.uuid
|
||||
if (isStorageTaskActive(id)) return
|
||||
if (isStorageTaskActive(id)) {
|
||||
notifyUser(R.string.vault_msg_storage_pipeline_busy)
|
||||
return
|
||||
}
|
||||
taskOrchestrator.enqueue(
|
||||
title = uiStrings(R.string.task_title_close_encrypted_storage),
|
||||
dispatcher = Dispatchers.IO,
|
||||
@@ -310,7 +329,10 @@ abstract class AbstractVaultBrowserViewModel(
|
||||
|
||||
fun disableEncryption(storage: IStorageInfo) {
|
||||
val id = storage.uuid
|
||||
if (isStorageTaskActive(id)) return
|
||||
if (isStorageTaskActive(id)) {
|
||||
notifyUser(R.string.vault_msg_storage_pipeline_busy)
|
||||
return
|
||||
}
|
||||
taskOrchestrator.enqueue(
|
||||
title = uiStrings(R.string.task_title_disable_encryption),
|
||||
dispatcher = Dispatchers.IO,
|
||||
@@ -338,7 +360,10 @@ abstract class AbstractVaultBrowserViewModel(
|
||||
|
||||
fun rename(storage: IStorageInfo, newName: String) {
|
||||
val id = storage.uuid
|
||||
if (isStorageTaskActive(id)) return
|
||||
if (isStorageTaskActive(id)) {
|
||||
notifyUser(R.string.vault_msg_storage_pipeline_busy)
|
||||
return
|
||||
}
|
||||
taskOrchestrator.enqueue(
|
||||
title = uiStrings(R.string.task_title_rename_storage),
|
||||
dispatcher = Dispatchers.IO,
|
||||
@@ -357,7 +382,10 @@ abstract class AbstractVaultBrowserViewModel(
|
||||
|
||||
fun remove(storage: IStorageInfo) {
|
||||
val id = storage.uuid
|
||||
if (isStorageTaskActive(id)) return
|
||||
if (isStorageTaskActive(id)) {
|
||||
notifyUser(R.string.vault_msg_storage_pipeline_busy)
|
||||
return
|
||||
}
|
||||
taskOrchestrator.enqueue(
|
||||
title = uiStrings(R.string.task_title_remove_storage),
|
||||
dispatcher = Dispatchers.IO,
|
||||
@@ -399,7 +427,10 @@ abstract class AbstractVaultBrowserViewModel(
|
||||
|
||||
fun clearStorageSyncLock(storage: IStorageInfo) {
|
||||
val id = storage.uuid
|
||||
if (isStorageTaskActive(id)) return
|
||||
if (isStorageTaskActive(id)) {
|
||||
notifyUser(R.string.vault_msg_storage_pipeline_busy)
|
||||
return
|
||||
}
|
||||
taskOrchestrator.enqueue(
|
||||
title = uiStrings(R.string.task_title_clear_sync_lock),
|
||||
dispatcher = Dispatchers.IO,
|
||||
|
||||
@@ -27,9 +27,12 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -37,6 +40,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -71,12 +75,33 @@ fun StorageSyncScreen(
|
||||
.flatMap { vault -> flattenStorageTree(vault.storages) }
|
||||
.associateBy { it.uuid }
|
||||
|
||||
val groupEditLocked = state.isBusy || state.isStorageSyncRunning
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(state.userMessage) {
|
||||
val um = state.userMessage ?: return@LaunchedEffect
|
||||
val text = when (um) {
|
||||
is UserNotification.TextRes -> {
|
||||
if (um.formatArgs.isEmpty()) {
|
||||
context.getString(um.id)
|
||||
} else {
|
||||
context.getString(um.id, *um.formatArgs.toTypedArray())
|
||||
}
|
||||
}
|
||||
is UserNotification.Plain -> um.message
|
||||
}
|
||||
snackbarHostState.showSnackbar(text)
|
||||
viewModel.consumeUserMessage()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = { if (!state.isBusy) viewModel.createGroup() },
|
||||
modifier = Modifier.alpha(if (state.isBusy) 0.38f else 1f),
|
||||
onClick = { if (!groupEditLocked) viewModel.createGroup() },
|
||||
modifier = Modifier.alpha(if (groupEditLocked) 0.38f else 1f),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Add,
|
||||
@@ -92,16 +117,13 @@ fun StorageSyncScreen(
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (state.isBusy) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Button(
|
||||
onClick = viewModel::runSyncNow,
|
||||
enabled = !state.isBusy,
|
||||
enabled = !groupEditLocked && !state.anyVaultStoragesScanning,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.Sync,
|
||||
@@ -112,18 +134,46 @@ fun StorageSyncScreen(
|
||||
}
|
||||
}
|
||||
|
||||
state.userMessage?.let { um ->
|
||||
val text = when (um) {
|
||||
is UserNotification.TextRes -> {
|
||||
if (um.formatArgs.isEmpty()) {
|
||||
stringResource(um.id)
|
||||
when {
|
||||
state.isStorageSyncRunning -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.sync_progress_section_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
state.storageSyncProgressLabel?.let { line ->
|
||||
Text(
|
||||
text = line,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
val frac = state.storageSyncProgressFraction
|
||||
if (frac != null) {
|
||||
LinearProgressIndicator(
|
||||
progress = { frac },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
stringResource(um.id, *um.formatArgs.toTypedArray())
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
is UserNotification.Plain -> um.message
|
||||
}
|
||||
Text(text = text, style = MaterialTheme.typography.bodyMedium)
|
||||
state.isBusy -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.sync_groups_busy_section_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
@@ -164,7 +214,7 @@ fun StorageSyncScreen(
|
||||
)
|
||||
IconButton(
|
||||
onClick = { pendingRemoveGroupId = group.id },
|
||||
enabled = !state.isBusy,
|
||||
enabled = !groupEditLocked,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Delete,
|
||||
@@ -254,7 +304,7 @@ fun StorageSyncScreen(
|
||||
}
|
||||
IconButton(
|
||||
onClick = { pendingRemoveStorage = group.id to storageUuid },
|
||||
enabled = !state.isBusy,
|
||||
enabled = !groupEditLocked,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Delete,
|
||||
@@ -274,7 +324,7 @@ fun StorageSyncScreen(
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { viewModel.openPicker(group.id) },
|
||||
enabled = !state.isBusy,
|
||||
enabled = !groupEditLocked,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Add,
|
||||
@@ -376,6 +426,7 @@ private fun StoragePickerScreen(
|
||||
onToggleVault: (UUID) -> Unit,
|
||||
) {
|
||||
val selected = state.groups.firstOrNull { it.id == groupId }?.storageUuids ?: emptySet()
|
||||
val groupEditLocked = state.isBusy || state.isStorageSyncRunning
|
||||
Scaffold(modifier = modifier) { inner ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -465,7 +516,7 @@ private fun StoragePickerScreen(
|
||||
node = storage,
|
||||
depth = 0,
|
||||
selected = selected,
|
||||
isBusy = state.isBusy,
|
||||
isBusy = groupEditLocked,
|
||||
onAddStorage = onAddStorage,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,12 @@ data class StorageSyncScreenState(
|
||||
val expandedVaultUuids: Set<UUID> = emptySet(),
|
||||
val pickerGroupId: String? = null,
|
||||
val isBusy: Boolean = false,
|
||||
/** Долгая синхронизация storages через пайплайн задач. */
|
||||
val isStorageSyncRunning: Boolean = false,
|
||||
/** Доля прогресса активной синхронизации; null — неопределённый прогресс. */
|
||||
val storageSyncProgressFraction: Float? = null,
|
||||
/** Подпись этапа из пайплайна (если есть). */
|
||||
val storageSyncProgressLabel: String? = null,
|
||||
/** Любой vault ещё загружает список storages — UUID из группы могут появиться позже. */
|
||||
val anyVaultStoragesScanning: Boolean = false,
|
||||
val userMessage: UserNotification? = null,
|
||||
|
||||
@@ -4,6 +4,8 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
|
||||
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
|
||||
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
|
||||
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
|
||||
import com.github.nullptroma.wallenc.ui.R
|
||||
import com.github.nullptroma.wallenc.ui.ViewModelBase
|
||||
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
|
||||
@@ -28,12 +30,14 @@ class StorageSyncViewModel @Inject constructor(
|
||||
private val groupsUseCase: ManageStorageSyncGroupsUseCase,
|
||||
private val runStorageSyncUseCase: RunStorageSyncUseCase,
|
||||
private val vaultsManager: IVaultsManager,
|
||||
private val taskOrchestrator: ITaskOrchestrator,
|
||||
private val uiStrings: UiStringResolver,
|
||||
) : ViewModelBase<StorageSyncScreenState>(StorageSyncScreenState()) {
|
||||
|
||||
init {
|
||||
refreshGroups()
|
||||
observeVaults()
|
||||
observeStorageSyncPipeline()
|
||||
viewModelScope.launch {
|
||||
vaultsManager.vaults
|
||||
.flatMapLatest { vaults ->
|
||||
@@ -52,6 +56,36 @@ class StorageSyncViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeStorageSyncPipeline() {
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
runStorageSyncUseCase.syncRunning,
|
||||
runStorageSyncUseCase.activeSyncTaskId,
|
||||
taskOrchestrator.pipelineState,
|
||||
) { syncRunning, taskId, pipe ->
|
||||
val progress = taskId?.let { id ->
|
||||
val task = pipe.tasks.find { it.id == id }
|
||||
(task?.state as? TaskRunState.Running)?.progress
|
||||
}
|
||||
Triple(
|
||||
syncRunning,
|
||||
progress?.fraction,
|
||||
progress?.label?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { (running, frac, label) ->
|
||||
updateState(
|
||||
state.value.copy(
|
||||
isStorageSyncRunning = running,
|
||||
storageSyncProgressFraction = if (running) frac else null,
|
||||
storageSyncProgressLabel = if (running) label else null,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshGroups() {
|
||||
viewModelScope.launch {
|
||||
updateState(state.value.copy(groups = reloadGroupsUi()))
|
||||
@@ -80,6 +114,7 @@ class StorageSyncViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun openPicker(groupId: String) {
|
||||
if (state.value.isBusy || state.value.isStorageSyncRunning) return
|
||||
updateState(
|
||||
state.value.copy(
|
||||
pickerGroupId = groupId,
|
||||
@@ -131,15 +166,21 @@ class StorageSyncViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun runSyncNow() {
|
||||
runStorageSyncUseCase.enqueue(
|
||||
val started = runStorageSyncUseCase.enqueue(
|
||||
displayTitle = uiStrings(R.string.task_title_storage_sync),
|
||||
logReason = "sync-tab",
|
||||
)
|
||||
updateState(
|
||||
state.value.copy(
|
||||
userMessage = UserNotification.TextRes(R.string.sync_msg_task_enqueued),
|
||||
),
|
||||
)
|
||||
if (!started) {
|
||||
updateState(
|
||||
state.value.copy(
|
||||
userMessage = UserNotification.TextRes(R.string.sync_msg_sync_already_running),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeUserMessage() {
|
||||
updateState(state.value.copy(userMessage = null))
|
||||
}
|
||||
|
||||
private fun observeVaults() {
|
||||
@@ -297,6 +338,14 @@ class StorageSyncViewModel @Inject constructor(
|
||||
clearPicker: Boolean = false,
|
||||
block: suspend () -> UserNotification?,
|
||||
) {
|
||||
if (state.value.isStorageSyncRunning) {
|
||||
updateState(
|
||||
state.value.copy(
|
||||
userMessage = UserNotification.TextRes(R.string.sync_msg_blocked_during_sync),
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
updateState(state.value.copy(isBusy = true, userMessage = null))
|
||||
val message = block()
|
||||
val groups = reloadGroupsUi()
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
<string name="settings_title">Настройки</string>
|
||||
<string name="sync_groups_title">Группы синхронизации</string>
|
||||
<string name="sync_progress_section_title">Синхронизация хранилищ</string>
|
||||
<string name="sync_groups_busy_section_title">Сохранение групп синхронизации</string>
|
||||
<string name="sync_run_now">Запустить синхронизацию</string>
|
||||
<string name="sync_cd_run_now">Запустить синхронизацию сейчас</string>
|
||||
<string name="sync_refresh">Обновить</string>
|
||||
@@ -43,6 +45,8 @@
|
||||
<string name="sync_msg_storage_added">Хранилище добавлено в %1$s</string>
|
||||
<string name="sync_msg_storage_removed">Хранилище убрано из %1$s</string>
|
||||
<string name="sync_msg_task_enqueued">Задача синхронизации поставлена в очередь</string>
|
||||
<string name="sync_msg_sync_already_running">Синхронизация уже выполняется</string>
|
||||
<string name="sync_msg_blocked_during_sync">Дождитесь окончания синхронизации</string>
|
||||
<string name="sync_encryption_unknown">Неизвестно</string>
|
||||
<string name="sync_storage_encryption_line">Шифрование: %1$s</string>
|
||||
<string name="sync_storage_missing_title">Не найдено в текущих vault</string>
|
||||
@@ -80,6 +84,8 @@
|
||||
<string name="vault_fab_add_storage_cd">Создать хранилище</string>
|
||||
<string name="vault_fab_add_storage_disabled_cd">Создание недоступно: хранилище недоступно</string>
|
||||
<string name="vault_fab_add_storage_busy_cd">Создание хранилища уже выполняется</string>
|
||||
<string name="vault_msg_storage_pipeline_busy">С этим хранилищем уже выполняется операция</string>
|
||||
<string name="vault_msg_vault_list_mutation_busy">Список хранилищ сейчас меняется — подождите</string>
|
||||
<string name="vault_unavailable_banner">Хранилище недоступно. Проверьте сеть, путь или разблокировку.</string>
|
||||
<string name="vault_loading_storages">Загрузка списка хранилищ…</string>
|
||||
<string name="vault_empty_list_hint">В этом хранилище пока нет каталогов. Создайте хранилище кнопкой «+», когда оно доступно.</string>
|
||||
|
||||
Reference in New Issue
Block a user