feat(storage): добавлены маршруты и экраны для управления текстовыми секретами и 2FA токенами

This commit is contained in:
2026-05-13 20:39:55 +03:00
parent c6df089668
commit 5777f8e459
36 changed files with 1894 additions and 9 deletions

View File

@@ -35,6 +35,16 @@ import com.github.nullptroma.wallenc.ui.navigation.rememberNavigationState
import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsScreen
import com.github.nullptroma.wallenc.ui.screens.main.screens.remotes.RemoteVaultsViewModel
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.StorageHomeRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.StorageHomeScreen
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets.TextSecretDetailsRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets.TextSecretDetailsScreen
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets.TextSecretEditRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets.TextSecretEditScreen
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets.TextSecretsRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets.TextSecretsScreen
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa.TwoFaTokensRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa.TwoFaTokensScreen
import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.LocalVaultRoute
import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.LocalVaultScreen
import com.github.nullptroma.wallenc.ui.screens.main.screens.vault.LocalVaultViewModel
@@ -141,8 +151,8 @@ fun MainScreen(
) {
LocalVaultScreen(
viewModel = localVaultViewModel,
openTextEdit = { text ->
navState.push(TextEditRoute(text))
onOpenStorageHome = { storageUuid ->
navState.push(StorageHomeRoute(storageUuid = storageUuid))
},
)
}
@@ -162,10 +172,95 @@ fun MainScreen(
exitTransition = { fadeOut(tween(200)) },
) { entry ->
val remoteVaultViewModel: RemoteVaultViewModel = hiltViewModel(entry)
val route: VaultBrowserRoute = entry.toRoute()
VaultBrowserScreen(
viewModel = remoteVaultViewModel,
openTextEdit = { text ->
navState.push(TextEditRoute(text))
onOpenStorageHome = { storageUuid ->
navState.push(
StorageHomeRoute(
vaultUuid = route.vaultUuid,
storageUuid = storageUuid,
),
)
},
)
}
composable<StorageHomeRoute>(
enterTransition = { fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) },
) {
StorageHomeScreen(
onOpenTwoFa = { storageUuid ->
navState.push(TwoFaTokensRoute(storageUuid))
},
onOpenTextSecrets = { storageUuid ->
navState.push(TextSecretsRoute(storageUuid))
},
)
}
composable<TwoFaTokensRoute>(
enterTransition = { fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) },
) {
TwoFaTokensScreen()
}
composable<TextSecretsRoute>(
enterTransition = { fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) },
) { entry ->
val route: TextSecretsRoute = entry.toRoute()
TextSecretsScreen(
onOpenSecret = { secret ->
navState.push(
TextSecretDetailsRoute(
storageUuid = route.storageUuid,
secretId = secret.id,
),
)
},
onCreateSecret = {
navState.push(
TextSecretEditRoute(
storageUuid = route.storageUuid,
secretId = null,
),
)
},
)
}
composable<TextSecretDetailsRoute>(
enterTransition = { fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) },
) { entry ->
val route: TextSecretDetailsRoute = entry.toRoute()
TextSecretDetailsScreen(
onEdit = { secretId ->
navState.push(
TextSecretEditRoute(
storageUuid = route.storageUuid,
secretId = secretId,
),
)
},
onDeleted = {
navState.navHostController.popBackStack()
},
)
}
composable<TextSecretEditRoute>(
enterTransition = { fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) },
) { entry ->
val route: TextSecretEditRoute = entry.toRoute()
TextSecretEditScreen(
onSaved = { savedSecretId ->
navState.navHostController.popBackStack()
navState.push(
TextSecretDetailsRoute(
storageUuid = route.storageUuid,
secretId = savedSecretId,
),
)
},
)
}

View File

@@ -0,0 +1,12 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class StorageHomeRoute(
val vaultUuid: String? = null,
val storageUuid: String,
) : ScreenRoute()

