feat(ui): add a dedicated Compose screen for editing passwords

This commit is contained in:
Harsh Shandilya 2023-07-05 14:20:22 +05:30
parent 4c28098cbb
commit fa03ca0ad7
No known key found for this signature in database
5 changed files with 156 additions and 63 deletions

View File

@ -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()
)

View File

@ -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 = {},
) )
} }

View File

@ -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>

View File

@ -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),
)
}
}

View 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>