diff --git a/src/app/modules/keycard_channel/constants.nim b/src/app/modules/keycard_channel/constants.nim
deleted file mode 100644
index 523aa7b4f28..00000000000
--- a/src/app/modules/keycard_channel/constants.nim
+++ /dev/null
@@ -1,9 +0,0 @@
-## Constants for keycard channel operational states
-## These values must match the strings emitted by status-keycard-qt
-
-const KEYCARD_CHANNEL_STATE_IDLE* = "idle"
-const KEYCARD_CHANNEL_STATE_WAITING_FOR_KEYCARD* = "waiting-for-keycard"
-const KEYCARD_CHANNEL_STATE_READING* = "reading"
-const KEYCARD_CHANNEL_STATE_ERROR* = "error"
-
-
diff --git a/src/app/modules/keycard_channel/module.nim b/src/app/modules/keycard_channel/module.nim
index 79b77c47857..26f8956aa81 100644
--- a/src/app/modules/keycard_channel/module.nim
+++ b/src/app/modules/keycard_channel/module.nim
@@ -3,10 +3,8 @@ import nimqml
import io_interface, view, controller
import app/global/global_singleton
import app/core/eventemitter
-import ./constants
export io_interface
-export constants
type
Module* = ref object of io_interface.AccessInterface
diff --git a/src/app/modules/keycard_channel/view.nim b/src/app/modules/keycard_channel/view.nim
index 51128d5e7ee..5e1e24a4b6e 100644
--- a/src/app/modules/keycard_channel/view.nim
+++ b/src/app/modules/keycard_channel/view.nim
@@ -1,7 +1,6 @@
import nimqml
import ./io_interface
-import ./constants
QtObject:
type
@@ -14,7 +13,6 @@ QtObject:
proc newView*(delegate: io_interface.AccessInterface): View =
new(result, delete)
result.delegate = delegate
- result.keycardChannelState = KEYCARD_CHANNEL_STATE_IDLE
result.setup()
proc load*(self: View) =
@@ -33,27 +31,6 @@ QtObject:
write = setKeycardChannelState
notify = keycardChannelStateChanged
- # Constants for channel states (readonly properties for QML)
- proc getStateIdle*(self: View): string {.slot.} =
- return KEYCARD_CHANNEL_STATE_IDLE
- QtProperty[string] stateIdle:
- read = getStateIdle
-
- proc getStateWaitingForKeycard*(self: View): string {.slot.} =
- return KEYCARD_CHANNEL_STATE_WAITING_FOR_KEYCARD
- QtProperty[string] stateWaitingForKeycard:
- read = getStateWaitingForKeycard
-
- proc getStateReading*(self: View): string {.slot.} =
- return KEYCARD_CHANNEL_STATE_READING
- QtProperty[string] stateReading:
- read = getStateReading
-
- proc getStateError*(self: View): string {.slot.} =
- return KEYCARD_CHANNEL_STATE_ERROR
- QtProperty[string] stateError:
- read = getStateError
-
proc setup(self: View) =
self.QObject.setup
diff --git a/storybook/pages/KeycardChannelDrawerPage.qml b/storybook/pages/KeycardChannelDrawerPage.qml
new file mode 100644
index 00000000000..fb7952c0409
--- /dev/null
+++ b/storybook/pages/KeycardChannelDrawerPage.qml
@@ -0,0 +1,222 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+import Storybook
+
+import StatusQ.Core
+import StatusQ.Core.Theme
+import StatusQ.Controls
+import StatusQ.Components
+
+import shared.popups
+
+SplitView {
+ id: root
+
+ orientation: Qt.Horizontal
+
+ Logs { id: logs }
+
+ // Helper timers for test scenarios
+ Timer {
+ id: timer1
+ interval: 1500
+ onTriggered: {
+ if (root.currentScenario === "success") {
+ logs.logEvent("Changing to reading state")
+ stateCombo.currentIndex = 2 // reading
+ timer2.start()
+ } else if (root.currentScenario === "error") {
+ logs.logEvent("Changing to reading state")
+ stateCombo.currentIndex = 2 // reading
+ timer2.start()
+ } else if (root.currentScenario === "quick") {
+ logs.logEvent("Quick change to reading")
+ stateCombo.currentIndex = 2 // reading
+ timer2.start()
+ }
+ }
+ }
+
+ Timer {
+ id: timer2
+ interval: root.currentScenario === "quick" ? 300 : 1500
+ onTriggered: {
+ if (root.currentScenario === "success") {
+ logs.logEvent("Changing to idle state (success)")
+ stateCombo.currentIndex = 0 // idle (will trigger success)
+ } else if (root.currentScenario === "error") {
+ logs.logEvent("Changing to error state")
+ stateCombo.currentIndex = 3 // error
+ } else if (root.currentScenario === "quick") {
+ logs.logEvent("Quick change to idle (success)")
+ stateCombo.currentIndex = 0 // idle
+ }
+ root.currentScenario = ""
+ }
+ }
+
+ property string currentScenario: ""
+
+ Item {
+ SplitView.fillWidth: true
+ SplitView.fillHeight: true
+
+ KeycardChannelDrawer {
+ id: drawer
+
+ currentState: stateCombo.currentValue
+ closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+ onDismissed: {
+ logs.logEvent("KeycardChannelDrawer::dismissed()")
+ }
+ }
+ }
+
+ LogsAndControlsPanel {
+ id: logsAndControlsPanel
+
+ SplitView.preferredWidth: 350
+ SplitView.fillHeight: true
+
+ logsView.logText: logs.logText
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Theme.padding
+
+ // State control section
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Theme.halfPadding
+
+ Label {
+ Layout.preferredWidth: 120
+ text: "Current state:"
+ }
+
+ ComboBox {
+ id: stateCombo
+ Layout.fillWidth: true
+
+ textRole: "text"
+ valueRole: "value"
+
+ model: ListModel {
+ ListElement { text: "Idle"; value: "idle" }
+ ListElement { text: "Waiting for Keycard"; value: "waiting-for-keycard" }
+ ListElement { text: "Reading"; value: "reading" }
+ ListElement { text: "Error"; value: "error" }
+ }
+
+ currentIndex: 0
+ }
+ }
+
+ // State info display
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.preferredHeight: infoColumn.implicitHeight + Theme.padding * 2
+ color: Theme.palette.baseColor5
+ radius: Theme.radius
+ border.width: 1
+ border.color: Theme.palette.baseColor2
+
+ ColumnLayout {
+ id: infoColumn
+ anchors.fill: parent
+ anchors.margins: Theme.padding
+ spacing: Theme.halfPadding
+
+ StatusBaseText {
+ Layout.fillWidth: true
+ text: "State Information"
+ font.bold: true
+ font.pixelSize: Theme.primaryTextFontSize
+ }
+
+ StatusBaseText {
+ Layout.fillWidth: true
+ text: "Current: %1".arg(stateCombo.currentText)
+ font.pixelSize: Theme.tertiaryTextFontSize
+ color: Theme.palette.baseColor1
+ }
+
+ StatusBaseText {
+ Layout.fillWidth: true
+ text: "Opened: %1".arg(drawer.opened ? "Yes" : "No")
+ font.pixelSize: Theme.tertiaryTextFontSize
+ color: Theme.palette.baseColor1
+ }
+ }
+ }
+
+ // Scenario buttons section
+ Label {
+ Layout.fillWidth: true
+ Layout.topMargin: Theme.padding
+ text: "Test Scenarios:"
+ font.bold: true
+ }
+
+ Button {
+ Layout.fillWidth: true
+ text: "Simulate Success Flow"
+ onClicked: {
+ logs.logEvent("Starting success flow simulation")
+ root.currentScenario = "success"
+ stateCombo.currentIndex = 1 // waiting-for-keycard
+ timer1.start()
+ }
+ }
+
+ Button {
+ Layout.fillWidth: true
+ text: "Simulate Error Flow"
+ onClicked: {
+ logs.logEvent("Starting error flow simulation")
+ root.currentScenario = "error"
+ stateCombo.currentIndex = 1 // waiting-for-keycard
+ timer1.start()
+ }
+ }
+
+ Button {
+ Layout.fillWidth: true
+ text: "Simulate Quick State Changes"
+ onClicked: {
+ logs.logEvent("Testing state queue with rapid changes")
+ root.currentScenario = "quick"
+ stateCombo.currentIndex = 1 // waiting-for-keycard
+ timer1.interval = 300
+ timer1.start()
+ }
+ }
+
+ Button {
+ Layout.fillWidth: true
+ text: "Open Drawer Manually"
+ onClicked: {
+ logs.logEvent("Manually opening drawer")
+ drawer.open()
+ }
+ }
+
+ Button {
+ Layout.fillWidth: true
+ text: "Clear Logs"
+ onClicked: logs.clear()
+ }
+
+ Item {
+ Layout.fillHeight: true
+ }
+ }
+ }
+}
+
+// category: Popups
+// status: good
+
diff --git a/ui/StatusQ/src/StatusQ/Controls/StatusPinInput.qml b/ui/StatusQ/src/StatusQ/Controls/StatusPinInput.qml
index fa355bd3fa9..9b4bf6c2cf3 100644
--- a/ui/StatusQ/src/StatusQ/Controls/StatusPinInput.qml
+++ b/ui/StatusQ/src/StatusQ/Controls/StatusPinInput.qml
@@ -102,6 +102,13 @@ Item {
*/
property int additionalSpacing: 0
+ /*!
+ \qmlproperty flags StatusPinInput::inputMethodHints
+ This property allows you to customize the input method hints for the virtual keyboard.
+ The default value is Qt.ImhNone which allows any input based on the validator.
+ */
+ property int inputMethodHints: Qt.ImhNone
+
signal pinEditedManually()
QtObject {
@@ -158,9 +165,10 @@ Item {
Convenient method to force active focus in case it gets stolen by any other component.
*/
function forceFocus() {
- if (Utils.isMobile)
- return
inputText.forceActiveFocus()
+ if (Qt.inputMethod.visible == false) {
+ Qt.inputMethod.show()
+ }
d.activateBlink()
}
@@ -208,10 +216,14 @@ Item {
TextInput {
id: inputText
objectName: "pinInputTextInput"
- visible: false
- focus: !Utils.isMobile
+ visible: true
+ // Set explicit dimensions for Android keyboard input to work
+ width: 1
+ height: 1
+ opacity: 0
maximumLength: root.pinLen
- validator: d.statusValidator.validatorObj
+ inputMethodHints: root.inputMethodHints
+ // validator: d.statusValidator.validatorObj
onTextChanged: {
// Modify state of current introduced character position:
if(text.length >= (d.currentPinIndex + 1)) {
diff --git a/ui/app/AppLayouts/Onboarding/components/LoginKeycardBox.qml b/ui/app/AppLayouts/Onboarding/components/LoginKeycardBox.qml
index 39ac90c954f..b7410eb5a85 100644
--- a/ui/app/AppLayouts/Onboarding/components/LoginKeycardBox.qml
+++ b/ui/app/AppLayouts/Onboarding/components/LoginKeycardBox.qml
@@ -113,6 +113,7 @@ Control {
objectName: "pinInput"
validator: StatusIntValidator { bottom: 0; top: 999999 }
visible: false
+ inputMethodHints: Qt.ImhDigitsOnly
onPinInputChanged: {
if (pinInput.length === 6) {
@@ -235,6 +236,7 @@ Control {
PropertyChanges {
target: pinInputField
visible: true
+ focus: true
}
PropertyChanges {
target: background
@@ -251,4 +253,9 @@ Control {
}
}
]
+
+ TapHandler {
+ enabled: pinInputField.visible
+ onTapped: pinInputField.forceFocus()
+ }
}
diff --git a/ui/app/AppLayouts/Onboarding/pages/KeycardEnterPinPage.qml b/ui/app/AppLayouts/Onboarding/pages/KeycardEnterPinPage.qml
index 1ba5a2ae6ba..266718a53b9 100644
--- a/ui/app/AppLayouts/Onboarding/pages/KeycardEnterPinPage.qml
+++ b/ui/app/AppLayouts/Onboarding/pages/KeycardEnterPinPage.qml
@@ -161,6 +161,7 @@ KeycardBasePage {
anchors.horizontalCenter: parent.horizontalCenter
pinLen: Constants.keycard.general.keycardPinLength
validator: StatusIntValidator { bottom: 0; top: 999999 }
+ inputMethodHints: Qt.ImhDigitsOnly
onPinInputChanged: {
if (pinInput.pinInput.length === pinInput.pinLen) {
root.authorizationRequested(pinInput.pinInput)
diff --git a/ui/i18n/qml_base_en.ts b/ui/i18n/qml_base_en.ts
index b5124cca84e..b9bf1ba2fe5 100644
--- a/ui/i18n/qml_base_en.ts
+++ b/ui/i18n/qml_base_en.ts
@@ -8888,6 +8888,45 @@ L2 fee: %2
+
+ KeycardChannelDrawer
+
+ Please tap your Keycard to the back of your device
+
+
+
+ Reading Keycard
+
+
+
+ Please keep your Keycard in place
+
+
+
+ Success
+
+
+
+ Keycard operation completed successfully
+
+
+
+ Keycard Error
+
+
+
+ An error occurred. Please try again.
+
+
+
+ Dismiss
+
+
+
+ Ready to scan
+
+
+
KeycardConfirmation
diff --git a/ui/i18n/qml_base_lokalise_en.ts b/ui/i18n/qml_base_lokalise_en.ts
index a7165318228..099b638ea7f 100644
--- a/ui/i18n/qml_base_lokalise_en.ts
+++ b/ui/i18n/qml_base_lokalise_en.ts
@@ -10844,6 +10844,54 @@
A key pair is your shareable public address and a secret private key that controls your wallet. Your key pair is being generated on your Keycard — keep it plugged in until the process completes.
+
+ KeycardChannelDrawer
+
+ Please tap your Keycard to the back of your device
+ KeycardChannelDrawer
+ Please tap your Keycard to the back of your device
+
+
+ Reading Keycard
+ KeycardChannelDrawer
+ Reading Keycard
+
+
+ Please keep your Keycard in place
+ KeycardChannelDrawer
+ Please keep your Keycard in place
+
+
+ Success
+ KeycardChannelDrawer
+ Success
+
+
+ Keycard operation completed successfully
+ KeycardChannelDrawer
+ Keycard operation completed successfully
+
+
+ Keycard Error
+ KeycardChannelDrawer
+ Keycard Error
+
+
+ An error occurred. Please try again.
+ KeycardChannelDrawer
+ An error occurred. Please try again.
+
+
+ Dismiss
+ KeycardChannelDrawer
+ Dismiss
+
+
+ Ready to scan
+ KeycardChannelDrawer
+ Ready to scan
+
+
KeycardConfirmation
diff --git a/ui/i18n/qml_cs.ts b/ui/i18n/qml_cs.ts
index 504db3a2a1d..3461ba06b7f 100644
--- a/ui/i18n/qml_cs.ts
+++ b/ui/i18n/qml_cs.ts
@@ -8938,6 +8938,45 @@ L2 poplatek: %2
Pár klíčů je vaše sdílitelná veřejná adresa a tajný soukromý klíč, který ovládá vaši peněženku. Váš pár klíčů se generuje na vaší Keycard – nechte ji připojenou, dokud proces neskončí.
+
+ KeycardChannelDrawer
+
+ Please tap your Keycard to the back of your device
+
+
+
+ Reading Keycard
+
+
+
+ Please keep your Keycard in place
+
+
+
+ Success
+
+
+
+ Keycard operation completed successfully
+
+
+
+ Keycard Error
+
+
+
+ An error occurred. Please try again.
+
+
+
+ Dismiss
+
+
+
+ Ready to scan
+
+
+
KeycardConfirmation
diff --git a/ui/i18n/qml_es.ts b/ui/i18n/qml_es.ts
index 4ec9d16d3fd..07a3789878d 100644
--- a/ui/i18n/qml_es.ts
+++ b/ui/i18n/qml_es.ts
@@ -8903,6 +8903,45 @@ Tarifa L2: %2
Un par de claves es tu dirección pública compartible y una clave privada secreta que controla tu billetera. Tu par de claves se está generando en tu Keycard — manténlo conectado hasta que el proceso se complete.
+
+ KeycardChannelDrawer
+
+ Please tap your Keycard to the back of your device
+
+
+
+ Reading Keycard
+
+
+
+ Please keep your Keycard in place
+
+
+
+ Success
+
+
+
+ Keycard operation completed successfully
+
+
+
+ Keycard Error
+
+
+
+ An error occurred. Please try again.
+
+
+
+ Dismiss
+
+
+
+ Ready to scan
+
+
+
KeycardConfirmation
diff --git a/ui/i18n/qml_ko.ts b/ui/i18n/qml_ko.ts
index 51771bcf355..f4f5fbbc8b5 100644
--- a/ui/i18n/qml_ko.ts
+++ b/ui/i18n/qml_ko.ts
@@ -8867,6 +8867,45 @@ L2 수수료: %2
키 페어는 다른 사람과 공유할 수 있는 공개 주소와, 지갑을 제어하는 비밀 개인 키로 이루어져 있습니다. 지금 Keycard에서 키 페어를 생성 중입니다 — 과정이 끝날 때까지 분리하지 마세요.
+
+ KeycardChannelDrawer
+
+ Please tap your Keycard to the back of your device
+
+
+
+ Reading Keycard
+
+
+
+ Please keep your Keycard in place
+
+
+
+ Success
+
+
+
+ Keycard operation completed successfully
+
+
+
+ Keycard Error
+
+
+
+ An error occurred. Please try again.
+
+
+
+ Dismiss
+
+
+
+ Ready to scan
+
+
+
KeycardConfirmation
diff --git a/ui/imports/shared/popups/KeycardChannelDrawer.qml b/ui/imports/shared/popups/KeycardChannelDrawer.qml
new file mode 100644
index 00000000000..829764339dc
--- /dev/null
+++ b/ui/imports/shared/popups/KeycardChannelDrawer.qml
@@ -0,0 +1,155 @@
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Controls
+
+import StatusQ.Core
+import StatusQ.Core.Theme
+import StatusQ.Components
+import StatusQ.Controls
+import StatusQ.Popups.Dialog
+
+/**
+ * @brief A drawer that displays the current keycard channel state.
+ *
+ * This channel drawer will inform the user about the current keycard channel state.
+ * It is built to avoid flashing the drawer when the state changes and allow the user to see the keycard states.
+ * The drawer will display the current state and the next state will be displayed after a short delay.
+ * The drawer will close automatically after the success, error or idle state is displayed.
+ * Some states can be dismissed by the user.
+ */
+
+StatusDialog {
+ id: root
+
+ // ============================================================
+ // PUBLIC API
+ // ============================================================
+
+ /// The current keycard channel state from the backend
+ /// Expected values: "idle", "waiting-for-keycard", "reading", "error"
+ property string currentState: ""
+
+ /// Emitted when the user dismisses the drawer without completing the operation
+ signal dismissed()
+
+ // ============================================================
+ // STATE MANAGEMENT
+ // ============================================================
+
+ KeycardChannelStateManager {
+ id: stateManager
+ backendState: root.currentState
+
+ onReadyToOpen: {
+ if (!root.opened) {
+ root.open()
+ }
+ }
+
+ onReadyToClose: {
+ root.close()
+ }
+ }
+
+ // ============================================================
+ // DIALOG CONFIGURATION
+ // ============================================================
+
+ closePolicy: Popup.NoAutoClose
+ modal: true
+
+ header: null
+ footer: null
+ padding: Theme.padding
+
+ implicitWidth: 480
+
+ // ============================================================
+ // CONTENT
+ // ============================================================
+
+ contentItem: ColumnLayout {
+ id: content
+ spacing: Theme.padding
+
+ // State display
+ KeycardStateDisplay {
+ id: stateDisplay
+ Layout.fillWidth: true
+ Layout.preferredHeight: 300
+ opacity: stateManager.displayState !== "" ? 1 : 0
+
+ states: [
+ State {
+ name: "waiting"
+ when: stateManager.displayState === stateManager.stateWaitingForCard
+ PropertyChanges {
+ target: stateDisplay
+ iconSource: Assets.png("onboarding/carousel/keycard")
+ title: qsTr("Ready to scan")
+ description: qsTr("Please tap your Keycard to the back of your device")
+ isError: false
+ showLoading: false
+ }
+ },
+ State {
+ name: "reading"
+ when: stateManager.displayState === stateManager.stateReading
+ PropertyChanges {
+ target: stateDisplay
+ iconSource: Assets.png("onboarding/status_generate_keycard")
+ title: qsTr("Reading Keycard")
+ description: qsTr("Please keep your Keycard in place")
+ isError: false
+ showLoading: true
+ }
+ },
+ State {
+ name: "success"
+ when: stateManager.displayState === stateManager.stateSuccess
+ PropertyChanges {
+ target: stateDisplay
+ iconSource: Assets.png("onboarding/status_key")
+ title: qsTr("Success")
+ description: qsTr("Keycard operation completed successfully")
+ isError: false
+ showLoading: false
+ }
+ },
+ State {
+ name: "error"
+ when: stateManager.displayState === stateManager.stateError
+ PropertyChanges {
+ target: stateDisplay
+ iconSource: Assets.png("onboarding/status_generate_keys")
+ title: qsTr("Keycard Error")
+ description: qsTr("An error occurred. Please try again.")
+ isError: true
+ showLoading: false
+ }
+ }
+ ]
+ }
+
+ // Dismiss button (only show when not in success state)
+ StatusButton {
+ Layout.fillWidth: true
+ Layout.topMargin: Theme.halfPadding
+ Layout.leftMargin: Theme.xlPadding * 2
+ Layout.rightMargin: Theme.xlPadding * 2
+ opacity: stateManager.displayState !== "success" && stateManager.displayState !== "" ? 1 : 0
+ text: qsTr("Dismiss")
+ type: StatusButton.Type.Normal
+
+ onClicked: {
+ stateManager.clearAndClose()
+ root.dismissed()
+ }
+ }
+
+ Item {
+ Layout.fillHeight: true
+ Layout.minimumHeight: Theme.padding
+ }
+ }
+}
diff --git a/ui/imports/shared/popups/KeycardChannelStateManager.qml b/ui/imports/shared/popups/KeycardChannelStateManager.qml
new file mode 100644
index 00000000000..d9e4ca2aced
--- /dev/null
+++ b/ui/imports/shared/popups/KeycardChannelStateManager.qml
@@ -0,0 +1,195 @@
+import QtQuick
+
+import utils
+
+/**
+ * @brief State manager for KeycardChannelDrawer
+ *
+ * Manages the queue-based state transitions for keycard operations.
+ * Handles timing, state transitions, and provides signals for UI updates.
+ * This component is separate from the UI to enable independent testing
+ * and maintain separation of concerns.
+ */
+QtObject {
+ id: root
+
+ // ============================================================
+ // PUBLIC API
+ // ============================================================
+
+ /// Input: Backend state from the keycard system
+ /// Expected values: "idle", "waiting-for-keycard", "reading", "error"
+ property string backendState: Constants.keycardChannelState.idle
+
+ /// Output: Current display state for the UI
+ /// Values: "", "waiting-for-card", "reading", "success", "error"
+ readonly property string displayState: d.displayState
+
+ /// Configuration: Minimum time to show each state (ms)
+ property int minimumStateDuration: 600
+
+ /// Configuration: How long to show success before closing (ms)
+ property int successDisplayDuration: 1200
+
+ // Display states definition
+ // These are slightly different from the backend states
+ readonly property string stateSuccess: "success"
+ readonly property string stateIdle: ""
+ readonly property string stateReading: Constants.keycardChannelState.reading
+ readonly property string stateError: Constants.keycardChannelState.error
+ readonly property string stateWaitingForCard: Constants.keycardChannelState.waitingForKeycard
+
+ /// Signals
+ signal readyToOpen() // Drawer should open
+ signal readyToClose() // Drawer should close
+
+ /// Public method: Clear queue and reset to idle
+ function clearAndClose() {
+ d.clearAndClose()
+ }
+
+ // ============================================================
+ // INTERNAL IMPLEMENTATION
+ // ============================================================
+
+ property QtObject d: QtObject {
+ // Current display state (what the user sees)
+ property string displayState: stateIdle
+
+ // State queue - stores states to be displayed
+ property var stateQueue: []
+
+ // Track previous backend state for success detection
+ property string previousBackendState: Constants.keycardChannelState.idle
+
+ /// Map backend state to display state
+ function mapBackendStateToDisplayState(backendState) {
+ switch(backendState) {
+ case Constants.keycardChannelState.waitingForKeycard:
+ case Constants.keycardChannelState.reading:
+ case Constants.keycardChannelState.error:
+ return backendState
+ case Constants.keycardChannelState.idle:
+ // Success detection: were we just reading?
+ if (previousBackendState === Constants.keycardChannelState.reading) {
+ return stateSuccess
+ }
+ return stateIdle
+ default:
+ return stateIdle
+ }
+ }
+
+ /// Add a state to the queue
+ function enqueueState(state) {
+ // Don't queue if it's the same as the last queued state
+ if (stateQueue.length > 0 && stateQueue[stateQueue.length - 1] === state) {
+ return
+ }
+
+ // Don't queue if it's the same as current display state and queue is empty
+ if (stateQueue.length === 0 && state === displayState) {
+ return
+ }
+
+ stateQueue.push(state)
+
+ // If timer not running, start processing immediately
+ if (!stateTimer.running) {
+ processNextState()
+ }
+ }
+
+ /// Process the next state from the queue
+ function processNextState() {
+ if (stateQueue.length === 0) {
+ return
+ }
+
+ const nextState = stateQueue.shift() // Remove and get first item
+
+ // Set the display state
+ displayState = nextState
+
+ // Signal to open drawer if showing a state
+ if (nextState !== stateIdle) {
+ root.readyToOpen()
+ }
+
+ // Determine timer duration based on state
+ if (nextState === stateSuccess) {
+ stateTimer.interval = root.successDisplayDuration
+ } else if (nextState === stateIdle) {
+ // Closing - signal to close drawer
+ root.readyToClose()
+ // Clear any remaining queue (stale states from before completion)
+ if (stateQueue.length > 0) {
+ processNextState()
+ }
+ return
+ } else {
+ stateTimer.interval = root.minimumStateDuration
+ }
+
+ // Start timer for next transition
+ stateTimer.restart()
+ }
+
+ /// Handle backend state changes
+ function onBackendStateChanged() {
+ const newDisplayState = mapBackendStateToDisplayState(root.backendState)
+
+ // Special handling: Backend went to idle unexpectedly (not after reading)
+ // Clear everything and close immediately
+ if (newDisplayState === stateIdle && displayState !== stateSuccess) {
+ stateQueue = []
+ stateTimer.stop()
+ displayState = stateIdle
+ previousBackendState = root.backendState
+ root.readyToClose()
+ return // Don't process further
+ }
+
+ // Update previous state tracking
+ previousBackendState = root.backendState
+
+ // Enqueue the new state
+ enqueueState(newDisplayState)
+
+ // If we just enqueued success, also enqueue idle to close the drawer after
+ if (newDisplayState === stateSuccess) {
+ enqueueState(stateIdle)
+ }
+ }
+
+ /// Clear queue and reset to idle
+ function clearAndClose() {
+ stateQueue = []
+ stateTimer.stop()
+ displayState = stateIdle
+ root.readyToClose()
+ }
+ }
+
+ // Single timer that handles all state transitions
+ property Timer stateTimer: Timer {
+ id: stateTimer
+ repeat: false
+ onTriggered: {
+ // When timer fires, move to next state in queue
+ d.processNextState()
+ }
+ }
+
+ // Watch for backend state changes - push to queue
+ onBackendStateChanged: {
+ d.onBackendStateChanged()
+ }
+
+ // Initialize on component load
+ Component.onCompleted: {
+ d.previousBackendState = root.backendState
+ d.onBackendStateChanged()
+ }
+}
+
diff --git a/ui/imports/shared/popups/KeycardStateDisplay.qml b/ui/imports/shared/popups/KeycardStateDisplay.qml
new file mode 100644
index 00000000000..d7bb342cf6b
--- /dev/null
+++ b/ui/imports/shared/popups/KeycardStateDisplay.qml
@@ -0,0 +1,89 @@
+import QtQuick
+import QtQuick.Layouts
+
+import StatusQ.Core
+import StatusQ.Core.Theme
+import StatusQ.Components
+
+import shared
+
+/// Reusable component for displaying a state in the KeycardChannelDrawer
+/// Shows an icon, title, and description in a consistent layout
+Item {
+ id: root
+
+ // ============================================================
+ // PUBLIC API
+ // ============================================================
+
+ /// Path to the icon image
+ property string iconSource: ""
+
+ /// Main title text
+ property string title: ""
+
+ /// Description text below the title
+ property string description: ""
+
+ /// Whether this is an error state (affects text color)
+ property bool isError: false
+
+ /// Whether to show a loading animation
+ property bool showLoading: false
+
+ implicitWidth: layout.implicitWidth
+ implicitHeight: layout.implicitHeight
+
+ // ============================================================
+ // INTERNAL LAYOUT
+ // ============================================================
+
+ ColumnLayout {
+ id: layout
+ anchors.centerIn: parent
+ width: parent.width
+ spacing: Theme.padding
+
+ // Icon
+ StatusImage {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.preferredWidth: 164
+ Layout.preferredHeight: 164
+ source: root.iconSource
+ visible: root.iconSource !== ""
+ }
+
+ // Title
+ StatusBaseText {
+ Layout.fillWidth: true
+ Layout.topMargin: Theme.padding
+ horizontalAlignment: Text.AlignHCenter
+ text: root.title
+ font.pixelSize: Theme.fontSize25
+ font.bold: true
+ color: root.isError ? Theme.palette.dangerColor1 : Theme.palette.directColor1
+ wrapMode: Text.WordWrap
+ visible: root.title !== ""
+ }
+
+ // Description
+ StatusBaseText {
+ Layout.fillWidth: true
+ Layout.topMargin: Theme.halfPadding
+ horizontalAlignment: Text.AlignHCenter
+ text: root.description
+ font.pixelSize: Theme.primaryTextFontSize
+ color: Theme.palette.baseColor1
+ wrapMode: Text.WordWrap
+ visible: root.description !== ""
+ }
+
+ // Loading animation (shown for reading state)
+ LoadingAnimation {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.topMargin: Theme.padding
+ visible: root.showLoading
+ }
+ }
+}
+
diff --git a/ui/imports/shared/popups/qmldir b/ui/imports/shared/popups/qmldir
index f6835d28445..f11e01d968d 100644
--- a/ui/imports/shared/popups/qmldir
+++ b/ui/imports/shared/popups/qmldir
@@ -16,6 +16,9 @@ ImageContextMenu 1.0 ImageContextMenu.qml
ImageCropWorkflow 1.0 ImageCropWorkflow.qml
ImportCommunityPopup 1.0 ImportCommunityPopup.qml
InviteFriendsPopup 1.0 InviteFriendsPopup.qml
+KeycardChannelDrawer 1.0 KeycardChannelDrawer.qml
+KeycardChannelStateManager 1.0 KeycardChannelStateManager.qml
+KeycardStateDisplay 1.0 KeycardStateDisplay.qml
IntroduceYourselfPopup 1.0 IntroduceYourselfPopup.qml
MarkAsIDVerifiedDialog 1.0 MarkAsIDVerifiedDialog.qml
MarkAsUntrustedPopup 1.0 MarkAsUntrustedPopup.qml
diff --git a/ui/imports/shared/stores/KeycardStateStore.qml b/ui/imports/shared/stores/KeycardStateStore.qml
new file mode 100644
index 00000000000..5d29ddf4ad9
--- /dev/null
+++ b/ui/imports/shared/stores/KeycardStateStore.qml
@@ -0,0 +1,13 @@
+import QtQuick
+import utils
+
+QtObject {
+ id: root
+
+ readonly property var keycardChannelModuleInst: typeof keycardChannelModule !== "undefined" ? keycardChannelModule : null
+
+ // Channel state property
+ readonly property string state: keycardChannelModuleInst ? keycardChannelModuleInst.keycardChannelState : Constants.keycardChannelState.idle
+}
+
+
diff --git a/ui/imports/shared/stores/qmldir b/ui/imports/shared/stores/qmldir
index 2b838e77f8a..d6131c238bc 100644
--- a/ui/imports/shared/stores/qmldir
+++ b/ui/imports/shared/stores/qmldir
@@ -3,6 +3,7 @@ CommunityTokensStore 1.0 CommunityTokensStore.qml
CurrenciesStore 1.0 CurrenciesStore.qml
DAppsStore 1.0 DAppsStore.qml
GifStore 1.0 GifStore.qml
+KeycardStateStore 1.0 KeycardStateStore.qml
MetricsStore 1.0 MetricsStore.qml
NetworkConnectionStore 1.0 NetworkConnectionStore.qml
NetworksStore 1.0 NetworksStore.qml
diff --git a/ui/imports/utils/Constants.qml b/ui/imports/utils/Constants.qml
index 66e54163673..505d48dfd29 100644
--- a/ui/imports/utils/Constants.qml
+++ b/ui/imports/utils/Constants.qml
@@ -1432,4 +1432,12 @@ QtObject {
InProgress,
Completed
}
+
+ readonly property QtObject keycardChannelState: QtObject {
+ readonly property string idle: "idle"
+ readonly property string waitingForKeycard: "waiting-for-keycard"
+ readonly property string reading: "reading"
+ readonly property string error: "error"
+ }
+
}
diff --git a/ui/main.qml b/ui/main.qml
index b1f328af706..81762bb01da 100644
--- a/ui/main.qml
+++ b/ui/main.qml
@@ -52,6 +52,7 @@ Window {
readonly property UtilsStore utilsStore: UtilsStore {}
readonly property LanguageStore languageStore: LanguageStore {}
readonly property bool appThemeDark: Theme.style === Theme.Style.Dark
+ readonly property KeycardStateStore keycardStateStore: KeycardStateStore {}
readonly property bool portraitLayout: height > width
property bool biometricFlowPending: false
@@ -674,6 +675,15 @@ Window {
}
}
+
+ Loader {
+ active: SQUtils.Utils.isAndroid
+ sourceComponent: KeycardChannelDrawer {
+ id: keycardChannelDrawer
+ currentState: applicationWindow.keycardStateStore.state
+ }
+ }
+
Loader {
id: macOSSafeAreaLoader
anchors.left: parent.left