View File

@@ -0,0 +1,133 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
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.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
@Composable
fun StorageHomeScreen(
modifier: Modifier = Modifier,
viewModel: StorageHomeViewModel = hiltViewModel(),
onOpenTwoFa: (String) -> Unit,
onOpenTextSecrets: (String) -> Unit,
) {
val uiState by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (uiState.isLoading) {
CircularProgressIndicator()
return@Column
}
Text(
text = if (uiState.storageName.isBlank()) {
stringResource(R.string.storage_home_unnamed_storage)
} else {
uiState.storageName
},
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = uiState.storageUuid,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(
R.string.storage_home_status_line,
if (uiState.isAvailable) {
stringResource(R.string.storage_home_status_available)
} else {
stringResource(R.string.storage_home_status_unavailable)
},
if (uiState.isEncrypted) {
stringResource(R.string.storage_home_status_encrypted)
} else {
stringResource(R.string.storage_home_status_not_encrypted)
},
),
)
uiState.errorMessage?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
)
}
Card(colors = CardDefaults.cardColors()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResource(R.string.storage_home_two_fa_title, uiState.twoFaCount),
style = MaterialTheme.typography.titleMedium,
)
Button(
onClick = { onOpenTwoFa(uiState.storageUuid) },
enabled = uiState.isAvailable,
) {
Text(stringResource(R.string.storage_home_open_two_fa))
}
}
}
Card(colors = CardDefaults.cardColors()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResource(R.string.storage_home_text_secrets_title, uiState.textSecretsCount),
style = MaterialTheme.typography.titleMedium,
)
Button(
onClick = { onOpenTextSecrets(uiState.storageUuid) },
enabled = uiState.isAvailable,
) {
Text(stringResource(R.string.storage_home_open_text_secrets))
}
}
}
Text(
text = stringResource(R.string.storage_home_future_sections),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View File

@@ -0,0 +1,15 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage
import androidx.compose.runtime.Immutable
@Immutable
data class StorageHomeScreenState(
val storageUuid: String = "",
val storageName: String = "",
val isLoading: Boolean = true,
val isAvailable: Boolean = false,
val isEncrypted: Boolean = false,
val twoFaCount: Int = 0,
val textSecretsCount: Int = 0,
val errorMessage: String? = null,
)

View File

@@ -0,0 +1,58 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.usecases.FindStorageUseCase
import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase
import com.github.nullptroma.wallenc.usecases.ManageTwoFaTokensUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class StorageHomeViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val findStorageUseCase: FindStorageUseCase,
private val manageTwoFaTokensUseCase: ManageTwoFaTokensUseCase,
private val manageTextSecretsUseCase: ManageTextSecretsUseCase,
) : ViewModelBase<StorageHomeScreenState>(StorageHomeScreenState()) {
private val storageUuid = savedStateHandle.requireStorageUuid()
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid)
if (storage == null) {
updateState(
state.value.copy(
isLoading = false,
storageUuid = storageUuid.toString(),
errorMessage = "Storage not found",
),
)
return@launch
}
val twoFa = manageTwoFaTokensUseCase.list(storage)
val secrets = manageTextSecretsUseCase.list(storage)
updateState(
state.value.copy(
isLoading = false,
storageUuid = storage.uuid.toString(),
storageName = storage.metaInfo.value.name.orEmpty(),
isAvailable = storage.isAvailable.first(),
isEncrypted = storage.metaInfo.value.encInfo != null,
twoFaCount = twoFa.size,
textSecretsCount = secrets.size,
errorMessage = null,
),
)
}
}
}

View File

@@ -0,0 +1,11 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage
import androidx.lifecycle.SavedStateHandle
import java.util.UUID
internal fun SavedStateHandle.requireStorageUuid(): UUID {
val raw = get<String>("storageUuid") ?: error("Missing storage UUID in navigation arguments")
return UUID.fromString(raw)
}
internal fun SavedStateHandle.optionalSecretId(): String? = get<String>("secretId")

