diff --git a/app/src/main/java/pl/lebihan/authnkey/CredentialBottomSheet.kt b/app/src/main/java/pl/lebihan/authnkey/CredentialBottomSheet.kt index ca635e8..0e4e74a 100644 --- a/app/src/main/java/pl/lebihan/authnkey/CredentialBottomSheet.kt +++ b/app/src/main/java/pl/lebihan/authnkey/CredentialBottomSheet.kt @@ -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(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 { diff --git a/app/src/main/java/pl/lebihan/authnkey/PinInputField.kt b/app/src/main/java/pl/lebihan/authnkey/PinInputField.kt new file mode 100644 index 0000000..7344f90 --- /dev/null +++ b/app/src/main/java/pl/lebihan/authnkey/PinInputField.kt @@ -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(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 + } +} diff --git a/app/src/main/res/layout/bottom_sheet_credential.xml b/app/src/main/res/layout/bottom_sheet_credential.xml index 7ea05fd..5415d18 100644 --- a/app/src/main/res/layout/bottom_sheet_credential.xml +++ b/app/src/main/res/layout/bottom_sheet_credential.xml @@ -69,25 +69,13 @@ android:visibility="gone" android:layout_marginBottom="16dp" /> - - - - - + style="@style/Widget.Material3.TextInputLayout.OutlinedBox" /> + + + + + +