feat(ui): добавлены новые состояния и компоненты для отображения статуса работы

This commit is contained in:
2026-05-13 17:22:31 +03:00
parent 6c18a1d741
commit f551efe4a6
40 changed files with 1787 additions and 542 deletions

View File

@@ -17,7 +17,6 @@ import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -32,6 +31,7 @@ import androidx.navigation.navDeepLink
import com.github.nullptroma.wallenc.ui.navigation.NavBarItemData
import com.github.nullptroma.wallenc.ui.navigation.WallencDeepLinks
import com.github.nullptroma.wallenc.ui.navigation.matchesWallencDeepLink
import com.github.nullptroma.wallenc.ui.elements.NavigationBarMarqueeText
import com.github.nullptroma.wallenc.ui.navigation.rememberNavigationState
import com.github.nullptroma.wallenc.ui.screens.main.MainRoute
import com.github.nullptroma.wallenc.ui.screens.main.MainScreen
@@ -124,7 +124,11 @@ fun WallencNavRoot(
contentDescription = stringResource(navBarItemData.iconContentDescriptionResourceId),
)
},
label = { Text(stringResource(navBarItemData.nameStringResourceId)) },
label = {
NavigationBarMarqueeText(
text = stringResource(navBarItemData.nameStringResourceId),
)
},
selected = currentRoute?.startsWith(routeClassName) == true,
onClick = {
val route = topLevelRoutes[navBarItemData.screenRouteClass]

View File

@@ -8,10 +8,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Checkbox
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -26,35 +26,47 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.github.nullptroma.wallenc.ui.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TextEditCancelOkDialog(onDismiss: () -> Unit, onConfirmation: (String) -> Unit, title: String, startString: String = "") {
fun TextEditCancelOkDialog(
onDismiss: () -> Unit,
onConfirmation: (String) -> Unit,
title: String,
startString: String = "",
) {
var name by remember { mutableStateOf(startString) }
val focusRequester = remember { FocusRequester() }
BasicAlertDialog(
onDismissRequest = { onDismiss() }
onDismissRequest = { onDismiss() },
) {
Card {
Column(modifier = Modifier.padding(12.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge)
TextField(modifier = Modifier.focusRequester(focusRequester), value = name, onValueChange = {
name = it
})
TextField(
modifier = Modifier.focusRequester(focusRequester),
value = name,
onValueChange = { name = it },
)
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Button(modifier = Modifier.weight(1f), onClick = onDismiss) {
Text("Cancel")
Text(stringResource(R.string.dialog_cancel))
}
Spacer(modifier = Modifier.width(12.dp))
Button(modifier = Modifier.weight(1f), onClick = {
onConfirmation(name)
}) {
Text("Ok")
Button(
modifier = Modifier.weight(1f),
onClick = {
onConfirmation(name)
},
) {
Text(stringResource(R.string.dialog_ok))
}
}
}
@@ -66,12 +78,11 @@ fun TextEditCancelOkDialog(onDismiss: () -> Unit, onConfirmation: (String) -> Un
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConfirmationCancelOkDialog(onDismiss: () -> Unit, onConfirmation: () -> Unit, title: String) {
BasicAlertDialog(
onDismissRequest = { onDismiss() }
onDismissRequest = { onDismiss() },
) {
Card {
Column(modifier = Modifier.padding(12.dp)) {
@@ -82,13 +93,16 @@ fun ConfirmationCancelOkDialog(onDismiss: () -> Unit, onConfirmation: () -> Unit
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Button(modifier = Modifier.weight(1f), onClick = onDismiss) {
Text("Cancel")
Text(stringResource(R.string.dialog_cancel))
}
Spacer(modifier = Modifier.width(12.dp))
Button(modifier = Modifier.weight(1f), onClick = {
onConfirmation()
}) {
Text("Ok")
Button(
modifier = Modifier.weight(1f),
onClick = {
onConfirmation()
},
) {
Text(stringResource(R.string.dialog_ok))
}
}
}
@@ -107,22 +121,33 @@ fun EncryptionSetupDialog(
BasicAlertDialog(onDismissRequest = onDismiss) {
Card {
Column(modifier = Modifier.padding(12.dp)) {
Text("Enable encryption", style = MaterialTheme.typography.titleLarge)
Text(
stringResource(R.string.dialog_encryption_enable_title),
style = MaterialTheme.typography.titleLarge,
)
Spacer(modifier = Modifier.height(12.dp))
TextField(value = password, onValueChange = { password = it }, label = { Text("Password") })
TextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.dialog_password_label)) },
)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = encryptPath, onCheckedChange = { encryptPath = it })
Text("Encrypt paths")
Text(stringResource(R.string.dialog_encrypt_paths))
}
Spacer(modifier = Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
Button(modifier = Modifier.weight(1f), onClick = onDismiss) { Text("Cancel") }
Button(modifier = Modifier.weight(1f), onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
Spacer(modifier = Modifier.width(12.dp))
Button(
modifier = Modifier.weight(1f),
onClick = { onConfirmation(password, encryptPath) },
enabled = password.isNotEmpty()
) { Text("Apply") }
enabled = password.isNotEmpty(),
) {
Text(stringResource(R.string.dialog_apply))
}
}
}
}
@@ -140,22 +165,33 @@ fun OpenEncryptedStorageDialog(
BasicAlertDialog(onDismissRequest = onDismiss) {
Card {
Column(modifier = Modifier.padding(12.dp)) {
Text("Open encrypted storage", style = MaterialTheme.typography.titleLarge)
Text(
stringResource(R.string.dialog_open_encrypted_title),
style = MaterialTheme.typography.titleLarge,
)
Spacer(modifier = Modifier.height(12.dp))
TextField(value = password, onValueChange = { password = it }, label = { Text("Password") })
TextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.dialog_password_label)) },
)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = rememberPassword, onCheckedChange = { rememberPassword = it })
Text("Remember password")
Text(stringResource(R.string.dialog_remember_password))
}
Spacer(modifier = Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
Button(modifier = Modifier.weight(1f), onClick = onDismiss) { Text("Cancel") }
Button(modifier = Modifier.weight(1f), onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
Spacer(modifier = Modifier.width(12.dp))
Button(
modifier = Modifier.weight(1f),
onClick = { onConfirmation(password, rememberPassword) },
enabled = password.isNotEmpty()
) { Text("Open") }
enabled = password.isNotEmpty(),
) {
Text(stringResource(R.string.dialog_open))
}
}
}
}
@@ -178,14 +214,22 @@ fun StorageEncryptionActionsDialog(
Text(title, style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(12.dp))
if (isOpened) {
Button(onClick = onClose, modifier = Modifier.fillMaxWidth()) { Text("Close") }
Button(onClick = onClose, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.dialog_close))
}
} else {
Button(onClick = onOpen, modifier = Modifier.fillMaxWidth()) { Text("Open") }
Button(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.dialog_open))
}
}
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onDisable, modifier = Modifier.fillMaxWidth()) { Text("Disable encryption") }
Button(onClick = onDisable, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.dialog_disable_encryption))
}
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { Text("Done") }
Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.dialog_done))
}
}
}
}

View File

