feat(sync): добавил механизм синхронизации хранилищ и управление группами
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.List
|
||||
import androidx.compose.material.icons.rounded.Menu
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material.icons.rounded.Sync
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
@@ -40,6 +41,9 @@ import com.github.nullptroma.wallenc.ui.screens.main.screens.tasks.TaskPipelineS
|
||||
import com.github.nullptroma.wallenc.ui.screens.settings.SettingsRoute
|
||||
import com.github.nullptroma.wallenc.ui.screens.settings.SettingsScreen
|
||||
import com.github.nullptroma.wallenc.ui.screens.settings.SettingsViewModel
|
||||
import com.github.nullptroma.wallenc.ui.screens.sync.StorageSyncRoute
|
||||
import com.github.nullptroma.wallenc.ui.screens.sync.StorageSyncScreen
|
||||
import com.github.nullptroma.wallenc.ui.screens.sync.StorageSyncViewModel
|
||||
import com.github.nullptroma.wallenc.ui.theme.WallencTheme
|
||||
|
||||
|
||||
@@ -70,6 +74,7 @@ fun WallencNavRoot(
|
||||
|
||||
val mainViewModel: MainViewModel = hiltViewModel()
|
||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||
val storageSyncViewModel: StorageSyncViewModel = hiltViewModel()
|
||||
|
||||
val topLevelRoutes = viewModel.routes
|
||||
|
||||
@@ -90,6 +95,11 @@ fun WallencNavRoot(
|
||||
Icons.AutoMirrored.Rounded.List,
|
||||
R.string.task_pipeline_open,
|
||||
),
|
||||
StorageSyncRoute::class.qualifiedName!! to NavBarItemData(
|
||||
R.string.nav_label_sync,
|
||||
StorageSyncRoute::class.qualifiedName!!,
|
||||
Icons.Rounded.Sync,
|
||||
),
|
||||
SettingsRoute::class.qualifiedName!! to NavBarItemData(
|
||||
R.string.nav_label_settings,
|
||||
SettingsRoute::class.qualifiedName!!,
|
||||
@@ -158,6 +168,20 @@ fun WallencNavRoot(
|
||||
}) {
|
||||
SettingsScreen(Modifier.padding(innerPaddings), settingsViewModel)
|
||||
}
|
||||
composable<StorageSyncRoute>(
|
||||
deepLinks = listOf(
|
||||
navDeepLink { uriPattern = WallencDeepLinks.SYNC_URI_PATTERN },
|
||||
),
|
||||
enterTransition = {
|
||||
fadeIn(tween(200))
|
||||
}, exitTransition = {
|
||||
fadeOut(tween(200))
|
||||
}) {
|
||||
StorageSyncScreen(
|
||||
modifier = Modifier.padding(innerPaddings),
|
||||
viewModel = storageSyncViewModel,
|
||||
)
|
||||
}
|
||||
composable<TaskPipelineRoute>(
|
||||
deepLinks = listOf(
|
||||
navDeepLink { uriPattern = WallencDeepLinks.TASKS_URI_PATTERN },
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.MainRoute
|
||||
import com.github.nullptroma.wallenc.ui.screens.main.screens.tasks.TaskPipelineRoute
|
||||
import com.github.nullptroma.wallenc.ui.screens.settings.SettingsRoute
|
||||
import com.github.nullptroma.wallenc.ui.screens.sync.StorageSyncRoute
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlin.collections.set
|
||||
|
||||
@@ -20,6 +21,7 @@ class WallencViewModel @javax.inject.Inject constructor(savedStateHandle: SavedS
|
||||
mapOf(
|
||||
MainRoute::class.qualifiedName!! to MainRoute(),
|
||||
TaskPipelineRoute::class.qualifiedName!! to TaskPipelineRoute(),
|
||||
StorageSyncRoute::class.qualifiedName!! to StorageSyncRoute(),
|
||||
SettingsRoute::class.qualifiedName!! to SettingsRoute()
|
||||
)
|
||||
)
|
||||
|
||||
@@ -16,11 +16,13 @@ object WallencDeepLinks {
|
||||
object Host {
|
||||
const val MAIN = "main"
|
||||
const val TASKS = "tasks"
|
||||
const val SYNC = "sync"
|
||||
const val SETTINGS = "settings"
|
||||
}
|
||||
|
||||
const val MAIN_URI_PATTERN = "$SCHEME://${Host.MAIN}"
|
||||
const val TASKS_URI_PATTERN = "$SCHEME://${Host.TASKS}"
|
||||
const val SYNC_URI_PATTERN = "$SCHEME://${Host.SYNC}"
|
||||
const val SETTINGS_URI_PATTERN = "$SCHEME://${Host.SETTINGS}"
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,11 @@ import com.github.nullptroma.wallenc.ui.R
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(modifier: Modifier, viewModel: SettingsViewModel) {
|
||||
Column (modifier = modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.settings_title))
|
||||
// Text(text = viewModel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.sync
|
||||
|
||||
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
class StorageSyncRoute : ScreenRoute()
|
||||
@@ -0,0 +1,427 @@
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
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.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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 com.github.nullptroma.wallenc.ui.R
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
fun StorageSyncScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: StorageSyncViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var pendingRemoveGroupId by remember { mutableStateOf<String?>(null) }
|
||||
var pendingRemoveStorage by remember { mutableStateOf<Pair<String, UUID>?>(null) }
|
||||
val pickerGroupId = state.pickerGroupId
|
||||
if (pickerGroupId != null) {
|
||||
StoragePickerScreen(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
groupId = pickerGroupId,
|
||||
onBack = viewModel::closePicker,
|
||||
onAddStorage = viewModel::addStorageToCurrentGroup,
|
||||
onToggleVault = viewModel::toggleVaultExpanded,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val storageByUuid = state.vaults
|
||||
.flatMap { vault -> flattenStorageTree(vault.storages) }
|
||||
.associateBy { it.uuid }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = viewModel::createGroup,
|
||||
) {
|
||||
Text("+")
|
||||
}
|
||||
},
|
||||
) { inner ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(inner)
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = viewModel::runSyncNow, enabled = !state.isBusy) {
|
||||
Text(stringResource(id = R.string.sync_run_now))
|
||||
}
|
||||
}
|
||||
|
||||
state.message?.let {
|
||||
Text(text = it, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.sync_groups_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
items(state.groups, key = { it.id }) { group ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.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),
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { pendingRemoveGroupId = group.id },
|
||||
enabled = !state.isBusy,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Delete,
|
||||
contentDescription = stringResource(id = R.string.sync_remove_group),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (group.storageUuids.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.sync_group_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
} else {
|
||||
val hasMixedEncryption = hasEncryptionMismatch(group, state.vaults)
|
||||
if (hasMixedEncryption) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.sync_group_mixed_encryption_warning),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
group.storageUuids.forEach { storageUuid ->
|
||||
val storage = storageByUuid[storageUuid]
|
||||
val storageLabel = storage?.name ?: storageUuid.toString()
|
||||
val encryptionStatus = storage?.encryptionStatus ?: "Unknown"
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = "$storageLabel ($storageUuid) | $encryptionStatus",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(
|
||||
onClick = { pendingRemoveStorage = group.id to storageUuid },
|
||||
enabled = !state.isBusy,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Delete,
|
||||
contentDescription = stringResource(id = R.string.sync_remove_storage),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingRemoveGroupId != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingRemoveGroupId = null },
|
||||
title = { Text(text = stringResource(id = R.string.sync_remove_group_confirm_title)) },
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = R.string.sync_remove_group_confirm_message,
|
||||
pendingRemoveGroupId.orEmpty(),
|
||||
),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
val groupId = pendingRemoveGroupId
|
||||
pendingRemoveGroupId = null
|
||||
if (groupId != null) {
|
||||
viewModel.removeGroup(groupId)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.sync_confirm_delete))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Button(onClick = { pendingRemoveGroupId = null }) {
|
||||
Text(text = stringResource(id = R.string.sync_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (pendingRemoveStorage != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingRemoveStorage = null },
|
||||
title = { Text(text = stringResource(id = R.string.sync_remove_storage_confirm_title)) },
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = R.string.sync_remove_storage_confirm_message,
|
||||
pendingRemoveStorage?.second.toString(),
|
||||
),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
val payload = pendingRemoveStorage
|
||||
pendingRemoveStorage = null
|
||||
if (payload != null) {
|
||||
viewModel.removeStorageFromGroup(payload.first, payload.second)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.sync_confirm_delete))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Button(onClick = { pendingRemoveStorage = null }) {
|
||||
Text(text = stringResource(id = R.string.sync_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StoragePickerScreen(
|
||||
modifier: Modifier,
|
||||
state: StorageSyncScreenState,
|
||||
groupId: String,
|
||||
onBack: () -> Unit,
|
||||
onAddStorage: (UUID) -> Unit,
|
||||
onToggleVault: (UUID) -> Unit,
|
||||
) {
|
||||
val selected = state.groups.firstOrNull { it.id == groupId }?.storageUuids ?: emptySet()
|
||||
Scaffold(modifier = modifier) { inner ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(inner)
|
||||
.fillMaxSize()
|
||||
.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))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(id = R.string.sync_picker_title, groupId),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
items(state.vaults, key = { it.uuid }) { vault ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
val expanded = vault.uuid in state.expandedVaultUuids
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onToggleVault(vault.uuid) },
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
|
||||
if (expanded) {
|
||||
if (vault.storages.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.sync_picker_no_storages),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
} else {
|
||||
vault.storages.forEach { storage ->
|
||||
StoragePickerNode(
|
||||
node = storage,
|
||||
depth = 0,
|
||||
selected = selected,
|
||||
isBusy = state.isBusy,
|
||||
onAddStorage = onAddStorage,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StoragePickerNode(
|
||||
node: StorageSyncStorageUi,
|
||||
depth: Int,
|
||||
selected: Set<UUID>,
|
||||
isBusy: Boolean,
|
||||
onAddStorage: (UUID) -> Unit,
|
||||
) {
|
||||
val isSelected = node.uuid in selected
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = (depth * 14).dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = node.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = "${node.uuid} | ${node.encryptionStatus}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
node.children.forEach { child ->
|
||||
StoragePickerNode(
|
||||
node = child,
|
||||
depth = depth + 1,
|
||||
selected = selected,
|
||||
isBusy = isBusy,
|
||||
onAddStorage = onAddStorage,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun flattenStorageTree(nodes: List<StorageSyncStorageUi>): List<StorageSyncStorageUi> {
|
||||
return nodes.flatMap { node ->
|
||||
listOf(node) + flattenStorageTree(node.children)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasEncryptionMismatch(
|
||||
group: StorageSyncGroupUi,
|
||||
vaults: List<StorageSyncVaultUi>,
|
||||
): 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" }
|
||||
return hasEncrypted && hasPlain
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.github.nullptroma.wallenc.ui.screens.sync
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class StorageSyncStorageUi(
|
||||
val uuid: UUID,
|
||||
val name: String,
|
||||
val encryptionStatus: String,
|
||||
val children: List<StorageSyncStorageUi> = emptyList(),
|
||||
)
|
||||
|
||||
data class StorageSyncVaultUi(
|
||||
val uuid: UUID,
|
||||
val title: String,
|
||||
val type: String,
|
||||
val storages: List<StorageSyncStorageUi>,
|
||||
)
|
||||
|
||||
data class StorageSyncGroupUi(
|
||||
val id: String,
|
||||
val storageUuids: Set<UUID>,
|
||||
)
|
||||
|
||||
data class StorageSyncScreenState(
|
||||
val groups: List<StorageSyncGroupUi> = emptyList(),
|
||||
val vaults: List<StorageSyncVaultUi> = emptyList(),
|
||||
val expandedVaultUuids: Set<UUID> = emptySet(),
|
||||
val pickerGroupId: String? = null,
|
||||
val isBusy: Boolean = false,
|
||||
val message: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,272 @@
|
||||
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.IVaultsManager
|
||||
import com.github.nullptroma.wallenc.ui.ViewModelBase
|
||||
import com.github.nullptroma.wallenc.usecases.ManageStorageSyncGroupsUseCase
|
||||
import com.github.nullptroma.wallenc.usecases.RunStorageSyncUseCase
|
||||
import com.github.nullptroma.wallenc.vault.contract.DescribedVault
|
||||
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.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
@HiltViewModel
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class StorageSyncViewModel @javax.inject.Inject constructor(
|
||||
private val groupsUseCase: ManageStorageSyncGroupsUseCase,
|
||||
private val runStorageSyncUseCase: RunStorageSyncUseCase,
|
||||
private val vaultsManager: IVaultsManager,
|
||||
) : ViewModelBase<StorageSyncScreenState>(StorageSyncScreenState()) {
|
||||
|
||||
init {
|
||||
refreshGroups()
|
||||
observeVaults()
|
||||
}
|
||||
|
||||
fun refreshGroups() {
|
||||
viewModelScope.launch {
|
||||
val groups = groupsUseCase.getGroups()
|
||||
updateState(
|
||||
state.value.copy(
|
||||
groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createGroup() {
|
||||
viewModelScope.launch {
|
||||
updateState(state.value.copy(isBusy = true, message = null))
|
||||
val group = groupsUseCase.createGroup()
|
||||
val groups = groupsUseCase.getGroups()
|
||||
updateState(
|
||||
state.value.copy(
|
||||
groups = groups.map { StorageSyncGroupUi(it.id, it.storageUuids) },
|
||||
pickerGroupId = null,
|
||||
isBusy = false,
|
||||
message = "Group ${group.id} created",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeGroup(groupId: String) {
|
||||
viewModelScope.launch {
|
||||
updateState(state.value.copy(isBusy = true, message = 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",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun openPicker(groupId: String) {
|
||||
updateState(
|
||||
state.value.copy(
|
||||
pickerGroupId = groupId,
|
||||
message = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun closePicker() {
|
||||
updateState(
|
||||
state.value.copy(
|
||||
pickerGroupId = null,
|
||||
message = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun toggleVaultExpanded(vaultUuid: UUID) {
|
||||
val expanded = state.value.expandedVaultUuids.toMutableSet()
|
||||
if (!expanded.add(vaultUuid)) {
|
||||
expanded.remove(vaultUuid)
|
||||
}
|
||||
updateState(state.value.copy(expandedVaultUuids = expanded))
|
||||
}
|
||||
|
||||
fun addStorageToCurrentGroup(storageUuid: UUID) {
|
||||
val groupId = state.value.pickerGroupId ?: return
|
||||
viewModelScope.launch {
|
||||
updateState(state.value.copy(isBusy = true, message = 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",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeStorageFromGroup(groupId: String, storageUuid: UUID) {
|
||||
viewModelScope.launch {
|
||||
updateState(state.value.copy(isBusy = true, message = 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",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun runSyncNow() {
|
||||
runStorageSyncUseCase.enqueue("sync-tab")
|
||||
updateState(state.value.copy(message = "Sync task enqueued"))
|
||||
}
|
||||
|
||||
private fun observeVaults() {
|
||||
viewModelScope.launch {
|
||||
vaultsManager.vaults
|
||||
.flatMapLatest { vaults ->
|
||||
if (vaults.isEmpty()) {
|
||||
flowOf(emptyList())
|
||||
} else {
|
||||
combine(vaults.map { it.storages }) { rootStoragesByVault ->
|
||||
vaults.zip(rootStoragesByVault.toList())
|
||||
}.flatMapLatest { vaultWithRoots ->
|
||||
vaultsManager.unlockManager.openedStorages.flatMapLatest { opened ->
|
||||
val vaultNodes = vaultWithRoots.map { (vault, roots) ->
|
||||
vault to roots.map { root ->
|
||||
buildStorageTree(
|
||||
root = root,
|
||||
opened = opened,
|
||||
)
|
||||
}
|
||||
}
|
||||
val allStorages = vaultNodes
|
||||
.flatMap { (_, trees) -> trees.flatMap(::flattenStorages) }
|
||||
.distinctBy { it.uuid }
|
||||
|
||||
if (allStorages.isEmpty()) {
|
||||
flowOf(
|
||||
vaultNodes.map { (vault, trees) ->
|
||||
StorageSyncVaultUi(
|
||||
uuid = vault.uuid,
|
||||
title = vaultTitle(vault as? DescribedVault),
|
||||
type = vaultType(vault as? DescribedVault),
|
||||
storages = trees.map { tree ->
|
||||
toStorageUi(tree)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
combine(allStorages.map { it.metaInfo }) {
|
||||
val metaByStorageUuid = allStorages
|
||||
.mapIndexed { index, storage -> storage.uuid to it[index] }
|
||||
.toMap()
|
||||
|
||||
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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.collect { mapped ->
|
||||
updateState(state.value.copy(vaults = mapped))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
private fun vaultTitle(vault: DescribedVault?): String {
|
||||
val descriptor = vault?.descriptor
|
||||
return when (descriptor) {
|
||||
is VaultDescriptor.LocalDevice -> "Local vault"
|
||||
is VaultDescriptor.LinkedRemote -> descriptor.accountDisplayName
|
||||
null -> "Unknown vault"
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildStorageTree(
|
||||
root: IStorage,
|
||||
opened: Map<UUID, IStorage>,
|
||||
visited: MutableSet<UUID> = mutableSetOf(),
|
||||
): StorageTreeNode {
|
||||
if (!visited.add(root.uuid)) {
|
||||
return StorageTreeNode(storage = root, children = emptyList())
|
||||
}
|
||||
val child = opened[root.uuid]
|
||||
val children = if (child == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
listOf(
|
||||
buildStorageTree(
|
||||
root = child,
|
||||
opened = opened,
|
||||
visited = visited,
|
||||
),
|
||||
)
|
||||
}
|
||||
return StorageTreeNode(storage = root, children = children)
|
||||
}
|
||||
|
||||
private fun flattenStorages(node: StorageTreeNode): List<IStorage> {
|
||||
return listOf(node.storage) + node.children.flatMap(::flattenStorages)
|
||||
}
|
||||
|
||||
private fun toStorageUi(
|
||||
node: StorageTreeNode,
|
||||
metaByStorageUuid: Map<UUID, com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo> = 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"
|
||||
}
|
||||
return StorageSyncStorageUi(
|
||||
uuid = node.storage.uuid,
|
||||
name = meta.name ?: "<noname>",
|
||||
encryptionStatus = encryptionStatus,
|
||||
children = node.children.map { child ->
|
||||
toStorageUi(
|
||||
node = child,
|
||||
metaByStorageUuid = metaByStorageUuid,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private data class StorageTreeNode(
|
||||
val storage: IStorage,
|
||||
val children: List<StorageTreeNode>,
|
||||
)
|
||||
}
|
||||
@@ -3,9 +3,29 @@
|
||||
<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="settings_title">Settings Screen Title!</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"><noname></string>
|
||||
<string name="show_storage_item_menu">Show storage item menu</string>
|
||||
<string name="rename">Rename</string>
|
||||
|
||||
Reference in New Issue
Block a user