From 4105ef4e62b7efebabffb1f0be3b92dc02d93d82 Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Tue, 9 Dec 2025 18:05:30 +0200 Subject: [PATCH 1/4] feat(Keycard): Adding a keycard info drawer for Android to guide the keycard interactions Adding a `KeycardChannelDrawer` that's guiding the user whenever the keycard is needed. On IOS the system drawer is used --- storybook/pages/KeycardChannelDrawerPage.qml | 222 ++++++++++++ .../src/StatusQ/Controls/StatusPinInput.qml | 22 +- .../Onboarding/components/LoginKeycardBox.qml | 7 + .../Onboarding/pages/KeycardEnterPinPage.qml | 1 + ui/i18n/qml_base_en.ts | 39 +++ ui/i18n/qml_base_lokalise_en.ts | 48 +++ ui/i18n/qml_cs.ts | 39 +++ ui/i18n/qml_es.ts | 39 +++ ui/i18n/qml_ko.ts | 39 +++ .../shared/popups/KeycardChannelDrawer.qml | 320 ++++++++++++++++++ .../shared/popups/KeycardStateDisplay.qml | 77 +++++ ui/imports/shared/popups/qmldir | 2 + .../shared/stores/KeycardStateStore.qml | 24 ++ ui/imports/shared/stores/qmldir | 1 + ui/main.qml | 10 + 15 files changed, 885 insertions(+), 5 deletions(-) create mode 100644 storybook/pages/KeycardChannelDrawerPage.qml create mode 100644 ui/imports/shared/popups/KeycardChannelDrawer.qml create mode 100644 ui/imports/shared/popups/KeycardStateDisplay.qml create mode 100644 ui/imports/shared/stores/KeycardStateStore.qml 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..d6aacb965a8 100644 --- a/ui/i18n/qml_base_en.ts +++ b/ui/i18n/qml_base_en.ts @@ -8888,6 +8888,45 @@ L2 fee: %2 + + KeycardChannelDrawer + + Insert Keycard + + + + 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 + + + KeycardConfirmation diff --git a/ui/i18n/qml_base_lokalise_en.ts b/ui/i18n/qml_base_lokalise_en.ts index a7165318228..432a13ac0f3 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 + + Insert Keycard + KeycardChannelDrawer + Insert Keycard + + + 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 + + KeycardConfirmation diff --git a/ui/i18n/qml_cs.ts b/ui/i18n/qml_cs.ts index 504db3a2a1d..f7d416273d1 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 + + Insert Keycard + + + + 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 + + + KeycardConfirmation diff --git a/ui/i18n/qml_es.ts b/ui/i18n/qml_es.ts index 4ec9d16d3fd..52d1504ee49 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 + + Insert Keycard + + + + 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 + + + KeycardConfirmation diff --git a/ui/i18n/qml_ko.ts b/ui/i18n/qml_ko.ts index 51771bcf355..d746260230e 100644 --- a/ui/i18n/qml_ko.ts +++ b/ui/i18n/qml_ko.ts @@ -8867,6 +8867,45 @@ L2 수수료: %2 키 페어는 다른 사람과 공유할 수 있는 공개 주소와, 지갑을 제어하는 비밀 개인 키로 이루어져 있습니다. 지금 Keycard에서 키 페어를 생성 중입니다 — 과정이 끝날 때까지 분리하지 마세요. + + KeycardChannelDrawer + + Insert Keycard + + + + 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 + + + KeycardConfirmation diff --git a/ui/imports/shared/popups/KeycardChannelDrawer.qml b/ui/imports/shared/popups/KeycardChannelDrawer.qml new file mode 100644 index 00000000000..a81d263f021 --- /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("Insert Keycard") + 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 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 From a7e523d0c27dabd2caf23050c455cc47d06f6e23 Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Thu, 11 Dec 2025 15:50:22 +0200 Subject: [PATCH 2/4] fix: Update waiting for keycard text in the keycard drawer --- ui/i18n/qml_base_en.ts | 8 ++++---- ui/i18n/qml_base_lokalise_en.ts | 10 +++++----- ui/i18n/qml_cs.ts | 8 ++++---- ui/i18n/qml_es.ts | 8 ++++---- ui/i18n/qml_ko.ts | 8 ++++---- ui/imports/shared/popups/KeycardChannelDrawer.qml | 2 +- 6 files changed, 22 insertions(+), 22 deletions(-) diff --git a/ui/i18n/qml_base_en.ts b/ui/i18n/qml_base_en.ts index d6aacb965a8..b9bf1ba2fe5 100644 --- a/ui/i18n/qml_base_en.ts +++ b/ui/i18n/qml_base_en.ts @@ -8890,10 +8890,6 @@ L2 fee: %2 KeycardChannelDrawer - - Insert Keycard - - Please tap your Keycard to the back of your device @@ -8926,6 +8922,10 @@ L2 fee: %2 Dismiss + + Ready to scan + + KeycardConfirmation diff --git a/ui/i18n/qml_base_lokalise_en.ts b/ui/i18n/qml_base_lokalise_en.ts index 432a13ac0f3..099b638ea7f 100644 --- a/ui/i18n/qml_base_lokalise_en.ts +++ b/ui/i18n/qml_base_lokalise_en.ts @@ -10846,11 +10846,6 @@ KeycardChannelDrawer - - Insert Keycard - KeycardChannelDrawer - Insert Keycard - Please tap your Keycard to the back of your device KeycardChannelDrawer @@ -10891,6 +10886,11 @@ 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 f7d416273d1..3461ba06b7f 100644 --- a/ui/i18n/qml_cs.ts +++ b/ui/i18n/qml_cs.ts @@ -8940,10 +8940,6 @@ L2 poplatek: %2 KeycardChannelDrawer - - Insert Keycard - - Please tap your Keycard to the back of your device @@ -8976,6 +8972,10 @@ L2 poplatek: %2 Dismiss + + Ready to scan + + KeycardConfirmation diff --git a/ui/i18n/qml_es.ts b/ui/i18n/qml_es.ts index 52d1504ee49..07a3789878d 100644 --- a/ui/i18n/qml_es.ts +++ b/ui/i18n/qml_es.ts @@ -8905,10 +8905,6 @@ Tarifa L2: %2 KeycardChannelDrawer - - Insert Keycard - - Please tap your Keycard to the back of your device @@ -8941,6 +8937,10 @@ Tarifa L2: %2 Dismiss + + Ready to scan + + KeycardConfirmation diff --git a/ui/i18n/qml_ko.ts b/ui/i18n/qml_ko.ts index d746260230e..f4f5fbbc8b5 100644 --- a/ui/i18n/qml_ko.ts +++ b/ui/i18n/qml_ko.ts @@ -8869,10 +8869,6 @@ L2 수수료: %2 KeycardChannelDrawer - - Insert Keycard - - Please tap your Keycard to the back of your device @@ -8905,6 +8901,10 @@ L2 수수료: %2 Dismiss + + Ready to scan + + KeycardConfirmation diff --git a/ui/imports/shared/popups/KeycardChannelDrawer.qml b/ui/imports/shared/popups/KeycardChannelDrawer.qml index a81d263f021..069306582c9 100644 --- a/ui/imports/shared/popups/KeycardChannelDrawer.qml +++ b/ui/imports/shared/popups/KeycardChannelDrawer.qml @@ -226,7 +226,7 @@ StatusDialog { opacity: d.displayState === d.stateWaitingForCard ? 1 : 0 iconSource: Assets.png("onboarding/carousel/keycard") - title: qsTr("Insert Keycard") + title: qsTr("Ready to scan") description: qsTr("Please tap your Keycard to the back of your device") Behavior on opacity { From 5169c39e6fb02f10a66d25fafb405597d69b4313 Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Mon, 22 Dec 2025 11:39:36 +0200 Subject: [PATCH 3/4] fix(KeycardChannelDrawer): Separate the state machine from the UI components - use a single KeycardStateDisplay instance with the necessary states - The state queue, transitions and entire logic moved to KeycardChannelStateManager component --- .../shared/popups/KeycardChannelDrawer.qml | 291 ++++-------------- .../popups/KeycardChannelStateManager.qml | 194 ++++++++++++ .../shared/popups/KeycardStateDisplay.qml | 12 + ui/imports/shared/popups/qmldir | 1 + 4 files changed, 270 insertions(+), 228 deletions(-) create mode 100644 ui/imports/shared/popups/KeycardChannelStateManager.qml diff --git a/ui/imports/shared/popups/KeycardChannelDrawer.qml b/ui/imports/shared/popups/KeycardChannelDrawer.qml index 069306582c9..dbf7df3b289 100644 --- a/ui/imports/shared/popups/KeycardChannelDrawer.qml +++ b/ui/imports/shared/popups/KeycardChannelDrawer.qml @@ -12,7 +12,7 @@ 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. + * 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. @@ -33,167 +33,24 @@ StatusDialog { signal dismissed() // ============================================================ - // INTERNAL STATE MANAGEMENT - Queue-based approach + // STATE MANAGEMENT // ============================================================ - QtObject { - id: d + KeycardChannelStateManager { + id: stateManager + backendState: root.currentState - // 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) { + onReadyToOpen: { + if (!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 + onReadyToClose: { 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 // ============================================================ @@ -212,88 +69,66 @@ StatusDialog { // ============================================================ contentItem: ColumnLayout { + id: content spacing: Theme.padding - // State display area - Item { + // State display + KeycardStateDisplay { + id: stateDisplay 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 + Layout.preferredHeight: 300 + opacity: stateManager.displayState !== "" ? 1 : 0 + + states: [ + State { + name: "waiting" + when: stateManager.displayState === "waiting-for-card" + 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 } - } - } - - // 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 + }, + State { + name: "reading" + when: stateManager.displayState === "reading" + 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 } - } - } - - // 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 + }, + State { + name: "success" + when: stateManager.displayState === "success" + PropertyChanges { + target: stateDisplay + iconSource: Assets.png("onboarding/status_key") + title: qsTr("Success") + description: qsTr("Keycard operation completed successfully") + isError: false + showLoading: false } - } - } - - // 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 + }, + State { + name: "error" + when: stateManager.displayState === "error" + 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) @@ -302,19 +137,19 @@ StatusDialog { 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 + opacity: stateManager.displayState !== "success" && stateManager.displayState !== "" ? 1 : 0 text: qsTr("Dismiss") type: StatusButton.Type.Normal onClicked: { - d.clearAndClose() + 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..7e22309dbe7 --- /dev/null +++ b/ui/imports/shared/popups/KeycardChannelStateManager.qml @@ -0,0 +1,194 @@ +import QtQuick + +/** + * @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: "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 + + /// 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 { + // 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) { + 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 index 32d6ac5ec72..d7bb342cf6b 100644 --- a/ui/imports/shared/popups/KeycardStateDisplay.qml +++ b/ui/imports/shared/popups/KeycardStateDisplay.qml @@ -5,6 +5,8 @@ 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 { @@ -25,6 +27,9 @@ Item { /// 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 @@ -72,6 +77,13 @@ Item { 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 b0cfc59dbff..f11e01d968d 100644 --- a/ui/imports/shared/popups/qmldir +++ b/ui/imports/shared/popups/qmldir @@ -17,6 +17,7 @@ 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 From 1daf137f5bb855e463751fd50729391f3b9b407e Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Mon, 22 Dec 2025 17:06:30 +0200 Subject: [PATCH 4/4] fix: Move keycard channel state constants closer to where it's used The constants are moved from nim to qml --- src/app/modules/keycard_channel/constants.nim | 9 ----- src/app/modules/keycard_channel/module.nim | 2 -- src/app/modules/keycard_channel/view.nim | 23 ------------ .../shared/popups/KeycardChannelDrawer.qml | 10 +++--- .../popups/KeycardChannelStateManager.qml | 35 ++++++++++--------- .../shared/stores/KeycardStateStore.qml | 15 ++------ ui/imports/utils/Constants.qml | 8 +++++ 7 files changed, 33 insertions(+), 69 deletions(-) delete mode 100644 src/app/modules/keycard_channel/constants.nim 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/ui/imports/shared/popups/KeycardChannelDrawer.qml b/ui/imports/shared/popups/KeycardChannelDrawer.qml index dbf7df3b289..829764339dc 100644 --- a/ui/imports/shared/popups/KeycardChannelDrawer.qml +++ b/ui/imports/shared/popups/KeycardChannelDrawer.qml @@ -27,7 +27,7 @@ StatusDialog { /// The current keycard channel state from the backend /// Expected values: "idle", "waiting-for-keycard", "reading", "error" - property string currentState: "idle" + property string currentState: "" /// Emitted when the user dismisses the drawer without completing the operation signal dismissed() @@ -82,7 +82,7 @@ StatusDialog { states: [ State { name: "waiting" - when: stateManager.displayState === "waiting-for-card" + when: stateManager.displayState === stateManager.stateWaitingForCard PropertyChanges { target: stateDisplay iconSource: Assets.png("onboarding/carousel/keycard") @@ -94,7 +94,7 @@ StatusDialog { }, State { name: "reading" - when: stateManager.displayState === "reading" + when: stateManager.displayState === stateManager.stateReading PropertyChanges { target: stateDisplay iconSource: Assets.png("onboarding/status_generate_keycard") @@ -106,7 +106,7 @@ StatusDialog { }, State { name: "success" - when: stateManager.displayState === "success" + when: stateManager.displayState === stateManager.stateSuccess PropertyChanges { target: stateDisplay iconSource: Assets.png("onboarding/status_key") @@ -118,7 +118,7 @@ StatusDialog { }, State { name: "error" - when: stateManager.displayState === "error" + when: stateManager.displayState === stateManager.stateError PropertyChanges { target: stateDisplay iconSource: Assets.png("onboarding/status_generate_keys") diff --git a/ui/imports/shared/popups/KeycardChannelStateManager.qml b/ui/imports/shared/popups/KeycardChannelStateManager.qml index 7e22309dbe7..d9e4ca2aced 100644 --- a/ui/imports/shared/popups/KeycardChannelStateManager.qml +++ b/ui/imports/shared/popups/KeycardChannelStateManager.qml @@ -1,5 +1,7 @@ import QtQuick +import utils + /** * @brief State manager for KeycardChannelDrawer * @@ -17,7 +19,7 @@ QtObject { /// Input: Backend state from the keycard system /// Expected values: "idle", "waiting-for-keycard", "reading", "error" - property string backendState: "idle" + property string backendState: Constants.keycardChannelState.idle /// Output: Current display state for the UI /// Values: "", "waiting-for-card", "reading", "success", "error" @@ -28,6 +30,14 @@ QtObject { /// 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 @@ -43,13 +53,6 @@ QtObject { // ============================================================ property QtObject d: QtObject { - // 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 @@ -57,20 +60,18 @@ QtObject { property var stateQueue: [] // Track previous backend state for success detection - property string previousBackendState: "idle" + property string previousBackendState: Constants.keycardChannelState.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": + 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 === "reading") { + if (previousBackendState === Constants.keycardChannelState.reading) { return stateSuccess } return stateIdle diff --git a/ui/imports/shared/stores/KeycardStateStore.qml b/ui/imports/shared/stores/KeycardStateStore.qml index 1eba48c3244..5d29ddf4ad9 100644 --- a/ui/imports/shared/stores/KeycardStateStore.qml +++ b/ui/imports/shared/stores/KeycardStateStore.qml @@ -1,4 +1,5 @@ import QtQuick +import utils QtObject { id: root @@ -6,19 +7,7 @@ QtObject { 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 + readonly property string state: keycardChannelModuleInst ? keycardChannelModuleInst.keycardChannelState : Constants.keycardChannelState.idle } 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" + } + }