Extract PIN input into reusable PinInputField component

This commit is contained in:
mimi89999
2025-12-25 14:20:21 +01:00
parent ff24f6b612
commit 29b6eceec2
4 changed files with 225 additions and 107 deletions

View File

@@ -5,13 +5,10 @@ import android.content.Context
import android.content.DialogInterface
import android.content.res.ColorStateList
import android.os.Bundle
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
@@ -23,8 +20,6 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.button.MaterialButton
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
class CredentialBottomSheet : BottomSheetDialogFragment() {
@@ -49,8 +44,7 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
private lateinit var progressBar: ProgressBar
private lateinit var btnCancel: MaterialButton
private lateinit var btnContinue: MaterialButton
private lateinit var pinInputLayout: TextInputLayout
private lateinit var pinEditText: TextInputEditText
private lateinit var pinInputField: PinInputField
private lateinit var iconStatus: ImageView
private lateinit var iconBackground: View
private lateinit var accountList: RecyclerView
@@ -66,10 +60,6 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
var onPinEntered: ((String) -> Unit)? = null
var onAccountSelected: ((Int) -> Unit)? = null
private val useNumericKeyboard: Boolean
get() = requireContext().getSharedPreferences("authnkey_prefs", Context.MODE_PRIVATE)
.getBoolean("use_numeric_keyboard", true)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -95,22 +85,24 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
progressBar = view.findViewById(R.id.progressBar)
btnCancel = view.findViewById(R.id.btnCancel)
btnContinue = view.findViewById(R.id.btnContinue)
pinInputLayout = view.findViewById(R.id.pinInputLayout)
pinEditText = view.findViewById(R.id.pinEditText)
pinInputField = view.findViewById(R.id.pinInputField)
iconStatus = view.findViewById(R.id.iconStatus)
iconBackground = view.findViewById(R.id.iconBackground)
accountList = view.findViewById(R.id.accountList)
accountList.layoutManager = LinearLayoutManager(context)
// Configure PIN input field
pinInputField.useNumericKeyboard = getKeyboardPreference()
pinInputField.onKeyboardModeChanged = { saveKeyboardPreference(it) }
pendingStatus?.let { statusText.text = it }
pendingInstruction?.let { instructionText.text = it }
if (pendingShowPinInput) {
pinInputLayout.visibility = View.VISIBLE
pinInputField.visibility = View.VISIBLE
btnContinue.visibility = View.VISIBLE
applyKeyboardMode(useNumericKeyboard)
pinEditText.requestFocus()
pinInputField.focus()
}
applyState(pendingState)
@@ -123,17 +115,8 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
submitPin()
}
pinEditText.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
submitPin()
true
} else {
false
}
}
pinInputLayout.setStartIconOnClickListener {
onKeyboardModeToggled()
pinInputField.setOnDoneAction {
submitPin()
}
(dialog as? BottomSheetDialog)?.behavior?.apply {
@@ -153,57 +136,18 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
}
private fun submitPin() {
val pin = pinEditText.text?.toString() ?: ""
if (pin.length >= 4) {
pinInputLayout.error = null
pinInputField.validateAndGetPin()?.let { pin ->
onPinEntered?.invoke(pin)
} else {
pinInputLayout.error = getString(R.string.pin_too_short)
}
}
private fun applyKeyboardMode(numeric: Boolean) {
pinInputLayout.setStartIconDrawable(
if (numeric) R.drawable.keyboard_24 else R.drawable.dialpad_24
)
private fun getKeyboardPreference(): Boolean =
requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getBoolean(PREF_USE_NUMERIC_KEYBOARD, true)
pinEditText.inputType = if (numeric) {
InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
} else {
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
}
private fun onKeyboardModeToggled() {
val newMode = !useNumericKeyboard
requireContext().getSharedPreferences("authnkey_prefs", Context.MODE_PRIVATE)
.edit {
putBoolean("use_numeric_keyboard", newMode)
}
pinEditText.inputType = if (newMode) {
InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
} else {
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
val iconRes = if (newMode) R.drawable.keyboard_24 else R.drawable.dialpad_24
pinInputLayout.findViewById<View>(com.google.android.material.R.id.text_input_start_icon)?.let { iconView ->
iconView.animate()
.alpha(0f)
.setDuration(100)
.withEndAction {
pinInputLayout.setStartIconDrawable(iconRes)
iconView.alpha = 1f
}
.start()
} ?: pinInputLayout.setStartIconDrawable(iconRes)
pinEditText.post {
val imm = requireContext().getSystemService(InputMethodManager::class.java)
imm?.restartInput(pinEditText)
}
private fun saveKeyboardPreference(numeric: Boolean) {
requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit { putBoolean(PREF_USE_NUMERIC_KEYBOARD, numeric) }
}
fun setState(state: State) {
@@ -284,23 +228,14 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
}
fun showPinInput(show: Boolean) {
if (::pinInputLayout.isInitialized) {
pinInputLayout.visibility = if (show) View.VISIBLE else View.GONE
if (::pinInputField.isInitialized) {
pinInputField.visibility = if (show) View.VISIBLE else View.GONE
btnContinue.visibility = if (show) View.VISIBLE else View.GONE
if (show) {
hideAccounts()
pinEditText.text?.clear()
pinInputLayout.error = null
applyKeyboardMode(useNumericKeyboard)
pinEditText.requestFocus()
pinInputField.clear()
setState(State.PIN)
pinEditText.post {
val imm = requireContext().getSystemService(InputMethodManager::class.java)
imm?.showSoftInput(pinEditText, InputMethodManager.SHOW_IMPLICIT)
}
} else {
val imm = requireContext().getSystemService(InputMethodManager::class.java)
imm?.hideSoftInputFromWindow(pinEditText.windowToken, 0)
pinInputField.focus()
}
} else {
pendingShowPinInput = show
@@ -312,7 +247,7 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
if (!::accountList.isInitialized) return
setState(State.ACCOUNT_SELECT)
pinInputLayout.visibility = View.GONE
pinInputField.visibility = View.GONE
btnContinue.visibility = View.GONE
accountList.visibility = View.VISIBLE
accountList.adapter = AccountAdapter(accounts) { index ->
@@ -327,15 +262,15 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
}
fun setPinError(error: String?) {
if (::pinInputLayout.isInitialized) {
pinInputLayout.error = error
if (::pinInputField.isInitialized) {
pinInputField.error = error
}
}
fun getCurrentPinIfValid(): String? {
if (!::pinEditText.isInitialized) return null
val pin = pinEditText.text?.toString() ?: return null
return if (pin.length >= 4) pin else null
if (!::pinInputField.isInitialized) return null
val pin = pinInputField.pin ?: return null
return if (pin.length >= pinInputField.minPinLength) pin else null
}
private class AccountAdapter(
@@ -375,6 +310,8 @@ class CredentialBottomSheet : BottomSheetDialogFragment() {
const val TAG = "CredentialBottomSheet"
private const val ARG_STATUS = "status"
private const val ARG_INSTRUCTION = "instruction"
private const val PREFS_NAME = "authnkey_prefs"
private const val PREF_USE_NUMERIC_KEYBOARD = "use_numeric_keyboard"
fun newInstance(status: String, instruction: String): CredentialBottomSheet {
return CredentialBottomSheet().apply {

View File

@@ -0,0 +1,186 @@
package pl.lebihan.authnkey
import android.content.Context
import android.text.InputType
import android.util.AttributeSet
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import androidx.core.content.withStyledAttributes
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import com.google.android.material.theme.overlay.MaterialThemeOverlay
import androidx.core.view.isVisible
/**
* A reusable PIN input field that extends [TextInputLayout] with:
* - Password visibility toggle
* - Switchable numeric/alphanumeric keyboard
* - Built-in validation for minimum PIN length
*
* Configure via XML attributes [R.styleable.PinInputField] or programmatically.
*/
class PinInputField @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = com.google.android.material.R.attr.textInputStyle
) : TextInputLayout(
MaterialThemeOverlay.wrap(context, attrs, defStyleAttr, 0),
attrs,
defStyleAttr
) {
private val pinEditText = TextInputEditText(this.context).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
imeOptions = EditorInfo.IME_ACTION_DONE
}
/**
* Whether to use a numeric keyboard. Updating this property will immediately
* update the keyboard type and toggle icon.
*/
var useNumericKeyboard: Boolean = true
set(value) {
if (field != value) {
field = value
applyKeyboardMode()
}
}
/**
* Callback invoked when the user toggles the keyboard mode via the start icon.
* Use this to persist the preference if desired.
*/
var onKeyboardModeChanged: ((Boolean) -> Unit)? = null
/**
* Minimum PIN length for validation. Defaults to 4.
*/
var minPinLength: Int = 4
init {
addView(pinEditText)
context.withStyledAttributes(attrs, R.styleable.PinInputField) {
hint = getString(R.styleable.PinInputField_pinHint)
?: context.getString(R.string.pin_hint)
minPinLength = getInt(R.styleable.PinInputField_minPinLength, 4)
}
endIconMode = END_ICON_PASSWORD_TOGGLE
setStartIconContentDescription(R.string.toggle_keyboard_type)
setStartIconOnClickListener { onKeyboardModeToggled() }
applyKeyboardMode(false)
}
/**
* The current PIN text.
*/
val pin: String?
get() = pinEditText.text?.toString()
/**
* Validates the PIN against [minPinLength].
* Sets the error message if invalid, clears it if valid.
* @return true if valid, false otherwise
*/
fun validate(): Boolean {
val currentPin = pin
return if (currentPin != null && currentPin.length >= minPinLength) {
error = null
true
} else {
error = context.getString(R.string.pin_too_short)
false
}
}
/**
* Validates the PIN against [minPinLength]. Returns the PIN if valid,
* or null if invalid (also sets the error message).
*/
fun validateAndGetPin(): String? {
return if (validate()) pin else null
}
/**
* Clears the PIN text and any error message.
*/
fun clear() {
pinEditText.text?.clear()
error = null
}
/**
* Requests focus and shows the soft keyboard.
*/
fun focus() {
pinEditText.requestFocus()
pinEditText.post {
val imm = context.getSystemService(InputMethodManager::class.java)
imm?.showSoftInput(pinEditText, InputMethodManager.SHOW_IMPLICIT)
}
}
/**
* Sets a callback for the IME "Done" action.
*/
fun setOnDoneAction(action: () -> Unit) {
pinEditText.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
action()
true
} else {
false
}
}
}
override fun setVisibility(visibility: Int) {
if (visibility != VISIBLE && pinEditText.hasFocus()) {
val imm = context.getSystemService(InputMethodManager::class.java)
imm?.hideSoftInputFromWindow(pinEditText.windowToken, 0)
}
super.setVisibility(visibility)
}
private fun applyKeyboardMode(animate: Boolean = true) {
val iconRes = if (useNumericKeyboard) R.drawable.keyboard_24 else R.drawable.dialpad_24
if (animate && isVisible) {
findViewById<View>(com.google.android.material.R.id.text_input_start_icon)?.let { iconView ->
iconView.animate()
.alpha(0f)
.setDuration(ICON_ANIMATION_DURATION)
.withEndAction {
setStartIconDrawable(iconRes)
iconView.alpha = 1f
}
.start()
} ?: setStartIconDrawable(iconRes)
} else {
setStartIconDrawable(iconRes)
}
pinEditText.inputType = if (useNumericKeyboard) {
InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
} else {
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
}
private fun onKeyboardModeToggled() {
useNumericKeyboard = !useNumericKeyboard
onKeyboardModeChanged?.invoke(useNumericKeyboard)
pinEditText.post {
val imm = context.getSystemService(InputMethodManager::class.java)
imm?.restartInput(pinEditText)
}
}
private companion object {
const val ICON_ANIMATION_DURATION = 100L
}
}

View File

@@ -69,25 +69,13 @@
android:visibility="gone"
android:layout_marginBottom="16dp" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/pinInputLayout"
<pl.lebihan.authnkey.PinInputField
android:id="@+id/pinInputField"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:visibility="gone"
android:hint="@string/pin_hint"
app:startIconDrawable="@drawable/keyboard_24"
app:startIconContentDescription="@string/toggle_keyboard_type"
app:endIconMode="password_toggle"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/pinEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionDone" />
</com.google.android.material.textfield.TextInputLayout>
style="@style/Widget.Material3.TextInputLayout.OutlinedBox" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnContinue"

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="PinInputField">
<attr name="pinHint" format="string" />
<attr name="minPinLength" format="integer" />
</declare-styleable>
</resources>