Улучшение UI/UX

This commit is contained in:
2026-05-17 11:54:02 +03:00
parent 5777f8e459
commit 555448d998
17 changed files with 330 additions and 139 deletions

View File

@@ -254,13 +254,16 @@ fun MainScreen(
val route: TextSecretEditRoute = entry.toRoute()
TextSecretEditScreen(
onSaved = { savedSecretId ->
val editingExisting = route.secretId != null
navState.navHostController.popBackStack()
navState.push(
TextSecretDetailsRoute(
storageUuid = route.storageUuid,
secretId = savedSecretId,
),
)
if (!editingExisting) {
navState.push(
TextSecretDetailsRoute(
storageUuid = route.storageUuid,
secretId = savedSecretId,
),
)
}
},
)
}

View File

@@ -2,20 +2,26 @@ 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.Row
import androidx.compose.foundation.layout.size
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.material.icons.Icons
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Notes
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
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.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -83,45 +89,21 @@ fun StorageHomeScreen(
)
}
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))
}
}
}
StorageSectionCard(
title = stringResource(R.string.storage_home_two_fa_title, uiState.twoFaCount),
description = stringResource(R.string.storage_home_two_fa_subtitle),
icon = Icons.Outlined.Lock,
enabled = uiState.isAvailable,
onClick = { onOpenTwoFa(uiState.storageUuid) },
)
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))
}
}
}
StorageSectionCard(
title = stringResource(R.string.storage_home_text_secrets_title, uiState.textSecretsCount),
description = stringResource(R.string.storage_home_text_secrets_subtitle),
icon = Icons.Outlined.Notes,
enabled = uiState.isAvailable,
onClick = { onOpenTextSecrets(uiState.storageUuid) },
)
Text(
text = stringResource(R.string.storage_home_future_sections),
@@ -131,3 +113,48 @@ fun StorageHomeScreen(
}
}
}
@Composable
private fun StorageSectionCard(
title: String,
description: String,
icon: ImageVector,
enabled: Boolean,
onClick: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
onClick = onClick,
enabled = enabled,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}

View File

@@ -7,7 +7,7 @@ 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.flow.combine
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -22,10 +22,10 @@ class StorageHomeViewModel @Inject constructor(
private val storageUuid = savedStateHandle.requireStorageUuid()
init {
refresh()
observeStorageState()
}
fun refresh() {
private fun observeStorageState() {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid)
if (storage == null) {
@@ -38,21 +38,25 @@ class StorageHomeViewModel @Inject constructor(
)
return@launch
}
val twoFa = manageTwoFaTokensUseCase.list(storage)
val secrets = manageTextSecretsUseCase.list(storage)
updateState(
combine(
storage.isAvailable,
storage.metaInfo,
manageTwoFaTokensUseCase.observe(storage),
manageTextSecretsUseCase.observe(storage),
) { available, meta, twoFa, secrets ->
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,
storageName = meta.name.orEmpty(),
isAvailable = available,
isEncrypted = meta.encInfo != null,
twoFaCount = twoFa.size,
textSecretsCount = secrets.size,
errorMessage = null,
),
)
)
}.collect { ui ->
updateState(ui)
}
}
}
}

View File

@@ -10,6 +10,8 @@ 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.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -64,12 +66,28 @@ fun TextSecretDetailsScreen(
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)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = item.label ?: stringResource(R.string.text_secret_item_without_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = item.value,
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
}

View File

@@ -7,7 +7,8 @@ import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStor
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.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -23,10 +24,10 @@ class TextSecretDetailsViewModel @Inject constructor(
?: error("Missing secret id")
init {
refresh()
observeSecret()
}
fun refresh() {
private fun observeSecret() {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid)
if (storage == null) {
@@ -38,15 +39,21 @@ class TextSecretDetailsViewModel @Inject constructor(
)
return@launch
}
val secret = manageTextSecretsUseCase.get(storage, secretId)
updateState(
combine(
storage.isAvailable,
manageTextSecretsUseCase.observe(storage).map { list ->
list.firstOrNull { it.id == secretId }
},
) { available, secret ->
state.value.copy(
isLoading = false,
isAvailable = storage.isAvailable.first(),
isAvailable = available,
secret = secret,
errorMessage = if (secret == null) "Secret not found" else null,
),
)
)
}.collect { ui ->
updateState(ui)
}
}
}

View File

