diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineScreen.kt index 8bcf9a0..6dd5a8d 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineScreen.kt @@ -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)) + } } } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/AbstractVaultBrowserViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/AbstractVaultBrowserViewModel.kt index b823095..82bf41e 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/AbstractVaultBrowserViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/AbstractVaultBrowserViewModel.kt @@ -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, diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt index 2d60a70..fbf13fe 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt @@ -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, ) } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreenState.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreenState.kt index 98d2c50..1b4e6a1 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreenState.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreenState.kt @@ -36,6 +36,12 @@ data class StorageSyncScreenState( val expandedVaultUuids: Set = 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, diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt index cd741ca..63282c2 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt @@ -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()) { 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() diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 8ca08b1..c830c07 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -14,6 +14,8 @@ Настройки Группы синхронизации + Синхронизация хранилищ + Сохранение групп синхронизации Запустить синхронизацию Запустить синхронизацию сейчас Обновить @@ -43,6 +45,8 @@ Хранилище добавлено в %1$s Хранилище убрано из %1$s Задача синхронизации поставлена в очередь + Синхронизация уже выполняется + Дождитесь окончания синхронизации Неизвестно Шифрование: %1$s Не найдено в текущих vault @@ -80,6 +84,8 @@ Создать хранилище Создание недоступно: хранилище недоступно Создание хранилища уже выполняется + С этим хранилищем уже выполняется операция + Список хранилищ сейчас меняется — подождите Хранилище недоступно. Проверьте сеть, путь или разблокировку. Загрузка списка хранилищ… В этом хранилище пока нет каталогов. Создайте хранилище кнопкой «+», когда оно доступно. diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt index 3aac372..5f858fb 100644 --- a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt @@ -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 = _syncRunning.asStateFlow() + + private val _activeSyncTaskId = MutableStateFlow(null) + val activeSyncTaskId: StateFlow = _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() {