@@ -0,0 +1,34 @@
package com.github.nullptroma.wallenc.ui.elements
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
/**
* Однострочная подпись таба нижней навигации: при нехватке ширины текст
* прокручивается (marquee), без переноса последних букв на вторую строку.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NavigationBarMarqueeText(
text: String,
modifier: Modifier = Modifier,
) {
Text(
text = text,
modifier = modifier
.fillMaxWidth()
.basicMarquee(),
style = LocalTextStyle.current,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Clip,
textAlign = TextAlign.Center,
)
}

View File

@@ -17,8 +17,10 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
@@ -36,7 +38,6 @@ import androidx.compose.runtime.remember
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
@@ -50,11 +51,13 @@ import com.github.nullptroma.wallenc.domain.datatypes.Tree
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.utils.debouncedLambda
import java.util.UUID
@Composable
fun StorageTree(
modifier: Modifier,
tree: Tree<IStorageInfo>,
isUuidBusy: (UUID) -> Boolean,
onClick: (Tree<IStorageInfo>) -> Unit,
onRename: (Tree<IStorageInfo>, String) -> Unit,
onRemove: (Tree<IStorageInfo>) -> Unit,
@@ -62,13 +65,13 @@ fun StorageTree(
onOpenEncrypted: (Tree<IStorageInfo>, String, Boolean) -> Unit,
onCloseEncrypted: (Tree<IStorageInfo>) -> Unit,
onDisableEncryption: (Tree<IStorageInfo>) -> Unit,
getStatusText: (Tree<IStorageInfo>) -> String,
getStatusTextRes: (Tree<IStorageInfo>) -> Int,
isEncryptionOpened: (Tree<IStorageInfo>) -> Boolean,
isStorageSyncLockHeld: suspend (IStorageInfo) -> Boolean,
onClearStorageSyncLock: (IStorageInfo) -> Unit,
) {
val cur = tree.value
val available by cur.isAvailable.collectAsStateWithLifecycle()
val rowBusy = isUuidBusy(cur.uuid)
val numOfFiles by cur.numberOfFiles.collectAsStateWithLifecycle()
val size by cur.size.collectAsStateWithLifecycle()
val metaInfo by cur.metaInfo.collectAsStateWithLifecycle()
@@ -77,17 +80,20 @@ fun StorageTree(
val isOpened = isEncryptionOpened(tree)
val borderColor =
if (cur.isVirtualStorage) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.primary
val yesWord = stringResource(R.string.storage_value_yes)
val noWord = stringResource(R.string.storage_value_no)
val unavailableHint = stringResource(R.string.storage_unavailable_hint)
Column(modifier) {
Box(
modifier = Modifier
.height(IntrinsicSize.Min)
.zIndex(100f)
.zIndex(100f),
) {
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = Modifier
.clip(
CardDefaults.shape
CardDefaults.shape,
)
.padding(0.dp, 0.dp, 16.dp, 0.dp)
.fillMaxSize()
@@ -96,8 +102,8 @@ fun StorageTree(
interactionSource = interactionSource,
indication = ripple(),
enabled = false,
onClick = { }
)
onClick = { },
),
)
Card(
interactionSource = interactionSource,
@@ -105,27 +111,57 @@ fun StorageTree(
.padding(8.dp, 0.dp, 0.dp, 0.dp)
.fillMaxWidth(),
elevation = CardDefaults.cardElevation(
defaultElevation = 4.dp
defaultElevation = 4.dp,
),
enabled = isAvailable && !rowBusy,
onClick = debouncedLambda(debounceMs = 500) {
onClick(tree)
}
if (isAvailable && !rowBusy) {
onClick(tree)
}
},
) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
Column(modifier = Modifier.padding(8.dp)) {
Text(metaInfo.name ?: stringResource(R.string.no_name))
Text(
text = "IsAvailable: $available"
text = stringResource(
R.string.storage_field_available,
if (isAvailable) yesWord else noWord,
),
style = MaterialTheme.typography.bodySmall,
)
Text("Files: $numOfFiles")
Text("Size: $size")
Text("IsVirtual: ${cur.isVirtualStorage}")
Text(
text = stringResource(
R.string.storage_field_files,
numOfFiles?.toString() ?: "",
),
style = MaterialTheme.typography.bodySmall,
)
Text(
text = stringResource(
R.string.storage_field_size,
size?.toString() ?: "",
),
style = MaterialTheme.typography.bodySmall,
)
Text(
text = stringResource(
R.string.storage_field_virtual,
if (cur.isVirtualStorage) yesWord else noWord,
),
style = MaterialTheme.typography.bodySmall,
)
if (!isAvailable) {
Text(
text = unavailableHint,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
Column(
modifier = Modifier,
horizontalAlignment = Alignment.End
horizontalAlignment = Alignment.End,
) {
var expanded by remember { mutableStateOf(false) }
var syncLockHeld by remember { mutableStateOf<Boolean?>(null) }
@@ -143,47 +179,104 @@ fun StorageTree(
var showSetupEncryptionDialog by remember { mutableStateOf(false) }
var showOpenEncryptionDialog by remember { mutableStateOf(false) }
Box(modifier = Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp)) {
IconButton(onClick = { expanded = !expanded }) {
Icon(
Icons.Default.MoreVert,
contentDescription = stringResource(R.string.show_storage_item_menu)
)
Row(
verticalAlignment = Alignment.CenterVertically,
) {
if (rowBusy) {
CircularProgressIndicator(
modifier = Modifier
.padding(end = 4.dp)
.size(22.dp),
strokeWidth = 2.dp,
)
}
IconButton(
onClick = { expanded = !expanded },
enabled = isAvailable && !rowBusy,
) {
Icon(
Icons.Default.MoreVert,
contentDescription = stringResource(
if (rowBusy) {
R.string.storage_row_task_running_cd
} else {
R.string.show_storage_item_menu
},
),
)
}
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
onDismissRequest = { expanded = false },
) {
DropdownMenuItem(
enabled = isAvailable && !rowBusy,
onClick = {
expanded = false
showRenameDialog = true
if (isAvailable && !rowBusy) showRenameDialog = true
},
text = {
Text(
when {
!isAvailable -> stringResource(
R.string.storage_menu_unavailable,
stringResource(R.string.rename),
)
rowBusy -> stringResource(R.string.storage_menu_busy, stringResource(R.string.rename))
else -> stringResource(R.string.rename)
},
)
},
text = { Text(stringResource(R.string.rename)) }
)
HorizontalDivider()
DropdownMenuItem(
enabled = isAvailable && !rowBusy,
onClick = {
expanded = false
showRemoveConfirmDialog = true
if (isAvailable && !rowBusy) showRemoveConfirmDialog = true
},
text = {
Text(
when {
!isAvailable -> stringResource(
R.string.storage_menu_unavailable,
stringResource(R.string.remove),
)
rowBusy -> stringResource(R.string.storage_menu_busy, stringResource(R.string.remove))
else -> stringResource(R.string.remove)
},
)
},
text = { Text(stringResource(R.string.remove)) }
)
if (!isEncrypted) {
HorizontalDivider()
DropdownMenuItem(
enabled = isAvailable && !rowBusy,
onClick = {
expanded = false
showSetupEncryptionDialog = true
if (isAvailable && !rowBusy) showSetupEncryptionDialog = true
},
text = {
Text(
when {
!isAvailable -> stringResource(
R.string.storage_menu_unavailable,
stringResource(R.string.encrypt),
)
rowBusy -> stringResource(R.string.storage_menu_busy, stringResource(R.string.encrypt))
else -> stringResource(R.string.encrypt)
},
)
},
text = { Text(stringResource(R.string.encrypt)) }
)
}
HorizontalDivider()
DropdownMenuItem(
enabled = syncLockHeld == true,
enabled = syncLockHeld == true && !rowBusy,
onClick = {
expanded = false
if (syncLockHeld == true) {
if (syncLockHeld == true && !rowBusy) {
onClearStorageSyncLock(cur)
}
},
@@ -207,7 +300,7 @@ fun StorageTree(
onRename(tree, newName)
},
title = stringResource(R.string.new_name_title),
startString = metaInfo.name ?: ""
startString = metaInfo.name ?: "",
)
}
@@ -216,12 +309,12 @@ fun StorageTree(
onDismiss = { showRemoveConfirmDialog = false },
title = stringResource(
R.string.remove_confirmation_dialog,
metaInfo.name ?: "<noname>"
metaInfo.name ?: stringResource(R.string.no_name),
),
onConfirmation = {
showRemoveConfirmDialog = false
onRemove(tree)
}
},
)
}
@@ -241,7 +334,7 @@ fun StorageTree(
onDisable = {
showLockDialog = false
onDisableEncryption(tree)
}
},
)
}
@@ -251,7 +344,7 @@ fun StorageTree(
onConfirmation = { password, encryptPath ->
showSetupEncryptionDialog = false
onEncrypt(tree, password, encryptPath)
}
},
)
}
@@ -261,16 +354,19 @@ fun StorageTree(
onConfirmation = { password, rememberPassword ->
showOpenEncryptionDialog = false
onOpenEncrypted(tree, password, rememberPassword)
}
},
)
}
}
Spacer(modifier = Modifier.weight(1f))
if (isEncrypted) {
IconButton(onClick = { showLockDialog = true }) {
IconButton(
onClick = { showLockDialog = true },
enabled = isAvailable && !rowBusy,
) {
Icon(
if (isOpened) Icons.Default.LockOpen else Icons.Default.Lock,
contentDescription = stringResource(R.string.storage_lock_actions)
contentDescription = stringResource(R.string.storage_lock_actions),
)
}
}
@@ -279,7 +375,7 @@ fun StorageTree(
.fillMaxWidth()
.padding(0.dp, 0.dp, 12.dp, 0.dp)
.align(Alignment.End),
text = getStatusText(tree),
text = stringResource(getStatusTextRes(tree)),
textAlign = TextAlign.End,
fontSize = 11.sp,
)
@@ -293,39 +389,29 @@ fun StorageTree(
fontSize = 8.sp,
style = LocalTextStyle.current.copy(
platformStyle = PlatformTextStyle(
includeFontPadding = true
)
)
includeFontPadding = true,
),
),
)
}
}
}
if(!isAvailable) {
Box(
modifier = Modifier
.clip(
CardDefaults.shape
)
.fillMaxSize()
.alpha(0.5f)
.background(Color.Black)
)
}
}
for (i in tree.children ?: listOf()) {
StorageTree(
Modifier
.padding(16.dp, 0.dp, 0.dp, 0.dp)
.offset(y = (-4).dp),
i,
onClick,
tree = i,
isUuidBusy = isUuidBusy,
onClick = onClick,
onRename,
onRemove,
onEncrypt,
onOpenEncrypted,
onCloseEncrypted,
onDisableEncryption,
getStatusText,
getStatusTextRes,
isEncryptionOpened,
isStorageSyncLockHeld,
onClearStorageSyncLock,
@@ -333,4 +419,3 @@ fun StorageTree(
}
}
}

View File

@@ -0,0 +1,8 @@
package com.github.nullptroma.wallenc.ui.resources
import androidx.annotation.StringRes
/** Разрешение Android-строк для код-домена (ViewModel, без Compose). */
fun interface UiStringResolver {
operator fun invoke(@StringRes id: Int, vararg formatArgs: Any): String
}

View File

@@ -0,0 +1,8 @@
package com.github.nullptroma.wallenc.ui.resources
import androidx.annotation.StringRes
sealed class UserNotification {
data class TextRes(@param:StringRes val id: Int, val formatArgs: List<Any> = emptyList()) : UserNotification()
data class Plain(val message: String) : UserNotification()
}

View File

