Skip to content

Commit 7306229

Browse files
authored
Merge pull request #126 from code-payments/feat/create-reusable-textinput
feat: create reusable TextInput using BTF2
2 parents 34c3b9a + 26cee59 commit 7306229

3 files changed

Lines changed: 295 additions & 64 deletions

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
package com.getcode.view.components
2+
3+
import android.view.ViewTreeObserver
4+
import androidx.compose.foundation.ExperimentalFoundationApi
5+
import androidx.compose.foundation.ScrollState
6+
import androidx.compose.foundation.background
7+
import androidx.compose.foundation.border
8+
import androidx.compose.foundation.layout.Arrangement
9+
import androidx.compose.foundation.layout.Box
10+
import androidx.compose.foundation.layout.Row
11+
import androidx.compose.foundation.layout.defaultMinSize
12+
import androidx.compose.foundation.layout.fillMaxWidth
13+
import androidx.compose.foundation.layout.padding
14+
import androidx.compose.foundation.rememberScrollState
15+
import androidx.compose.foundation.text.KeyboardActions
16+
import androidx.compose.foundation.text.KeyboardOptions
17+
import androidx.compose.foundation.text2.BasicSecureTextField
18+
import androidx.compose.foundation.text2.BasicTextField2
19+
import androidx.compose.foundation.text2.input.TextFieldLineLimits
20+
import androidx.compose.foundation.text2.input.TextFieldState
21+
import androidx.compose.foundation.text2.input.TextObfuscationMode
22+
import androidx.compose.foundation.text2.input.textAsFlow
23+
import androidx.compose.material.ExperimentalMaterialApi
24+
import androidx.compose.material.Text
25+
import androidx.compose.material.TextFieldColors
26+
import androidx.compose.runtime.Composable
27+
import androidx.compose.runtime.DisposableEffect
28+
import androidx.compose.runtime.LaunchedEffect
29+
import androidx.compose.runtime.State
30+
import androidx.compose.runtime.getValue
31+
import androidx.compose.runtime.mutableStateOf
32+
import androidx.compose.runtime.remember
33+
import androidx.compose.ui.Alignment
34+
import androidx.compose.ui.Modifier
35+
import androidx.compose.ui.graphics.Color
36+
import androidx.compose.ui.graphics.RectangleShape
37+
import androidx.compose.ui.graphics.Shape
38+
import androidx.compose.ui.graphics.SolidColor
39+
import androidx.compose.ui.platform.LocalFocusManager
40+
import androidx.compose.ui.platform.LocalView
41+
import androidx.compose.ui.text.TextStyle
42+
import androidx.compose.ui.text.input.ImeAction
43+
import androidx.compose.ui.unit.dp
44+
import androidx.core.view.ViewCompat
45+
import androidx.core.view.WindowInsetsCompat
46+
import com.getcode.theme.BrandLight
47+
import com.getcode.theme.CodeTheme
48+
import com.getcode.theme.extraSmall
49+
import com.getcode.theme.inputColors
50+
import kotlinx.coroutines.flow.launchIn
51+
import kotlinx.coroutines.flow.onEach
52+
53+
@OptIn(ExperimentalFoundationApi::class)
54+
@Composable
55+
fun TextInput(
56+
modifier: Modifier = Modifier,
57+
placeholder: String = "",
58+
minLines: Int = 1,
59+
maxLines: Int = 4,
60+
state: TextFieldState,
61+
onStateChanged: () -> Unit = { },
62+
keyboardActions: KeyboardActions = KeyboardActions(),
63+
keyboardOptions: KeyboardOptions = KeyboardOptions(),
64+
style: TextStyle = CodeTheme.typography.body1,
65+
placeholderStyle: TextStyle = CodeTheme.typography.body1,
66+
shape: Shape = CodeTheme.shapes.extraSmall,
67+
colors: TextFieldColors = inputColors(),
68+
enabled: Boolean = true,
69+
readOnly: Boolean = false,
70+
leadingIcon: (@Composable () -> Unit)? = null,
71+
trailingIcon: (@Composable () -> Unit)? = null,
72+
scrollState: ScrollState = rememberScrollState(),
73+
) {
74+
val backgroundColor by colors.backgroundColor(enabled = enabled)
75+
val textColor by colors.textColor(enabled = enabled)
76+
val placeholderColor by colors.placeholderColor(enabled = enabled)
77+
BasicTextField2(
78+
modifier = modifier
79+
.background(backgroundColor, shape)
80+
.defaultMinSize(minHeight = 56.dp),
81+
enabled = enabled,
82+
readOnly = readOnly,
83+
state = state,
84+
cursorBrush = SolidColor(colors.cursorColor(isError = false).value),
85+
keyboardOptions = keyboardOptions,
86+
keyboardActions = keyboardActions,
87+
textStyle = style.copy(color = textColor),
88+
lineLimits = if (maxLines == 1) {
89+
TextFieldLineLimits.SingleLine
90+
} else {
91+
TextFieldLineLimits.MultiLine(minHeightInLines = minLines, maxHeightInLines = maxLines)
92+
},
93+
decorator = {
94+
DecoratorBox(
95+
state = state,
96+
placeholder = placeholder,
97+
placeholderStyle = placeholderStyle,
98+
placeholderColor = placeholderColor,
99+
leadingIcon = leadingIcon,
100+
trailingIcon = trailingIcon,
101+
shape = shape,
102+
innerTextField = it
103+
)
104+
},
105+
scrollState = scrollState
106+
)
107+
108+
LaunchedEffect(Unit) {
109+
state.textAsFlow()
110+
.onEach { onStateChanged() }
111+
.launchIn(this)
112+
}
113+
114+
val focusManager = LocalFocusManager.current
115+
val keyboardState by keyboardAsState()
116+
LaunchedEffect(keyboardState) {
117+
if (!keyboardState) {
118+
focusManager.clearFocus(true)
119+
}
120+
}
121+
}
122+
123+
@OptIn(ExperimentalFoundationApi::class)
124+
@Composable
125+
fun SecureTextInput(
126+
modifier: Modifier = Modifier,
127+
placeholder: String = "",
128+
state: TextFieldState,
129+
onStateChanged: () -> Unit = { },
130+
style: TextStyle = CodeTheme.typography.subtitle1,
131+
placeholderStyle: TextStyle = CodeTheme.typography.subtitle1,
132+
shape: Shape = RectangleShape,
133+
colors: TextFieldColors = inputColors(),
134+
enabled: Boolean = true,
135+
leadingIcon: (@Composable () -> Unit)? = null,
136+
trailingIcon: (@Composable () -> Unit)? = null,
137+
scrollState: ScrollState = rememberScrollState(),
138+
textObfuscationMode: TextObfuscationMode = TextObfuscationMode.RevealLastTyped,
139+
onSubmit: () -> Unit = { },
140+
) {
141+
val backgroundColor by colors.backgroundColor(enabled = enabled)
142+
val textColor by colors.textColor(enabled = enabled)
143+
val placeholderColor by colors.placeholderColor(enabled = enabled)
144+
145+
BasicSecureTextField(
146+
modifier = modifier
147+
.background(backgroundColor, shape)
148+
.defaultMinSize(minHeight = 56.dp),
149+
enabled = enabled,
150+
state = state,
151+
textStyle = style.copy(color = textColor),
152+
textObfuscationMode = textObfuscationMode,
153+
onSubmit = {
154+
if (it == ImeAction.Go) {
155+
onSubmit()
156+
return@BasicSecureTextField true
157+
}
158+
false
159+
},
160+
decorator = {
161+
DecoratorBox(
162+
state = state,
163+
placeholder = placeholder,
164+
placeholderStyle = placeholderStyle,
165+
placeholderColor = placeholderColor,
166+
leadingIcon = leadingIcon,
167+
trailingIcon = trailingIcon,
168+
shape = shape,
169+
innerTextField = it
170+
)
171+
},
172+
scrollState = scrollState
173+
)
174+
175+
LaunchedEffect(Unit) {
176+
state.textAsFlow()
177+
.onEach { onStateChanged() }
178+
.launchIn(this)
179+
}
180+
181+
val focusManager = LocalFocusManager.current
182+
val keyboardState by keyboardAsState()
183+
LaunchedEffect(keyboardState) {
184+
if (!keyboardState) {
185+
focusManager.clearFocus(true)
186+
}
187+
}
188+
}
189+
190+
@OptIn(ExperimentalFoundationApi::class)
191+
@Composable
192+
private fun DecoratorBox(
193+
state: TextFieldState,
194+
placeholder: String,
195+
placeholderStyle: TextStyle,
196+
placeholderColor: Color,
197+
leadingIcon: (@Composable () -> Unit)?,
198+
trailingIcon: (@Composable () -> Unit)?,
199+
shape: Shape,
200+
innerTextField: @Composable () -> Unit,
201+
202+
) {
203+
Row(
204+
modifier = Modifier
205+
.fillMaxWidth()
206+
.border(
207+
width = CodeTheme.dimens.border,
208+
color = BrandLight,
209+
shape = shape,
210+
),
211+
verticalAlignment = Alignment.CenterVertically,
212+
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x2)
213+
) {
214+
leadingIcon?.invoke()
215+
Box(
216+
modifier = Modifier
217+
.weight(1f)
218+
.padding(horizontal = CodeTheme.dimens.staticGrid.x2),
219+
contentAlignment = Alignment.CenterStart
220+
) {
221+
innerTextField()
222+
if (state.text.isEmpty() && placeholder.isNotEmpty()) {
223+
Text(
224+
text = placeholder,
225+
style = placeholderStyle.copy(color = placeholderColor),
226+
maxLines = 1,
227+
)
228+
}
229+
}
230+
trailingIcon?.invoke()
231+
}
232+
}
233+
234+
@Composable
235+
fun keyboardAsState(): State<Boolean> {
236+
val keyboardState = remember { mutableStateOf(false) }
237+
val view = LocalView.current
238+
val viewTreeObserver = view.viewTreeObserver
239+
DisposableEffect(viewTreeObserver) {
240+
val listener = ViewTreeObserver.OnGlobalLayoutListener {
241+
keyboardState.value = ViewCompat.getRootWindowInsets(view)
242+
?.isVisible(WindowInsetsCompat.Type.ime()) ?: true
243+
}
244+
viewTreeObserver.addOnGlobalLayoutListener(listener)
245+
onDispose { viewTreeObserver.removeOnGlobalLayoutListener(listener) }
246+
}
247+
return keyboardState
248+
}

