Улучшение UI/UX

This commit is contained in:
2026-05-13 18:11:48 +03:00
parent abac9d93eb
commit c6df089668
7 changed files with 235 additions and 69 deletions

View File

@@ -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))
}
}
}

View File

@@ -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,

View File

@@ -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,
)
}

View File

@@ -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,

View File

@@ -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()

View File

@@ -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>

View File

@@ -2,8 +2,12 @@ package com.github.nullptroma.wallenc.usecases
import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncEngine
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskId
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.atomic.AtomicBoolean
class RunStorageSyncUseCase(
@@ -12,37 +16,53 @@ class RunStorageSyncUseCase(
) {
private val running = AtomicBoolean(false)
private val _syncRunning = MutableStateFlow(false)
val syncRunning: StateFlow<Boolean> = _syncRunning.asStateFlow()
private val _activeSyncTaskId = MutableStateFlow<TaskId?>(null)
val activeSyncTaskId: StateFlow<TaskId?> = _activeSyncTaskId.asStateFlow()
/**
* @param displayTitle заголовок задачи в UI (локализованный на стороне вызова)
* @param logReason техническая метка для логов (не для UI)
* @return false, если синхронизация уже в очереди или выполняется — новая задача не создана
*/
fun enqueue(displayTitle: String, logReason: String) {
orchestrator.enqueue(
title = displayTitle,
dispatcher = Dispatchers.IO,
work = { ctx ->
if (!running.compareAndSet(false, true)) {
ctx.log(TaskLogLevel.Info, "Storage sync skipped (already running), reason=$logReason")
return@enqueue
}
try {
ctx.log(TaskLogLevel.Info, "Storage sync started, reason=$logReason")
ctx.reportProgress(null, "Storage sync: started")
syncEngine.syncAllGroups { fraction, label ->
ctx.reportProgress(fraction, label)
fun enqueue(displayTitle: String, logReason: String): Boolean {
if (!running.compareAndSet(false, true)) {
return false
}
_syncRunning.value = true
try {
val taskId = orchestrator.enqueue(
title = displayTitle,
dispatcher = Dispatchers.IO,
work = { ctx ->
try {
ctx.log(TaskLogLevel.Info, "Storage sync started, reason=$logReason")
ctx.reportProgress(null, "Storage sync: started")
syncEngine.syncAllGroups { fraction, label ->
ctx.reportProgress(fraction, label)
}
ctx.log(TaskLogLevel.Info, "Storage sync finished")
ctx.reportProgress(null, "Storage sync: completed")
} catch (e: Exception) {
ctx.log(TaskLogLevel.Error, "Storage sync failed: ${e.message}")
ctx.reportProgress(null, "Storage sync: failed - ${e.message}")
} finally {
running.set(false)
_syncRunning.value = false
_activeSyncTaskId.value = null
}
ctx.log(TaskLogLevel.Info, "Storage sync finished")
ctx.reportProgress(null, "Storage sync: completed")
}
catch (e: Exception) {
ctx.log(TaskLogLevel.Error, "Storage sync failed: ${e.message}")
ctx.reportProgress(null, "Storage sync: failed - ${e.message}")
}
finally {
running.set(false)
}
},
)
},
)
_activeSyncTaskId.value = taskId
return true
} catch (t: Throwable) {
running.set(false)
_syncRunning.value = false
_activeSyncTaskId.value = null
throw t
}
}
suspend fun runBlocking() {