@@ -5,24 +5,29 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Cloud
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.toRoute
import com.github.nullptroma.wallenc.ui.elements.NavigationBarMarqueeText
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.navigation.NavBarItemData
import com.github.nullptroma.wallenc.ui.navigation.NavigationState
@@ -39,6 +44,10 @@ import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.VaultBrowserS
import com.github.nullptroma.wallenc.ui.screens.shared.TextEditRoute
import com.github.nullptroma.wallenc.ui.screens.shared.TextEditScreen
private fun isTextEditDestination(route: String?): Boolean {
val q = TextEditRoute::class.qualifiedName ?: return false
return route?.startsWith(q) == true
}
@OptIn(ExperimentalMaterial3Api::class)
@androidx.compose.runtime.Composable
@@ -48,85 +57,112 @@ fun MainScreen(
navState: NavigationState = rememberNavigationState(),
) {
val routes = viewModel.routes
val mainUi by viewModel.state.collectAsStateWithLifecycle()
val localVaultViewModel: LocalVaultViewModel = hiltViewModel()
val remoteVaultsViewModel: RemoteVaultsViewModel = hiltViewModel()
val childBackStackEntry by navState.navHostController.currentBackStackEntryAsState()
val showWorkStatusBar = !isTextEditDestination(childBackStackEntry?.destination?.route)
val workStatus = mainUi.workStatus
val topLevelNavBarItems = remember {
mapOf(
LocalVaultRoute::class.qualifiedName!! to NavBarItemData(
R.string.nav_label_local_vault, LocalVaultRoute::class.qualifiedName!!, null
nameStringResourceId = R.string.nav_label_local_vault,
screenRouteClass = LocalVaultRoute::class.qualifiedName!!,
icon = Icons.Outlined.Folder,
iconContentDescriptionResourceId = R.string.nav_cd_local_vault,
),
RemoteVaultsRoute::class.qualifiedName!! to NavBarItemData(
R.string.nav_label_remote_vaults, RemoteVaultsRoute::class.qualifiedName!!, null
)
nameStringResourceId = R.string.nav_label_remote_vaults,
screenRouteClass = RemoteVaultsRoute::class.qualifiedName!!,
icon = Icons.Outlined.Cloud,
iconContentDescriptionResourceId = R.string.nav_cd_remote_vaults,
),
)
}
Scaffold(modifier = modifier, contentWindowInsets = WindowInsets(0.dp), bottomBar = {
Column {
NavigationBar(windowInsets = WindowInsets(0), modifier = Modifier.height(48.dp)) {
val navBackStackEntry by navState.navHostController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
topLevelNavBarItems.forEach {
val routeClassName = it.key
val navBarItemData = it.value
NavigationBarItem(modifier = Modifier
.weight(1f),
icon = { Text(stringResource(navBarItemData.nameStringResourceId)) },
selected = currentRoute?.startsWith(routeClassName) == true,
onClick = {
val route = routes[navBarItemData.screenRouteClass]
?: throw NullPointerException("Route ${navBarItemData.screenRouteClass} not found")
if (currentRoute?.startsWith(routeClassName) != true)
navState.changeTop(
route
)
},
label = null
)
}
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
topBar = {
if (showWorkStatusBar) {
MainWorkStatusBar(status = workStatus)
}
HorizontalDivider()
}
}) { innerPaddings ->
},
bottomBar = {
Column {
NavigationBar(windowInsets = WindowInsets(0)) {
val navBackStackEntry by navState.navHostController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
topLevelNavBarItems.forEach {
val routeClassName = it.key
val navBarItemData = it.value
val iconVector = navBarItemData.icon
?: error("Main tab requires icon")
NavigationBarItem(
modifier = Modifier.weight(1f),
icon = {
Icon(
imageVector = iconVector,
contentDescription = stringResource(navBarItemData.iconContentDescriptionResourceId),
)
},
label = {
NavigationBarMarqueeText(
text = stringResource(navBarItemData.nameStringResourceId),
)
},
selected = currentRoute?.startsWith(routeClassName) == true,
onClick = {
val route = routes[navBarItemData.screenRouteClass]
?: throw NullPointerException("Route ${navBarItemData.screenRouteClass} not found")
if (currentRoute?.startsWith(routeClassName) != true) {
navState.changeTop(route)
}
},
)
}
}
HorizontalDivider()
}
},
) { innerPaddings ->
NavHost(
navState.navHostController,
startDestination = routes[LocalVaultRoute::class.qualifiedName]!!
navController = navState.navHostController,
startDestination = routes[LocalVaultRoute::class.qualifiedName]!!,
modifier = Modifier
.fillMaxSize()
.padding(innerPaddings),
) {
composable<LocalVaultRoute>(enterTransition = {
fadeIn(tween(200))
}, exitTransition = {
fadeOut(tween(200))
}) {
composable<LocalVaultRoute>(
enterTransition = { fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) },
) {
LocalVaultScreen(
modifier = Modifier.padding(innerPaddings),
viewModel = localVaultViewModel,
openTextEdit = { text ->
navState.push(TextEditRoute(text))
},
)
}
composable<RemoteVaultsRoute>(enterTransition = {
fadeIn(tween(200))
}, exitTransition = {
fadeOut(tween(200))
}) {
composable<RemoteVaultsRoute>(
enterTransition = { fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) },
) {
RemoteVaultsScreen(
modifier = Modifier.padding(innerPaddings),
viewModel = remoteVaultsViewModel,
onOpenVault = { item ->
navState.push(VaultBrowserRoute(item.uuid.toString()))
},
)
}
composable<VaultBrowserRoute>(enterTransition = {
fadeIn(tween(200))
}, exitTransition = {
fadeOut(tween(200))
}) { entry ->
composable<VaultBrowserRoute>(
enterTransition = { fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) },
) { entry ->
val remoteVaultViewModel: RemoteVaultViewModel = hiltViewModel(entry)
VaultBrowserScreen(
modifier = Modifier.padding(innerPaddings),
viewModel = remoteVaultViewModel,
openTextEdit = { text ->
navState.push(TextEditRoute(text))
@@ -139,4 +175,4 @@ fun MainScreen(
}
}
}
}
}

View File

@@ -1,3 +1,8 @@
package com.github.nullptroma.wallenc.ui.screens.main
class MainScreenState
import androidx.compose.runtime.Immutable
@Immutable
data class MainScreenState(
val workStatus: MainWorkStatus = MainWorkStatus.Idle,
)

View File

@@ -2,32 +2,142 @@ package com.github.nullptroma.wallenc.ui.screens.main
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskForegroundUiState
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
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.LocalVaultRoute
import com.github.nullptroma.wallenc.ui.ViewModelBase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class MainViewModel @javax.inject.Inject constructor(savedStateHandle: SavedStateHandle) :
ViewModelBase<MainScreenState>(MainScreenState()) {
class MainViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val taskOrchestrator: ITaskOrchestrator,
private val vaultsManager: IVaultsManager,
private val uiStrings: UiStringResolver,
) : ViewModelBase<MainScreenState>(MainScreenState()) {
@OptIn(SavedStateHandleSaveableApi::class)
var routes by savedStateHandle.saveable {
mutableStateOf(
mapOf<String, ScreenRoute>(
LocalVaultRoute::class.qualifiedName!! to LocalVaultRoute(),
RemoteVaultsRoute::class.qualifiedName!! to RemoteVaultsRoute()
)
RemoteVaultsRoute::class.qualifiedName!! to RemoteVaultsRoute(),
),
)
}
private set
init {
viewModelScope.launch {
combine(
taskOrchestrator.foregroundUi,
taskOrchestrator.pipelineState,
vaultsManager.vaults.flatMapLatest { vaults ->
if (vaults.isEmpty()) {
flowOf(false)
} else {
combine(vaults.map { it.storagesScanInProgress }) { flags ->
flags.any { it }
}
}
},
) { fg, pipe, anyVaultScanning ->
mapWorkStatus(fg, pipe, anyVaultScanning)
}
.distinctUntilChanged()
.collect { status ->
updateState(state.value.copy(workStatus = status))
}
}
}
fun updateRoute(qualifiedName: String, route: ScreenRoute) {
routes = routes.toMutableMap().apply {
this[qualifiedName] = route
}
}
}
private fun mapWorkStatus(
fg: TaskForegroundUiState,
pipe: com.github.nullptroma.wallenc.domain.tasks.PipelineState,
anyVaultScanning: Boolean,
): MainWorkStatus {
when (fg) {
is TaskForegroundUiState.Visible -> {
if (fg.tasks.isEmpty()) {
return mapBackgroundWork(pipe, anyVaultScanning)
}
val head = fg.tasks.first()
val p = head.progress
val frac = p?.fraction
val indeterminate = p == null || frac == null
val label = p?.label?.takeIf { it.isNotBlank() }
val line = if (label != null) "${head.title}$label" else head.title
return MainWorkStatus.Active(
line = line,
progressFraction = frac,
indeterminate = indeterminate,
)
}
TaskForegroundUiState.Hidden -> return mapBackgroundWork(pipe, anyVaultScanning)
}
}
private fun mapBackgroundWork(
pipe: com.github.nullptroma.wallenc.domain.tasks.PipelineState,
anyVaultScanning: Boolean,
): MainWorkStatus {
val fromPipeline = mapPipelineRunningOnly(pipe)
if (fromPipeline != null) return fromPipeline
if (anyVaultScanning) {
return MainWorkStatus.Active(
line = uiStrings(R.string.main_status_vault_scanning_storages),
progressFraction = null,
indeterminate = true,
)
}
return MainWorkStatus.Idle
}
private fun mapPipelineRunningOnly(
pipe: com.github.nullptroma.wallenc.domain.tasks.PipelineState,
): MainWorkStatus? {
val running = pipe.tasks.filter { it.id in pipe.runningTaskIds }
if (running.isEmpty()) return null
if (running.size == 1) {
val t = running.first()
val prog = (t.state as? TaskRunState.Running)?.progress
val frac = prog?.fraction
val indeterminate = prog == null || frac == null
val label = prog?.label?.takeIf { it.isNotBlank() }
val line = if (label != null) "${t.title}$label" else t.title
return MainWorkStatus.Active(
line = line,
progressFraction = frac,
indeterminate = indeterminate,
)
}
return MainWorkStatus.Active(
line = uiStrings(R.string.main_status_multiple_tasks, running.size),
progressFraction = null,
indeterminate = true,
)
}
}

View File

@@ -0,0 +1,13 @@
package com.github.nullptroma.wallenc.ui.screens.main
/** Состояние полосы «текущая работа» на Main. */
sealed class MainWorkStatus {
data object Idle : MainWorkStatus()
/** [line] — строка для отображения (уже локализованная, из оркестратора или фолбэк). */
data class Active(
val line: String,
val progressFraction: Float?,
val indeterminate: Boolean,
) : MainWorkStatus()
}

View File

@@ -0,0 +1,97 @@
package com.github.nullptroma.wallenc.ui.screens.main
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.github.nullptroma.wallenc.ui.R
private val progressTrackHeight = 2.dp
/**
* Полоса статуса работы на Main: всегда видна (пока экран не скрывает её целиком).
* Слева подпись «Статус:», справа — описание текущей задачи или пусто; внизу — тонкий индикатор прогресса.
*/
@Composable
fun MainWorkStatusBar(
status: MainWorkStatus,
modifier: Modifier = Modifier,
) {
val taskLine = when (status) {
MainWorkStatus.Idle -> ""
is MainWorkStatus.Active -> status.line
}
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = 1.dp,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResource(R.string.main_work_status_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = taskLine,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
textAlign = TextAlign.End,
)
}
when (status) {
MainWorkStatus.Idle -> {
LinearProgressIndicator(
progress = { 0f },
modifier = Modifier
.fillMaxWidth()
.height(progressTrackHeight)
.padding(top = 6.dp),
)
}
is MainWorkStatus.Active -> {
if (status.indeterminate) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.height(progressTrackHeight)
.padding(top = 6.dp),
)
} else {
val frac = status.progressFraction ?: 0f
LinearProgressIndicator(
progress = { frac },
modifier = Modifier
.fillMaxWidth()
.height(progressTrackHeight)
.padding(top = 6.dp),
)
}
}
}
}
}
}

View File