View File

@@ -0,0 +1,12 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class TextSecretDetailsRoute(
val storageUuid: String,
val secretId: String,
) : ScreenRoute()

View File

@@ -0,0 +1,85 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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
@Composable
fun TextSecretDetailsScreen(
modifier: Modifier = Modifier,
viewModel: TextSecretDetailsViewModel = hiltViewModel(),
onEdit: (String) -> Unit,
onDeleted: () -> Unit,
) {
val uiState by viewModel.state.collectAsStateWithLifecycle()
val secret = uiState.secret
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
uiState.errorMessage?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
if (secret == null) return@Column
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = secret.title,
style = MaterialTheme.typography.headlineSmall,
)
TextButton(onClick = { onEdit(secret.id) }, enabled = uiState.isAvailable) {
Text(stringResource(R.string.edit))
}
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(secret.items) { item ->
Column {
Text(
text = item.label ?: stringResource(R.string.text_secret_item_without_label),
style = MaterialTheme.typography.labelMedium,
)
Text(text = item.value)
}
}
}
Button(
onClick = { viewModel.delete(onDeleted) },
enabled = uiState.isAvailable,
) {
Text(stringResource(R.string.remove))
}
}
}
}

View File

@@ -0,0 +1,12 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.compose.runtime.Immutable
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
@Immutable
data class TextSecretDetailsScreenState(
val isLoading: Boolean = true,
val isAvailable: Boolean = false,
val secret: TextSecretRecord? = null,
val errorMessage: String? = null,
)

View File

@@ -0,0 +1,62 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStorageUuid
import com.github.nullptroma.wallenc.usecases.FindStorageUseCase
import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class TextSecretDetailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val findStorageUseCase: FindStorageUseCase,
private val manageTextSecretsUseCase: ManageTextSecretsUseCase,
) : ViewModelBase<TextSecretDetailsScreenState>(TextSecretDetailsScreenState()) {
private val storageUuid = savedStateHandle.requireStorageUuid()
private val secretId: String = savedStateHandle.get<String>("secretId")
?: error("Missing secret id")
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid)
if (storage == null) {
updateState(
state.value.copy(
isLoading = false,
errorMessage = "Storage not found",
),
)
return@launch
}
val secret = manageTextSecretsUseCase.get(storage, secretId)
updateState(
state.value.copy(
isLoading = false,
isAvailable = storage.isAvailable.first(),
secret = secret,
errorMessage = if (secret == null) "Secret not found" else null,
),
)
}
}
fun delete(onDeleted: () -> Unit) {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) ?: return@launch
val removed = manageTextSecretsUseCase.delete(storage, secretId)
if (removed) {
onDeleted()
}
}
}
}

View File

@@ -0,0 +1,12 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class TextSecretEditRoute(
val storageUuid: String,
val secretId: String? = null,
) : ScreenRoute()

View File