app/src/main/java/com/getcode/view/main/account/withdraw/AccountWithdrawAddress.kt

Lines changed: 19 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
package com.getcode.view.main.account.withdraw
22

3+
import androidx.compose.foundation.ExperimentalFoundationApi
34
import androidx.compose.foundation.Image
5+
import androidx.compose.foundation.layout.Column
46
import androidx.compose.foundation.layout.Row
7+
import androidx.compose.foundation.layout.Spacer
58
import androidx.compose.foundation.layout.fillMaxHeight
69
import androidx.compose.foundation.layout.fillMaxWidth
710
import androidx.compose.foundation.layout.imePadding
811
import androidx.compose.foundation.layout.padding
912
import androidx.compose.foundation.layout.size
1013
import androidx.compose.foundation.text.KeyboardOptions
11-
import androidx.compose.material.OutlinedTextField
1214
import androidx.compose.material.Text
1315
import androidx.compose.runtime.Composable
1416
import androidx.compose.runtime.SideEffect
@@ -20,21 +22,20 @@ import androidx.compose.ui.graphics.Color
2022
import androidx.compose.ui.res.painterResource
2123
import androidx.compose.ui.res.stringResource
2224
import androidx.compose.ui.text.input.KeyboardType
23-
import androidx.compose.ui.text.input.VisualTransformation
2425
import androidx.compose.ui.text.style.TextAlign
2526
import androidx.compose.ui.unit.sp
26-
import androidx.constraintlayout.compose.ConstraintLayout
2727
import com.getcode.R
2828
import com.getcode.navigation.core.LocalCodeNavigator
2929
import com.getcode.navigation.screens.WithdrawalArgs
3030
import com.getcode.theme.BrandLight
3131
import com.getcode.theme.CodeTheme
32-
import com.getcode.theme.extraSmall
3332
import com.getcode.theme.green
34-
import com.getcode.theme.inputColors
33+
import com.getcode.util.debugBounds
3534
import com.getcode.view.components.ButtonState
3635
import com.getcode.view.components.CodeButton
36+
import com.getcode.view.components.TextInput
3737