@@ -37,6 +37,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -66,6 +67,7 @@ fun RemoteVaultsScreen(
onClick = {
if (!uiState.isBusy) viewModel.setAddChoiceVisible(true)
},
modifier = Modifier.alpha(if (uiState.isBusy) 0.38f else 1f),
) {
Icon(
Icons.Filled.Add,

View File

@@ -4,7 +4,9 @@ import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import com.github.nullptroma.wallenc.vault.contract.RemoteVaultAuthenticator
import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor
import com.github.nullptroma.wallenc.vault.contract.VaultRegistrar
@@ -25,6 +27,7 @@ class RemoteVaultsViewModel @Inject constructor(
private val vaultRegistrar: VaultRegistrar,
val remoteAuthenticator: RemoteVaultAuthenticator,
private val taskOrchestrator: ITaskOrchestrator,
private val uiStrings: UiStringResolver,
) : ViewModelBase<RemoteVaultsScreenState>(RemoteVaultsScreenState()) {
val uiState = combine(
@@ -58,7 +61,7 @@ class RemoteVaultsViewModel @Inject constructor(
fun onLinkSucceeded(registration: VaultRegistration) {
setBusy(true)
taskOrchestrator.enqueue(
title = "Add remote vault",
title = uiStrings(R.string.task_title_add_remote_vault),
dispatcher = Dispatchers.IO,
work = { ctx ->
try {
@@ -90,7 +93,7 @@ class RemoteVaultsViewModel @Inject constructor(
val uuid = pending.uuid
setBusy(true)
taskOrchestrator.enqueue(
title = "Remove remote vault",
title = uiStrings(R.string.task_title_remove_remote_vault),
dispatcher = Dispatchers.IO,
work = { ctx ->
try {

View File

@@ -3,6 +3,8 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.tasks
import androidx.lifecycle.ViewModel
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -11,13 +13,17 @@ import javax.inject.Inject
@HiltViewModel
class TaskPipelineViewModel @Inject constructor(
val orchestrator: ITaskOrchestrator,
private val uiStrings: UiStringResolver,
) : ViewModel() {
fun startTestTask(durationSec: Int, infinityIndeterminateProgress: Boolean) {
val safeDurationSec = durationSec.coerceIn(0, 60)
val title =
if (infinityIndeterminateProgress) "Test task (${safeDurationSec}s, ∞)"
else "Test task (${safeDurationSec}s)"
if (infinityIndeterminateProgress) {
uiStrings(R.string.task_pipeline_test_running_infinity, safeDurationSec)
} else {
uiStrings(R.string.task_pipeline_test_running, safeDurationSec)
}
orchestrator.enqueue(
title = title,
dispatcher = Dispatchers.Default,

View File

@@ -1,5 +1,6 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
import androidx.annotation.StringRes
import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
import com.github.nullptroma.wallenc.domain.datatypes.Tree
@@ -10,20 +11,25 @@ import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import com.github.nullptroma.wallenc.domain.tasks.TaskRunState
import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase
import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase
import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase
import com.github.nullptroma.wallenc.usecases.RemoveStorageUseCase
import com.github.nullptroma.wallenc.usecases.RenameStorageUseCase
import com.github.nullptroma.wallenc.usecases.StorageFileManagementUseCase
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.extensions.toPrintable
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import com.github.nullptroma.wallenc.ui.resources.UserNotification
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.util.UUID
import kotlin.system.measureTimeMillis
@@ -42,28 +48,24 @@ abstract class AbstractVaultBrowserViewModel(
private val renameStorageUseCase: RenameStorageUseCase,
private val manageVaultUseCase: ManageVaultUseCase,
private val taskOrchestrator: ITaskOrchestrator,
private val uiStrings: UiStringResolver,
private val logger: ILogger,
) : ViewModelBase<VaultBrowserScreenState>(
VaultBrowserScreenState(storagesList = emptyList(), isLoading = true, addStorageFabEnabled = false),
VaultBrowserScreenState(
storagesList = emptyList(),
storagesRefreshing = true,
busyStorageUuids = emptySet(),
vaultListMutationActive = false,
addStorageFabEnabled = false,
),
) {
private val _messages = MutableSharedFlow<String>()
val messages: SharedFlow<String> = _messages
private var taskCount: Int = 0
set(value) {
field = value
updateStateLoading()
}
private var storagesLoading: Boolean = false
set(value) {
field = value
updateStateLoading()
}
private val _userNotifications = MutableSharedFlow<UserNotification>(extraBufferCapacity = 8)
val userNotifications: SharedFlow<UserNotification> = _userNotifications
init {
collectFlows(storagesFlow)
collectStoragesFlow(storagesFlow)
collectPipelineBusyFlags()
viewModelScope.launch {
vaultAvailabilityFlow
.distinctUntilChanged()
@@ -74,40 +76,80 @@ abstract class AbstractVaultBrowserViewModel(
}
}
private fun updateStateLoading() {
updateState(
state.value.copy(
isLoading = storagesLoading || taskCount > 0,
),
)
private fun isPipelineTaskActive(state: TaskRunState): Boolean =
when (state) {
is TaskRunState.Queued,
is TaskRunState.Running,
-> true
else -> false
}
private fun isStorageTaskActive(storageUuid: UUID): Boolean =
taskOrchestrator.pipelineState.value.tasks.any { t ->
t.busyStorageUuid == storageUuid && isPipelineTaskActive(t.state)
}
private fun isVaultListMutationActive(): Boolean =
taskOrchestrator.pipelineState.value.tasks.any { t ->
t.locksVaultStorageList && isPipelineTaskActive(t.state)
}
private fun collectStoragesFlow(storagesFlow: Flow<List<IStorage>>) {
viewModelScope.launch {
combine(
storagesFlow,
getOpenedStoragesUseCase.openedStorages,
) { storages, opened -> storages to opened }
.collect { (storages, opened) ->
val list = mutableListOf<Tree<IStorageInfo>>()
for (storage in storages) {
var tree = Tree<IStorageInfo>(storage)
list.add(tree)
while (opened.containsKey(tree.value.uuid)) {
val child = opened.getValue(tree.value.uuid)
val nextTree = Tree<IStorageInfo>(child)
tree.children = listOf(nextTree)
tree = nextTree
}
}
updateState(
state.value.copy(
storagesList = list,
storagesRefreshing = false,
),
)
}
}
}
private fun collectFlows(storagesFlow: Flow<List<IStorage>>) {
private fun collectPipelineBusyFlags() {
viewModelScope.launch {
storagesFlow.combine(getOpenedStoragesUseCase.openedStorages) { storages, opened ->
val list = mutableListOf<Tree<IStorageInfo>>()
for (storage in storages) {
var tree = Tree<IStorageInfo>(storage)
list.add(tree)
while (opened.containsKey(tree.value.uuid)) {
val child = opened.getValue(tree.value.uuid)
val nextTree = Tree<IStorageInfo>(child)
tree.children = listOf(nextTree)
tree = nextTree
}
taskOrchestrator.pipelineState
.map { pipe ->
val activeTasks = pipe.tasks.filter { isPipelineTaskActive(it.state) }
val busyUuids = activeTasks.mapNotNull { it.busyStorageUuid }.toSet()
val listMut = activeTasks.any { it.locksVaultStorageList }
busyUuids to listMut
}
.distinctUntilChanged()
.collect { (busyUuids, listMut) ->
updateState(
state.value.copy(
busyStorageUuids = busyUuids,
vaultListMutationActive = listMut,
),
)
}
list
}.collect { trees ->
storagesLoading = false
updateState(state.value.copy(storagesList = trees))
}
}
}
fun printStorageInfoToLog(storage: IStorageInfo) {
val id = storage.uuid
if (isStorageTaskActive(id)) return
taskOrchestrator.enqueue(
title = "Dump storage to log",
title = uiStrings(R.string.task_title_dump_storage_log),
dispatcher = Dispatchers.IO,
busyStorageUuid = id,
work = { ctx ->
storageFileManagementUseCase.setStorage(storage)
ctx.log(TaskLogLevel.Info, "Enumerating files and directories…")
@@ -138,10 +180,15 @@ abstract class AbstractVaultBrowserViewModel(
logger.debug(TAG, "createStorage ignored (vault unavailable or FAB disabled)")
return
}
if (isVaultListMutationActive()) {
logger.debug(TAG, "createStorage ignored (vault list mutation already running)")
return
}
logger.debug(TAG, "createStorage: enqueue task")
taskOrchestrator.enqueue(
title = "Create storage",
title = uiStrings(R.string.task_title_create_storage),
dispatcher = Dispatchers.IO,
locksVaultStorageList = true,
work = { ctx ->
try {
ctx.log(TaskLogLevel.Info, "Creating storage…")
@@ -160,24 +207,14 @@ abstract class AbstractVaultBrowserViewModel(
)
}
private companion object {
private const val TAG = "VaultBrowser"
}
private val storageOpMutex = Any()
private val runningStorages = mutableSetOf<UUID>()
fun enableEncryption(storage: IStorageInfo, password: String, encryptPath: Boolean) {
val id = storage.uuid
synchronized(storageOpMutex) {
if (runningStorages.contains(id)) return
runningStorages.add(id)
taskCount++
}
if (isStorageTaskActive(id)) return
val key = EncryptKey(password)
taskOrchestrator.enqueue(
title = "Enable encryption",
title = uiStrings(R.string.task_title_enable_encryption),
dispatcher = Dispatchers.IO,
busyStorageUuid = id,
work = { ctx ->
try {
ctx.log(TaskLogLevel.Info, "Checking storage…")
@@ -187,33 +224,33 @@ abstract class AbstractVaultBrowserViewModel(
manageStoragesEncryptionUseCase.enableEncryption(storage, key, encryptPath)
manageStoragesEncryptionUseCase.openStorage(storage, key, true)
ctx.log(TaskLogLevel.Info, "Encryption enabled")
_messages.emit("Encryption enabled")
_userNotifications.emit(UserNotification.TextRes(R.string.msg_encryption_enabled))
}
ManageStoragesEncryptionUseCase.CanEncryptResult.AlreadyEncrypted -> {
ctx.log(TaskLogLevel.Info, "Storage is already encrypted")
_messages.emit("Storage is already encrypted")
_userNotifications.emit(UserNotification.TextRes(R.string.msg_storage_already_encrypted))
}
ManageStoragesEncryptionUseCase.CanEncryptResult.StorageIsNotEmpty -> {
ctx.log(TaskLogLevel.Info, "Storage is not empty")
_messages.emit("Storage is not empty")
_userNotifications.emit(UserNotification.TextRes(R.string.msg_storage_not_empty))
}
ManageStoragesEncryptionUseCase.CanEncryptResult.StorageStateUnknown -> {
ctx.log(TaskLogLevel.Info, "Cannot determine whether storage is empty")
_messages.emit("Cannot determine whether storage is empty")
_userNotifications.emit(UserNotification.TextRes(R.string.msg_storage_empty_state_unknown))
}
ManageStoragesEncryptionUseCase.CanEncryptResult.UnsupportedStorageType -> {
ctx.log(TaskLogLevel.Info, "Unsupported storage type")
_messages.emit("Unsupported storage type")
_userNotifications.emit(UserNotification.TextRes(R.string.msg_unsupported_storage_type))
}
}
} catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Failed to enable encryption")
_messages.emit(e.message ?: "Failed to enable encryption")
} finally {
synchronized(storageOpMutex) {
runningStorages.remove(id)
taskCount--
}
_userNotifications.emit(
UserNotification.TextRes(
R.string.msg_failed_enable_encryption,
listOf(e.message ?: e.toString()),
),
)
}
},
)
@@ -221,37 +258,38 @@ abstract class AbstractVaultBrowserViewModel(
fun openEncryptedStorage(storage: IStorageInfo, password: String, rememberPassword: Boolean) {
val id = storage.uuid
synchronized(storageOpMutex) {
if (runningStorages.contains(id)) return
runningStorages.add(id)
taskCount++
}
if (isStorageTaskActive(id)) return
val key = EncryptKey(password)
taskOrchestrator.enqueue(
title = "Open encrypted storage",
title = uiStrings(R.string.task_title_open_encrypted_storage),
dispatcher = Dispatchers.IO,
busyStorageUuid = id,
work = { ctx ->
try {
ctx.log(TaskLogLevel.Info, "Opening storage…")
ctx.reportProgress(null, uiStrings(R.string.task_progress_decrypt_running))
ctx.log(TaskLogLevel.Info, "Opening encrypted storage…")
manageStoragesEncryptionUseCase.openStorage(storage, key, rememberPassword)
ctx.log(TaskLogLevel.Info, "Storage opened")
} catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Failed to open encrypted storage")
_messages.emit(e.message ?: "Failed to open encrypted storage")
} finally {
synchronized(storageOpMutex) {
runningStorages.remove(id)
taskCount--
}
_userNotifications.emit(
UserNotification.TextRes(
R.string.msg_failed_open_storage,
listOf(e.message ?: e.toString()),
),
)
}
},
)
}
fun closeEncryptedStorage(storage: IStorageInfo) {
val id = storage.uuid
if (isStorageTaskActive(id)) return
taskOrchestrator.enqueue(
title = "Close encrypted storage",
title = uiStrings(R.string.task_title_close_encrypted_storage),
dispatcher = Dispatchers.IO,
busyStorageUuid = id,
work = { ctx ->
try {
ctx.log(TaskLogLevel.Info, "Closing storage…")
@@ -259,16 +297,24 @@ abstract class AbstractVaultBrowserViewModel(
ctx.log(TaskLogLevel.Info, "Storage closed")
} catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Failed to close encrypted storage")
_messages.emit(e.message ?: "Failed to close encrypted storage")
_userNotifications.emit(
UserNotification.TextRes(
R.string.msg_failed_close_storage,
listOf(e.message ?: e.toString()),
),
)
}
},
)
}
fun disableEncryption(storage: IStorageInfo) {
val id = storage.uuid
if (isStorageTaskActive(id)) return
taskOrchestrator.enqueue(
title = "Disable encryption",
title = uiStrings(R.string.task_title_disable_encryption),
dispatcher = Dispatchers.IO,
busyStorageUuid = id,
work = { ctx ->
try {
ctx.log(TaskLogLevel.Info, "Disabling encryption…")
@@ -276,19 +322,27 @@ abstract class AbstractVaultBrowserViewModel(
ctx.reportProgress(p)
}
ctx.log(TaskLogLevel.Info, "Encryption disabled")
_messages.emit("Encryption disabled")
_userNotifications.emit(UserNotification.TextRes(R.string.msg_encryption_disabled))
} catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Failed")
_messages.emit(e.message ?: "Failed to disable encryption")
_userNotifications.emit(
UserNotification.TextRes(
R.string.msg_failed_disable_encryption,
listOf(e.message ?: e.toString()),
),
)
}
},
)
}
fun rename(storage: IStorageInfo, newName: String) {
val id = storage.uuid
if (isStorageTaskActive(id)) return
taskOrchestrator.enqueue(
title = "Rename storage",
title = uiStrings(R.string.task_title_rename_storage),
dispatcher = Dispatchers.IO,
busyStorageUuid = id,
work = { ctx ->
try {
ctx.log(TaskLogLevel.Info, "Renaming…")
@@ -302,9 +356,13 @@ abstract class AbstractVaultBrowserViewModel(
}
fun remove(storage: IStorageInfo) {
val id = storage.uuid
if (isStorageTaskActive(id)) return
taskOrchestrator.enqueue(
title = "Remove storage",
title = uiStrings(R.string.task_title_remove_storage),
dispatcher = Dispatchers.IO,
busyStorageUuid = id,
locksVaultStorageList = true,
work = { ctx ->
try {
ctx.log(TaskLogLevel.Info, "Removing storage…")
@@ -317,11 +375,12 @@ abstract class AbstractVaultBrowserViewModel(
)
}
fun getStorageStatus(storage: IStorageInfo): String {
@StringRes
fun getStorageStatusRes(storage: IStorageInfo): Int {
val encrypted = storage.metaInfo.value.encInfo != null
if (!encrypted) return "Not encrypted"
if (!encrypted) return R.string.storage_status_not_encrypted
val opened = isEncryptionSessionOpen(storage)
return if (opened) "Encrypted (opened)" else "Encrypted (closed)"
return if (opened) R.string.storage_status_encrypted_open else R.string.storage_status_encrypted_closed
}
fun isEncryptionSessionOpen(storage: IStorageInfo): Boolean {
@@ -339,26 +398,38 @@ abstract class AbstractVaultBrowserViewModel(
}
fun clearStorageSyncLock(storage: IStorageInfo) {
val id = storage.uuid
if (isStorageTaskActive(id)) return
taskOrchestrator.enqueue(
title = "Снятие блокировки синхронизации",
title = uiStrings(R.string.task_title_clear_sync_lock),
dispatcher = Dispatchers.IO,
busyStorageUuid = id,
work = { ctx ->
try {
val s = storage as? IStorage
if (s == null) {
ctx.log(TaskLogLevel.Error, "Некорректное хранилище")
_messages.emit("Некорректное хранилище")
ctx.log(TaskLogLevel.Error, "Invalid storage")
_userNotifications.emit(UserNotification.TextRes(R.string.msg_invalid_storage_for_sync_lock))
return@enqueue
}
ctx.log(TaskLogLevel.Info, "Снимаю блокировку синхронизации")
ctx.log(TaskLogLevel.Info, "Clearing sync lock")
s.accessor.forceClearSyncLock()
ctx.log(TaskLogLevel.Info, "Блокировка синхронизации снята")
_messages.emit("Блокировка синхронизации снята")
ctx.log(TaskLogLevel.Info, "Sync lock cleared")
_userNotifications.emit(UserNotification.TextRes(R.string.msg_sync_lock_cleared))
} catch (e: Exception) {
ctx.log(TaskLogLevel.Error, e.message ?: "Не удалось снять блокировку")
_messages.emit(e.message ?: "Не удалось снять блокировку синхронизации")
ctx.log(TaskLogLevel.Error, e.message ?: "clear sync lock failed")
_userNotifications.emit(
UserNotification.TextRes(
R.string.msg_sync_lock_clear_failed,
listOf(e.message ?: e.toString()),
),
)
}
},
)
}
private companion object {
private const val TAG = "VaultBrowser"
}
}

View File

@@ -3,6 +3,7 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
import com.github.nullptroma.wallenc.domain.interfaces.ILogger
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase
import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase
import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase
@@ -29,6 +30,7 @@ class LocalVaultViewModel @Inject constructor(
manageStoragesEncryptionUseCase: ManageStoragesEncryptionUseCase,
renameStorageUseCase: RenameStorageUseCase,
taskOrchestrator: ITaskOrchestrator,
uiStrings: UiStringResolver,
logger: ILogger,
) : AbstractVaultBrowserViewModel(
storagesFlow = vaultsManager.vaults
@@ -45,5 +47,6 @@ class LocalVaultViewModel @Inject constructor(
renameStorageUseCase = renameStorageUseCase,
manageVaultUseCase = manageVaultUseCase,
taskOrchestrator = taskOrchestrator,
uiStrings = uiStrings,
logger = logger,
)

View File

@@ -3,6 +3,7 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
import androidx.lifecycle.SavedStateHandle
import com.github.nullptroma.wallenc.domain.interfaces.ILogger
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import com.github.nullptroma.wallenc.usecases.GetOpenedStoragesUseCase
import com.github.nullptroma.wallenc.usecases.ManageStoragesEncryptionUseCase
import com.github.nullptroma.wallenc.usecases.ManageVaultUseCase
@@ -27,6 +28,7 @@ class RemoteVaultViewModel @Inject constructor(
manageStoragesEncryptionUseCase: ManageStoragesEncryptionUseCase,
renameStorageUseCase: RenameStorageUseCase,
taskOrchestrator: ITaskOrchestrator,
uiStrings: UiStringResolver,
logger: ILogger,
) : AbstractVaultBrowserViewModel(
storagesFlow = manageVaultUseCase.storagesOf(savedStateHandle.requireVaultUuid()),
@@ -40,6 +42,7 @@ class RemoteVaultViewModel @Inject constructor(
renameStorageUseCase = renameStorageUseCase,
manageVaultUseCase = manageVaultUseCase,
taskOrchestrator = taskOrchestrator,
uiStrings = uiStrings,
logger = logger,
)

View File

@@ -2,10 +2,13 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -18,6 +21,7 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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
@@ -26,10 +30,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.elements.StorageTree
import com.github.nullptroma.wallenc.ui.extensions.gesturesDisabled
import com.github.nullptroma.wallenc.ui.resources.UserNotification
import java.util.UUID
@Composable
fun VaultBrowserScreen(
@@ -40,72 +48,142 @@ fun VaultBrowserScreen(
val uiState by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.messages.collect { message ->
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
viewModel.userNotifications.collect { notification ->
val text = when (notification) {
is UserNotification.TextRes -> {
if (notification.formatArgs.isEmpty()) {
context.getString(notification.id)
} else {
context.getString(notification.id, *notification.formatArgs.toTypedArray())
}
}
is UserNotification.Plain -> notification.message
}
Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
}
}
val fabEnabled = uiState.addStorageFabEnabled
val fabBusy = uiState.vaultListMutationActive
val showFullscreenLoader = uiState.storagesList.isEmpty() && uiState.storagesRefreshing
val showEmptyState = uiState.storagesList.isEmpty() && !uiState.storagesRefreshing
val isUuidBusy: (UUID) -> Boolean = { uuid -> uuid in uiState.busyStorageUuids }
Box {
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
floatingActionButton = {
val fabEnabled = uiState.addStorageFabEnabled
FloatingActionButton(
onClick = { if (fabEnabled) viewModel.createStorage() },
containerColor = if (fabEnabled) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
contentColor = if (fabEnabled) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f)
onClick = {
if (fabEnabled && !fabBusy) {
viewModel.createStorage()
}
},
modifier = Modifier.alpha(if (fabEnabled && !fabBusy) 1f else 0.38f),
) {
Icon(Icons.Filled.Add, contentDescription = null)
Icon(
Icons.Filled.Add,
contentDescription = stringResource(
when {
!fabEnabled -> R.string.vault_fab_add_storage_disabled_cd
fabBusy -> R.string.vault_fab_add_storage_busy_cd
else -> R.string.vault_fab_add_storage_cd
},
),
)
}
},
) { innerPadding ->
LazyColumn(
Column(
modifier = Modifier
.padding(innerPadding)
.gesturesDisabled(uiState.isLoading),
.fillMaxSize(),
) {
items(uiState.storagesList) { listItem ->
StorageTree(
modifier = Modifier.padding(8.dp, 8.dp, 8.dp, 0.dp),
tree = listItem,
onClick = { openTextEdit(it.value.uuid.toString()) },
onRename = { tree, newName -> viewModel.rename(tree.value, newName) },
onRemove = { tree -> viewModel.remove(tree.value) },
onEncrypt = { tree, password, encryptPath ->
viewModel.enableEncryption(tree.value, password, encryptPath)
},
onOpenEncrypted = { tree, password, remember ->
viewModel.openEncryptedStorage(tree.value, password, remember)
},
onCloseEncrypted = { tree -> viewModel.closeEncryptedStorage(tree.value) },
onDisableEncryption = { tree -> viewModel.disableEncryption(tree.value) },
getStatusText = { tree -> viewModel.getStorageStatus(tree.value) },
isEncryptionOpened = { tree -> viewModel.isEncryptionSessionOpen(tree.value) },
isStorageSyncLockHeld = { info -> viewModel.isStorageSyncLockHeld(info) },
onClearStorageSyncLock = { info -> viewModel.clearStorageSyncLock(info) },
if (!fabEnabled) {
Text(
text = stringResource(R.string.vault_unavailable_banner),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
item { Spacer(modifier = Modifier.height(8.dp)) }
Box(
modifier = Modifier.fillMaxSize(),
) {
when {
showEmptyState -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.vault_empty_list_hint),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(uiState.storagesList) { listItem ->
StorageTree(
modifier = Modifier.padding(8.dp, 8.dp, 8.dp, 0.dp),
tree = listItem,
isUuidBusy = isUuidBusy,
onClick = { openTextEdit(it.value.uuid.toString()) },
onRename = { tree, newName -> viewModel.rename(tree.value, newName) },
onRemove = { tree -> viewModel.remove(tree.value) },
onEncrypt = { tree, password, encryptPath ->
viewModel.enableEncryption(tree.value, password, encryptPath)
},
onOpenEncrypted = { tree, password, remember ->
viewModel.openEncryptedStorage(tree.value, password, remember)
},
onCloseEncrypted = { tree -> viewModel.closeEncryptedStorage(tree.value) },
onDisableEncryption = { tree -> viewModel.disableEncryption(tree.value) },
getStatusTextRes = { tree -> viewModel.getStorageStatusRes(tree.value) },
isEncryptionOpened = { tree -> viewModel.isEncryptionSessionOpen(tree.value) },
isStorageSyncLockHeld = { info -> viewModel.isStorageSyncLockHeld(info) },
onClearStorageSyncLock = { info -> viewModel.clearStorageSyncLock(info) },
)
}
item { Spacer(modifier = Modifier.height(8.dp)) }
}
}
}
}
}
}
if (uiState.isLoading) {
if (showFullscreenLoader) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.fillMaxSize().alpha(0.6f).background(Color.Black))
CircularProgressIndicator(
modifier = Modifier.size(64.dp),
color = MaterialTheme.colorScheme.secondary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(64.dp),
color = MaterialTheme.colorScheme.secondary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
Text(
text = stringResource(R.string.vault_loading_storages),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onPrimary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
)
}
}
}
}

View File

@@ -2,10 +2,15 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
import com.github.nullptroma.wallenc.domain.datatypes.Tree
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import java.util.UUID
data class VaultBrowserScreenState(
val storagesList: List<Tree<IStorageInfo>>,
val isLoading: Boolean,
/** FAB «добавить storage»: активна только когда vault доступен (сеть/API/путь). */
/** Первый снимок списка storages ещё не получен (удалённый vault). */
val storagesRefreshing: Boolean,
/** Storages с активной задачей в [com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator]. */
val busyStorageUuids: Set<UUID>,
/** Активна задача, меняющая состав списка storages (создание и т.п.). */
val vaultListMutationActive: Boolean,
val addStorageFabEnabled: Boolean = false,
)

View File

@@ -1,9 +1,19 @@
package com.github.nullptroma.wallenc.ui.screens.shared
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.github.nullptroma.wallenc.ui.R
@Composable
fun TextEditScreen(text: String) {
Text("Hello from TextEdit with text $text")
}
Text(
text = stringResource(R.string.text_edit_screen_placeholder, text),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(16.dp),
)
}

View File

@@ -3,6 +3,7 @@ package com.github.nullptroma.wallenc.ui.screens.sync
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -10,15 +11,21 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Sync
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -27,12 +34,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.resources.UserNotification
import java.util.UUID
@Composable
@@ -64,9 +75,13 @@ fun StorageSyncScreen(
modifier = modifier,
floatingActionButton = {
FloatingActionButton(
onClick = viewModel::createGroup,
onClick = { if (!state.isBusy) viewModel.createGroup() },
modifier = Modifier.alpha(if (state.isBusy) 0.38f else 1f),
) {
Text("+")
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = stringResource(R.string.sync_fab_create_group_cd),
)
}
},
) { inner ->
@@ -77,14 +92,38 @@ fun StorageSyncScreen(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = viewModel::runSyncNow, enabled = !state.isBusy) {
if (state.isBusy) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = viewModel::runSyncNow,
enabled = !state.isBusy,
) {
Icon(
Icons.Rounded.Sync,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
)
Text(stringResource(id = R.string.sync_run_now))
}
}
state.message?.let {
Text(text = it, style = MaterialTheme.typography.bodyMedium)
state.userMessage?.let { um ->
val text = when (um) {
is UserNotification.TextRes -> {
if (um.formatArgs.isEmpty()) {
stringResource(um.id)
} else {
stringResource(um.id, *um.formatArgs.toTypedArray())
}
}
is UserNotification.Plain -> um.message
}
Text(text = text, style = MaterialTheme.typography.bodyMedium)
}
Text(
@@ -105,20 +144,24 @@ fun StorageSyncScreen(
),
) {
Column(
modifier = Modifier.padding(10.dp),
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = group.id, style = MaterialTheme.typography.titleSmall)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(
onClick = { viewModel.openPicker(group.id) },
enabled = !state.isBusy,
) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = stringResource(id = R.string.sync_add_storage),
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Text(
text = group.id,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.weight(1f)
.padding(end = 4.dp),
maxLines = 4,
)
IconButton(
onClick = { pendingRemoveGroupId = group.id },
enabled = !state.isBusy,
@@ -134,6 +177,7 @@ fun StorageSyncScreen(
Text(
text = stringResource(id = R.string.sync_group_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
val hasMixedEncryption = hasEncryptionMismatch(group, state.vaults)
@@ -147,8 +191,25 @@ fun StorageSyncScreen(
group.storageUuids.forEach { storageUuid ->
val storage = storageByUuid[storageUuid]
val storageLabel = storage?.name ?: storageUuid.toString()
val encryptionStatus = storage?.encryptionStatus ?: "Unknown"
val titleText = storage?.name
?: stringResource(R.string.sync_storage_missing_title)
val encLabel = encryptionKindLabel(storage?.encryptionKind)
val statusLine = when {
storage != null && !storage.isReachable ->
stringResource(R.string.sync_storage_unreachable)
storage == null && state.anyVaultStoragesScanning ->
stringResource(R.string.sync_storage_pending_vault_scan)
storage == null && !state.anyVaultStoragesScanning ->
stringResource(R.string.sync_storage_not_in_vaults)
else -> null
}
val statusColor = when {
storage != null && !storage.isReachable ->
MaterialTheme.colorScheme.error
storage == null ->
MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
@@ -158,14 +219,39 @@ fun StorageSyncScreen(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Text(
text = "$storageLabel ($storageUuid) | $encryptionStatus",
style = MaterialTheme.typography.bodySmall,
Column(
modifier = Modifier.weight(1f),
)
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = titleText,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = storageUuid.toString(),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
statusLine?.let { line ->
Text(
text = line,
style = MaterialTheme.typography.bodySmall,
color = statusColor,
)
}
Text(
text = stringResource(
R.string.sync_storage_encryption_line,
encLabel,
),
style = MaterialTheme.typography.bodySmall,
)
}
IconButton(
onClick = { pendingRemoveStorage = group.id to storageUuid },
enabled = !state.isBusy,
@@ -179,6 +265,23 @@ fun StorageSyncScreen(
}
}
}
HorizontalDivider(modifier = Modifier.padding(top = 4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
IconButton(
onClick = { viewModel.openPicker(group.id) },
enabled = !state.isBusy,
) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = stringResource(id = R.string.sync_add_storage),
)
}
}
}
}
}
@@ -253,6 +356,16 @@ fun StorageSyncScreen(
}
}
@Composable
private fun encryptionKindLabel(kind: StorageSyncEncryptionKind?): String {
return when (kind) {
null -> stringResource(R.string.sync_encryption_unknown)
StorageSyncEncryptionKind.NotEncrypted -> stringResource(R.string.enc_status_not_encrypted)
StorageSyncEncryptionKind.EncryptedOpened -> stringResource(R.string.enc_status_encrypted_open)
StorageSyncEncryptionKind.EncryptedClosed -> stringResource(R.string.enc_status_encrypted)
}
}
@Composable
private fun StoragePickerScreen(
modifier: Modifier,
@@ -271,9 +384,15 @@ private fun StoragePickerScreen(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onBack) {
Text(stringResource(id = R.string.sync_picker_back))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.sync_cd_picker_back),
)
}
Text(
text = stringResource(id = R.string.sync_picker_title, groupId),
@@ -306,22 +425,35 @@ private fun StoragePickerScreen(
.fillMaxWidth()
.clickable { onToggleVault(vault.uuid) },
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = vault.title,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = if (expanded) "Hide" else "Show",
style = MaterialTheme.typography.bodySmall,
)
}
Text(
text = "${vault.type} | ${vault.uuid}",
style = MaterialTheme.typography.bodySmall,
text = vault.title,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
Icon(
imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
contentDescription = if (expanded) {
stringResource(R.string.sync_picker_collapse)
} else {
stringResource(R.string.sync_picker_expand)
},
)
}
Text(
text = vault.type,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = vault.uuid.toString(),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (expanded) {
if (expanded) {
if (vault.storages.isEmpty()) {
Text(
text = stringResource(id = R.string.sync_picker_no_storages),
@@ -369,30 +501,46 @@ private fun StoragePickerNode(
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = node.name,
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "${node.uuid} | ${node.encryptionStatus}",
text = node.uuid.toString(),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(
R.string.sync_storage_encryption_line,
encryptionKindLabel(node.encryptionKind),
),
style = MaterialTheme.typography.bodySmall,
)
if (!node.isReachable) {
Text(
text = stringResource(R.string.sync_storage_unreachable),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
Button(
IconButton(
enabled = !isSelected && !isBusy,
onClick = { onAddStorage(node.uuid) },
) {
Text(
text = if (isSelected) {
stringResource(id = R.string.sync_picker_added)
} else {
stringResource(id = R.string.sync_picker_add)
},
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = stringResource(
if (isSelected) R.string.sync_picker_added else R.string.sync_picker_cd_add,
),
)
}
}
@@ -420,8 +568,9 @@ private fun hasEncryptionMismatch(
): Boolean {
if (group.storageUuids.isEmpty()) return false
val byUuid = flattenStorageTree(vaults.flatMap { it.storages }).associateBy { it.uuid }
val statuses = group.storageUuids.mapNotNull { byUuid[it]?.encryptionStatus }.toSet()
val hasEncrypted = statuses.any { it.startsWith("Encrypted") }
val hasPlain = statuses.any { it == "Not encrypted" }
val kinds = group.storageUuids.mapNotNull { byUuid[it]?.encryptionKind }.toSet()
if (kinds.isEmpty()) return false
val hasEncrypted = kinds.any { it != StorageSyncEncryptionKind.NotEncrypted }
val hasPlain = kinds.contains(StorageSyncEncryptionKind.NotEncrypted)
return hasEncrypted && hasPlain
}

View File

@@ -1,11 +1,20 @@
package com.github.nullptroma.wallenc.ui.screens.sync
import com.github.nullptroma.wallenc.ui.resources.UserNotification
import java.util.UUID
enum class StorageSyncEncryptionKind {
NotEncrypted,
EncryptedOpened,
EncryptedClosed,
}
data class StorageSyncStorageUi(
val uuid: UUID,
val name: String,
val encryptionStatus: String,
val encryptionKind: StorageSyncEncryptionKind,
/** false, если storage есть в дереве, но сейчас недоступен (например, vault offline). */
val isReachable: Boolean = true,
val children: List<StorageSyncStorageUi> = emptyList(),
)
@@ -27,5 +36,7 @@ data class StorageSyncScreenState(
val expandedVaultUuids: Set<UUID> = emptySet(),
val pickerGroupId: String? = null,
val isBusy: Boolean = false,
val message: String? = null,
/** Любой vault ещё загружает список storages — UUID из группы могут появиться позже. */
val anyVaultStoragesScanning: Boolean = false,
val userMessage: UserNotification? = null,
)

View File

@@ -2,8 +2,12 @@ package com.github.nullptroma.wallenc.ui.screens.sync
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.ui.R
import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.resources.UiStringResolver
import com.github.nullptroma.wallenc.ui.resources.UserNotification
import com.github.nullptroma.wallenc.usecases.ManageStorageSyncGroupsUseCase
import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase
import com.github.nullptroma.wallenc.vault.contract.DescribedVault
@@ -11,22 +15,41 @@ import com.github.nullptroma.wallenc.vault.contract.VaultDescriptor
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
@OptIn(ExperimentalCoroutinesApi::class)
class StorageSyncViewModel @javax.inject.Inject constructor(
class StorageSyncViewModel @Inject constructor(
private val groupsUseCase: ManageStorageSyncGroupsUseCase,
private val runStorageSyncUseCase: RunStorageSyncUseCase,
private val vaultsManager: IVaultsManager,
private val uiStrings: UiStringResolver,
) : ViewModelBase<StorageSyncScreenState>(StorageSyncScreenState()) {
init {
refreshGroups()
observeVaults()
viewModelScope.launch {
vaultsManager.vaults
.flatMapLatest { vaults ->
if (vaults.isEmpty()) {
flowOf(false)
} else {
combine(vaults.map { it.storagesScanInProgress }) { flags ->
flags.any { it }
}
}
}
.distinctUntilChanged()
.collect { scanning ->
updateState(state.value.copy(anyVaultStoragesScanning = scanning))
}
}
}
fun refreshGroups() {
@@ -42,7 +65,7 @@ class StorageSyncViewModel @javax.inject.Inject constructor(
fun createGroup() {
viewModelScope.launch {
updateState(state.value.copy(isBusy = true, message = null))
updateState(state.value.copy(isBusy = true, userMessage = null))
val group = groupsUseCase.createGroup()
val groups = groupsUseCase.getGroups()
updateState(
@@ -50,7 +73,10 @@ class StorageSyncViewModel @javax.inject.Inject constructor(
groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) },
pickerGroupId = null,
isBusy = false,
message = "Group ${group.id} created",
userMessage = UserNotification.TextRes(
R.string.sync_msg_group_created,
listOf(group.id),
),
),
)
}
@@ -58,14 +84,14 @@ class StorageSyncViewModel @javax.inject.Inject constructor(
fun removeGroup(groupId: String) {
viewModelScope.launch {
updateState(state.value.copy(isBusy = true, message = null))
updateState(state.value.copy(isBusy = true, userMessage = null))
groupsUseCase.removeGroup(groupId)
val groups = groupsUseCase.getGroups()
updateState(
state.value.copy(
groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) },
isBusy = false,
message = "Group removed",
userMessage = UserNotification.TextRes(R.string.sync_msg_group_removed),
),
)
}
@@ -75,7 +101,7 @@ class StorageSyncViewModel @javax.inject.Inject constructor(
updateState(
state.value.copy(
pickerGroupId = groupId,
message = null,
userMessage = null,
),
)
}
@@ -84,7 +110,7 @@ class StorageSyncViewModel @javax.inject.Inject constructor(
updateState(
state.value.copy(
pickerGroupId = null,
message = null,
userMessage = null,
),
)
}
@@ -100,14 +126,17 @@ class StorageSyncViewModel @javax.inject.Inject constructor(
fun addStorageToCurrentGroup(storageUuid: UUID) {
val groupId = state.value.pickerGroupId ?: return
viewModelScope.launch {
updateState(state.value.copy(isBusy = true, message = null))
updateState(state.value.copy(isBusy = true, userMessage = null))
groupsUseCase.addStorageToGroup(groupId, storageUuid)
val groups = groupsUseCase.getGroups()
updateState(
state.value.copy(
groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) },
isBusy = false,
message = "Storage added to $groupId",
userMessage = UserNotification.TextRes(
R.string.sync_msg_storage_added,
listOf(groupId),
),
),
)
}
@@ -115,22 +144,32 @@ class StorageSyncViewModel @javax.inject.Inject constructor(
fun removeStorageFromGroup(groupId: String, storageUuid: UUID) {
viewModelScope.launch {
updateState(state.value.copy(isBusy = true, message = null))
updateState(state.value.copy(isBusy = true, userMessage = null))
groupsUseCase.removeStorageFromGroup(groupId, storageUuid)
val groups = groupsUseCase.getGroups()
updateState(
state.value.copy(
groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) },
isBusy = false,
message = "Storage removed from $groupId",
userMessage = UserNotification.TextRes(
R.string.sync_msg_storage_removed,
listOf(groupId),
),
),
)
}
}
fun runSyncNow() {
runStorageSyncUseCase.enqueue("sync-tab")
updateState(state.value.copy(message = "Sync task enqueued"))
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),
),
)
}
private fun observeVaults() {
@@ -170,18 +209,21 @@ class StorageSyncViewModel @javax.inject.Inject constructor(
},
)
} else {
combine(allStorages.map { it.metaInfo }) {
val metaByStorageUuid = allStorages
.mapIndexed { index, storage -> storage.uuid to it[index] }
.toMap()
combine(
allStorages.map { storage ->
combine(storage.metaInfo, storage.isAvailable) { meta, avail ->
storage.uuid to StorageSnapshot(meta, avail)
}
},
) { pairs ->
val snapByUuid = pairs.associate { it.first to it.second }
vaultNodes.map { (vault, trees) ->
StorageSyncVaultUi(
uuid = vault.uuid,
title = vaultTitle(vault as? DescribedVault),
type = vaultType(vault as? DescribedVault),
storages = trees.map { tree ->
toStorageUi(tree, metaByStorageUuid)
toStorageUi(tree, snapByUuid)
},
)
}
@@ -200,18 +242,18 @@ class StorageSyncViewModel @javax.inject.Inject constructor(
private fun vaultType(vault: DescribedVault?): String {
val descriptor = vault?.descriptor
return when (descriptor) {
is VaultDescriptor.LocalDevice -> "Local device"
is VaultDescriptor.LinkedRemote -> "Remote ${descriptor.brand.name.lowercase()}"
null -> "Unknown"
is VaultDescriptor.LocalDevice -> uiStrings(R.string.vault_type_local_device)
is VaultDescriptor.LinkedRemote -> uiStrings(R.string.vault_type_remote, descriptor.brand.name)
null -> uiStrings(R.string.vault_type_unknown)
}
}
private fun vaultTitle(vault: DescribedVault?): String {
val descriptor = vault?.descriptor
return when (descriptor) {
is VaultDescriptor.LocalDevice -> "Local vault"
is VaultDescriptor.LocalDevice -> uiStrings(R.string.vault_title_local)
is VaultDescriptor.LinkedRemote -> descriptor.accountDisplayName
null -> "Unknown vault"
null -> uiStrings(R.string.vault_title_unknown)
}
}
@@ -244,27 +286,35 @@ class StorageSyncViewModel @javax.inject.Inject constructor(
private fun toStorageUi(
node: StorageTreeNode,
metaByStorageUuid: Map<UUID, com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo> = emptyMap(),
snapshotByUuid: Map<UUID, StorageSnapshot> = emptyMap(),
): StorageSyncStorageUi {
val meta = metaByStorageUuid[node.storage.uuid] ?: node.storage.metaInfo.value
val encryptionStatus = when {
meta.encInfo == null -> "Not encrypted"
node.storage.isVirtualStorage -> "Encrypted (opened)"
else -> "Encrypted"
val snap = snapshotByUuid[node.storage.uuid]
val meta = snap?.meta ?: node.storage.metaInfo.value
val isReachable = snap?.isAvailable ?: node.storage.isAvailable.value
val encryptionKind = when {
meta.encInfo == null -> StorageSyncEncryptionKind.NotEncrypted
node.storage.isVirtualStorage -> StorageSyncEncryptionKind.EncryptedOpened
else -> StorageSyncEncryptionKind.EncryptedClosed
}
return StorageSyncStorageUi(
uuid = node.storage.uuid,
name = meta.name ?: "<noname>",
encryptionStatus = encryptionStatus,
name = meta.name ?: uiStrings(R.string.no_name),
encryptionKind = encryptionKind,
isReachable = isReachable,
children = node.children.map { child ->
toStorageUi(
node = child,
metaByStorageUuid = metaByStorageUuid,
snapshotByUuid = snapshotByUuid,
)
},
)
}
private data class StorageSnapshot(
val meta: IStorageMetaInfo,
val isAvailable: Boolean,
)
private data class StorageTreeNode(
val storage: IStorage,
val children: List<StorageTreeNode>,

View File

@@ -1,69 +1,173 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="nav_label_local_vault">Local</string>
<string name="nav_label_remote_vaults">Remotes</string>
<string name="nav_label_main">Main</string>
<string name="nav_label_sync">Sync</string>
<string name="nav_label_settings">Settings</string>
<string name="nav_label_local_vault">Локальное хранилище</string>
<string name="nav_cd_local_vault">Локальное хранилище</string>
<string name="nav_label_remote_vaults">Удалённые хранилища</string>
<string name="nav_cd_remote_vaults">Удалённые хранилища</string>
<string name="nav_label_main">Главная</string>
<string name="nav_label_sync">Синхронизация</string>
<string name="nav_label_settings">Настройки</string>
<string name="settings_title">Settings</string>
<string name="sync_groups_title">Sync groups</string>
<string name="sync_run_now">Run sync now</string>
<string name="sync_refresh">Refresh</string>
<string name="sync_add_storage">Add</string>
<string name="sync_remove_group">Remove group</string>
<string name="sync_group_empty">No storages in group</string>
<string name="sync_remove_storage">Remove</string>
<string name="sync_picker_back">Back</string>
<string name="sync_picker_title">Select storage for %1$s</string>
<string name="sync_picker_add">Add</string>
<string name="sync_picker_added">Added</string>
<string name="sync_picker_no_storages">No storages in this vault</string>
<string name="sync_group_mixed_encryption_warning">Mixed encryption in group: define one canonical encryption mode</string>
<string name="sync_remove_group_confirm_title">Remove group?</string>
<string name="sync_remove_group_confirm_message">Delete sync group \"%1$s\"?</string>
<string name="sync_remove_storage_confirm_title">Remove storage?</string>
<string name="sync_remove_storage_confirm_message">Remove storage \"%1$s\" from the group?</string>
<string name="sync_confirm_delete">Delete</string>
<string name="sync_cancel">Cancel</string>
<string name="no_name">&lt;noname&gt;</string>
<string name="show_storage_item_menu">Show storage item menu</string>
<string name="rename">Rename</string>
<string name="remove">Remove</string>
<string name="encrypt">Encrypt</string>
<string name="new_name_title">New name</string>
<string name="remove_confirmation_dialog">Delete storage "%1$s"?</string>
<string name="storage_lock_actions">Storage encryption actions</string>
<string name="main_work_status_label">Статус:</string>
<string name="main_status_multiple_tasks">Выполняется задач: %1$d</string>
<string name="main_status_vault_scanning_storages">Сканирование vault: загрузка списка хранилищ…</string>
<string name="settings_title">Настройки</string>
<string name="sync_groups_title">Группы синхронизации</string>
<string name="sync_run_now">Запустить синхронизацию</string>
<string name="sync_cd_run_now">Запустить синхронизацию сейчас</string>
<string name="sync_refresh">Обновить</string>
<string name="sync_add_storage">Добавить хранилище в группу</string>
<string name="sync_remove_group">Удалить группу</string>
<string name="sync_group_empty">В группе нет хранилищ</string>
<string name="sync_remove_storage">Убрать хранилище из группы</string>
<string name="sync_picker_back">Назад</string>
<string name="sync_cd_picker_back">Закрыть выбор хранилища</string>
<string name="sync_picker_title">Выбор хранилища для %1$s</string>
<string name="sync_picker_add">Добавить</string>
<string name="sync_picker_added">Добавлено</string>
<string name="sync_picker_cd_add">Добавить хранилище в группу</string>
<string name="sync_picker_no_storages">В этом хранилище нет доступных каталогов</string>
<string name="sync_picker_expand">Развернуть</string>
<string name="sync_picker_collapse">Свернуть</string>
<string name="sync_fab_create_group_cd">Создать группу синхронизации</string>
<string name="sync_group_mixed_encryption_warning">В группе разное шифрование: задайте единый режим</string>
<string name="sync_remove_group_confirm_title">Удалить группу?</string>
<string name="sync_remove_group_confirm_message">Удалить группу синхронизации «%1$s»?</string>
<string name="sync_remove_storage_confirm_title">Убрать хранилище?</string>
<string name="sync_remove_storage_confirm_message">Убрать хранилище «%1$s» из группы?</string>
<string name="sync_confirm_delete">Удалить</string>
<string name="sync_cancel">Отмена</string>
<string name="sync_msg_group_created">Создана группа %1$s</string>
<string name="sync_msg_group_removed">Группа удалена</string>
<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_encryption_unknown">Неизвестно</string>
<string name="sync_storage_encryption_line">Шифрование: %1$s</string>
<string name="sync_storage_missing_title">Не найдено в текущих vault</string>
<string name="sync_storage_pending_vault_scan">Ожидание: список хранилищ в vault ещё загружается</string>
<string name="sync_storage_not_in_vaults">Нет в дереве хранилищ (удалено, другой аккаунт или не прошёл init)</string>
<string name="sync_storage_unreachable">Хранилище недоступно (vault или сеть)</string>
<string name="no_name">&lt;без имени&gt;</string>
<string name="show_storage_item_menu">Меню хранилища</string>
<string name="storage_row_task_running_cd">Выполняется операция с этим хранилищем</string>
<string name="storage_menu_busy">%1$s (задача выполняется)</string>
<string name="rename">Переименовать</string>
<string name="remove">Удалить</string>
<string name="encrypt">Шифрование</string>
<string name="new_name_title">Новое имя</string>
<string name="remove_confirmation_dialog">Удалить хранилище «%1$s»?</string>
<string name="storage_lock_actions">Действия с шифрованием</string>
<string name="storage_sync_lock_checking">Проверка блокировки…</string>
<string name="storage_sync_unlock_action">Снять блокировку синхронизации</string>
<string name="storage_sync_not_locked">Синхронизация не заблокирована</string>
<string name="task_pipeline_title">Task pipeline</string>
<string name="task_pipeline_jobs">Jobs</string>
<string name="task_pipeline_log">Log</string>
<string name="task_pipeline_cancel_all">Cancel all</string>
<string name="task_pipeline_open">Open task pipeline</string>
<string name="task_pipeline_run_test">Run test task</string>
<string name="task_pipeline_test_dialog_title">Test task setup</string>
<string name="task_pipeline_test_dialog_duration">Duration: %1$d s</string>
<string name="task_pipeline_test_dialog_start">Start</string>
<string name="task_pipeline_test_dialog_cancel">Cancel</string>
<string name="task_pipeline_test_dialog_infinity">Infinity (indeterminate progress)</string>
<string name="task_state_queued">Queued</string>
<string name="task_state_running">Running</string>
<string name="task_state_completed">Completed</string>
<string name="task_state_cancelled">Cancelled</string>
<string name="task_state_failed">Failed: %1$s</string>
<string name="storage_field_available">Доступно: %1$s</string>
<string name="storage_value_yes">да</string>
<string name="storage_value_no">нет</string>
<string name="storage_field_files">Файлов: %1$s</string>
<string name="storage_field_size">Размер: %1$s</string>
<string name="storage_field_virtual">Виртуальное: %1$s</string>
<string name="storage_unavailable_hint">Хранилище недоступно</string>
<string name="storage_menu_unavailable">Недоступно: %1$s</string>
<string name="remote_vaults_add_cd">Add remote vault</string>
<string name="remote_vaults_empty_hint">No remote vaults yet. Tap + to add Yandex.</string>
<string name="remote_vaults_add_title">Add vault</string>
<string name="remote_vaults_add_pick_provider">Choose provider:</string>
<string name="remote_vaults_provider_yandex">Yandex</string>
<string name="remote_vaults_add_cancel">Cancel</string>
<string name="remote_vault_type_yandex">Yandex</string>
<string name="remote_vault_delete_cd">Remove remote vault</string>
<string name="remote_vault_remove_title">Remove remote vault?</string>
<string name="remote_vault_remove_message">Remove \"%1$s\" from this device? The account data on the server is not deleted.</string>
<string name="storage_status_not_encrypted">Не зашифровано</string>
<string name="storage_status_encrypted_open">Зашифровано (открыто)</string>
<string name="storage_status_encrypted_closed">Зашифровано (закрыто)</string>
</resources>
<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_unavailable_banner">Хранилище недоступно. Проверьте сеть, путь или разблокировку.</string>
<string name="vault_loading_storages">Загрузка списка хранилищ…</string>
<string name="vault_empty_list_hint">В этом хранилище пока нет каталогов. Создайте хранилище кнопкой «+», когда оно доступно.</string>
<string name="task_pipeline_title">Очередь задач</string>
<string name="task_pipeline_jobs">Задачи</string>
<string name="task_pipeline_log">Журнал</string>
<string name="task_pipeline_cancel_all">Отменить все</string>
<string name="task_pipeline_open">Открыть очередь задач</string>
<string name="task_pipeline_run_test">Тестовая задача</string>
<string name="task_pipeline_test_dialog_title">Параметры тестовой задачи</string>
<string name="task_pipeline_test_dialog_duration">Длительность: %1$d с</string>
<string name="task_pipeline_test_dialog_start">Запустить</string>
<string name="task_pipeline_test_dialog_cancel">Отмена</string>
<string name="task_pipeline_test_dialog_infinity">Бесконечно (неопределённый прогресс)</string>
<string name="task_pipeline_test_running">Тестовая задача (%1$d с)</string>
<string name="task_pipeline_test_running_infinity">Тестовая задача (%1$d с, ∞)</string>
<string name="task_state_queued">В очереди</string>
<string name="task_state_running">Выполняется</string>
<string name="task_state_completed">Завершено</string>
<string name="task_state_cancelled">Отменено</string>
<string name="task_state_failed">Ошибка: %1$s</string>
<string name="task_title_dump_storage_log">Выгрузка дерева в журнал</string>
<string name="task_title_create_storage">Создание хранилища</string>
<string name="task_title_enable_encryption">Включение шифрования</string>
<string name="task_title_open_encrypted_storage">Расшифровка и открытие хранилища</string>
<string name="task_progress_decrypt_running">Расшифровка…</string>
<string name="task_title_close_encrypted_storage">Закрытие зашифрованного хранилища</string>
<string name="task_title_disable_encryption">Отключение шифрования</string>
<string name="task_title_rename_storage">Переименование хранилища</string>
<string name="task_title_remove_storage">Удаление хранилища</string>
<string name="task_title_clear_sync_lock">Снятие блокировки синхронизации</string>
<string name="task_title_add_remote_vault">Добавление удалённого хранилища</string>
<string name="task_title_remove_remote_vault">Удаление удалённого хранилища</string>
<string name="task_title_storage_sync">Синхронизация хранилищ</string>
<string name="task_title_storage_sync_background">Фоновая синхронизация хранилищ</string>
<string name="msg_encryption_enabled">Шифрование включено</string>
<string name="msg_storage_already_encrypted">Хранилище уже зашифровано</string>
<string name="msg_storage_not_empty">Хранилище не пустое</string>
<string name="msg_storage_empty_state_unknown">Не удалось определить, пусто ли хранилище</string>
<string name="msg_unsupported_storage_type">Неподдерживаемый тип хранилища</string>
<string name="msg_failed_enable_encryption">Не удалось включить шифрование: %1$s</string>
<string name="msg_failed_open_storage">Не удалось открыть хранилище: %1$s</string>
<string name="msg_failed_close_storage">Не удалось закрыть хранилище: %1$s</string>
<string name="msg_encryption_disabled">Шифрование отключено</string>
<string name="msg_failed_disable_encryption">Не удалось отключить шифрование: %1$s</string>
<string name="msg_invalid_storage_for_sync_lock">Некорректное хранилище</string>
<string name="msg_sync_lock_cleared">Блокировка синхронизации снята</string>
<string name="msg_sync_lock_clear_failed">Не удалось снять блокировку: %1$s</string>
<string name="remote_vaults_add_cd">Добавить удалённое хранилище</string>
<string name="remote_vaults_empty_hint">Пока нет удалённых хранилищ. Нажмите «+», чтобы добавить Yandex.</string>
<string name="remote_vaults_add_title">Добавить хранилище</string>
<string name="remote_vaults_add_pick_provider">Выберите провайдера:</string>
<string name="remote_vaults_provider_yandex">Яндекс</string>
<string name="remote_vaults_add_cancel">Отмена</string>
<string name="remote_vault_type_yandex">Яндекс</string>
<string name="remote_vault_delete_cd">Удалить удалённое хранилище</string>
<string name="remote_vault_remove_title">Удалить удалённое хранилище?</string>
<string name="remote_vault_remove_message">Удалить «%1$s» с этого устройства? Данные на сервере не удаляются.</string>
<string name="dialog_cancel">Отмена</string>
<string name="dialog_ok">ОК</string>
<string name="dialog_encryption_enable_title">Включить шифрование</string>
<string name="dialog_password_label">Пароль</string>
<string name="dialog_encrypt_paths">Шифровать пути</string>
<string name="dialog_apply">Применить</string>
<string name="dialog_open_encrypted_title">Открыть зашифрованное хранилище</string>
<string name="dialog_remember_password">Запомнить пароль</string>
<string name="dialog_open">Открыть</string>
<string name="dialog_close">Закрыть</string>
<string name="dialog_disable_encryption">Отключить шифрование</string>
<string name="dialog_done">Готово</string>
<string name="vault_type_local_device">Локальное устройство</string>
<string name="vault_type_remote">Удалённое: %1$s</string>
<string name="vault_type_unknown">Неизвестный тип</string>
<string name="vault_title_local">Локальное хранилище</string>
<string name="vault_title_unknown">Неизвестное хранилище</string>
<string name="enc_status_not_encrypted">Не зашифровано</string>
<string name="enc_status_encrypted_open">Зашифровано (открыто)</string>
<string name="enc_status_encrypted">Зашифровано</string>
<string name="text_edit_screen_title">Текст</string>
<string name="text_edit_screen_placeholder">Содержимое: %1$s</string>
<string name="common_unknown">Неизвестно</string>
</resources>