@@ -0,0 +1,143 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
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.domain.datatypes.TextSecretEntryRecord
import com.github.nullptroma.wallenc.ui.R
@Composable
fun TextSecretEditScreen(
modifier: Modifier = Modifier,
viewModel: TextSecretEditViewModel = hiltViewModel(),
onSaved: (String) -> Unit,
) {
val uiState by viewModel.state.collectAsStateWithLifecycle()
val currentOnSaved by rememberUpdatedState(onSaved)
var title by remember(uiState.initialSecret) {
mutableStateOf(uiState.initialSecret?.title.orEmpty())
}
val items = remember(uiState.initialSecret) {
mutableStateListOf<TextSecretEntryRecord>().apply {
addAll(
uiState.initialSecret?.items.orEmpty().ifEmpty {
listOf(TextSecretEntryRecord(label = null, value = ""))
},
)
}
}
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
if (uiState.initialSecret == null) {
stringResource(R.string.text_secret_create)
} else {
stringResource(R.string.text_secret_edit)
},
)
OutlinedTextField(
value = title,
onValueChange = { title = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.text_secret_title)) },
)
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
itemsIndexed(items) { index, item ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = item.label.orEmpty(),
onValueChange = { newLabel ->
items[index] = item.copy(label = newLabel.ifBlank { null })
},
modifier = Modifier.weight(0.45f),
label = { Text(stringResource(R.string.text_secret_item_label_optional)) },
)
OutlinedTextField(
value = item.value,
onValueChange = { newValue ->
items[index] = item.copy(value = newValue)
},
modifier = Modifier.weight(0.55f),
label = { Text(stringResource(R.string.text_secret_item_value)) },
)
IconButton(
onClick = {
if (items.size > 1) {
items.removeAt(index)
} else {
items[index] = TextSecretEntryRecord(label = null, value = "")
}
},
) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.remove))
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = { items.add(TextSecretEntryRecord(label = null, value = "")) }) {
Icon(Icons.Default.Add, contentDescription = null)
Text(stringResource(R.string.text_secret_add_item))
}
TextButton(
onClick = {
viewModel.save(
title = title,
items = items.toList(),
onSaved = currentOnSaved,
)
},
enabled = title.isNotBlank(),
) {
Text(stringResource(R.string.save))
}
}
}
}
}

View File

@@ -0,0 +1,12 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.compose.runtime.Immutable
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
@Immutable
data class TextSecretEditScreenState(
val isLoading: Boolean = true,
val isAvailable: Boolean = false,
val initialSecret: TextSecretRecord? = null,
val errorMessage: String? = null,
)

View File

@@ -0,0 +1,83 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.optionalSecretId
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStorageUuid
import com.github.nullptroma.wallenc.usecases.FindStorageUseCase
import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class TextSecretEditViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val findStorageUseCase: FindStorageUseCase,
private val manageTextSecretsUseCase: ManageTextSecretsUseCase,
) : ViewModelBase<TextSecretEditScreenState>(TextSecretEditScreenState()) {
private val storageUuid = savedStateHandle.requireStorageUuid()
private val secretId: String? = savedStateHandle.optionalSecretId()
init {
load()
}
private fun load() {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid)
if (storage == null) {
updateState(
state.value.copy(
isLoading = false,
errorMessage = "Storage not found",
),
)
return@launch
}
val initial = secretId?.let { manageTextSecretsUseCase.get(storage, it) }
updateState(
state.value.copy(
isLoading = false,
isAvailable = storage.isAvailable.first(),
initialSecret = initial,
errorMessage = null,
),
)
}
}
fun save(
title: String,
items: List<TextSecretEntryRecord>,
onSaved: (secretId: String) -> Unit,
) {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) ?: return@launch
val existingId = secretId
if (existingId == null) {
val created = manageTextSecretsUseCase.create(
storageInfo = storage,
title = title,
items = items,
)
onSaved(created.id)
} else {
manageTextSecretsUseCase.update(
storageInfo = storage,
secret = TextSecretRecord(
id = existingId,
title = title,
items = items,
),
)
onSaved(existingId)
}
}
}
}

View File

@@ -0,0 +1,11 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class TextSecretsRoute(
val storageUuid: String,
) : ScreenRoute()

View File

