feat(storage): добавлены маршруты и экраны для управления текстовыми секретами и 2FA токенами
This commit is contained in:
@@ -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,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user