Skip to content

Commit 9948c57

Browse files
committed
Fixes #14409
1 parent 366badb commit 9948c57

File tree

10 files changed

+313
-599
lines changed

10 files changed

+313
-599
lines changed

app/src/main/java/org/thoughtcrime/securesms/components/registration/VerificationCodeView.kt

Lines changed: 0 additions & 90 deletions
This file was deleted.
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/*
2+
* Copyright 2025 Signal Messenger, LLC
3+
* SPDX-License-Identifier: AGPL-3.0-only
4+
*/
5+
6+
package org.thoughtcrime.securesms.components.registration
7+
8+
import androidx.compose.foundation.background
9+
import androidx.compose.foundation.clickable
10+
import androidx.compose.foundation.layout.Arrangement
11+
import androidx.compose.foundation.layout.Box
12+
import androidx.compose.foundation.layout.BoxWithConstraints
13+
import androidx.compose.foundation.layout.Row
14+
import androidx.compose.foundation.layout.fillMaxWidth
15+
import androidx.compose.foundation.layout.height
16+
import androidx.compose.foundation.layout.size
17+
import androidx.compose.foundation.shape.RoundedCornerShape
18+
import androidx.compose.material3.MaterialTheme
19+
import androidx.compose.material3.Text
20+
import androidx.compose.runtime.Composable
21+
import androidx.compose.runtime.LaunchedEffect
22+
import androidx.compose.runtime.derivedStateOf
23+
import androidx.compose.runtime.getValue
24+
import androidx.compose.runtime.mutableStateOf
25+
import androidx.compose.runtime.remember
26+
import androidx.compose.runtime.rememberUpdatedState
27+
import androidx.compose.runtime.setValue
28+
import androidx.compose.ui.Alignment
29+
import androidx.compose.ui.Modifier
30+
import androidx.compose.ui.platform.LocalDensity
31+
import androidx.compose.ui.platform.LocalFocusManager
32+
import androidx.compose.ui.text.AnnotatedString
33+
import androidx.compose.ui.text.TextStyle
34+
import androidx.compose.ui.text.rememberTextMeasurer
35+
import androidx.compose.ui.text.style.TextAlign
36+
import androidx.compose.ui.tooling.preview.PreviewFontScale
37+
import androidx.compose.ui.unit.Dp
38+
import androidx.compose.ui.unit.dp
39+
import androidx.compose.ui.unit.sp
40+
import org.signal.core.ui.compose.theme.SignalTheme
41+
42+
private const val DEFAULT_CODE_LENGTH: Int = 6
43+
private val BOX_SPACING = 4.dp
44+
private val MIN_BOX_WIDTH = 24.dp
45+
private val SEPARATOR_FONT_SIZE = 28.sp
46+
private val BOX_CORNER_RADIUS = 4.dp
47+
private val BOTTOM_ACCENT_HEIGHT = 2.dp
48+
private const val DASH_INSERT_INDEX: Int = 2
49+
private const val DASH_MIN_CODE_LENGTH: Int = 3
50+
private const val BOX_HEIGHT_NUMERATOR: Float = 7f
51+
private const val BOX_HEIGHT_DENOMINATOR: Float = 6f
52+
53+
@Composable
54+
fun VerificationCodeViewCompose(
55+
codeLength: Int = DEFAULT_CODE_LENGTH,
56+
onCodeComplete: (String) -> Unit,
57+
codeState: List<String>
58+
) {
59+
val focusManager = LocalFocusManager.current
60+
61+
val firstEmptyIndex by remember(codeState, codeLength) { derivedStateOf { nextEmptyIndex(codeState, codeLength) } }
62+
var focusedIndex by remember { mutableStateOf(firstEmptyIndex) }
63+
64+
val onComplete by rememberUpdatedState(onCodeComplete)
65+
66+
val codeString by remember(codeState) { derivedStateOf { codeState.joinToString("") } }
67+
68+
LaunchedEffect(firstEmptyIndex) { focusManager.clearFocus() }
69+
70+
LaunchedEffect(codeString) {
71+
focusedIndex = nextEmptyIndex(codeState, codeLength)
72+
73+
if (codeState.all { it.length == 1 }) {
74+
onComplete(codeString)
75+
focusManager.clearFocus()
76+
}
77+
}
78+
79+
SignalTheme {
80+
val boxSpacing = BOX_SPACING
81+
82+
BoxWithConstraints {
83+
// Use the full maxWidth so there are no absolute start/end margins.
84+
val availableWidth = this.maxWidth
85+
86+
val textMeasurer = rememberTextMeasurer()
87+
val density = LocalDensity.current
88+
val separatorWidth = remember(textMeasurer, density) {
89+
val layout = textMeasurer.measure(AnnotatedString("-"), style = TextStyle(fontSize = SEPARATOR_FONT_SIZE))
90+
density.run { layout.size.width.toDp() }
91+
}
92+
93+
val hasSeparator = codeLength > DASH_MIN_CODE_LENGTH
94+
val elementsCount = if (hasSeparator) codeLength + 1 else codeLength
95+
val totalGaps = (elementsCount - 1)
96+
val totalSpacing = boxSpacing * totalGaps
97+
98+
val availableForBoxes = availableWidth - totalSpacing - if (hasSeparator) separatorWidth else 0.dp
99+
val computedBoxWidth = availableForBoxes / codeLength
100+
val boxWidth = computedBoxWidth.coerceAtLeast(MIN_BOX_WIDTH)
101+
val boxHeight = boxWidth * BOX_HEIGHT_NUMERATOR / BOX_HEIGHT_DENOMINATOR
102+
103+
Row(
104+
modifier = Modifier.fillMaxWidth(),
105+
verticalAlignment = Alignment.CenterVertically,
106+
horizontalArrangement = Arrangement.spacedBy(boxSpacing, Alignment.CenterHorizontally)
107+
) {
108+
for (i in 0 until codeLength) {
109+
val char = codeState.getOrNull(i) ?: ""
110+
val isFocused = focusedIndex == i
111+
112+
DigitCell(
113+
char = char,
114+
isFocused = isFocused,
115+
boxWidth = boxWidth,
116+
boxHeight = boxHeight,
117+
onClick = { focusedIndex = i }
118+
)
119+
120+
if (i == DASH_INSERT_INDEX && hasSeparator) {
121+
Text(
122+
text = "-",
123+
style = TextStyle(fontSize = SEPARATOR_FONT_SIZE, color = MaterialTheme.colorScheme.onSurfaceVariant),
124+
modifier = Modifier
125+
)
126+
}
127+
}
128+
}
129+
}
130+
}
131+
}
132+
133+
@Composable
134+
private fun DigitCell(
135+
char: String,
136+
isFocused: Boolean,
137+
boxWidth: Dp,
138+
boxHeight: Dp,
139+
onClick: () -> Unit
140+
) {
141+
Box(
142+
modifier = Modifier
143+
.size(width = boxWidth, height = boxHeight)
144+
.clickable { onClick() }
145+
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(topStart = BOX_CORNER_RADIUS, topEnd = BOX_CORNER_RADIUS, bottomEnd = 0.dp, bottomStart = 0.dp)),
146+
contentAlignment = Alignment.Center
147+
) {
148+
if (char.isEmpty()) {
149+
Text("", style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)), textAlign = TextAlign.Center)
150+
} else {
151+
Text(char, style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), textAlign = TextAlign.Center)
152+
}
153+
154+
Box(
155+
modifier = Modifier
156+
.align(Alignment.BottomCenter)
157+
.height(BOTTOM_ACCENT_HEIGHT)
158+
.fillMaxWidth()
159+
.background(MaterialTheme.colorScheme.primary.copy(alpha = if (isFocused) 1f else 0f))
160+
)
161+
}
162+
}
163+
164+
private fun nextEmptyIndex(codeState: List<String>, codeLength: Int): Int {
165+
val idx = codeState.indexOfFirst { it.isEmpty() }
166+
return if (idx == -1) codeLength - 1 else idx
167+
}
168+
169+
@PreviewFontScale
170+
@Composable
171+
fun VerificationCodeViewComposePreview() {
172+
SignalTheme {
173+
VerificationCodeViewCompose(
174+
codeLength = DEFAULT_CODE_LENGTH,
175+
codeState = listOf("1", "5", "6", "", "", ""),
176+
onCodeComplete = {}
177+
)
178+
}
179+
}

0 commit comments

Comments
 (0)