@@ -0,0 +1,99 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
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.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
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.domain.datatypes.TextSecretRecord
import com.github.nullptroma.wallenc.ui.R
@Composable
fun TextSecretsScreen(
modifier: Modifier = Modifier,
viewModel: TextSecretsViewModel = hiltViewModel(),
onOpenSecret: (TextSecretRecord) -> Unit,
onCreateSecret: () -> Unit,
) {
val uiState by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
floatingActionButton = {
FloatingActionButton(
onClick = {
if (uiState.isAvailable) {
onCreateSecret()
}
},
modifier = Modifier.alpha(if (uiState.isAvailable) 1f else 0.5f),
) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.text_secret_create))
}
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
uiState.errorMessage?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
}
if (uiState.items.isEmpty()) {
Text(stringResource(R.string.text_secret_empty_state))
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.items) { secret ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 12.dp),
) {
Text(text = secret.title)
Text(
text = stringResource(R.string.text_secret_items_count, secret.items.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
androidx.compose.material3.TextButton(
onClick = { onOpenSecret(secret) },
enabled = uiState.isAvailable,
) {
Text(stringResource(R.string.open))
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,12 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.compose.runtime.Immutable
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
@Immutable
data class TextSecretsScreenState(
val isLoading: Boolean = true,
val isAvailable: Boolean = false,
val items: List<TextSecretRecord> = emptyList(),
val errorMessage: String? = null,
)

View File

@@ -0,0 +1,50 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStorageUuid
import com.github.nullptroma.wallenc.usecases.FindStorageUseCase
import com.github.nullptroma.wallenc.usecases.ManageTextSecretsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class TextSecretsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val findStorageUseCase: FindStorageUseCase,
private val manageTextSecretsUseCase: ManageTextSecretsUseCase,
) : ViewModelBase<TextSecretsScreenState>(TextSecretsScreenState()) {
private val storageUuid = savedStateHandle.requireStorageUuid()
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid)
if (storage == null) {
updateState(
state.value.copy(
isLoading = false,
errorMessage = "Storage not found",
),
)
return@launch
}
val items = manageTextSecretsUseCase.list(storage)
updateState(
state.value.copy(
isLoading = false,
isAvailable = storage.isAvailable.first(),
items = items,
errorMessage = null,
),
)
}
}
}

View File

@@ -0,0 +1,11 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa
import com.github.nullptroma.wallenc.ui.screens.ScreenRoute
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class TwoFaTokensRoute(
val storageUuid: String,
) : ScreenRoute()

View File

@@ -0,0 +1,204 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.draw.alpha
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.domain.datatypes.TwoFaTokenRecord
import com.github.nullptroma.wallenc.ui.R
@Composable
fun TwoFaTokensScreen(
modifier: Modifier = Modifier,
viewModel: TwoFaTokensViewModel = hiltViewModel(),
) {
val uiState by viewModel.state.collectAsStateWithLifecycle()
var editingToken by remember { mutableStateOf<TwoFaTokenRecord?>(null) }
var creating by remember { mutableStateOf(false) }
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
floatingActionButton = {
FloatingActionButton(
onClick = {
if (uiState.isAvailable) {
creating = true
}
},
modifier = Modifier.alpha(if (uiState.isAvailable) 1f else 0.5f),
) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.two_fa_add_token))
}
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
) {
if (uiState.isLoading) {
CircularProgressIndicator()
return@Column
}
uiState.errorMessage?.let {
Text(it)
}
if (uiState.items.isEmpty()) {
Text(stringResource(R.string.two_fa_empty_state))
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.items) { item ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = item.issuer)
Text(text = item.account)
Text(text = item.secret)
}
IconButton(onClick = { editingToken = item }, enabled = uiState.isAvailable) {
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.edit))
}
IconButton(
onClick = { viewModel.deleteToken(item.id) },
enabled = uiState.isAvailable,
) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.remove))
}
}
}
}
}
}
}
if (creating) {
TwoFaTokenEditDialog(
startValue = null,
onDismiss = { creating = false },
onSave = { issuer, account, secret, notes ->
creating = false
viewModel.saveToken(
existingId = null,
issuer = issuer,
account = account,
secret = secret,
notes = notes,
)
},
)
}
editingToken?.let { token ->
TwoFaTokenEditDialog(
startValue = token,
onDismiss = { editingToken = null },
onSave = { issuer, account, secret, notes ->
editingToken = null
viewModel.saveToken(
existingId = token.id,
issuer = issuer,
account = account,
secret = secret,
notes = notes,
)
},
)
}
}
@Composable
private fun TwoFaTokenEditDialog(
startValue: TwoFaTokenRecord?,
onDismiss: () -> Unit,
onSave: (String, String, String, String?) -> Unit,
) {
var issuer by remember(startValue) { mutableStateOf(startValue?.issuer.orEmpty()) }
var account by remember(startValue) { mutableStateOf(startValue?.account.orEmpty()) }
var secret by remember(startValue) { mutableStateOf(startValue?.secret.orEmpty()) }
var notes by remember(startValue) { mutableStateOf(startValue?.notes.orEmpty()) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
if (startValue == null) {
stringResource(R.string.two_fa_create_title)
} else {
stringResource(R.string.two_fa_edit_title)
},
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = issuer,
onValueChange = { issuer = it },
label = { Text(stringResource(R.string.two_fa_field_issuer)) },
)
OutlinedTextField(
value = account,
onValueChange = { account = it },
label = { Text(stringResource(R.string.two_fa_field_account)) },
)
OutlinedTextField(
value = secret,
onValueChange = { secret = it },
label = { Text(stringResource(R.string.two_fa_field_secret)) },
)
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text(stringResource(R.string.two_fa_field_notes_optional)) },
)
}
},
confirmButton = {
TextButton(
enabled = issuer.isNotBlank() && account.isNotBlank() && secret.isNotBlank(),
onClick = { onSave(issuer, account, secret, notes.ifBlank { null }) },
) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
},
)
}

