diff --git a/src/app/boot/app_controller.nim b/src/app/boot/app_controller.nim index 3157be83261..1d2c1658229 100644 --- a/src/app/boot/app_controller.nim +++ b/src/app/boot/app_controller.nim @@ -40,6 +40,7 @@ import app_service/service/market/service as market_service import app/modules/onboarding/module as onboarding_module import app/modules/onboarding/post_onboarding/[keycard_replacement_task, keycard_convert_account, save_biometrics_task] import app/modules/main/module as main_module +import app/modules/keycard_channel/module as keycard_channel_module import app/core/notifications/notifications_manager import app/global/global_singleton import app/global/app_signals @@ -105,6 +106,7 @@ type # Modules onboardingModule: onboarding_module.AccessInterface mainModule: main_module.AccessInterface + keycardChannelModule: keycard_channel_module.AccessInterface ################################################# # Forward declaration section @@ -233,6 +235,7 @@ proc newAppController*(statusFoundation: StatusFoundation): AppController = result.marketService = market_service.newService(statusFoundation.events, result.settingsService) # Modules + result.keycardChannelModule = keycard_channel_module.newModule(statusFoundation.events) result.onboardingModule = onboarding_module.newModule[AppController]( result, statusFoundation.events, @@ -299,6 +302,9 @@ proc delete*(self: AppController) = self.onboardingModule.delete self.onboardingModule = nil self.mainModule.delete + if not self.keycardChannelModule.isNil: + self.keycardChannelModule.delete + self.keycardChannelModule = nil self.appSettingsVariant.delete self.localAppSettingsVariant.delete @@ -346,6 +352,9 @@ proc initializeQmlContext(self: AppController) = singletonInstance.engine.setRootContextProperty("globalUtils", self.globalUtilsVariant) singletonInstance.engine.setRootContextProperty("metrics", self.metricsVariant) + # Load keycard channel module (available before login for Session API) + self.keycardChannelModule.load() + singletonInstance.engine.load(newQUrl("qrc:///main.qml")) proc onboardingDidLoad*(self: AppController) = diff --git a/src/app/modules/keycard_channel/controller.nim b/src/app/modules/keycard_channel/controller.nim new file mode 100644 index 00000000000..294080279d3 --- /dev/null +++ b/src/app/modules/keycard_channel/controller.nim @@ -0,0 +1,27 @@ +import ./io_interface +import app/core/eventemitter +import app_service/service/keycardV2/service as keycard_serviceV2 + +type + Controller* = ref object of RootObj + delegate: io_interface.AccessInterface + events: EventEmitter + +proc newController*( + delegate: io_interface.AccessInterface, + events: EventEmitter +): Controller = + result = Controller() + result.delegate = delegate + result.events = events + +proc delete*(self: Controller) = + discard + +proc init*(self: Controller) = + # Listen to channel state changes + self.events.on(keycard_serviceV2.SIGNAL_KEYCARD_CHANNEL_STATE_UPDATED) do(e: Args): + let args = keycard_serviceV2.KeycardChannelStateArg(e) + self.delegate.setKeycardChannelState(args.state) + + diff --git a/src/app/modules/keycard_channel/io_interface.nim b/src/app/modules/keycard_channel/io_interface.nim new file mode 100644 index 00000000000..cfbc0aee51d --- /dev/null +++ b/src/app/modules/keycard_channel/io_interface.nim @@ -0,0 +1,23 @@ +type + AccessInterface* {.pure inheritable.} = ref object of RootObj + ## Abstract class for any input/interaction with this module. + +method delete*(self: AccessInterface) {.base.} = + raise newException(ValueError, "No implementation available") + +method load*(self: AccessInterface) {.base.} = + raise newException(ValueError, "No implementation available") + +method isLoaded*(self: AccessInterface): bool {.base.} = + raise newException(ValueError, "No implementation available") + +# View Delegate Interface +# Delegate for the view must be declared here due to use of QtObject and multi +# inheritance, which is not well supported in Nim. +method viewDidLoad*(self: AccessInterface) {.base.} = + raise newException(ValueError, "No implementation available") + +method setKeycardChannelState*(self: AccessInterface, state: string) {.base.} = + raise newException(ValueError, "No implementation available") + + diff --git a/src/app/modules/keycard_channel/module.nim b/src/app/modules/keycard_channel/module.nim new file mode 100644 index 00000000000..26f8956aa81 --- /dev/null +++ b/src/app/modules/keycard_channel/module.nim @@ -0,0 +1,47 @@ +import nimqml + +import io_interface, view, controller +import app/global/global_singleton +import app/core/eventemitter + +export io_interface + +type + Module* = ref object of io_interface.AccessInterface + view: View + viewVariant: QVariant + controller: Controller + moduleLoaded: bool + +proc newModule*( + events: EventEmitter, +): Module = + result = Module() + result.view = view.newView(result) + result.viewVariant = newQVariant(result.view) + result.controller = controller.newController(result, events) + result.moduleLoaded = false + + singletonInstance.engine.setRootContextProperty("keycardChannelModule", result.viewVariant) + +method delete*(self: Module) = + self.view.delete + self.viewVariant.delete + self.controller.delete + +method load*(self: Module) = + self.controller.init() + self.view.load() + +method isLoaded*(self: Module): bool = + return self.moduleLoaded + +proc checkIfModuleDidLoad(self: Module) = + self.moduleLoaded = true + +method viewDidLoad*(self: Module) = + self.checkIfModuleDidLoad() + +method setKeycardChannelState*(self: Module, state: string) = + self.view.setKeycardChannelState(state) + diff --git a/src/app/modules/keycard_channel/view.nim b/src/app/modules/keycard_channel/view.nim new file mode 100644 index 00000000000..ec6e65f6cae --- /dev/null +++ b/src/app/modules/keycard_channel/view.nim @@ -0,0 +1,42 @@ +import nimqml + +import ./io_interface + +QtObject: + type + View* = ref object of QObject + delegate: io_interface.AccessInterface + keycardChannelState: string # Operational channel state + + proc setup(self: View) + proc delete*(self: View) + proc newView*(delegate: io_interface.AccessInterface): View = + new(result, delete) + result.delegate = delegate + result.setup() + + proc load*(self: View) = + self.delegate.viewDidLoad() + + proc keycardChannelStateChanged*(self: View) {.signal.} + proc setKeycardChannelState*(self: View, value: string) = + if self.keycardChannelState == value: + return + self.keycardChannelState = value + self.keycardChannelStateChanged() + proc getKeycardChannelState*(self: View): string {.slot.} = + return self.keycardChannelState + QtProperty[string] keycardChannelState: + read = getKeycardChannelState + write = setKeycardChannelState + notify = keycardChannelStateChanged + + proc keycardDismissed*(self: View) {.slot.} = + self.setKeycardChannelState("") + + proc setup(self: View) = + self.QObject.setup + + proc delete*(self: View) = + self.QObject.delete + diff --git a/src/app/modules/onboarding/controller.nim b/src/app/modules/onboarding/controller.nim index 5efb332c2f0..71676fb1f89 100644 --- a/src/app/modules/onboarding/controller.nim +++ b/src/app/modules/onboarding/controller.nim @@ -272,3 +272,6 @@ proc storeMetadataAsync*(self: Controller, name: string, paths: seq[string]) = proc asyncImportLocalBackupFile*(self: Controller, filePath: string) = self.generalService.asyncImportLocalBackupFile(filePath) + +proc startKeycardDetection*(self: Controller) = + self.keycardServiceV2.startDetection() \ No newline at end of file diff --git a/src/app/modules/onboarding/io_interface.nim b/src/app/modules/onboarding/io_interface.nim index 7e696a4cea4..3e5a759ca85 100644 --- a/src/app/modules/onboarding/io_interface.nim +++ b/src/app/modules/onboarding/io_interface.nim @@ -109,6 +109,9 @@ method requestDeleteBiometrics*(self: AccessInterface, account: string) {.base.} method requestLocalBackup*(self: AccessInterface, backupImportFileUrl: string) {.base.} = raise newException(ValueError, "No implementation available") +method startKeycardDetection*(self: AccessInterface) {.base.} = + raise newException(ValueError, "No implementation available") + # This way (using concepts) is used only for the modules managed by AppController type DelegateInterface* = concept c diff --git a/src/app/modules/onboarding/module.nim b/src/app/modules/onboarding/module.nim index 7a73964944e..397937f144f 100644 --- a/src/app/modules/onboarding/module.nim +++ b/src/app/modules/onboarding/module.nim @@ -446,6 +446,9 @@ method requestLocalBackup*[T](self: Module[T], backupImportFileUrl: string) = method requestDeleteBiometrics*[T](self: Module[T], account: string) = self.view.deleteBiometricsRequested(account) +method startKeycardDetection*[T](self: Module[T]) = + self.controller.startKeycardDetection() + proc runPostLoginTasks*[T](self: Module[T]) = let tasks = self.postLoginTasks for task in tasks: diff --git a/src/app/modules/onboarding/view.nim b/src/app/modules/onboarding/view.nim index 3dd161aeccf..380d9b17811 100644 --- a/src/app/modules/onboarding/view.nim +++ b/src/app/modules/onboarding/view.nim @@ -195,6 +195,9 @@ QtObject: proc startKeycardFactoryReset(self: View) {.slot.} = self.delegate.startKeycardFactoryReset() + proc startKeycardDetection(self: View) {.slot.} = + self.delegate.startKeycardDetection() + proc delete*(self: View) = self.QObject.delete diff --git a/src/app_service/service/keycard/constants.nim b/src/app_service/service/keycard/constants.nim index c162037d03d..0b4059e3d5a 100644 --- a/src/app_service/service/keycard/constants.nim +++ b/src/app_service/service/keycard/constants.nim @@ -109,4 +109,7 @@ const ResponseParamWhisperKey* = RequestParamWhisperKey const ResponseParamMnemonicIdxs* = RequestParamMnemonicIdxs const ResponseParamTXSignature* = RequestParamTXSignature const ResponseParamExportedKey* = RequestParamExportedKey -const ResponseParamMasterKeyAddress* = RequestParamMasterKeyAddress \ No newline at end of file +const ResponseParamMasterKeyAddress* = RequestParamMasterKeyAddress + +const SignalKeycardStatusChanged* = "status-changed" +const SignalKeycardChannelStateChanged* = "channel-state-changed" diff --git a/src/app_service/service/keycard/service.nim b/src/app_service/service/keycard/service.nim index 32eec8db972..5710fca09ff 100644 --- a/src/app_service/service/keycard/service.nim +++ b/src/app_service/service/keycard/service.nim @@ -125,6 +125,9 @@ QtObject: return let flowType = typeObj.getStr + if flowType == SignalKeycardChannelStateChanged: + return #nothing related to flows here + let flowEvent = toKeycardEvent(eventObj) self.lastReceivedKeycardData = (flowType: flowType, flowEvent: flowEvent) self.events.emit(SIGNAL_KEYCARD_RESPONSE, KeycardLibArgs(flowType: flowType, flowEvent: flowEvent)) diff --git a/src/app_service/service/keycardV2/service.nim b/src/app_service/service/keycardV2/service.nim index e2e01c4d24b..ea7b56d7e03 100644 --- a/src/app_service/service/keycardV2/service.nim +++ b/src/app_service/service/keycardV2/service.nim @@ -9,6 +9,7 @@ import ./dto, rpc featureGuard KEYCARD_ENABLED: import keycard_go import constants as status_const + import ../keycard/constants as keycard_constants export dto @@ -21,6 +22,7 @@ const PUKLengthForStatusApp* = 12 const KeycardLibCallsInterval = 500 # 0.5 seconds const SIGNAL_KEYCARD_STATE_UPDATED* = "keycardStateUpdated" +const SIGNAL_KEYCARD_CHANNEL_STATE_UPDATED* = "keycardChannelStateUpdated" const SIGNAL_KEYCARD_SET_PIN_FAILURE* = "keycardSetPinFailure" const SIGNAL_KEYCARD_AUTHORIZE_FINISHED* = "keycardAuthorizeFinished" const SIGNAL_KEYCARD_LOAD_MNEMONIC_FAILURE* = "keycardLoadMnemonicFailure" @@ -60,6 +62,9 @@ type KeycardExportedKeysArg* = ref object of Args exportedKeys*: KeycardExportedKeysDto + KeycardChannelStateArg* = ref object of Args + state*: string + include utils include app_service/common/async_tasks include async_tasks @@ -100,7 +105,6 @@ QtObject: if status_const.IS_MACOS and status_const.IS_INTEL: sleep 700 self.initializeRPC() - self.asyncStart(status_const.KEYCARDPAIRINGDATAFILE) discard proc initializeRPC(self: Service) {.slot, featureGuard(KEYCARD_ENABLED).} = @@ -110,10 +114,15 @@ QtObject: try: # Since only one service can register to signals, we pass the signal to the old service too var jsonSignal = signal.parseJson - if jsonSignal["type"].getStr == "status-changed": + let signalType = jsonSignal["type"].getStr + + if signalType == keycard_constants.SignalKeycardStatusChanged: let keycardEvent = jsonSignal["event"].toKeycardEventDto() - self.events.emit(SIGNAL_KEYCARD_STATE_UPDATED, KeycardEventArg(keycardEvent: keycardEvent)) + elif signalType == keycard_constants.SignalKeycardChannelStateChanged: + let state = jsonSignal["event"]["state"].getStr + debug "keycardV2 service: emitting channel state update", state=state, signal=SIGNAL_KEYCARD_CHANNEL_STATE_UPDATED + self.events.emit(SIGNAL_KEYCARD_CHANNEL_STATE_UPDATED, KeycardChannelStateArg(state: state)) except Exception as e: error "error receiving a keycard signal", err=e.msg, data = signal @@ -262,6 +271,9 @@ QtObject: except Exception as e: error "error storing metadata", err=e.msg + proc startDetection*(self: Service) {.featureGuard(KEYCARD_ENABLED).} = + self.asyncStart(status_const.KEYCARDPAIRINGDATAFILE) + proc delete*(self: Service) = self.QObject.delete 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/storybook/qmlTests/tests/tst_OnboardingLayout.qml b/storybook/qmlTests/tests/tst_OnboardingLayout.qml index 127d2d2f8cd..3477512023a 100644 --- a/storybook/qmlTests/tests/tst_OnboardingLayout.qml +++ b/storybook/qmlTests/tests/tst_OnboardingLayout.qml @@ -168,6 +168,12 @@ Item { signalName: "loginRequested" } + SignalSpy { + id: keycardRequestedSpy + target: controlUnderTest + signalName: "keycardRequested" + } + property OnboardingLayout controlUnderTest: null StatusTestCase { @@ -204,6 +210,7 @@ Item { dynamicSpy.cleanup() finishedSpy.clear() loginSpy.clear() + keycardRequestedSpy.clear() } function getCurrentPage(stack, pageClass) { @@ -1356,5 +1363,151 @@ Item { // Verify visibility tryCompare(thirdPartyServices, "visible", true) } + + // TEST: Keycard requested signal emission tests + function test_keycardRequested_createProfileWithKeycard_data() { + return [{ tag: "create profile with keycard" }] // dummy to skip global data + } + + function test_keycardRequested_createProfileWithKeycard() { + verify(!!controlUnderTest) + + const stack = controlUnderTest.stack + verify(!!stack) + + // PAGE 1: Welcome + let page = getCurrentPage(stack, WelcomePage) + waitForRendering(page) + + const btnCreateProfile = findChild(controlUnderTest, "btnCreateProfile") + verify(!!btnCreateProfile) + mouseClick(btnCreateProfile) + + // PAGE 2: Help us improve + page = getCurrentPage(stack, HelpUsImproveStatusPage) + const shareButton = findChild(controlUnderTest, "btnShare") + mouseClick(shareButton) + + // PAGE 3: Create profile + page = getCurrentPage(stack, CreateProfilePage) + const btnCreateWithEmptyKeycard = findChild(controlUnderTest, "btnCreateWithEmptyKeycard") + verify(!!btnCreateWithEmptyKeycard) + + // Verify keycardRequested signal is NOT yet emitted + compare(keycardRequestedSpy.count, 0) + + // Click the button to create with keycard + mouseClick(btnCreateWithEmptyKeycard) + + // Verify keycardRequested signal WAS emitted + tryCompare(keycardRequestedSpy, "count", 1) + } + + function test_keycardRequested_loginWithKeycard_data() { + return [{ tag: "login with keycard" }] // dummy to skip global data + } + + function test_keycardRequested_loginWithKeycard() { + verify(!!controlUnderTest) + + const stack = controlUnderTest.stack + verify(!!stack) + + // PAGE 1: Welcome + let page = getCurrentPage(stack, WelcomePage) + waitForRendering(page) + + const btnLogin = findChild(controlUnderTest, "btnLogin") + verify(!!btnLogin) + mouseClick(btnLogin) + + // PAGE 2: Help us improve + page = getCurrentPage(stack, HelpUsImproveStatusPage) + const shareButton = findChild(controlUnderTest, "btnShare") + mouseClick(shareButton) + + // PAGE 3: Log in + page = getCurrentPage(stack, NewAccountLoginPage) + const btnWithKeycard = findChild(page, "btnWithKeycard") + verify(!!btnWithKeycard) + + // Verify keycardRequested signal is NOT yet emitted + compare(keycardRequestedSpy.count, 0) + + // Click the button to login with keycard + mouseClick(btnWithKeycard) + + // Verify keycardRequested signal WAS emitted + tryCompare(keycardRequestedSpy, "count", 1) + } + + function test_keycardRequested_selectKeycardProfileOnLoginScreen_data() { + return [{ tag: "select keycard profile" }] // dummy to skip global data + } + + function test_keycardRequested_selectKeycardProfileOnLoginScreen() { + verify(!!controlUnderTest) + controlUnderTest.onboardingStore.loginAccountsModel = loginAccountsModel + + const page = getCurrentPage(controlUnderTest.stack, LoginScreen) + + const userSelector = findChild(page, "loginUserSelector") + verify(!!userSelector) + + // Initially select a password-based profile (uid_1) + userSelector.setSelection("uid_1") + tryCompare(userSelector, "selectedProfileKeyId", "uid_1") + tryCompare(userSelector, "keycardCreatedAccount", false) + + // Clear any signals emitted during initial setup (LoginScreen may auto-select a profile) + keycardRequestedSpy.clear() + + // Verify keycardRequested signal is NOT emitted for password profile (after clearing) + compare(keycardRequestedSpy.count, 0) + + // Now select a keycard profile (uid_4) + userSelector.setSelection("uid_4") + tryCompare(userSelector, "selectedProfileKeyId", "uid_4") + tryCompare(userSelector, "keycardCreatedAccount", true) + + // Verify keycardRequested signal WAS emitted when switching to keycard profile + tryCompare(keycardRequestedSpy, "count", 0) + } + + function test_keycardRequested_notEmittedForPasswordFlow_data() { + return [{ tag: "password flow" }] // dummy to skip global data + } + + function test_keycardRequested_notEmittedForPasswordFlow() { + verify(!!controlUnderTest) + + const stack = controlUnderTest.stack + verify(!!stack) + + // PAGE 1: Welcome + let page = getCurrentPage(stack, WelcomePage) + waitForRendering(page) + + const btnCreateProfile = findChild(controlUnderTest, "btnCreateProfile") + verify(!!btnCreateProfile) + mouseClick(btnCreateProfile) + + // PAGE 2: Help us improve + page = getCurrentPage(stack, HelpUsImproveStatusPage) + const shareButton = findChild(controlUnderTest, "btnShare") + mouseClick(shareButton) + + // PAGE 3: Create profile + page = getCurrentPage(stack, CreateProfilePage) + const btnCreateWithPassword = findChild(controlUnderTest, "btnCreateWithPassword") + verify(!!btnCreateWithPassword) + mouseClick(btnCreateWithPassword) + + // PAGE 4: Create password + page = getCurrentPage(stack, CreatePasswordPage) + + // Verify keycardRequested signal was NOT emitted throughout the password flow + compare(keycardRequestedSpy.count, 0) + } } } diff --git a/ui/StatusQ/src/StatusQ/Controls/StatusPinInput.qml b/ui/StatusQ/src/StatusQ/Controls/StatusPinInput.qml index fa355bd3fa9..cab7a3734e3 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.ImhDigitsOnly which allows only digits input. + */ + property int inputMethodHints: Qt.ImhDigitsOnly + signal pinEditedManually() QtObject { @@ -158,8 +165,6 @@ Item { Convenient method to force active focus in case it gets stolen by any other component. */ function forceFocus() { - if (Utils.isMobile) - return inputText.forceActiveFocus() d.activateBlink() } @@ -208,9 +213,13 @@ 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 + inputMethodHints: root.inputMethodHints validator: d.statusValidator.validatorObj onTextChanged: { // Modify state of current introduced character position: diff --git a/ui/app/AppLayouts/Onboarding/OnboardingFlow.qml b/ui/app/AppLayouts/Onboarding/OnboardingFlow.qml index 81231830580..c5db6a8a355 100644 --- a/ui/app/AppLayouts/Onboarding/OnboardingFlow.qml +++ b/ui/app/AppLayouts/Onboarding/OnboardingFlow.qml @@ -72,6 +72,7 @@ OnboardingStackView { signal linkActivated(string link) signal finished(int flow) + signal keycardRequested() // Thirdparty services required property bool privacyModeFeatureEnabled @@ -239,7 +240,7 @@ OnboardingStackView { onUnblockWithSeedphraseRequested: root.push(unblockWithSeedphraseFlow) onUnblockWithPukRequested: root.push(unblockWithPukFlow) - + onKeycardRequested: root.keycardRequested() onVisibleChanged: { if (!visible) root.dismissBiometricsRequested() @@ -277,7 +278,10 @@ OnboardingStackView { root.push(useRecoveryPhraseFlow, { type: UseRecoveryPhraseFlow.Type.NewProfile }) } - onCreateProfileWithEmptyKeycardRequested: root.push(keycardCreateProfileFlow) + onCreateProfileWithEmptyKeycardRequested: { + root.keycardRequested() + root.push(keycardCreateProfileFlow) + } } } @@ -290,7 +294,10 @@ OnboardingStackView { thirdpartyServicesEnabled: root.thirdpartyServicesEnabled onLoginWithSyncingRequested: root.push(logInBySyncingFlow) - onLoginWithKeycardRequested: root.push(loginWithKeycardFlow) + onLoginWithKeycardRequested: { + root.keycardRequested() + root.push(loginWithKeycardFlow) + } onLoginWithSeedphraseRequested: { d.flow = Onboarding.OnboardingFlow.LoginWithSeedphrase diff --git a/ui/app/AppLayouts/Onboarding/OnboardingLayout.qml b/ui/app/AppLayouts/Onboarding/OnboardingLayout.qml index 3b8075f6812..3c0110fc573 100644 --- a/ui/app/AppLayouts/Onboarding/OnboardingLayout.qml +++ b/ui/app/AppLayouts/Onboarding/OnboardingLayout.qml @@ -55,6 +55,8 @@ Page { // "data:var": contains "password" or "pin" signal loginRequested(string keyUid, int method, var data) + signal keycardRequested() + function restartFlow() { unload() onboardingFlow.restart() @@ -200,6 +202,10 @@ Page { onExportKeysRequested: root.onboardingStore.exportRecoverKeys() onImportLocalBackupRequested: (importFilePath) => d.backupImportFileUrl = importFilePath onFinished: (flow) => d.finishFlow(flow) + onKeycardRequested: { + root.keycardRequested() + root.onboardingStore.startKeycardDetection() + } onBiometricsRequested: (profileId) => { const isKeycardProfile = SQUtils.ModelUtils.getByKey( diff --git a/ui/app/AppLayouts/Onboarding/components/LoginKeycardBox.qml b/ui/app/AppLayouts/Onboarding/components/LoginKeycardBox.qml index 39ac90c954f..3ed4892d762 100644 --- a/ui/app/AppLayouts/Onboarding/components/LoginKeycardBox.qml +++ b/ui/app/AppLayouts/Onboarding/components/LoginKeycardBox.qml @@ -6,6 +6,7 @@ import StatusQ.Core import StatusQ.Controls import StatusQ.Controls.Validators import StatusQ.Core.Theme +import StatusQ.Core.Utils as SQUtils import AppLayouts.Onboarding.enums import AppLayouts.Onboarding.controls @@ -33,6 +34,8 @@ Control { signal loginRequested(string pin) + signal keycardRequested() + function clear() { d.wrongPin = false pinInputField.statesInitialization() @@ -82,11 +85,20 @@ Control { elide: Text.ElideRight color: Theme.palette.baseColor1 linkColor: hoveredLink ? Theme.palette.hoverColor(color) : color + visible: text !== "" HoverHandler { cursorShape: !!parent.hoveredLink ? Qt.PointingHandCursor : undefined } onLinkActivated: root.detailedErrorPopupRequested() } + + StatusButton { + Layout.fillWidth: true + id: scanKeycardButton + text: qsTr("Scan keycard") + visible: SQUtils.Utils.isMobile + onClicked: root.keycardRequested() + } Column { id: lockedButtons Layout.fillWidth: true @@ -113,6 +125,7 @@ Control { objectName: "pinInput" validator: StatusIntValidator { bottom: 0; top: 999999 } visible: false + inputMethodHints: Qt.ImhDigitsOnly onPinInputChanged: { if (pinInput.length === 6) { @@ -123,6 +136,11 @@ Control { d.wrongPin = false root.pinEditedManually() } + onVisibleChanged: { + if (visible) { + pinInputField.forceFocus() + } + } } } @@ -130,7 +148,7 @@ Control { // normal/intro states State { name: "plugin" - when: root.keycardState === Onboarding.KeycardState.PluginReader + when: root.keycardState === Onboarding.KeycardState.PluginReader && !SQUtils.Utils.isMobile PropertyChanges { target: infoText text: qsTr("Plug in Keycard reader...") @@ -138,7 +156,7 @@ Control { }, State { name: "insert" - when: root.keycardState === Onboarding.KeycardState.InsertKeycard + when: root.keycardState === Onboarding.KeycardState.InsertKeycard && !SQUtils.Utils.isMobile PropertyChanges { target: infoText text: qsTr("Insert your Keycard...") @@ -146,7 +164,7 @@ Control { }, State { name: "reading" - when: root.keycardState === Onboarding.KeycardState.ReadingKeycard + when: root.keycardState === Onboarding.KeycardState.ReadingKeycard && !SQUtils.Utils.isMobile PropertyChanges { target: infoText text: qsTr("Reading Keycard...") @@ -159,7 +177,7 @@ Control { PropertyChanges { target: infoText color: Theme.palette.dangerColor1 - text: qsTr("Oops this isn't a Keycard.
Remove card and insert a Keycard.") + text: qsTr("Oops this isn't a Keycard.
Try using a Keycard instead.") } }, State { @@ -168,20 +186,33 @@ Control { PropertyChanges { target: infoText color: Theme.palette.dangerColor1 - text: qsTr("Wrong Keycard for this profile inserted") + text: qsTr("Wrong Keycard for this profile") + } + PropertyChanges { + target: scanKeycardButton + visible: SQUtils.Utils.isMobile } }, State { name: "genericError" - when: root.keycardState === -1 || + when: (root.keycardState === -1 || root.keycardState === Onboarding.KeycardState.NoPCSCService || - root.keycardState === Onboarding.KeycardState.MaxPairingSlotsReached // TODO add a generic/fallback keycard error here too + root.keycardState === Onboarding.KeycardState.MaxPairingSlotsReached ) && !SQUtils.Utils.isMobile// TODO add a generic/fallback keycard error here too PropertyChanges { target: infoText color: Theme.palette.dangerColor1 text: qsTr("Issue detecting Keycard.
Remove and re-insert reader and Keycard.") } }, + State { + name: "maxPairingSlotsReached" + when: root.keycardState === Onboarding.KeycardState.MaxPairingSlotsReached && SQUtils.Utils.isMobile + PropertyChanges { + target: infoText + color: Theme.palette.dangerColor1 + text: qsTr("Max pairing slots reached.") + } + }, State { name: "blocked" when: root.keycardState === Onboarding.KeycardState.BlockedPIN || @@ -202,7 +233,11 @@ Control { PropertyChanges { target: infoText color: Theme.palette.dangerColor1 - text: qsTr("The inserted Keycard is empty.
Insert the correct Keycard for this profile.") + text: qsTr("The scanned Keycard is empty.
Use the correct Keycard for this profile.") + } + PropertyChanges { + target: scanKeycardButton + visible: SQUtils.Utils.isMobile } }, State { @@ -227,7 +262,7 @@ Control { // exit states State { name: "notEmpty" - when: root.keycardState === Onboarding.KeycardState.NotEmpty && !d.wrongPin + when: root.keycardState === Onboarding.KeycardState.NotEmpty && !d.wrongPin PropertyChanges { target: infoText text: qsTr("Enter Keycard PIN") @@ -235,20 +270,21 @@ Control { PropertyChanges { target: pinInputField visible: true + focus: true } PropertyChanges { target: background border.color: Theme.palette.primaryColor1 } - StateChangeScript { - script: { - pinInputField.forceFocus() - } - } PropertyChanges { target: touchIdIcon visible: root.isBiometricsLogin } } ] + + 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/app/AppLayouts/Onboarding/pages/LoginScreen.qml b/ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml index 9c355b87ae0..b45b2d7fc3e 100644 --- a/ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml +++ b/ui/app/AppLayouts/Onboarding/pages/LoginScreen.qml @@ -91,6 +91,7 @@ OnboardingPage { signal unblockWithSeedphraseRequested() signal unblockWithPukRequested() signal lostKeycardFlowRequested() + signal keycardRequested() QtObject { id: d @@ -290,6 +291,7 @@ OnboardingPage { onDetailedErrorPopupRequested: detailedErrorPopupComp.createObject(root, {detailedError: loginError}).open() onBiometricsRequested: root.biometricsRequested(loginUserSelector.selectedProfileKeyId) onLoginRequested: (pin) => d.doKeycardLogin(pin) + onKeycardRequested: root.keycardRequested() } Item { Layout.fillHeight: true } diff --git a/ui/app/AppLayouts/Onboarding/stores/OnboardingStore.qml b/ui/app/AppLayouts/Onboarding/stores/OnboardingStore.qml index c07a8d5c1e9..c2811116322 100644 --- a/ui/app/AppLayouts/Onboarding/stores/OnboardingStore.qml +++ b/ui/app/AppLayouts/Onboarding/stores/OnboardingStore.qml @@ -35,6 +35,10 @@ QtObject { readonly property int keycardRemainingPinAttempts: d.onboardingModuleInst.keycardRemainingPinAttempts readonly property int keycardRemainingPukAttempts: d.onboardingModuleInst.keycardRemainingPukAttempts + function startKeycardDetection() { + d.onboardingModuleInst.startKeycardDetection() + } + function finishOnboardingFlow(flow: int, data: Object) { // -> string return d.onboardingModuleInst.finishOnboardingFlow(flow, JSON.stringify(data)) } diff --git a/ui/i18n/qml_base_en.ts b/ui/i18n/qml_base_en.ts index 0eb4c5f68fe..4bf74d1a916 100644 --- a/ui/i18n/qml_base_en.ts +++ b/ui/i18n/qml_base_en.ts @@ -8961,6 +8961,61 @@ L2 fee: %2 + + KeycardChannelDrawer + + Ready to scan + + + + 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. + + + + Keycard Not Supported + + + + Your device does not support keycard operations. Please try again with a different device. + + + + Keycard Not Available + + + + Please enable NFC on your device to use the Keycard. + + + + Dismiss + + + KeycardConfirmation diff --git a/ui/i18n/qml_base_lokalise_en.ts b/ui/i18n/qml_base_lokalise_en.ts index 5fd49d75b8b..f12dbcf1578 100644 --- a/ui/i18n/qml_base_lokalise_en.ts +++ b/ui/i18n/qml_base_lokalise_en.ts @@ -10933,6 +10933,69 @@ 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. + + + Keycard Not Supported + KeycardChannelDrawer + Keycard Not Supported + + + Your device does not support keycard operations. Please try again with a different device. + KeycardChannelDrawer + Your device does not support keycard operations. Please try again with a different device. + + + Keycard Not Available + KeycardChannelDrawer + Keycard Not Available + + + Please enable NFC on your device to use the Keycard. + KeycardChannelDrawer + Please enable NFC on your device to use the Keycard. + + + Dismiss + KeycardChannelDrawer + Dismiss + + KeycardConfirmation diff --git a/ui/i18n/qml_cs.ts b/ui/i18n/qml_cs.ts index ea48c2838ae..3e8d54bea3b 100644 --- a/ui/i18n/qml_cs.ts +++ b/ui/i18n/qml_cs.ts @@ -9011,6 +9011,57 @@ 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. + + + + Keycard Not Supported + + + + Your device does not support keycard operations. Please try again with a different device. + + + + Keycard Not Available + + + + Please enable NFC on your device to use the Keycard. + + + + Dismiss + + + KeycardConfirmation diff --git a/ui/i18n/qml_es.ts b/ui/i18n/qml_es.ts index 3438eb849bb..3d9f3460e49 100644 --- a/ui/i18n/qml_es.ts +++ b/ui/i18n/qml_es.ts @@ -8976,6 +8976,57 @@ 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. + + + + Keycard Not Supported + + + + Your device does not support keycard operations. Please try again with a different device. + + + + Keycard Not Available + + + + Please enable NFC on your device to use the Keycard. + + + + Dismiss + + + KeycardConfirmation diff --git a/ui/i18n/qml_ko.ts b/ui/i18n/qml_ko.ts index 2ac4880e5c1..57384e7e825 100644 --- a/ui/i18n/qml_ko.ts +++ b/ui/i18n/qml_ko.ts @@ -8940,6 +8940,57 @@ 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. + + + + Keycard Not Supported + + + + Your device does not support keycard operations. Please try again with a different device. + + + + Keycard Not Available + + + + Please enable NFC on your device to use the Keycard. + + + + Dismiss + + + KeycardConfirmation diff --git a/ui/imports/shared/popups/KeycardChannelDrawer.qml b/ui/imports/shared/popups/KeycardChannelDrawer.qml new file mode 100644 index 00000000000..8e56ffd5725 --- /dev/null +++ b/ui/imports/shared/popups/KeycardChannelDrawer.qml @@ -0,0 +1,178 @@ +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 + } + }, + State { + name: "not-supported" + when: stateManager.displayState === stateManager.stateNotSupported + PropertyChanges { + target: stateDisplay + iconSource: Assets.png("onboarding/status_generate_keys") + title: qsTr("Keycard Not Supported") + description: qsTr("Your device does not support keycard operations. Please try again with a different device.") + isError: true + showLoading: false + } + }, + State { + name: "not-available" + when: stateManager.displayState === stateManager.stateNotAvailable + PropertyChanges { + target: stateDisplay + iconSource: Assets.png("onboarding/status_generate_keys") + title: qsTr("Keycard Not Available") + description: qsTr("Please enable NFC on your device to use the Keycard.") + 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: { + 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..2521037f4fb --- /dev/null +++ b/ui/imports/shared/popups/KeycardChannelStateManager.qml @@ -0,0 +1,201 @@ +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", "not-supported", "not-available" + property string backendState: Constants.keycardChannelState.idle + + /// Output: Current display state for the UI + /// Values: "", "waiting-for-card", "reading", "success", "error", "not-supported", "not-available" + 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 + readonly property string stateNotSupported: Constants.keycardChannelState.notSupported + readonly property string stateNotAvailable: Constants.keycardChannelState.notAvailable + + /// 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 + case Constants.keycardChannelState.notSupported: + return stateNotSupported + case Constants.keycardChannelState.notAvailable: + return stateNotAvailable + 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..1ce07039478 --- /dev/null +++ b/ui/imports/shared/stores/KeycardStateStore.qml @@ -0,0 +1,17 @@ +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 + + function keycardDismissed() { + keycardChannelModuleInst.keycardDismissed() + } +} + + 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 b0023eb04c9..d182352a7c7 100644 --- a/ui/imports/utils/Constants.qml +++ b/ui/imports/utils/Constants.qml @@ -1439,4 +1439,14 @@ 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" + readonly property string notSupported: "not-supported" + readonly property string notAvailable: "not-available" + } + } diff --git a/ui/main.qml b/ui/main.qml index b1f328af706..7a7c2da1a3d 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,18 @@ Window { } } + + Loader { + active: SQUtils.Utils.isAndroid + sourceComponent: KeycardChannelDrawer { + id: keycardChannelDrawer + currentState: applicationWindow.keycardStateStore.state + onDismissed: { + applicationWindow.keycardStateStore.keycardDismissed() + } + } + } + Loader { id: macOSSafeAreaLoader anchors.left: parent.left