mirror of
https://github.com/android-password-store/Android-Password-Store
synced 2025-08-29 13:27:46 +00:00
feat(ui): add a dedicated Compose screen for editing passwords
This commit is contained in:
parent
4c28098cbb
commit
fa03ca0ad7
@ -0,0 +1,100 @@
|
|||||||
|
package app.passwordstore.ui.crypto
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.passwordstore.R
|
||||||
|
import app.passwordstore.data.passfile.PasswordEntry
|
||||||
|
import app.passwordstore.ui.APSAppBar
|
||||||
|
import app.passwordstore.ui.compose.PasswordField
|
||||||
|
import app.passwordstore.ui.compose.theme.APSThemePreview
|
||||||
|
import app.passwordstore.util.time.UserClock
|
||||||
|
import app.passwordstore.util.totp.UriTotpFinder
|
||||||
|
|
||||||
|
/** Composable to show allow editing an existing [PasswordEntry]. */
|
||||||
|
@Composable
|
||||||
|
fun EditPasswordScreen(
|
||||||
|
entryName: String,
|
||||||
|
entry: PasswordEntry,
|
||||||
|
onNavigateUp: () -> Unit,
|
||||||
|
@Suppress("UNUSED_PARAMETER") onSave: (PasswordEntry) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
APSAppBar(
|
||||||
|
title = entryName,
|
||||||
|
navigationIcon = painterResource(R.drawable.ic_arrow_back_black_24dp),
|
||||||
|
onNavigationIconClick = onNavigateUp,
|
||||||
|
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(modifier = modifier.padding(paddingValues)) {
|
||||||
|
Column(modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp).fillMaxSize()) {
|
||||||
|
if (entry.password != null) {
|
||||||
|
PasswordField(
|
||||||
|
value = entry.password!!,
|
||||||
|
label = stringResource(R.string.password),
|
||||||
|
initialVisibility = false,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ExtraContent(entry = entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ExtraContent(
|
||||||
|
entry: PasswordEntry,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = entry.extraContentString,
|
||||||
|
onValueChange = {},
|
||||||
|
label = { Text("Extra content") },
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun EditPasswordScreenPreview() {
|
||||||
|
APSThemePreview {
|
||||||
|
EditPasswordScreen(
|
||||||
|
entryName = "Test Entry",
|
||||||
|
entry = createTestEntry(),
|
||||||
|
onNavigateUp = {},
|
||||||
|
onSave = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createTestEntry() =
|
||||||
|
PasswordEntry(
|
||||||
|
UserClock(),
|
||||||
|
UriTotpFinder(),
|
||||||
|
"""
|
||||||
|
|My Password
|
||||||
|
|otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30
|
||||||
|
|login: msfjarvis
|
||||||
|
|URL: example.com
|
||||||
|
"""
|
||||||
|
.trimMargin()
|
||||||
|
.encodeToByteArray()
|
||||||
|
)
|
@ -2,13 +2,9 @@ package app.passwordstore.ui.crypto
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@ -17,10 +13,8 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.capitalize
|
import androidx.compose.ui.text.capitalize
|
||||||
import androidx.compose.ui.text.intl.Locale
|
import androidx.compose.ui.text.intl.Locale
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
@ -28,6 +22,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import app.passwordstore.R
|
import app.passwordstore.R
|
||||||
import app.passwordstore.data.passfile.PasswordEntry
|
import app.passwordstore.data.passfile.PasswordEntry
|
||||||
import app.passwordstore.ui.APSAppBar
|
import app.passwordstore.ui.APSAppBar
|
||||||
|
import app.passwordstore.ui.compose.CopyButton
|
||||||
import app.passwordstore.ui.compose.PasswordField
|
import app.passwordstore.ui.compose.PasswordField
|
||||||
import app.passwordstore.ui.compose.theme.APSThemePreview
|
import app.passwordstore.ui.compose.theme.APSThemePreview
|
||||||
import app.passwordstore.util.time.UserClock
|
import app.passwordstore.util.time.UserClock
|
||||||
@ -35,22 +30,11 @@ import app.passwordstore.util.totp.UriTotpFinder
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
/**
|
/** Composable to show a decrypted [PasswordEntry]. */
|
||||||
* Composable to show a [PasswordEntry]. It can be used for both read-only usage (decrypt screen) or
|
|
||||||
* read-write (encrypt screen) to allow sharing UI logic for both these screens and deferring all
|
|
||||||
* the cryptographic aspects to its parent.
|
|
||||||
*
|
|
||||||
* When [readOnly] is `true`, the Composable assumes that we're showcasing the provided [entry] to
|
|
||||||
* the user and does not offer any edit capabilities.
|
|
||||||
*
|
|
||||||
* When [readOnly] is `false`, the [TextField]s are rendered editable but currently do not pass up
|
|
||||||
* their "updated" state to anything. This will be changed in later commits.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PasswordEntryScreen(
|
fun ViewPasswordScreen(
|
||||||
entryName: String,
|
entryName: String,
|
||||||
entry: PasswordEntry,
|
entry: PasswordEntry,
|
||||||
readOnly: Boolean,
|
|
||||||
onNavigateUp: () -> Unit,
|
onNavigateUp: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@ -71,32 +55,32 @@ fun PasswordEntryScreen(
|
|||||||
value = entry.password!!,
|
value = entry.password!!,
|
||||||
label = stringResource(R.string.password),
|
label = stringResource(R.string.password),
|
||||||
initialVisibility = false,
|
initialVisibility = false,
|
||||||
readOnly = readOnly,
|
readOnly = true,
|
||||||
modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(),
|
modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (entry.hasTotp() && readOnly) {
|
if (entry.hasTotp()) {
|
||||||
val totp by entry.totp.collectAsState(runBlocking { entry.totp.first() })
|
val totp by entry.totp.collectAsState(runBlocking { entry.totp.first() })
|
||||||
TextField(
|
TextField(
|
||||||
value = totp.value,
|
value = totp.value,
|
||||||
onValueChange = {},
|
onValueChange = {},
|
||||||
readOnly = true,
|
readOnly = true,
|
||||||
label = { Text("OTP (expires in ${totp.remainingTime.inWholeSeconds}s)") },
|
label = { Text("OTP (expires in ${totp.remainingTime.inWholeSeconds}s)") },
|
||||||
trailingIcon = { CopyButton({ totp.value }) },
|
trailingIcon = { CopyButton(totp.value, R.string.copy_label) },
|
||||||
modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(),
|
modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (entry.username != null && readOnly) {
|
if (entry.username != null) {
|
||||||
TextField(
|
TextField(
|
||||||
value = entry.username!!,
|
value = entry.username!!,
|
||||||
onValueChange = {},
|
onValueChange = {},
|
||||||
readOnly = true,
|
readOnly = true,
|
||||||
label = { Text(stringResource(R.string.username)) },
|
label = { Text(stringResource(R.string.username)) },
|
||||||
trailingIcon = { CopyButton({ entry.username!! }) },
|
trailingIcon = { CopyButton(entry.username!!, R.string.copy_label) },
|
||||||
modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(),
|
modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ExtraContent(entry = entry, readOnly = readOnly)
|
ExtraContent(entry = entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -105,56 +89,27 @@ fun PasswordEntryScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ExtraContent(
|
private fun ExtraContent(
|
||||||
entry: PasswordEntry,
|
entry: PasswordEntry,
|
||||||
readOnly: Boolean,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (readOnly) {
|
|
||||||
entry.extraContent.forEach { (label, value) ->
|
entry.extraContent.forEach { (label, value) ->
|
||||||
TextField(
|
TextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = {},
|
onValueChange = {},
|
||||||
readOnly = true,
|
readOnly = true,
|
||||||
label = { Text(label.capitalize(Locale.current)) },
|
label = { Text(label.capitalize(Locale.current)) },
|
||||||
trailingIcon = { CopyButton({ value }) },
|
trailingIcon = { CopyButton(value, R.string.copy_label) },
|
||||||
modifier = modifier.padding(bottom = 8.dp).fillMaxWidth(),
|
modifier = modifier.padding(bottom = 8.dp).fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
TextField(
|
|
||||||
value = entry.extraContentString,
|
|
||||||
onValueChange = {},
|
|
||||||
readOnly = false,
|
|
||||||
label = { Text("Extra content") },
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CopyButton(
|
|
||||||
textToCopy: () -> String,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val clipboard = LocalClipboardManager.current
|
|
||||||
IconButton(
|
|
||||||
onClick = { clipboard.setText(AnnotatedString(textToCopy())) },
|
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.ic_content_copy),
|
|
||||||
contentDescription = stringResource(R.string.copy_password),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun PasswordEntryPreview() {
|
private fun ViewPasswordScreenPreview() {
|
||||||
APSThemePreview {
|
APSThemePreview {
|
||||||
PasswordEntryScreen(
|
ViewPasswordScreen(
|
||||||
entryName = "Test Entry",
|
entryName = "Test Entry",
|
||||||
entry = createTestEntry(),
|
entry = createTestEntry(),
|
||||||
readOnly = true,
|
|
||||||
onNavigateUp = {},
|
onNavigateUp = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -88,6 +88,7 @@
|
|||||||
<string name="action_search">Search</string>
|
<string name="action_search">Search</string>
|
||||||
<string name="password">Password</string>
|
<string name="password">Password</string>
|
||||||
<string name="username">Username</string>
|
<string name="username">Username</string>
|
||||||
|
<string name="copy_label">Copy</string>
|
||||||
<string name="edit_password">Edit password</string>
|
<string name="edit_password">Edit password</string>
|
||||||
<string name="copy_password">Copy password</string>
|
<string name="copy_password">Copy password</string>
|
||||||
<string name="share_as_plaintext">Share as plaintext</string>
|
<string name="share_as_plaintext">Share as plaintext</string>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package app.passwordstore.ui.compose
|
package app.passwordstore.ui.compose
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@ -10,7 +11,10 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
|
||||||
@ -19,8 +23,8 @@ public fun PasswordField(
|
|||||||
value: String,
|
value: String,
|
||||||
label: String,
|
label: String,
|
||||||
initialVisibility: Boolean,
|
initialVisibility: Boolean,
|
||||||
readOnly: Boolean,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
readOnly: Boolean = false,
|
||||||
) {
|
) {
|
||||||
var visible by remember { mutableStateOf(initialVisibility) }
|
var visible by remember { mutableStateOf(initialVisibility) }
|
||||||
TextField(
|
TextField(
|
||||||
@ -58,3 +62,21 @@ private fun ToggleButton(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
public fun CopyButton(
|
||||||
|
textToCopy: String,
|
||||||
|
@StringRes buttonLabelRes: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
IconButton(
|
||||||
|
onClick = { clipboard.setText(AnnotatedString(textToCopy)) },
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_content_copy),
|
||||||
|
contentDescription = stringResource(buttonLabelRes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
15
ui-compose/src/main/res/drawable/ic_content_copy.xml
Normal file
15
ui-compose/src/main/res/drawable/ic_content_copy.xml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<!--
|
||||||
|
~ Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||||
|
~ SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM15,5L8,5c-1.1,0 -1.99,0.9 -1.99,2L6,21c0,1.1 0.89,2 1.99,2L19,23c1.1,0 2,-0.9 2,-2L21,11l-6,-6zM8,21L8,7h6v5h5v9L8,21z" />
|
||||||
|
</vector>
|
Loading…
x
Reference in New Issue
Block a user