View File

@@ -0,0 +1,12 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa
import androidx.compose.runtime.Immutable
import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
@Immutable
data class TwoFaTokensScreenState(
val isLoading: Boolean = true,
val isAvailable: Boolean = false,
val items: List<TwoFaTokenRecord> = emptyList(),
val errorMessage: String? = null,
)

View File

@@ -0,0 +1,92 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
import com.github.nullptroma.wallenc.ui.ViewModelBase
import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStorageUuid
import com.github.nullptroma.wallenc.usecases.FindStorageUseCase
import com.github.nullptroma.wallenc.usecases.ManageTwoFaTokensUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class TwoFaTokensViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val findStorageUseCase: FindStorageUseCase,
private val manageTwoFaTokensUseCase: ManageTwoFaTokensUseCase,
) : ViewModelBase<TwoFaTokensScreenState>(TwoFaTokensScreenState()) {
private val storageUuid = savedStateHandle.requireStorageUuid()
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid)
if (storage == null) {
updateState(
state.value.copy(
isLoading = false,
errorMessage = "Storage not found",
),
)
return@launch
}
val tokens = manageTwoFaTokensUseCase.list(storage)
updateState(
state.value.copy(
isLoading = false,
isAvailable = storage.isAvailable.first(),
items = tokens,
errorMessage = null,
),
)
}
}
fun saveToken(
existingId: String?,
issuer: String,
account: String,
secret: String,
notes: String?,
) {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) ?: return@launch
if (existingId == null) {
manageTwoFaTokensUseCase.create(
storageInfo = storage,
issuer = issuer,
account = account,
secret = secret,
notes = notes,
)
} else {
manageTwoFaTokensUseCase.update(
storageInfo = storage,
token = TwoFaTokenRecord(
id = existingId,
issuer = issuer,
account = account,
secret = secret,
notes = notes,
),
)
}
refresh()
}
}
fun deleteToken(id: String) {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) ?: return@launch
manageTwoFaTokensUseCase.delete(storage, id)
refresh()
}
}
}

View File

@@ -8,11 +8,11 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
fun LocalVaultScreen(
modifier: Modifier = Modifier,
viewModel: LocalVaultViewModel = hiltViewModel(),
openTextEdit: (String) -> Unit,
onOpenStorageHome: (String) -> Unit,
) {
VaultBrowserScreen(
modifier = modifier,
viewModel = viewModel,
openTextEdit = openTextEdit,
onOpenStorageHome = onOpenStorageHome,
)
}