38+
@OptIn(ExperimentalFoundationApi::class)
3839
@Composable
3940
fun AccountWithdrawAddress(
4041
viewModel: AccountWithdrawAddressViewModel,
@@ -43,59 +44,33 @@ fun AccountWithdrawAddress(
4344
val navigator = LocalCodeNavigator.current
4445
val dataState by viewModel.uiFlow.collectAsState()
4546

46-
ConstraintLayout(
47+
Column(
4748
modifier = Modifier
4849
.fillMaxWidth()
4950
.fillMaxHeight()
5051
.padding(horizontal = CodeTheme.dimens.inset)
5152
.imePadding()
5253
) {
53-
val (topText, addressField, resolveStatus, pasteButton, nextButton) = createRefs()
5454
Text(
55-
modifier = Modifier.constrainAs(topText) {
56-
start.linkTo(parent.start)
57-
end.linkTo(parent.end)
58-
},
55+
modifier = Modifier
56+
.fillMaxWidth(),
5957
text = stringResource(R.string.subtitle_whereToWithdrawKin),
6058
style = CodeTheme.typography.body1.copy(textAlign = TextAlign.Center),
6159
color = BrandLight
6260
)
6361

64-
OutlinedTextField(
62+
TextInput(
6563
modifier = Modifier
66-
.constrainAs(addressField) {
67-
top.linkTo(topText.bottom)
68-
}
69-
.padding(top = CodeTheme.dimens.grid.x4)
7064
.fillMaxWidth()
71-
.padding(vertical = CodeTheme.dimens.grid.x1),
72-
placeholder = {
73-
Text(
74-
text = stringResource(R.string.subtitle_enterDestinationAddress),
75-
style = CodeTheme.typography.subtitle1.copy(
76-
fontSize = 16.sp,
77-
)
78-
)
79-
},
65+
.padding(vertical = CodeTheme.dimens.grid.x4),
66+
state = dataState.addressText,
67+
maxLines = 1,
68+
placeholder = stringResource(R.string.subtitle_enterDestinationAddress),
8069
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
81-
visualTransformation = VisualTransformation.None,
82-
value = dataState.addressText,
83-
onValueChange = { viewModel.setAddress(it) },
84-
textStyle = CodeTheme.typography.subtitle1.copy(
85-
fontSize = 16.sp,
86-
),
87-
singleLine = true,
88-
colors = inputColors(),
89-
shape = CodeTheme.shapes.extraSmall
9070
)
9171

9272
Row(
93-
modifier = Modifier
94-
.constrainAs(resolveStatus) {
95-
top.linkTo(addressField.bottom)
96-
}
97-
.fillMaxWidth()
98-
.padding(vertical = CodeTheme.dimens.grid.x1),
73+
modifier = Modifier.fillMaxWidth(),
9974
verticalAlignment = Alignment.CenterVertically
10075
) {
10176
dataState.isValid?.let { isValid ->
@@ -129,25 +104,19 @@ fun AccountWithdrawAddress(
129104
}
130105

131106
CodeButton(
132-
modifier = Modifier
133-
.fillMaxWidth()
134-
.padding(bottom = CodeTheme.dimens.inset)
135-
.constrainAs(pasteButton) {
136-
top.linkTo(resolveStatus.bottom)
137-
},
107+
modifier = Modifier.fillMaxWidth()
108+
.padding(top = CodeTheme.dimens.grid.x2),
138109
onClick = { viewModel.pasteAddress() },
139110
enabled = dataState.isPasteEnabled,
140111
text = stringResource(R.string.action_pasteFromClipboard),
141112
buttonState = ButtonState.Filled,
142113
)
143114

115+
Spacer(modifier = Modifier.weight(1f))
144116
CodeButton(
145117
modifier = Modifier
146118
.fillMaxWidth()
147-
.padding(bottom = CodeTheme.dimens.inset)
148-
.constrainAs(nextButton) {
149-
bottom.linkTo(parent.bottom)
150-
},
119+
.padding(bottom = CodeTheme.dimens.inset),
151120
onClick = {
152121
viewModel.onSubmit(navigator, arguments)
153122
},

0 commit comments

Comments
 (0)