Общий VaultScreen

This commit is contained in:
2026-05-03 19:47:18 +03:00
parent 1034e134c2
commit 78aa776adc
14 changed files with 226 additions and 99 deletions

View File

@@ -27,12 +27,15 @@ import com.github.nullptroma.wallenc.presentation.R
import com.github.nullptroma.wallenc.presentation.navigation.NavBarItemData
import com.github.nullptroma.wallenc.presentation.navigation.NavigationState
import com.github.nullptroma.wallenc.presentation.navigation.rememberNavigationState
import com.github.nullptroma.wallenc.presentation.screens.main.screens.local.vault.LocalVaultRoute
import com.github.nullptroma.wallenc.presentation.screens.main.screens.local.vault.LocalVaultScreen
import com.github.nullptroma.wallenc.presentation.screens.main.screens.local.vault.LocalVaultViewModel
import com.github.nullptroma.wallenc.presentation.screens.main.screens.remotes.RemoteVaultsRoute
import com.github.nullptroma.wallenc.presentation.screens.main.screens.remotes.RemoteVaultsScreen
import com.github.nullptroma.wallenc.presentation.screens.main.screens.remotes.RemoteVaultsViewModel
import com.github.nullptroma.wallenc.presentation.screens.main.screens.vault.LocalVaultRoute
import com.github.nullptroma.wallenc.presentation.screens.main.screens.vault.LocalVaultScreen
import com.github.nullptroma.wallenc.presentation.screens.main.screens.vault.LocalVaultViewModel
import com.github.nullptroma.wallenc.presentation.screens.main.screens.vault.RemoteVaultViewModel
import com.github.nullptroma.wallenc.presentation.screens.main.screens.vault.VaultBrowserRoute
import com.github.nullptroma.wallenc.presentation.screens.main.screens.vault.VaultBrowserScreen
import com.github.nullptroma.wallenc.presentation.screens.shared.TextEditRoute
import com.github.nullptroma.wallenc.presentation.screens.shared.TextEditScreen
@@ -110,7 +113,24 @@ fun MainScreen(
}) {
RemoteVaultsScreen(
modifier = Modifier.padding(innerPaddings),
viewModel = remoteVaultsViewModel
viewModel = remoteVaultsViewModel,
onOpenVault = { item ->
navState.push(VaultBrowserRoute(item.uuid.toString()))
},
)
}
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))
},
)
}
composable<TextEditRoute> {

View File

@@ -5,8 +5,8 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import com.github.nullptroma.wallenc.presentation.screens.ScreenRoute
import com.github.nullptroma.wallenc.presentation.screens.main.screens.local.vault.LocalVaultRoute
import com.github.nullptroma.wallenc.presentation.screens.main.screens.remotes.RemoteVaultsRoute
import com.github.nullptroma.wallenc.presentation.screens.main.screens.vault.LocalVaultRoute
import com.github.nullptroma.wallenc.presentation.ViewModelBase
import dagger.hilt.android.lifecycle.HiltViewModel

View File

@@ -6,4 +6,4 @@ import kotlinx.serialization.Serializable
@Serializable
@Parcelize
class RemoteVaultsRoute: MainRoute()
class RemoteVaultsRoute : MainRoute()

View File

@@ -34,6 +34,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -41,7 +42,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.runtime.getValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.presentation.R
@@ -52,6 +52,7 @@ import com.github.nullptroma.wallenc.vaultapi.VaultLinkOutcome
fun RemoteVaultsScreen(
modifier: Modifier = Modifier,
viewModel: RemoteVaultsViewModel = hiltViewModel(),
onOpenVault: (RemoteVaultListItem) -> Unit,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
@@ -92,7 +93,13 @@ fun RemoteVaultsScreen(
) {
items(uiState.vaults, key = { it.uuid }) { item ->
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.clickable(
enabled = !uiState.isBusy,
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { onOpenVault(item) },
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
colors = CardDefaults.elevatedCardColors(

View File

@@ -4,10 +4,10 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog

View File

@@ -2,7 +2,6 @@ package com.github.nullptroma.wallenc.presentation.screens.main.screens.tasks
import androidx.lifecycle.ViewModel
import com.github.nullptroma.wallenc.domain.tasks.ITaskOrchestrator
import com.github.nullptroma.wallenc.domain.tasks.PipelineWork
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers

View File

@@ -1,4 +1,4 @@
package com.github.nullptroma.wallenc.presentation.screens.main.screens.local.vault
package com.github.nullptroma.wallenc.presentation.screens.main.screens.vault
import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
@@ -6,8 +6,8 @@ import com.github.nullptroma.wallenc.domain.datatypes.Tree
import com.github.nullptroma.wallenc.domain.interfaces.IDirectory
import com.github.nullptroma.wallenc.domain.interfaces.IFile
import com.github.nullptroma.wallenc.domain.interfaces.ILogger
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
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.domain.usecases.GetOpenedStoragesUseCase
@@ -18,76 +18,66 @@ import com.github.nullptroma.wallenc.domain.usecases.RenameStorageUseCase
import com.github.nullptroma.wallenc.domain.usecases.StorageFileManagementUseCase
import com.github.nullptroma.wallenc.presentation.ViewModelBase
import com.github.nullptroma.wallenc.presentation.extensions.toPrintable
import com.github.nullptroma.wallenc.vaultapi.described
import com.github.nullptroma.wallenc.vaultapi.locals
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.util.UUID
import javax.inject.Inject
import kotlin.system.measureTimeMillis
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class LocalVaultViewModel @Inject constructor(
private val vaultsManager: IVaultsManager,
private val manageVaultUseCase: ManageVaultUseCase,
/**
* Общая логика дерева storages для локального и удалённого vault (presentation).
*/
abstract class AbstractVaultBrowserViewModel(
storagesFlow: Flow<List<IStorage>>,
private val canAddStorage: Boolean,
private val resolveCreateVaultUuid: () -> UUID?,
private val removeStorageUseCase: RemoveStorageUseCase,
private val getOpenedStoragesUseCase: GetOpenedStoragesUseCase,
private val storageFileManagementUseCase: StorageFileManagementUseCase,
private val manageStoragesEncryptionUseCase: ManageStoragesEncryptionUseCase,
private val renameStorageUseCase: RenameStorageUseCase,
private val manageVaultUseCase: ManageVaultUseCase,
private val taskOrchestrator: ITaskOrchestrator,
private val logger: ILogger
) : ViewModelBase<LocalVaultScreenState>(LocalVaultScreenState(listOf(), true)) {
private val logger: ILogger,
) : ViewModelBase<VaultBrowserScreenState>(
VaultBrowserScreenState(storagesList = emptyList(), isLoading = true, canAddStorage = canAddStorage),
) {
private val localVaultUuid: UUID?
get() = vaultsManager.vaults.value.described().locals.firstOrNull()?.uuid
private val localStoragesFlow = vaultsManager.vaults
.map { vaults -> vaults.described().locals.firstOrNull() }
.flatMapLatest { v -> v?.storages ?: flowOf(emptyList()) }
private val _messages = MutableSharedFlow<String>()
val messages: SharedFlow<String> = _messages
private var _taskCount: Int = 0
private var tasksCount
get() = _taskCount
private var taskCount: Int = 0
set(value) {
_taskCount = value
field = value
updateStateLoading()
}
private var _isLoading: Boolean = false
private var isLoading
get() = _isLoading
private var storagesLoading: Boolean = false
set(value) {
_isLoading = value
field = value
updateStateLoading()
}
init {
collectFlows()
collectFlows(storagesFlow)
}
private fun updateStateLoading() {
updateState(state.value.copy(
isLoading = this.isLoading || this.tasksCount > 0
))
updateState(
state.value.copy(
isLoading = storagesLoading || taskCount > 0,
),
)
}
private fun collectFlows() {
private fun collectFlows(storagesFlow: Flow<List<IStorage>>) {
viewModelScope.launch {
localStoragesFlow.combine(getOpenedStoragesUseCase.openedStorages) { local, opened ->
storagesFlow.combine(getOpenedStoragesUseCase.openedStorages) { storages, opened ->
val list = mutableListOf<Tree<IStorageInfo>>()
for (storage in local) {
for (storage in storages) {
var tree = Tree<IStorageInfo>(storage)
list.add(tree)
while (opened.containsKey(tree.value.uuid)) {
@@ -99,7 +89,7 @@ class LocalVaultViewModel @Inject constructor(
}
list
}.collect { trees ->
isLoading = false
storagesLoading = false
updateState(state.value.copy(storagesList = trees))
}
}
@@ -135,13 +125,14 @@ class LocalVaultViewModel @Inject constructor(
}
fun createStorage() {
if (!state.value.canAddStorage) return
taskOrchestrator.enqueue(
title = "Create storage",
dispatcher = Dispatchers.IO,
work = { ctx ->
ctx.log(TaskLogLevel.Info, "Creating storage…")
val uuid = localVaultUuid
?: throw IllegalStateException("Local vault is not registered")
val uuid = resolveCreateVaultUuid()
?: throw IllegalStateException("Vault is not available")
manageVaultUseCase.createStorage(uuid)
ctx.log(TaskLogLevel.Info, "Storage created")
},
@@ -156,7 +147,7 @@ class LocalVaultViewModel @Inject constructor(
synchronized(storageOpMutex) {
if (runningStorages.contains(id)) return
runningStorages.add(id)
tasksCount++
taskCount++
}
val key = EncryptKey(password)
taskOrchestrator.enqueue(
@@ -196,7 +187,7 @@ class LocalVaultViewModel @Inject constructor(
} finally {
synchronized(storageOpMutex) {
runningStorages.remove(id)
tasksCount--
taskCount--
}
}
},
@@ -208,7 +199,7 @@ class LocalVaultViewModel @Inject constructor(
synchronized(storageOpMutex) {
if (runningStorages.contains(id)) return
runningStorages.add(id)
tasksCount++
taskCount++
}
val key = EncryptKey(password)
taskOrchestrator.enqueue(
@@ -225,7 +216,7 @@ class LocalVaultViewModel @Inject constructor(
} finally {
synchronized(storageOpMutex) {
runningStorages.remove(id)
tasksCount--
taskCount--
}
}
},

View File

@@ -1,4 +1,4 @@
package com.github.nullptroma.wallenc.presentation.screens.main.screens.local.vault
package com.github.nullptroma.wallenc.presentation.screens.main.screens.vault
import com.github.nullptroma.wallenc.presentation.screens.main.MainRoute
import kotlinx.parcelize.Parcelize
@@ -6,4 +6,4 @@ import kotlinx.serialization.Serializable
@Serializable
@Parcelize
class LocalVaultRoute: MainRoute()
class LocalVaultRoute : MainRoute()

View File

@@ -0,0 +1,18 @@
package com.github.nullptroma.wallenc.presentation.screens.main.screens.vault
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
@Composable
fun LocalVaultScreen(
modifier: Modifier = Modifier,
viewModel: LocalVaultViewModel = hiltViewModel(),
openTextEdit: (String) -> Unit,
) {
VaultBrowserScreen(
modifier = modifier,
viewModel = viewModel,
openTextEdit = openTextEdit,
)
}

View File

@@ -0,0 +1,47 @@
package com.github.nullptroma.wallenc.presentation.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.domain.usecases.GetOpenedStoragesUseCase
import com.github.nullptroma.wallenc.domain.usecases.ManageStoragesEncryptionUseCase
import com.github.nullptroma.wallenc.domain.usecases.ManageVaultUseCase
import com.github.nullptroma.wallenc.domain.usecases.RemoveStorageUseCase
import com.github.nullptroma.wallenc.domain.usecases.RenameStorageUseCase
import com.github.nullptroma.wallenc.domain.usecases.StorageFileManagementUseCase
import com.github.nullptroma.wallenc.vaultapi.described
import com.github.nullptroma.wallenc.vaultapi.locals
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class LocalVaultViewModel @Inject constructor(
vaultsManager: IVaultsManager,
manageVaultUseCase: ManageVaultUseCase,
removeStorageUseCase: RemoveStorageUseCase,
getOpenedStoragesUseCase: GetOpenedStoragesUseCase,
storageFileManagementUseCase: StorageFileManagementUseCase,
manageStoragesEncryptionUseCase: ManageStoragesEncryptionUseCase,
renameStorageUseCase: RenameStorageUseCase,
taskOrchestrator: ITaskOrchestrator,
logger: ILogger,
) : AbstractVaultBrowserViewModel(
storagesFlow = vaultsManager.vaults
.map { vaults -> vaults.described().locals.firstOrNull() }
.flatMapLatest { v -> v?.storages ?: flowOf(emptyList()) },
canAddStorage = true,
resolveCreateVaultUuid = { vaultsManager.vaults.value.described().locals.firstOrNull()?.uuid },
removeStorageUseCase = removeStorageUseCase,
getOpenedStoragesUseCase = getOpenedStoragesUseCase,
storageFileManagementUseCase = storageFileManagementUseCase,
manageStoragesEncryptionUseCase = manageStoragesEncryptionUseCase,
renameStorageUseCase = renameStorageUseCase,
manageVaultUseCase = manageVaultUseCase,
taskOrchestrator = taskOrchestrator,
logger = logger,
)

View File

@@ -0,0 +1,46 @@
package com.github.nullptroma.wallenc.presentation.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.domain.usecases.GetOpenedStoragesUseCase
import com.github.nullptroma.wallenc.domain.usecases.ManageStoragesEncryptionUseCase
import com.github.nullptroma.wallenc.domain.usecases.ManageVaultUseCase
import com.github.nullptroma.wallenc.domain.usecases.RemoveStorageUseCase
import com.github.nullptroma.wallenc.domain.usecases.RenameStorageUseCase
import com.github.nullptroma.wallenc.domain.usecases.StorageFileManagementUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import java.util.UUID
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class RemoteVaultViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
manageVaultUseCase: ManageVaultUseCase,
removeStorageUseCase: RemoveStorageUseCase,
getOpenedStoragesUseCase: GetOpenedStoragesUseCase,
storageFileManagementUseCase: StorageFileManagementUseCase,
manageStoragesEncryptionUseCase: ManageStoragesEncryptionUseCase,
renameStorageUseCase: RenameStorageUseCase,
taskOrchestrator: ITaskOrchestrator,
logger: ILogger,
) : AbstractVaultBrowserViewModel(
storagesFlow = manageVaultUseCase.storagesOf(savedStateHandle.requireVaultUuid()),
canAddStorage = false,
resolveCreateVaultUuid = { null },
removeStorageUseCase = removeStorageUseCase,
getOpenedStoragesUseCase = getOpenedStoragesUseCase,
storageFileManagementUseCase = storageFileManagementUseCase,
manageStoragesEncryptionUseCase = manageStoragesEncryptionUseCase,
renameStorageUseCase = renameStorageUseCase,
manageVaultUseCase = manageVaultUseCase,
taskOrchestrator = taskOrchestrator,
logger = logger,
)
private fun SavedStateHandle.requireVaultUuid(): UUID {
val raw = get<String>("vaultUuid") ?: error("Missing vault UUID in navigation arguments")
return UUID.fromString(raw)
}

View File

@@ -0,0 +1,9 @@
package com.github.nullptroma.wallenc.presentation.screens.main.screens.vault
import com.github.nullptroma.wallenc.presentation.screens.ScreenRoute
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class VaultBrowserRoute(val vaultUuid: String) : ScreenRoute()

View File

@@ -1,4 +1,4 @@
package com.github.nullptroma.wallenc.presentation.screens.main.screens.local.vault
package com.github.nullptroma.wallenc.presentation.screens.main.screens.vault
import android.widget.Toast
import androidx.compose.foundation.background
@@ -27,18 +27,16 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.presentation.elements.StorageTree
import com.github.nullptroma.wallenc.presentation.extensions.gesturesDisabled
@Composable
fun LocalVaultScreen(
fun VaultBrowserScreen(
modifier: Modifier = Modifier,
viewModel: LocalVaultViewModel = hiltViewModel(),
viewModel: AbstractVaultBrowserViewModel,
openTextEdit: (String) -> Unit,
) {
val uiState by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(Unit) {
@@ -52,55 +50,44 @@ fun LocalVaultScreen(
modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
floatingActionButton = {
FloatingActionButton(
onClick = {
viewModel.createStorage()
},
if (uiState.canAddStorage) {
FloatingActionButton(
onClick = { viewModel.createStorage() },
) {
Icon(Icons.Filled.Add, contentDescription = null)
}
}
},
) { innerPadding ->
LazyColumn(
modifier = Modifier
.padding(innerPadding)
.gesturesDisabled(uiState.isLoading),
) {
Icon(Icons.Filled.Add, "Floating action button.")
}
}) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding).gesturesDisabled(uiState.isLoading)) {
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)
},
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)
},
onCloseEncrypted = { tree -> viewModel.closeEncryptedStorage(tree.value) },
onDisableEncryption = { tree -> viewModel.disableEncryption(tree.value) },
getStatusText = { tree -> viewModel.getStorageStatus(tree.value) },
isEncryptionOpened = { tree -> viewModel.isEncryptionSessionOpen(tree.value) },
)
}
item {
Spacer(modifier = Modifier.height(8.dp))
}
item { Spacer(modifier = Modifier.height(8.dp)) }
}
}
if(uiState.isLoading) {
if (uiState.isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.fillMaxSize().alpha(0.6f).background(Color.Black))
CircularProgressIndicator(
@@ -112,4 +99,3 @@ fun LocalVaultScreen(
}
}
}

View File

@@ -1,6 +1,10 @@
package com.github.nullptroma.wallenc.presentation.screens.main.screens.local.vault
package com.github.nullptroma.wallenc.presentation.screens.main.screens.vault
import com.github.nullptroma.wallenc.domain.datatypes.Tree
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
data class LocalVaultScreenState(val storagesList: List<Tree<IStorageInfo>>, val isLoading: Boolean)
data class VaultBrowserScreenState(
val storagesList: List<Tree<IStorageInfo>>,
val isLoading: Boolean,
val canAddStorage: Boolean = false,
)