@@ -7,10 +7,15 @@ 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.size
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.KeyboardArrowRight
import androidx.compose.material.icons.outlined.Notes
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -68,28 +73,51 @@ fun TextSecretsScreen(
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.items) { secret ->
Row(
Card(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
onClick = { onOpenSecret(secret) },
enabled = uiState.isAvailable,
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
) {
Column(
Row(
modifier = Modifier
.weight(1f)
.padding(end = 12.dp),
.fillMaxWidth()
.padding(14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(text = secret.title)
Text(
text = stringResource(R.string.text_secret_items_count, secret.items.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Icons.Outlined.Notes,
contentDescription = null,
modifier = Modifier
.size(22.dp)
.padding(top = 2.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column(
modifier = Modifier.padding(end = 12.dp),
) {
Text(
text = secret.title,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringResource(R.string.text_secret_items_count, secret.items.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Icon(
imageVector = Icons.Default.KeyboardArrowRight,
contentDescription = null,
)
}
androidx.compose.material3.TextButton(
onClick = { onOpenSecret(secret) },
enabled = uiState.isAvailable,
) {
Text(stringResource(R.string.open))
}
}
}
}

View File

@@ -7,7 +7,7 @@ import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStor
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.flow.combine
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -21,10 +21,10 @@ class TextSecretsViewModel @Inject constructor(
private val storageUuid = savedStateHandle.requireStorageUuid()
init {
refresh()
observeSecrets()
}
fun refresh() {
private fun observeSecrets() {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid)
if (storage == null) {
@@ -36,15 +36,19 @@ class TextSecretsViewModel @Inject constructor(
)
return@launch
}
val items = manageTextSecretsUseCase.list(storage)
updateState(
combine(
storage.isAvailable,
manageTextSecretsUseCase.observe(storage),
) { available, items ->
state.value.copy(
isLoading = false,
isAvailable = storage.isAvailable.first(),
isAvailable = available,
items = items,
errorMessage = null,
),
)
)
}.collect { ui ->
updateState(ui)
}
}
}
}

View File

@@ -3,21 +3,27 @@ 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.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
import androidx.compose.foundation.layout.size
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.material.icons.outlined.Lock
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -66,6 +72,7 @@ fun TwoFaTokensScreen(
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (uiState.isLoading) {
CircularProgressIndicator()
@@ -81,23 +88,62 @@ fun TwoFaTokensScreen(
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.items) { item ->
Row(
Card(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
) {
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,
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.remove))
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Icons.Outlined.Lock,
contentDescription = null,
modifier = Modifier
.size(22.dp)
.padding(top = 2.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column {
Text(
text = item.issuer,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = item.account,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.size(4.dp))
Text(
text = maskedSecret(item.secret),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Row {
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))
}
}
}
}
}
@@ -202,3 +248,8 @@ private fun TwoFaTokenEditDialog(
},
)
}
private fun maskedSecret(secret: String): String {
if (secret.isBlank()) return ""
return "••••••••••••"
}

View File

@@ -8,7 +8,7 @@ import com.github.nullptroma.wallenc.ui.screens.main.screens.storage.requireStor
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.flow.combine
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -22,10 +22,10 @@ class TwoFaTokensViewModel @Inject constructor(
private val storageUuid = savedStateHandle.requireStorageUuid()
init {
refresh()
observeStorageTokens()
}
fun refresh() {
private fun observeStorageTokens() {
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid)
if (storage == null) {
@@ -37,15 +37,19 @@ class TwoFaTokensViewModel @Inject constructor(
)
return@launch
}
val tokens = manageTwoFaTokensUseCase.list(storage)
updateState(
combine(
storage.isAvailable,
manageTwoFaTokensUseCase.observe(storage),
) { available, items ->
state.value.copy(
isLoading = false,
isAvailable = storage.isAvailable.first(),
items = tokens,
isAvailable = available,
items = items,
errorMessage = null,
),
)
)
}.collect { ui ->
updateState(ui)
}
}
}
@@ -78,7 +82,6 @@ class TwoFaTokensViewModel @Inject constructor(
),
)
}
refresh()
}
}
@@ -86,7 +89,6 @@ class TwoFaTokensViewModel @Inject constructor(
viewModelScope.launch {
val storage = findStorageUseCase.find(storageUuid) ?: return@launch
manageTwoFaTokensUseCase.delete(storage, id)
refresh()
}
}
}

View File

@@ -183,8 +183,10 @@
<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_two_fa_subtitle">Коды и секреты двухфакторной аутентификации</string>
<string name="storage_home_text_secrets_title">Текстовые секреты (%1$d)</string>
<string name="storage_home_open_text_secrets">Открыть текстовые секреты</string>
<string name="storage_home_text_secrets_subtitle">Заметки, токены и произвольные пары ключ-значение</string>
<string name="storage_home_future_sections">Скоро здесь появятся Files, Media и другие типы данных.</string>
<string name="two_fa_add_token">Добавить токен</string>