diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index aeea32e40..c9855c7b6 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -4,7 +4,6 @@
Add Element
Search
Open website
- Copy to clipboard
Add new Element
diff --git a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/clipboard/Clipboard.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/clipboard/Clipboard.kt
new file mode 100644
index 000000000..fbd895a6a
--- /dev/null
+++ b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/clipboard/Clipboard.kt
@@ -0,0 +1,19 @@
+package de.davis.keygo.core.ui.clipboard
+
+import android.content.ClipData
+import android.content.ClipDescription
+import android.os.Build
+import android.os.PersistableBundle
+import androidx.compose.ui.platform.Clipboard
+import androidx.compose.ui.platform.toClipEntry
+
+suspend fun Clipboard.setText(label: String, text: String, sensitive: Boolean = false) {
+ val clipData = ClipData.newPlainText(label, text).apply {
+ if (sensitive && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
+ description.extras = PersistableBundle().apply {
+ putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
+ }
+ }
+
+ setClipEntry(clipData.toClipEntry())
+}
diff --git a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoCard.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoCard.kt
index 8e2b192eb..661ab5421 100644
--- a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoCard.kt
+++ b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoCard.kt
@@ -21,7 +21,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.semantics.onClick as onClickAction
@Immutable
data class KeyGoCardProperties(
@@ -68,40 +70,92 @@ fun KeyGoCard(
elevation = elevation,
border = border,
) {
- Row(
- modifier = Modifier.padding(16.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- leadingItem?.let {
- Box(modifier = Modifier.minimumInteractiveComponentSize()) {
- leadingItem()
- }
- }
+ KeyGoCardContent(
+ title = title,
+ leadingItem = leadingItem,
+ trailingItem = trailingItem,
+ content = content
+ )
+ }
+ }
+}
- Column(
- modifier = Modifier.weight(1f),
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- CompositionLocalProvider(
- LocalTextStyle provides MaterialTheme.typography.bodySmall
- ) {
- title()
- }
+@Composable
+fun KeyGoCard(
+ onClick: () -> Unit,
+ title: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ onClickLabel: String? = null,
+ properties: KeyGoCardProperties = KeyGoCardProperties.outlined(),
+ leadingItem: @Composable (() -> Unit)? = null,
+ trailingItem: @Composable (() -> Unit)? = null,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ // Semantics apply innermost first, so Card's own clickable has already written its click
+ // action with a null label by the time this runs. Setting an accessibility action merges field
+ // by field, so a null action here keeps that click, which is what carries the enabled state.
+ val labelled =
+ if (onClickLabel == null) modifier
+ else modifier.semantics { onClickAction(label = onClickLabel, action = null) }
- CompositionLocalProvider(
- LocalTextStyle provides MaterialTheme.typography.bodyLarge
- ) {
- content()
- }
- }
+ with(properties) {
+ Card(
+ onClick = onClick,
+ modifier = labelled,
+ shape = shape,
+ colors = colors,
+ elevation = elevation,
+ border = border,
+ ) {
+ KeyGoCardContent(
+ title = title,
+ leadingItem = leadingItem,
+ trailingItem = trailingItem,
+ content = content
+ )
+ }
+ }
+}
- trailingItem?.let {
- Box(modifier = Modifier.minimumInteractiveComponentSize()) {
- trailingItem()
- }
- }
+@Composable
+private fun KeyGoCardContent(
+ title: @Composable () -> Unit,
+ leadingItem: @Composable (() -> Unit)?,
+ trailingItem: @Composable (() -> Unit)?,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ leadingItem?.let {
+ Box(modifier = Modifier.minimumInteractiveComponentSize()) {
+ leadingItem()
+ }
+ }
+
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ CompositionLocalProvider(
+ LocalTextStyle provides MaterialTheme.typography.bodySmall
+ ) {
+ title()
+ }
+
+ CompositionLocalProvider(
+ LocalTextStyle provides MaterialTheme.typography.bodyLarge
+ ) {
+ content()
+ }
+ }
+
+ trailingItem?.let {
+ Box(modifier = Modifier.minimumInteractiveComponentSize()) {
+ trailingItem()
}
}
}
-}
\ No newline at end of file
+}
diff --git a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/theme/Typography.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/theme/Typography.kt
index ac78cb882..92d8f9546 100644
--- a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/theme/Typography.kt
+++ b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/theme/Typography.kt
@@ -1,5 +1,28 @@
package de.davis.keygo.core.ui.theme
+import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Typography
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
-val KeyGoTypography = Typography()
\ No newline at end of file
+val KeyGoTypography = Typography()
+
+private val Secret = TextStyle(
+ fontFamily = FontFamily.Monospace,
+ fontFeatureSettings = "tnum",
+)
+
+/**
+ * The ambient text style with secret typography applied: monospace so lookalike characters stay
+ * apart, tabular figures so digits keep their column while a value scrolls.
+ *
+ * Use it for anything the user reads character by character, such as passwords, card numbers, CVVs
+ * and TOTP codes.
+ *
+ * [FontFamily.Monospace] resolves to whatever font the device ships under that alias. That is
+ * usually Roboto Mono, but it varies by OEM and none of them guarantee a slashed zero. Bundling a
+ * font to make that deterministic is a change to [Secret] alone.
+ */
+val secretTextStyle: TextStyle
+ @Composable get() = LocalTextStyle.current.merge(Secret)
diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml
index cd5acd76b..84cb42e63 100644
--- a/core/ui/src/main/res/values/strings.xml
+++ b/core/ui/src/main/res/values/strings.xml
@@ -6,7 +6,6 @@
No matches found
- Copy to clipboard
Show password
Hide password
diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt
index 8304b6992..5aa85be33 100644
--- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt
+++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillActivity.kt
@@ -1,12 +1,8 @@
package de.davis.keygo.feature.autofill.presentation.activity
-import android.content.ClipData
-import android.content.ClipDescription
import android.content.Context
import android.content.Intent
-import android.os.Build
import android.os.Bundle
-import android.os.PersistableBundle
import android.service.autofill.Dataset
import android.view.autofill.AutofillManager
import androidx.activity.compose.setContent
@@ -14,7 +10,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalClipboard
-import androidx.compose.ui.platform.toClipEntry
+import androidx.compose.ui.res.stringResource
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.rememberNavController
@@ -22,6 +18,7 @@ import de.davis.keygo.core.identity.presentation.rememberBiometricUnlockAdapter
import de.davis.keygo.core.identity.presentation.useAdapter
import de.davis.keygo.core.security.domain.model.BiometricPolicy
import de.davis.keygo.core.security.presentation.rememberBiometricCryptoController
+import de.davis.keygo.core.ui.clipboard.setText
import de.davis.keygo.core.ui.theme.KeyGoTheme
import de.davis.keygo.core.util.onFailure
import de.davis.keygo.core.util.onSuccess
@@ -37,6 +34,7 @@ import de.davis.keygo.feature.autofill.presentation.model.Request
import de.davis.keygo.feature.autofill.presentation.model.RequestData
import de.davis.keygo.feature.item.create.presentation.password.GeneratePasswordModalBottomSheet
import org.koin.androidx.compose.koinViewModel
+import de.davis.keygo.core.item.R as CoreItemR
/**
@@ -67,20 +65,18 @@ internal class AutofillActivity : FragmentActivity() {
val biometricUnlockAdapter = rememberBiometricUnlockAdapter()
val clipboard = LocalClipboard.current
+ val passwordLabel = stringResource(CoreItemR.string.password)
ObserveAsEvents(viewModel.events) { event ->
when (event) {
AutofillEvent.Abort -> cancel()
is AutofillEvent.Fill -> {
event.copyToClipboard?.let {
- val clipData = ClipData.newPlainText(it, it).apply {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
- description.extras = PersistableBundle().apply {
- putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
- }
- }
-
- clipboard.setClipEntry(clipData.toClipEntry())
+ clipboard.setText(
+ label = passwordLabel,
+ text = it,
+ sensitive = true,
+ )
}
finishWithResult(event.dataset)
diff --git a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanEntry.kt b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanEntry.kt
index 3c5fd4997..03b338e75 100644
--- a/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanEntry.kt
+++ b/feature/credit-card/src/main/kotlin/de/davis/keygo/feature/credit_card/presentation/CardScanEntry.kt
@@ -1,12 +1,10 @@
package de.davis.keygo.feature.credit_card.presentation
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Contactless
-import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -17,7 +15,6 @@ 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.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -51,10 +48,8 @@ private fun ScanCardPrompt(
modifier: Modifier = Modifier,
) {
KeyGoCard(
- modifier = modifier
- .fillMaxWidth()
- .clip(CardDefaults.elevatedShape)
- .clickable(onClick = onClick),
+ onClick = onClick,
+ modifier = modifier.fillMaxWidth(),
properties = KeyGoCardProperties.elevated(),
leadingItem = {
Icon(
diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/LazyListEntry.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/LazyListEntry.kt
new file mode 100644
index 000000000..cd56d7e31
--- /dev/null
+++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/LazyListEntry.kt
@@ -0,0 +1,116 @@
+package de.davis.keygo.feature.item.core.presentation
+
+import android.os.Build
+import android.widget.Toast
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import de.davis.keygo.core.ui.clipboard.setText
+import de.davis.keygo.core.ui.components.KeyGoCard
+import de.davis.keygo.feature.item.core.R
+import kotlinx.coroutines.launch
+
+fun LazyListScope.entry(
+ title: String,
+ leadingIcon: ImageVector,
+ modifier: Modifier = Modifier,
+ trailingContent: @Composable (() -> Unit)? = null,
+ content: @Composable () -> Unit,
+) {
+ item(key = title) {
+ EntryCard(
+ title = title,
+ leadingIcon = leadingIcon,
+ modifier = modifier.animateItem(),
+ trailingContent = trailingContent,
+ onClick = null,
+ onClickLabel = null,
+ content = content,
+ )
+ }
+}
+
+fun LazyListScope.copyableEntry(
+ title: String,
+ leadingIcon: ImageVector,
+ dataToCopy: () -> String,
+ sensitive: Boolean = false,
+ modifier: Modifier = Modifier,
+ trailingContent: @Composable (() -> Unit)? = null,
+ content: @Composable () -> Unit,
+) {
+ item(key = title) {
+ val scope = rememberCoroutineScope()
+ val clipboard = LocalClipboard.current
+ val context = LocalContext.current
+ val copiedMessage = stringResource(R.string.copied, title)
+
+ EntryCard(
+ title = title,
+ leadingIcon = leadingIcon,
+ modifier = modifier.animateItem(),
+ trailingContent = trailingContent,
+ onClick = {
+ scope.launch {
+ clipboard.setText(
+ label = title,
+ text = dataToCopy(),
+ sensitive = sensitive,
+ )
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
+ Toast.makeText(context, copiedMessage, Toast.LENGTH_SHORT).show()
+ }
+ },
+ onClickLabel = stringResource(R.string.copy_entry, title),
+ content = content,
+ )
+ }
+}
+
+@Composable
+private fun EntryCard(
+ title: String,
+ leadingIcon: ImageVector,
+ modifier: Modifier,
+ trailingContent: @Composable (() -> Unit)?,
+ onClick: (() -> Unit)?,
+ onClickLabel: String?,
+ content: @Composable () -> Unit,
+) {
+ val cardTitle: @Composable () -> Unit = { Text(text = title) }
+ val cardLeadingItem: @Composable () -> Unit = {
+ Icon(
+ imageVector = leadingIcon,
+ contentDescription = null,
+ )
+ }
+
+ if (onClick == null)
+ KeyGoCard(
+ title = cardTitle,
+ modifier = modifier,
+ leadingItem = cardLeadingItem,
+ trailingItem = trailingContent,
+ ) {
+ content()
+ }
+ else
+ KeyGoCard(
+ onClick = onClick,
+ title = cardTitle,
+ modifier = modifier,
+ onClickLabel = onClickLabel,
+ leadingItem = cardLeadingItem,
+ trailingItem = trailingContent,
+ ) {
+ content()
+ }
+}
diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/CopyToClipboardButton.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/CopyToClipboardButton.kt
deleted file mode 100644
index 86ba0b187..000000000
--- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/CopyToClipboardButton.kt
+++ /dev/null
@@ -1,44 +0,0 @@
-package de.davis.keygo.feature.item.core.presentation.component
-
-import android.content.ClipData
-import android.content.ClipDescription
-import android.os.Build
-import android.os.PersistableBundle
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ContentCopy
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.platform.LocalClipboard
-import androidx.compose.ui.platform.toClipEntry
-import androidx.compose.ui.res.stringResource
-import de.davis.keygo.core.ui.R
-import kotlinx.coroutines.launch
-
-@Composable
-fun CopyToClipboardButton(data: String) {
- val scope = rememberCoroutineScope()
- val clipboard = LocalClipboard.current
-
- IconButton(
- onClick = {
- val clipData =
- ClipData.newPlainText(data, data)
- .apply {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
- description.extras = PersistableBundle().apply {
- putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
- }
- }
- scope.launch {
- clipboard.setClipEntry(clipData.toClipEntry())
- }
- },
- ) {
- Icon(
- imageVector = Icons.Default.ContentCopy,
- contentDescription = stringResource(R.string.copy_to_clipboard_content_description)
- )
- }
-}
\ No newline at end of file
diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt
index fbf6b55d3..8f9acd79a 100644
--- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt
+++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt
@@ -13,6 +13,7 @@ import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.TextObfuscationMode
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
+import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedSecureTextField
import androidx.compose.material3.OutlinedTextField
@@ -28,6 +29,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -41,6 +43,7 @@ import de.davis.keygo.feature.item.core.presentation.transformation.TrimTransfor
fun KeyGoFormField(
state: TextFieldState,
modifier: Modifier = Modifier,
+ textStyle: TextStyle = LocalTextStyle.current,
isSecure: Boolean = false,
label: @Composable (TextFieldLabelScope.() -> Unit)? = null,
prefix: @Composable (() -> Unit)? = null,
@@ -53,7 +56,7 @@ fun KeyGoFormField(
onKeyboardAction: KeyboardActionHandler? = null,
inputTransformation: InputTransformation? = TrimTransformation,
outputTransformation: OutputTransformation? = null,
- interactionSource: MutableInteractionSource? = null
+ interactionSource: MutableInteractionSource? = null,
) {
val supportingText: @Composable (() -> Unit)? = error?.let {
{
@@ -79,6 +82,7 @@ fun KeyGoFormField(
modifier = Modifier
.weight(1f)
.trimOnFocusLost(state, inputTransformation is TrimTransformation),
+ textStyle = textStyle,
label = label,
placeholder = placeholder,
prefix = prefix,
@@ -107,6 +111,7 @@ fun KeyGoFormField(
modifier = Modifier
.weight(1f)
.trimOnFocusLost(state, inputTransformation is TrimTransformation),
+ textStyle = textStyle,
label = label,
placeholder = placeholder,
prefix = prefix,
diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/login/ColoredPassword.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/login/ColoredPassword.kt
new file mode 100644
index 000000000..cf04ff405
--- /dev/null
+++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/login/ColoredPassword.kt
@@ -0,0 +1,32 @@
+package de.davis.keygo.feature.item.core.presentation.login
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.withStyle
+import de.davis.keygo.feature.item.core.presentation.login.model.UiPassword
+
+@Composable
+fun UiPassword.colored(
+ numberColor: Color = MaterialTheme.colorScheme.primary,
+ symbolColor: Color = MaterialTheme.colorScheme.tertiary,
+): AnnotatedString = remember(this, numberColor, symbolColor) {
+ buildAnnotatedString {
+ parts.forEach {
+ when (it) {
+ is UiPassword.Part.Letter -> append(it.text)
+ is UiPassword.Part.Number -> withStyle(SpanStyle(color = numberColor)) {
+ append(it.text)
+ }
+
+ is UiPassword.Part.Symbol -> withStyle(SpanStyle(color = symbolColor)) {
+ append(it.text)
+ }
+ }
+ }
+ }
+}
diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/UiPassword.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/login/model/UiPassword.kt
similarity index 56%
rename from feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/UiPassword.kt
rename to feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/login/model/UiPassword.kt
index 14c97d270..e885cf630 100644
--- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/UiPassword.kt
+++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/login/model/UiPassword.kt
@@ -1,16 +1,18 @@
-package de.davis.keygo.feature.item.create.presentation.password.model
+package de.davis.keygo.feature.item.core.presentation.login.model
data class UiPassword(val value: String) {
val parts: List = value.splitByCharClassRegex()
sealed interface Part {
- data class Letter(val text: String) : Part
- data class Number(val text: String) : Part
- data class Symbol(val text: String) : Part
+ val text: String
+
+ data class Letter(override val text: String) : Part
+ data class Number(override val text: String) : Part
+ data class Symbol(override val text: String) : Part
}
- private fun String.splitByCharClassRegex(): List {
+ private fun CharSequence.splitByCharClassRegex(): List {
return PATTERN.findAll(this)
.map {
when {
@@ -22,9 +24,10 @@ data class UiPassword(val value: String) {
.toList()
}
- internal companion object {
+ companion object {
+
+ private val PATTERN = Regex("""\p{L}+|\d+|[^\p{L}\d]+""")
- val PATTERN = Regex("""\p{L}+|\d+|[^\p{L}\d]+""")
fun String.asUiPassword() = UiPassword(this)
}
-}
\ No newline at end of file
+}
diff --git a/feature/item/core/src/main/res/values/strings.xml b/feature/item/core/src/main/res/values/strings.xml
index a2ce8e831..f416b33bf 100644
--- a/feature/item/core/src/main/res/values/strings.xml
+++ b/feature/item/core/src/main/res/values/strings.xml
@@ -27,4 +27,7 @@
This field can not be blank
This input is invalid
Something went wrong. Please try again.
+
+ %s copied
+ Copy %s
diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt
index 8547f4fa9..89af65ad1 100644
--- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt
+++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/login/LoginContent.kt
@@ -47,6 +47,7 @@ import de.davis.keygo.core.item.domain.model.VaultMetadata
import de.davis.keygo.core.item.generated.domain.model.VaultItemType
import de.davis.keygo.core.item.presentation.StrengthIndicator
import de.davis.keygo.core.ui.theme.KeyGoTheme
+import de.davis.keygo.core.ui.theme.secretTextStyle
import de.davis.keygo.feature.item.core.presentation.component.ChipFormGroup
import de.davis.keygo.feature.item.core.presentation.component.CreateOrModifyItemTopAppBar
import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormField
@@ -205,6 +206,7 @@ private fun LoginReadyContent(
modifier = Modifier.onFocusChanged {
forceCompact = !it.hasFocus
},
+ textStyle = secretTextStyle,
placeholder = { Text(text = stringResource(CoreItemR.string.password)) },
isSecure = true,
outsideTrailingContent = {
diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/GeneratePasswordContent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/GeneratePasswordContent.kt
index 40f499fce..1b68c36a2 100644
--- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/GeneratePasswordContent.kt
+++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/GeneratePasswordContent.kt
@@ -29,19 +29,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.SpanStyle
-import androidx.compose.ui.text.buildAnnotatedString
-import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.davis.keygo.core.item.presentation.StrengthIndicator
import de.davis.keygo.core.ui.components.KeyGoCard
import de.davis.keygo.core.ui.components.KeyGoCardProperties
+import de.davis.keygo.core.ui.theme.secretTextStyle
import de.davis.keygo.core.util.presentation.ObserveAsEvents
+import de.davis.keygo.feature.item.core.presentation.login.colored
import de.davis.keygo.feature.item.create.R
import de.davis.keygo.feature.item.create.presentation.password.model.GeneratePasswordUiEvent
import de.davis.keygo.feature.item.create.presentation.password.model.UiCharacterSet
-import de.davis.keygo.feature.item.create.presentation.password.model.UiPassword
import org.koin.androidx.compose.koinViewModel
import de.davis.keygo.core.item.R as CoreItemR
@@ -118,20 +116,8 @@ fun GeneratePasswordContent(
}
) {
Text(
- text = buildAnnotatedString {
- state.generatedPassword.parts.forEach {
- when (it) {
- is UiPassword.Part.Letter -> append(it.text)
- is UiPassword.Part.Number -> withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) {
- append(it.text)
- }
-
- is UiPassword.Part.Symbol -> withStyle(SpanStyle(color = MaterialTheme.colorScheme.tertiary)) {
- append(it.text)
- }
- }
- }
- }
+ text = state.generatedPassword.colored(),
+ style = secretTextStyle,
)
StrengthIndicator(
diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/GeneratePasswordViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/GeneratePasswordViewModel.kt
index 1be84b25f..04e0aae25 100644
--- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/GeneratePasswordViewModel.kt
+++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/GeneratePasswordViewModel.kt
@@ -6,11 +6,11 @@ import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator
+import de.davis.keygo.feature.item.core.presentation.login.model.UiPassword.Companion.asUiPassword
import de.davis.keygo.feature.item.create.domain.PasswordGenerator
import de.davis.keygo.feature.item.create.presentation.password.model.GeneratePasswordUiEvent
import de.davis.keygo.feature.item.create.presentation.password.model.GeneratePasswordUiState
import de.davis.keygo.feature.item.create.presentation.password.model.UiCharacterSet
-import de.davis.keygo.feature.item.create.presentation.password.model.UiPassword.Companion.asUiPassword
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/GeneratePasswordUiState.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/GeneratePasswordUiState.kt
index 82b065477..c958e0cdb 100644
--- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/GeneratePasswordUiState.kt
+++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/GeneratePasswordUiState.kt
@@ -1,6 +1,7 @@
package de.davis.keygo.feature.item.create.presentation.password.model
import de.davis.keygo.core.item.domain.model.PasswordScore
+import de.davis.keygo.feature.item.core.presentation.login.model.UiPassword
internal data class GeneratePasswordUiState(
val generatedPassword: UiPassword = UiPassword(""),
diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt
index b5e40dde5..7875b9975 100644
--- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt
+++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt
@@ -10,7 +10,6 @@ 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.LazyListScope
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
@@ -59,18 +58,19 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import de.davis.keygo.core.item.presentation.toImageVector
-import de.davis.keygo.core.ui.components.KeyGoCard
+import de.davis.keygo.core.ui.components.VisibilityButton
import de.davis.keygo.core.ui.composition.LocalIsInSinglePaneMode
-import de.davis.keygo.feature.item.core.presentation.component.CopyToClipboardButton
+import de.davis.keygo.core.ui.theme.secretTextStyle
import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormField
import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormSuggestionField
+import de.davis.keygo.feature.item.core.presentation.copyableEntry
+import de.davis.keygo.feature.item.core.presentation.entry
import de.davis.keygo.feature.item.core.presentation.transformation.TrimTransformation
import de.davis.keygo.feature.item.view.R
import de.davis.keygo.feature.item.view.creditcard.model.CreditCardFieldType
import de.davis.keygo.feature.item.view.creditcard.model.ViewCreditCardState
import de.davis.keygo.feature.item.view.creditcard.model.ViewCreditCardUiEvent
import de.davis.keygo.feature.item.view.login.model.ObfuscatedString
-import de.davis.keygo.feature.item.view.onHold
import de.davis.keygo.core.item.R as CoreItemR
import de.davis.keygo.core.ui.R as CoreUiR
import de.davis.keygo.feature.item.core.R as ItemCoreR
@@ -172,9 +172,10 @@ fun ViewCreditCardContent(state: ViewCreditCardState, onEvent: (ViewCreditCardUi
}
if (state.holder.isNotBlank()) {
- entry(
+ copyableEntry(
title = cardholder,
leadingIcon = Icons.Default.Person,
+ dataToCopy = { state.holder },
) {
Text(text = state.holder)
}
@@ -182,19 +183,22 @@ fun ViewCreditCardContent(state: ViewCreditCardState, onEvent: (ViewCreditCardUi
val cardNum = state.cardNumber
if (cardNum != null) {
- entry(
+ copyableEntry(
title = cardNumber,
leadingIcon = Icons.Default.CreditCard,
- modifier = Modifier.onHold {
- isCardNumberHidden = !it
- },
+ dataToCopy = { cardNum.raw },
+ sensitive = true,
trailingContent = {
- CopyToClipboardButton(cardNum.raw)
+ VisibilityButton(
+ isHidden = isCardNumberHidden,
+ onClick = { isCardNumberHidden = !isCardNumberHidden }
+ )
},
) {
val scrollState = rememberScrollState()
Text(
text = if (isCardNumberHidden) cardNum.hidden else cardNum.formatted,
+ style = secretTextStyle,
maxLines = 1,
modifier = Modifier.horizontalScroll(scrollState),
)
@@ -203,19 +207,22 @@ fun ViewCreditCardContent(state: ViewCreditCardState, onEvent: (ViewCreditCardUi
val cvvVal = state.cvv
if (cvvVal != null) {
- entry(
+ copyableEntry(
title = cvv,
leadingIcon = Icons.Default.Pin,
- modifier = Modifier.onHold {
- isCvvHidden = !it
- },
+ dataToCopy = { cvvVal.raw },
+ sensitive = true,
trailingContent = {
- CopyToClipboardButton(cvvVal.raw)
+ VisibilityButton(
+ isHidden = isCvvHidden,
+ onClick = { isCvvHidden = !isCvvHidden }
+ )
},
) {
val scrollState = rememberScrollState()
Text(
text = if (isCvvHidden) cvvVal.hidden else cvvVal.raw,
+ style = secretTextStyle,
maxLines = 1,
modifier = Modifier.horizontalScroll(scrollState),
)
@@ -223,9 +230,10 @@ fun ViewCreditCardContent(state: ViewCreditCardState, onEvent: (ViewCreditCardUi
}
if (state.expirationDate.isNotBlank()) {
- entry(
+ copyableEntry(
title = expiration,
leadingIcon = Icons.Default.CalendarMonth,
+ dataToCopy = { state.expirationDate },
) {
Text(text = state.expirationDate)
}
@@ -406,32 +414,6 @@ private fun CreditCardFieldType.addIcon(): ImageVector {
}
}
-private fun LazyListScope.entry(
- title: String,
- leadingIcon: ImageVector,
- modifier: Modifier = Modifier,
- trailingContent: @Composable (() -> Unit)? = null,
- content: @Composable () -> Unit,
-) {
- item(key = title) {
- KeyGoCard(
- title = {
- Text(text = title)
- },
- leadingItem = {
- Icon(
- imageVector = leadingIcon,
- contentDescription = null,
- )
- },
- trailingItem = trailingContent,
- modifier = modifier.animateItem(),
- ) {
- content()
- }
- }
-}
-
@Preview
@Composable
private fun ViewCreditCardContentPreview() {
diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginContent.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginContent.kt
index 21a554d04..3744dace6 100644
--- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginContent.kt
+++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/login/ViewLoginContent.kt
@@ -5,6 +5,7 @@ import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
@@ -13,7 +14,6 @@ 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.LazyListScope
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
@@ -40,11 +40,11 @@ import androidx.compose.material.icons.filled.Sell
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
+import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
-import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
@@ -53,6 +53,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.WavyProgressIndicatorDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@@ -68,6 +69,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import de.davis.keygo.core.item.domain.alias.newItemId
@@ -75,12 +77,16 @@ import de.davis.keygo.core.item.domain.model.DomainInfo
import de.davis.keygo.core.item.domain.model.PasswordScore
import de.davis.keygo.core.item.presentation.StrengthIndicator
import de.davis.keygo.core.item.presentation.toImageVector
-import de.davis.keygo.core.ui.components.KeyGoCard
+import de.davis.keygo.core.ui.components.VisibilityButton
import de.davis.keygo.core.ui.composition.LocalIsInSinglePaneMode
-import de.davis.keygo.feature.item.core.presentation.component.CopyToClipboardButton
+import de.davis.keygo.core.ui.theme.secretTextStyle
import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormField
import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormSuggestionField
+import de.davis.keygo.feature.item.core.presentation.copyableEntry
+import de.davis.keygo.feature.item.core.presentation.entry
+import de.davis.keygo.feature.item.core.presentation.login.colored
import de.davis.keygo.feature.item.core.presentation.login.model.FieldType
+import de.davis.keygo.feature.item.core.presentation.login.model.UiPassword.Companion.asUiPassword
import de.davis.keygo.feature.item.core.presentation.transformation.TrimTransformation
import de.davis.keygo.feature.item.core.presentation.transformation.rememberSchemeStrippingTransformation
import de.davis.keygo.feature.item.view.R
@@ -89,10 +95,11 @@ import de.davis.keygo.feature.item.view.login.model.ObfuscatedString
import de.davis.keygo.feature.item.view.login.model.TotpState
import de.davis.keygo.feature.item.view.login.model.ViewLoginState
import de.davis.keygo.feature.item.view.login.model.ViewLoginUiEvent
-import de.davis.keygo.feature.item.view.onHold
import de.davis.keygo.feature.totp.domain.model.TotpValue
import de.davis.keygo.feature.totp.presentation.component.QRScanner
import de.davis.keygo.feature.totp.presentation.component.TotpParseErrorDialog
+import kotlin.math.ceil
+import kotlin.time.Duration.Companion.milliseconds
import de.davis.keygo.core.item.R as CoreItemR
import de.davis.keygo.core.ui.R as CoreUiR
import de.davis.keygo.feature.item.core.R as ItemCoreR
@@ -226,19 +233,24 @@ fun ViewLoginContent(state: ViewLoginState, onEvent: (ViewLoginUiEvent) -> Unit)
val pwd = state.password
val score = state.passwordStrengthScore
if (pwd != null && score != null) {
- entry(
+ copyableEntry(
title = password,
leadingIcon = Icons.Default.Password,
- modifier = Modifier.onHold {
- isPasswordHidden = !it
- },
+ dataToCopy = { pwd.raw },
+ sensitive = true,
trailingContent = {
- CopyToClipboardButton(pwd.raw)
- },
+ VisibilityButton(
+ isHidden = isPasswordHidden,
+ onClick = { isPasswordHidden = !isPasswordHidden }
+ )
+ }
) {
val scrollState = rememberScrollState()
+ val uiPassword = remember(pwd.raw) { pwd.raw.asUiPassword() }
Text(
- text = if (isPasswordHidden) pwd.hidden else pwd.raw,
+ text = if (isPasswordHidden) AnnotatedString(pwd.hidden)
+ else uiPassword.colored(),
+ style = secretTextStyle,
maxLines = 1,
modifier = Modifier.horizontalScroll(scrollState),
)
@@ -250,18 +262,32 @@ fun ViewLoginContent(state: ViewLoginState, onEvent: (ViewLoginUiEvent) -> Unit)
}
when (totpState) {
- is TotpState.HasTotp -> entry(
+ is TotpState.HasTotp -> copyableEntry(
title = totp,
leadingIcon = Icons.Default.AccessTime,
+ dataToCopy = { state.totpState.value.code },
+ sensitive = true,
trailingContent = {
- CopyToClipboardButton(state.totpState.value.code)
- },
+ val indicatorSize = WavyProgressIndicatorDefaults.CircularContainerSize
+ Box(
+ modifier = Modifier.size(indicatorSize),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularWavyProgressIndicator(
+ progress = { progress.value },
+ )
+
+ Text(
+ text = ceil(totpState.value.maxLifetime.milliseconds.inWholeSeconds * progress.value).toInt()
+ .toString(),
+ fontSize = with(LocalDensity.current) { (indicatorSize * 0.35f).toSp() },
+ maxLines = 1,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
) {
Text(text = state.totpState.formattedCode)
- LinearProgressIndicator(
- progress = { progress.value },
- modifier = Modifier.fillMaxWidth(),
- )
}
is TotpState.Error -> entry(
@@ -275,9 +301,10 @@ fun ViewLoginContent(state: ViewLoginState, onEvent: (ViewLoginUiEvent) -> Unit)
}
if (state.username.isNotBlank()) {
- entry(
+ copyableEntry(
title = username,
leadingIcon = Icons.Default.Person,
+ dataToCopy = { state.username },
) {
Text(text = state.username)
}
@@ -521,32 +548,6 @@ private fun FieldType.addIcon(): ImageVector {
}
}
-private fun LazyListScope.entry(
- title: String,
- leadingIcon: ImageVector,
- modifier: Modifier = Modifier,
- trailingContent: @Composable (() -> Unit)? = null,
- content: @Composable () -> Unit,
-) {
- item(key = title) {
- KeyGoCard(
- title = {
- Text(text = title)
- },
- leadingItem = {
- Icon(
- imageVector = leadingIcon,
- contentDescription = null,
- )
- },
- trailingItem = trailingContent,
- modifier = modifier.animateItem(),
- ) {
- content()
- }
- }
-}
-
@Preview
@Composable
private fun ViewLoginContentPreview() {
diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/onHold.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/onHold.kt
deleted file mode 100644
index b654b21c7..000000000
--- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/onHold.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-package de.davis.keygo.feature.item.view
-
-import androidx.compose.foundation.gestures.awaitEachGesture
-import androidx.compose.foundation.gestures.awaitFirstDown
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
-import androidx.compose.ui.input.pointer.pointerInput
-
-fun Modifier.onHold(onHold: (Boolean) -> Unit) = this.pointerInput(Unit) {
- awaitEachGesture {
- val down = awaitFirstDown()
- onHold(true)
-
- try {
- val pointerId = down.id
- do {
- val event = awaitPointerEvent()
- val change = event.changes.firstOrNull { it.id == pointerId }
- if (change == null || change.changedToUpIgnoreConsumed()) {
- break
- }
-
- change.consume()
- } while (true)
- } finally {
- onHold(false)
- }
- }
-}
\ No newline at end of file