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 fa371bd8aee..6be4f42c943 100644
--- a/ui/i18n/qml_base_en.ts
+++ b/ui/i18n/qml_base_en.ts
@@ -2693,6 +2693,26 @@ Do you wish to override the security check and continue?
Zoom
+
+ Clear site data
+
+
+
+ Use it to reset the current site if it doesn't load or work properly.
+
+
+
+ Clearing cache...
+
+
+
+ Clear cache
+
+
+
+ Clears cached files, cookies, and history for the entire browser. Browsing is paused until it is done.
+
+
BrowserTabView
@@ -7622,13 +7642,6 @@ Please add it and try again.
-
- FeeRow
-
- Max.
-
-
-
FeesBox
@@ -8891,6 +8904,45 @@ L2 fee: %2
+
+ KeycardChannelDrawer
+
+ Please tap your Keycard to the back of your device
+
+
+
+ Reading Keycard
+
+
+
+ Please keep your Keycard in place
+
+
+
+ Keycard Error
+
+
+
+ An error occurred. Please try again.
+
+
+
+ Success
+
+
+
+ Keycard operation completed successfully
+
+
+
+ Dismiss
+
+
+
+ Ready to scan
+
+
+
KeycardConfirmation
@@ -8986,10 +9038,6 @@ Are you sure you want to do this?
PIN correct
-
- Keycard blocked
-
-
%n attempt(s) remaining
diff --git a/ui/i18n/qml_base_lokalise_en.ts b/ui/i18n/qml_base_lokalise_en.ts
index e883eb29140..fb2de900555 100644
--- a/ui/i18n/qml_base_lokalise_en.ts
+++ b/ui/i18n/qml_base_lokalise_en.ts
@@ -3298,6 +3298,31 @@
BrowserSettingsMenu
Zoom
+
+ Clear site data
+ BrowserSettingsMenu
+ Clear site data
+
+
+ Use it to reset the current site if it doesn't load or work properly.
+ BrowserSettingsMenu
+ Use it to reset the current site if it doesn't load or work properly.
+
+
+ Clearing cache...
+ BrowserSettingsMenu
+ Clearing cache...
+
+
+ Clear cache
+ BrowserSettingsMenu
+ Clear cache
+
+
+ Clears cached files, cookies, and history for the entire browser. Browsing is paused until it is done.
+ BrowserSettingsMenu
+ Clears cached files, cookies, and history for the entire browser. Browsing is paused until it is done.
+
BrowserTabView
@@ -9310,14 +9335,6 @@
Remove
-
- FeeRow
-
- Max.
- FeeRow
- Max.
-
-
FeesBox
@@ -10847,6 +10864,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
+
+
+ Keycard Error
+ KeycardChannelDrawer
+ Keycard Error
+
+
+ An error occurred. Please try again.
+ KeycardChannelDrawer
+ An error occurred. Please try again.
+
+
+ Success
+ KeycardChannelDrawer
+ Success
+
+
+ Keycard operation completed successfully
+ KeycardChannelDrawer
+ Keycard operation completed successfully
+
+
+ Dismiss
+ KeycardChannelDrawer
+ Dismiss
+
+
+ Ready to scan
+ KeycardChannelDrawer
+ Ready to scan
+
+
KeycardConfirmation
@@ -10959,11 +11024,6 @@
KeycardEnterPinPage
PIN correct
-
- Keycard blocked
- KeycardEnterPinPage
- Keycard blocked
-
%n attempt(s) remaining
KeycardEnterPinPage
diff --git a/ui/i18n/qml_cs.ts b/ui/i18n/qml_cs.ts
index bd7d952cf4e..771d537a425 100644
--- a/ui/i18n/qml_cs.ts
+++ b/ui/i18n/qml_cs.ts
@@ -2700,6 +2700,26 @@ Do you wish to override the security check and continue?
Zoom
+
+ Clear site data
+
+
+
+ Use it to reset the current site if it doesn't load or work properly.
+
+
+
+ Clearing cache...
+
+
+
+ Clear cache
+
+
+
+ Clears cached files, cookies, and history for the entire browser. Browsing is paused until it is done.
+
+
BrowserTabView
@@ -7649,13 +7669,6 @@ Please add it and try again.
Odstranit
-
- FeeRow
-
- Max.
-
-
-
FeesBox
@@ -8927,6 +8940,45 @@ L2 poplatek: %2
+
+ KeycardChannelDrawer
+
+ Please tap your Keycard to the back of your device
+
+
+
+ Reading Keycard
+
+
+
+ Please keep your Keycard in place
+
+
+
+ Keycard Error
+
+
+
+ An error occurred. Please try again.
+
+
+
+ Success
+
+
+
+ Keycard operation completed successfully
+
+
+
+ Dismiss
+
+
+
+ Ready to scan
+
+
+
KeycardConfirmation
@@ -9022,10 +9074,6 @@ Are you sure you want to do this?
PIN correct
-
- Keycard blocked
-
-
%n attempt(s) remaining
diff --git a/ui/i18n/qml_es.ts b/ui/i18n/qml_es.ts
index cb8528ff640..2753ae01ee2 100644
--- a/ui/i18n/qml_es.ts
+++ b/ui/i18n/qml_es.ts
@@ -2697,6 +2697,26 @@ Do you wish to override the security check and continue?
Settings
Ajustes
+
+ Clear site data
+
+
+
+ Use it to reset the current site if it doesn't load or work properly.
+
+
+
+ Clearing cache...
+
+
+
+ Clear cache
+
+
+
+ Clears cached files, cookies, and history for the entire browser. Browsing is paused until it is done.
+
+
BrowserTabView
@@ -7636,13 +7656,6 @@ Por favor, agrégala e intenta de nuevo.
Eliminar
-
- FeeRow
-
- Max.
-
-
-
FeesBox
@@ -8906,6 +8919,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
+
+
+
+ Keycard Error
+
+
+
+ An error occurred. Please try again.
+
+
+
+ Success
+
+
+
+ Keycard operation completed successfully
+
+
+
+ Dismiss
+
+
+
+ Ready to scan
+
+
+
KeycardConfirmation
@@ -9001,10 +9053,6 @@ Are you sure you want to do this?
PIN correct
PIN correcto
-
- Keycard blocked
-
-
%n attempt(s) remaining
diff --git a/ui/i18n/qml_ko.ts b/ui/i18n/qml_ko.ts
index 256d4bbe2a0..b6521fb9b8b 100644
--- a/ui/i18n/qml_ko.ts
+++ b/ui/i18n/qml_ko.ts
@@ -2470,6 +2470,10 @@ To backup you recovery phrase, write it down and store it securely in a safe pla
Backups are automatic (every 30 mins), secure (encrypted with your profile private key), and private (your data is stored <b>only</b> on your device).
백업은 자동(30분마다), 안전(프로필 개인 키로 암호화), 그리고 비공개(데이터는 <b>오직</b> 귀하의 기기에만 저장됨)입니다.
+
+ Choose a folder to store your backup files in.
+
+
BalanceExceeded
@@ -2685,6 +2689,26 @@ Do you wish to override the security check and continue?
Zoom
확대/축소
+
+ Clear site data
+
+
+
+ Use it to reset the current site if it doesn't load or work properly.
+
+
+
+ Clearing cache...
+
+
+
+ Clear cache
+
+
+
+ Clears cached files, cookies, and history for the entire browser. Browsing is paused until it is done.
+
+
BrowserTabView
@@ -6740,14 +6764,26 @@ Remember your password and don't share it with anyone.
Enable on-device message backup?
기기 내 메시지 백업을 활성화하시겠어요?
-
- On-device backups are:<br>Automatic – every 30 minutes<br>Secure – encrypted with your profile private key<br>Private – stored only on your device
- 기기 내 백업은:<br>자동 – 30분마다<br>안전 – 프로필 개인 키로 암호화<br>비공개 – 귀하의 기기에만 저장
-
Backups let you restore your 1-on-1, group, and community messages if you need to reinstall the app or switch devices. You can skip this step now and enable it anytime under: <i>Settings > On-device backup > Backup data</i>
백업을 사용하면 앱을 다시 설치하거나 기기를 전환해야 할 때 1:1, 그룹 및 커뮤니티 메시지를 복원할 수 있어요. 지금 이 단계를 건너뛰고 나중에 언제든지 <i>설정 > 기기 내 백업 > 데이터 백업</i>에서 활성화할 수 있어요.
+
+ Enable on-device backup?
+
+
+
+ On-device backups are:<br><b>Automatic</b> – created every 30 minutes<br><b>Secure</b> – encrypted with your profile’s private key<br><b>Private</b> – stored only on your device
+
+
+
+ To enable backups, choose a folder to store your backup files under the <b>Backup location</b> setting.<br><br>You can also <b>optionally</b> back up your <b>1-on-1, group, and community messages</b> by turning on the <b>Backup your messages</b> toggle under the <b>Backup data</b> setting.
+
+
+
+ Go to settings
+
+
EnsAddedView
@@ -7592,13 +7628,6 @@ Please add it and try again.
제거
-
- FeeRow
-
- Max.
-
-
-
FeesBox
@@ -8854,6 +8883,45 @@ L2 수수료: %2
키 페어는 다른 사람과 공유할 수 있는 공개 주소와, 지갑을 제어하는 비밀 개인 키로 이루어져 있습니다. 지금 Keycard에서 키 페어를 생성 중입니다 — 과정이 끝날 때까지 분리하지 마세요.
+
+ KeycardChannelDrawer
+
+ Please tap your Keycard to the back of your device
+
+
+
+ Reading Keycard
+
+
+
+ Please keep your Keycard in place
+
+
+
+ Keycard Error
+
+
+
+ An error occurred. Please try again.
+
+
+
+ Success
+
+
+
+ Keycard operation completed successfully
+
+
+
+ Dismiss
+
+
+
+ Ready to scan
+
+
+
KeycardConfirmation
@@ -8951,10 +9019,6 @@ Are you sure you want to do this?
PIN correct
PIN이 올바릅니다
-
- Keycard blocked
- Keycard가 차단됨
-
%n attempt(s) remaining
diff --git a/ui/imports/shared/popups/KeycardChannelDrawer.qml b/ui/imports/shared/popups/KeycardChannelDrawer.qml
new file mode 100644
index 00000000000..069306582c9
--- /dev/null
+++ b/ui/imports/shared/popups/KeycardChannelDrawer.qml
@@ -0,0 +1,320 @@
+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 flasing 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: "idle"
+
+ /// Emitted when the user dismisses the drawer without completing the operation
+ signal dismissed()
+
+ // ============================================================
+ // INTERNAL STATE MANAGEMENT - Queue-based approach
+ // ============================================================
+
+ QtObject {
+ id: d
+
+ // Timing constants
+ readonly property int minimumStateDuration: 600 // ms - minimum time to show each state
+ readonly property int successDisplayDuration: 1200 // ms - how long to show success before closing
+ readonly property int transitionDuration: 50 // ms - fade animation duration
+
+ // Display states (internal representation)
+ readonly property string stateWaitingForCard: "waiting-for-card"
+ readonly property string stateReading: "reading"
+ readonly property string stateSuccess: "success"
+ readonly property string stateError: "error"
+ readonly property string stateIdle: "" // empty = not showing anything
+
+ // 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: "idle"
+
+ /// Map backend state to display state
+ function mapBackendStateToDisplayState(backendState) {
+ switch(backendState) {
+ case "waiting-for-keycard":
+ return stateWaitingForCard
+ case "reading":
+ return stateReading
+ case "error":
+ return stateError
+ case "idle":
+ // Success detection: were we just reading?
+ if (previousBackendState === "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) {
+ console.log("KeycardChannelDrawer: Skipping duplicate state in queue")
+ return
+ }
+
+ // Don't queue if it's the same as current display state and queue is empty
+ if (stateQueue.length === 0 && state === displayState) {
+ console.log("KeycardChannelDrawer: Skipping - same as current display state")
+ 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
+
+ // Open drawer if showing a state
+ if (nextState !== stateIdle && !root.opened) {
+ root.open()
+ }
+
+ // Determine timer duration based on state
+ if (nextState === stateSuccess) {
+ stateTimer.interval = successDisplayDuration
+ } else if (nextState === stateIdle) {
+ // Closing - clear any remaining queue (stale states from before completion)
+ root.close()
+ if (stateQueue.length > 0) {
+ processNextState()
+ }
+ return
+ } else {
+ stateTimer.interval = minimumStateDuration
+ }
+
+ // Start timer for next transition
+ stateTimer.restart()
+ }
+
+ /// Handle backend state changes
+ function onBackendStateChanged() {
+ const newDisplayState = mapBackendStateToDisplayState(root.currentState)
+
+ // Special handling: Backend went to idle unexpectedly (not after reading)
+ // Clear everything and close immediately
+ if (newDisplayState === stateIdle && displayState !== stateSuccess) {
+ console.log("KeycardChannelDrawer: Unexpected idle, clearing and closing")
+ stateQueue = []
+ stateTimer.stop()
+ displayState = stateIdle
+ previousBackendState = root.currentState
+ root.close()
+ return // Don't process further
+ }
+
+ // Update previous state tracking
+ previousBackendState = root.currentState
+
+ // 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.close()
+ }
+ }
+
+ // Single timer that handles all state transitions
+ 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
+ onCurrentStateChanged: {
+ d.onBackendStateChanged()
+ }
+
+ // Initialize on component load
+ Component.onCompleted: {
+ d.previousBackendState = root.currentState
+ d.onBackendStateChanged()
+ }
+
+ // ============================================================
+ // DIALOG CONFIGURATION
+ // ============================================================
+
+ closePolicy: Popup.NoAutoClose
+ modal: true
+
+ header: null
+ footer: null
+ padding: Theme.padding
+
+ implicitWidth: 480
+
+ // ============================================================
+ // CONTENT
+ // ============================================================
+
+ contentItem: ColumnLayout {
+ spacing: Theme.padding
+
+ // State display area
+ Item {
+ Layout.fillWidth: true
+ Layout.preferredHeight: 300
+ // Waiting for card state
+ KeycardStateDisplay {
+ id: waitingDisplay
+ anchors.fill: parent
+ visible: opacity > 0
+ opacity: d.displayState === d.stateWaitingForCard ? 1 : 0
+
+ iconSource: Assets.png("onboarding/carousel/keycard")
+ title: qsTr("Ready to scan")
+ description: qsTr("Please tap your Keycard to the back of your device")
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: d.transitionDuration
+ easing.type: Easing.InOutQuad
+ }
+ }
+ }
+
+ // Reading state
+ KeycardStateDisplay {
+ id: readingDisplay
+ anchors.fill: parent
+ visible: opacity > 0
+ opacity: d.displayState === d.stateReading ? 1 : 0
+
+ iconSource: Assets.png("onboarding/status_generate_keycard")
+ title: qsTr("Reading Keycard")
+ description: qsTr("Please keep your Keycard in place")
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: d.transitionDuration
+ easing.type: Easing.InOutQuad
+ }
+ }
+ }
+
+ // Success state
+ KeycardStateDisplay {
+ id: successDisplay
+ anchors.fill: parent
+ visible: opacity > 0
+ opacity: d.displayState === d.stateSuccess ? 1 : 0
+
+ iconSource: Assets.png("onboarding/status_key")
+ title: qsTr("Success")
+ description: qsTr("Keycard operation completed successfully")
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: d.transitionDuration
+ easing.type: Easing.InOutQuad
+ }
+ }
+ }
+
+ // Error state
+ KeycardStateDisplay {
+ id: errorDisplay
+ anchors.fill: parent
+ visible: opacity > 0
+ opacity: d.displayState === d.stateError ? 1 : 0
+
+ iconSource: Assets.png("onboarding/status_generate_keys")
+ title: qsTr("Keycard Error")
+ description: qsTr("An error occurred. Please try again.")
+ isError: true
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: d.transitionDuration
+ easing.type: Easing.InOutQuad
+ }
+ }
+ }
+ }
+
+ // 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
+ // Preserve the spacing for the button even if it's not visible
+ opacity: d.displayState !== d.stateSuccess && d.displayState !== d.stateIdle ? 1 : 0
+ text: qsTr("Dismiss")
+ type: StatusButton.Type.Normal
+
+ onClicked: {
+ d.clearAndClose()
+ root.dismissed()
+ }
+ }
+
+ Item {
+ Layout.fillHeight: true
+ }
+ }
+}
diff --git a/ui/imports/shared/popups/KeycardStateDisplay.qml b/ui/imports/shared/popups/KeycardStateDisplay.qml
new file mode 100644
index 00000000000..32d6ac5ec72
--- /dev/null
+++ b/ui/imports/shared/popups/KeycardStateDisplay.qml
@@ -0,0 +1,77 @@
+import QtQuick
+import QtQuick.Layouts
+
+import StatusQ.Core
+import StatusQ.Core.Theme
+import StatusQ.Components
+
+/// 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
+
+ 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 !== ""
+ }
+ }
+}
+
diff --git a/ui/imports/shared/popups/qmldir b/ui/imports/shared/popups/qmldir
index f6835d28445..b0cfc59dbff 100644
--- a/ui/imports/shared/popups/qmldir
+++ b/ui/imports/shared/popups/qmldir
@@ -16,6 +16,8 @@ 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
+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..1eba48c3244
--- /dev/null
+++ b/ui/imports/shared/stores/KeycardStateStore.qml
@@ -0,0 +1,24 @@
+import QtQuick
+
+QtObject {
+ id: root
+
+ readonly property var keycardChannelModuleInst: typeof keycardChannelModule !== "undefined" ? keycardChannelModule : null
+
+ // Channel state property
+ readonly property string state: keycardChannelModuleInst ? keycardChannelModuleInst.keycardChannelState : "idle"
+
+ // State constants (for convenience)
+ readonly property string stateIdle: keycardChannelModuleInst ? keycardChannelModuleInst.stateIdle : "idle"
+ readonly property string stateWaitingForKeycard: keycardChannelModuleInst ? keycardChannelModuleInst.stateWaitingForKeycard : "waiting-for-keycard"
+ readonly property string stateReading: keycardChannelModuleInst ? keycardChannelModuleInst.stateReading : "reading"
+ readonly property string stateError: keycardChannelModuleInst ? keycardChannelModuleInst.stateError : "error"
+
+ // Helper properties for common state checks
+ readonly property bool isIdle: state === stateIdle
+ readonly property bool isWaitingForKeycard: state === stateWaitingForKeycard
+ readonly property bool isReading: state === stateReading
+ readonly property bool isError: state === stateError
+}
+
+
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/main.qml b/ui/main.qml
index 08a5d44c508..da202287f61 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