View File

@@ -43,7 +43,7 @@ import java.util.UUID
fun VaultBrowserScreen(
modifier: Modifier = Modifier,
viewModel: AbstractVaultBrowserViewModel,
openTextEdit: (String) -> Unit,
onOpenStorageHome: (String) -> Unit,
) {
val uiState by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
@@ -139,7 +139,7 @@ fun VaultBrowserScreen(
modifier = Modifier.padding(8.dp, 8.dp, 8.dp, 0.dp),
tree = listItem,
isUuidBusy = isUuidBusy,
onClick = { openTextEdit(it.value.uuid.toString()) },
onClick = { onOpenStorageHome(it.value.uuid.toString()) },
onRename = { tree, newName -> viewModel.rename(tree.value, newName) },
onRemove = { tree -> viewModel.remove(tree.value) },
onEncrypt = { tree, password, encryptPath ->

View File

@@ -4,6 +4,7 @@ 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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -97,6 +98,7 @@ fun StorageSyncScreen(
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = {
FloatingActionButton(
@@ -427,7 +429,10 @@ private fun StoragePickerScreen(
) {
val selected = state.groups.firstOrNull { it.id == groupId }?.storageUuids ?: emptySet()
val groupEditLocked = state.isBusy || state.isStorageSyncRunning
Scaffold(modifier = modifier) { inner ->
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0.dp),
) { inner ->
Column(
modifier = Modifier
.padding(inner)

View File

@@ -175,5 +175,41 @@
<string name="text_edit_screen_title">Текст</string>
<string name="text_edit_screen_placeholder">Содержимое: %1$s</string>
<string name="storage_home_unnamed_storage">Storage</string>
<string name="storage_home_status_line">Статус: %1$s, %2$s</string>
<string name="storage_home_status_available">доступно</string>
<string name="storage_home_status_unavailable">недоступно</string>
<string name="storage_home_status_encrypted">зашифровано</string>
<string name="storage_home_status_not_encrypted">не зашифровано</string>
<string name="storage_home_two_fa_title">2FA токены (%1$d)</string>
<string name="storage_home_open_two_fa">Открыть 2FA</string>
<string name="storage_home_text_secrets_title">Текстовые секреты (%1$d)</string>
<string name="storage_home_open_text_secrets">Открыть текстовые секреты</string>
<string name="storage_home_future_sections">Скоро здесь появятся Files, Media и другие типы данных.</string>
<string name="two_fa_add_token">Добавить токен</string>
<string name="two_fa_empty_state">Пока нет 2FA токенов</string>
<string name="two_fa_create_title">Новый 2FA токен</string>
<string name="two_fa_edit_title">Редактирование 2FA токена</string>
<string name="two_fa_field_issuer">Сервис</string>
<string name="two_fa_field_account">Аккаунт</string>
<string name="two_fa_field_secret">Секрет</string>
<string name="two_fa_field_notes_optional">Заметка (опционально)</string>
<string name="text_secret_create">Создать секрет</string>
<string name="text_secret_edit">Редактировать секрет</string>
<string name="text_secret_title">Название</string>
<string name="text_secret_empty_state">Пока нет текстовых секретов</string>
<string name="text_secret_items_count">Элементов: %1$d</string>
<string name="text_secret_item_without_label">Без названия</string>
<string name="text_secret_item_label_optional">Название (опционально)</string>
<string name="text_secret_item_value">Значение</string>
<string name="text_secret_add_item">Добавить пару</string>
<string name="save">Сохранить</string>
<string name="cancel">Отмена</string>
<string name="open">Открыть</string>
<string name="edit">Редактировать</string>
<string name="common_unknown">Неизвестно</string>
</resources>