Files
purple-electrumwallet/electrum/gui/qml/components/main.qml
T
Sander van Grieken 738992ac9e qml: don't add navigationbar padding when on-screen keyboard is visible,
also allow stackview pages to override navigationbar background color to
allow correct color runoff below buttons
2026-04-22 10:15:30 +02:00

904 lines
29 KiB
QML

import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Basic
import QtQuick.Controls.Material
import QtQuick.Controls.Material.impl
import QtQuick.Window
import QtQml
import org.electrum 1.0
import "controls"
ApplicationWindow
{
id: app
visible: false // initial value
readonly property int statusBarHeight: AppController ? AppController.getStatusBarHeight() : 0
readonly property int navigationBarHeight: AppController ? AppController.getNavigationBarHeight() : 0
// dimensions ignored on android
width: 480
height: 800
Material.theme: Material.Dark
Material.primary: Material.Indigo
Material.accent: Material.LightBlue
font.pixelSize: constants.fontSizeMedium
property QtObject constants: appconstants
Constants { id: appconstants }
property alias stack: mainStackView
property alias keyboardFreeZone: _keyboardFreeZone
property alias infobanner: _infobanner
property color _navigationBarBackgroundColor: 'transparent'
property string pendingIntent: ""
property variant activeDialogs: []
property var _exceptionDialog
property var pluginobjects: ({})
property QtObject appMenu: Menu {
id: menu
parent: Overlay.overlay
dim: true
modal: true
Overlay.modal: Rectangle {
color: "#44000000"
}
property int implicitChildrenWidth: 64
width: implicitChildrenWidth + 60 + constants.paddingLarge
MenuItem {
icon.color: action.enabled ? 'transparent' : Material.iconDisabledColor
icon.source: '../../icons/network.png'
action: Action {
text: qsTr('Network')
onTriggered: menu.openPage(Qt.resolvedUrl('NetworkOverview.qml'))
enabled: stack.currentItem.objectName != 'NetworkOverview'
}
}
MenuItem {
icon.color: action.enabled ? 'transparent' : Material.iconDisabledColor
icon.source: '../../icons/preferences.png'
action: Action {
text: qsTr('Preferences')
onTriggered: menu.openPage(Qt.resolvedUrl('Preferences.qml'))
enabled: stack.currentItem.objectName != 'Properties'
}
}
MenuItem {
icon.color: action.enabled ? 'transparent' : Material.iconDisabledColor
icon.source: '../../icons/electrum.png'
action: Action {
text: qsTr('About');
onTriggered: menu.openPage(Qt.resolvedUrl('About.qml'))
enabled: stack.currentItem.objectName != 'About'
}
}
function openPage(url) {
stack.pushOnRoot(url)
currentIndex = -1
}
// determine widest element and store in implicitChildrenWidth
function updateImplicitWidth() {
for (let i = 0; i < menu.count; i++) {
var item = menu.itemAt(i)
var txt = item.text
var txtwidth = fontMetrics.advanceWidth(txt)
if (txtwidth > menu.implicitChildrenWidth) {
menu.implicitChildrenWidth = txtwidth
}
}
}
FontMetrics {
id: fontMetrics
font: menu.font
}
Component.onCompleted: updateImplicitWidth()
}
function openAppMenu() {
appMenu.open()
appMenu.x = app.width - appMenu.width
appMenu.y = toolbar.height
}
header: ToolBar {
id: toolbar
// Add top margin for status bar on Android when using edge-to-edge
topPadding: app.statusBarHeight
background: Rectangle {
implicitHeight: 48
color: constants.dialogColor
layer.enabled: true
layer.effect: ElevationEffect {
elevation: 4
fullWidth: true
}
}
ColumnLayout {
spacing: 0
anchors.left: parent.left
anchors.right: parent.right
height: toolbar.availableHeight
RowLayout {
id: toolbarTopLayout
Layout.fillWidth: true
Layout.rightMargin: constants.paddingMedium
Layout.alignment: Qt.AlignVCenter
Item {
Layout.fillWidth: true
Layout.preferredHeight: Math.max(implicitHeight, toolbarTopLayout.height)
MouseArea {
anchors.fill: parent
enabled: Daemon.currentWallet &&
(!stack.currentItem || !stack.currentItem.title || stack.currentItem.title == Daemon.currentWallet.name)
onClicked: {
stack.getRoot().menu.open() // open wallet-menu
stack.getRoot().menu.y = toolbar.height
}
}
RowLayout {
width: parent.width
Item {
Layout.preferredWidth: constants.paddingXLarge
Layout.preferredHeight: 1
}
Image {
Layout.preferredWidth: constants.iconSizeSmall
Layout.preferredHeight: constants.iconSizeSmall
visible: Daemon.currentWallet &&
(!stack.currentItem || !stack.currentItem.title || stack.currentItem.title == Daemon.currentWallet.name)
source: '../../icons/wallet.png'
}
Label {
Layout.fillWidth: true
Layout.preferredHeight: Math.max(implicitHeight, toolbarTopLayout.height)
text: stack.currentItem && stack.currentItem.title
? stack.currentItem.title
: Daemon.currentWallet.name
elide: Label.ElideRight
verticalAlignment: Qt.AlignVCenter
font.pixelSize: constants.fontSizeMedium
font.bold: true
}
}
}
Item {
implicitHeight: 48
implicitWidth: statusIconsLayout.width
MouseArea {
anchors.fill: parent
onClicked: openAppMenu() // open global-app-menu
}
RowLayout {
id: statusIconsLayout
anchors.verticalCenter: parent.verticalCenter
Item {
Layout.preferredWidth: constants.paddingLarge
Layout.preferredHeight: 1
}
Item {
visible: Network.isTestNet
width: column.width
height: column.height
ColumnLayout {
id: column
spacing: 0
Image {
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: constants.iconSizeSmall
Layout.preferredHeight: constants.iconSizeSmall
source: "../../icons/info.png"
}
Label {
id: networkNameLabel
text: Network.networkName
color: Material.accentColor
font.pixelSize: constants.fontSizeXSmall
}
}
}
LightningNetworkStatusIndicator {
id: lnnsi
}
OnchainNetworkStatusIndicator { }
}
}
}
// hack to force relayout of toolbar
// since qt6 LightningNetworkStatusIndicator.visible doesn't trigger relayout(?)
Item {
Layout.preferredHeight: 1
Layout.topMargin: -1
Layout.preferredWidth: lnnsi.visible
? 1
: 2
}
}
}
ColumnLayout {
width: parent.width
height: _keyboardFreeZone.height - header.height
spacing: 0
InfoBanner {
id: _infobanner
Layout.fillWidth: true
}
StackView {
id: mainStackView
Layout.fillHeight: true
Layout.fillWidth: true
initialItem: Component {
Wallets {}
}
function getRoot() {
return mainStackView.get(0)
}
function pushOnRoot(item) {
if (mainStackView.depth > 1) {
mainStackView.replace(mainStackView.get(1), item)
} else {
mainStackView.push(item)
}
}
function replaceRoot(item_url) {
mainStackView.clear()
mainStackView.push(Qt.resolvedUrl(item_url))
}
function updateStylingFromItem(item) {
_navigationBarBackgroundColor = item && 'navigationBarBackgroundColor' in item
? item.navigationBarBackgroundColor
: 'transparent'
}
onCurrentItemChanged: updateStylingFromItem(currentItem)
}
// Add bottom padding for navigation bar on Android when UI is edge-to-edge
Item {
visible: app.navigationBarHeight > 0 && _keyboardFreeZone.state != 'visible'
Layout.fillWidth: true
Layout.preferredHeight: app.navigationBarHeight
Rectangle { anchors.fill: parent; color: _navigationBarBackgroundColor }
}
}
Timer {
id: coverTimer
interval: 10
onTriggered: {
app.visible = true
cover.opacity = 0
}
}
Rectangle {
id: cover
parent: Overlay.overlay
anchors.fill: parent
z: 1000
color: 'black'
Behavior on opacity {
enabled: AppController ? AppController.isAndroid() : false
NumberAnimation {
duration: 1000
easing.type: Easing.OutQuad;
}
}
}
Item {
id: _keyboardFreeZone
// Item as first child in Overlay that adjusts its size to the available
// screen space minus the virtual keyboard (e.g. to center dialogs in)
// see also ElDialog.resizeWithKeyboard property
parent: Overlay.overlay
width: parent.width
height: parent.height
states: [
State {
name: 'visible'
when: Qt.inputMethod.keyboardRectangle.y
PropertyChanges {
target: _keyboardFreeZone
height: _keyboardFreeZone.parent.height - (Screen.desktopAvailableHeight - (Qt.inputMethod.keyboardRectangle.y/Screen.devicePixelRatio))
}
}
]
transitions: [
Transition {
from: ''
to: 'visible'
NumberAnimation {
properties: 'height'
duration: 100
easing.type: Easing.OutQuad
}
},
Transition {
from: 'visible'
to: ''
SequentialAnimation {
PauseAnimation {
duration: 200
}
NumberAnimation {
properties: 'height'
duration: 50
easing.type: Easing.OutQuad
}
}
}
]
}
property alias newWalletWizard: _newWalletWizard
Component {
id: _newWalletWizard
NewWalletWizard {
onClosed: destroy()
}
}
property alias termsOfUseWizard: _termsOfUseWizard
Component {
id: _termsOfUseWizard
TermsOfUseWizard {
onClosed: destroy()
}
}
property alias serverConnectWizard: _serverConnectWizard
Component {
id: _serverConnectWizard
ServerConnectWizard {
onClosed: destroy()
}
}
property alias messageDialog: _messageDialog
Component {
id: _messageDialog
MessageDialog {
onClosed: destroy()
}
}
property alias helpDialog: _helpDialog
Component {
id: _helpDialog
HelpDialog {
onClosed: destroy()
}
}
property alias passwordDialog: _passwordDialog
Component {
id: _passwordDialog
PasswordDialog {
onClosed: destroy()
}
}
property alias genericShareDialog: _genericShareDialog
Component {
id: _genericShareDialog
GenericShareDialog {
onClosed: destroy()
}
}
property alias openWalletDialog: _openWalletDialog
Component {
id: _openWalletDialog
OpenWalletDialog {
onClosed: destroy()
}
}
property alias loadingWalletDialog: _loadingWalletDialog
Component {
id: _loadingWalletDialog
LoadingWalletDialog {
onClosed: destroy()
}
}
property Component scanDialog // set in Component.onCompleted
Component {
id: _scanDialog
QRScanner {
onFinished: destroy()
}
}
Component {
id: crashDialog
ExceptionDialog {
onClosed: destroy()
}
}
property alias channelOpenProgressDialog: _channelOpenProgressDialog
ChannelOpenProgressDialog {
id: _channelOpenProgressDialog
}
property alias signVerifyMessageDialog: _signVerifyMessageDialog
Component {
id: _signVerifyMessageDialog
SignVerifyMessageDialog {
onClosed: destroy()
}
}
property alias nostrSwapServersDialog: _nostrSwapServersDialog
Component {
id: _nostrSwapServersDialog
NostrSwapServersDialog {
onClosed: destroy()
}
}
Component {
id: swapDialog
SwapDialog {
id: _swapdialog
onClosed: destroy()
swaphelper: SwapHelper {
id: _swaphelper
wallet: Daemon.currentWallet
onAuthRequired: (method, authMessage) => {
app.handleAuthRequired(_swaphelper, method, authMessage)
}
onError: (message) => {
var dialog = app.messageDialog.createObject(app, {
title: qsTr('Error'),
iconSource: Qt.resolvedUrl('../../icons/warning.png'),
text: message
})
dialog.open()
}
onUndefinedNPub: {
var dialog = app.nostrSwapServersDialog.createObject(app, {
swaphelper: _swaphelper,
selectedPubkey: Config.swapServerNPub
})
dialog.accepted.connect(function() {
Config.swapServerNPub = dialog.selectedPubkey
_swaphelper.setReadyState()
})
dialog.rejected.connect(function() {
_swaphelper.npubSelectionCancelled()
})
dialog.open()
}
}
}
}
NotificationPopup {
id: notificationPopup
width: parent.width
}
Component.onCompleted: {
coverTimer.start()
if (AppController.isAndroid()) {
app.scanDialog = _scanDialog
} else {
// for running on Desktop. uses QtMultimedia.
app.scanDialog = Qt.createComponent('ScanDialog.qml')
}
function continueWithServerConnection() {
if (!Network.autoConnectDefined) {
var dialog = serverConnectWizard.createObject(app)
// without completed serverConnectWizard we can't start
dialog.rejected.connect(function() {
app.visible = false
AppController.wantClose = true
Qt.callLater(Qt.quit)
})
dialog.accepted.connect(function() {
Daemon.startNetwork()
var newww = app.newWalletWizard.createObject(app)
newww.walletCreated.connect(function() {
Daemon.availableWallets.reload()
// and load the new wallet
Daemon.loadWallet(newww.path, newww.wizard_data['password'])
})
newww.open()
})
dialog.open()
} else {
Daemon.startNetwork()
if (Daemon.availableWallets.rowCount() > 0) {
Daemon.loadWallet()
} else {
var newww = app.newWalletWizard.createObject(app)
newww.walletCreated.connect(function() {
Daemon.availableWallets.reload()
// and load the new wallet
Daemon.loadWallet(newww.path, newww.wizard_data['password'])
})
newww.open()
}
}
}
if (!Config.termsOfUseAccepted) {
var dialog = termsOfUseWizard.createObject(app)
dialog.rejected.connect(function() {
app.visible = false
AppController.wantClose = true
Qt.callLater(Qt.quit)
})
dialog.accepted.connect(function() {
Config.termsOfUseAccepted = true
continueWithServerConnection()
})
dialog.open()
} else {
continueWithServerConnection()
}
}
onClosing: (close) => {
if (AppController.wantClose) {
// destroy most GUI components so that we don't dump so many null reference warnings on exit
app.header.visible = false
mainStackView.clear()
return
}
if (activeDialogs.length > 0) {
var activeDialog = activeDialogs[activeDialogs.length - 1]
if (activeDialog.allowClose) {
console.log('main: dialog.doClose')
activeDialog.doClose()
} else {
console.log('dialog disallowed close')
}
close.accepted = false
return
}
if (stack.depth > 1) {
close.accepted = false
stack.pop()
} else {
var dialog = app.messageDialog.createObject(app, {
title: qsTr('Close Electrum?'),
yesno: true
})
dialog.accepted.connect(function() {
AppController.wantClose = true
app.close()
})
dialog.open()
close.accepted = false
}
}
property var _pendingBiometricAuth: null
property var _loadingWalletContext: null
Connections {
target: Biometrics
function onUnlockSuccess(password) {
if (app._pendingBiometricAuth) {
if (app._pendingBiometricAuth.action === 'load_wallet') {
app._loadingWalletContext = _pendingBiometricAuth
Daemon.loadWallet(app._pendingBiometricAuth.path, password)
app._pendingBiometricAuth = null
return
}
let qtobject = app._pendingBiometricAuth.qtobject
let method = app._pendingBiometricAuth.method
if (Daemon.currentWallet.verifyPassword(password)) {
qtobject.authProceed()
} else {
console.warn("Biometric password invalid falling back to manual input")
// this shouldn't really happen so we better disable biometric auth
Biometrics.disable()
handleManualAuth(qtobject, method, app._pendingBiometricAuth.authMessage)
}
app._pendingBiometricAuth = null
}
}
function onUnlockError(error) {
console.log("Biometric auth failed: " + error)
// we end up here if QEBiometrics fails to give us the decrypted password. The user might
// have cancelled the biometric auth popup or the key got invalidated because a new fingerprint got registered.
if (app._pendingBiometricAuth) {
if (app._pendingBiometricAuth.action === 'load_wallet') {
// set loadingWalletContext to disable biometric auth until the OpenWalletDialog is closed
app._loadingWalletContext = app._pendingBiometricAuth
showOpenWalletDialog(app._pendingBiometricAuth.name, app._pendingBiometricAuth.path)
} else {
console.log('biometric auth failed, not falling back to passwordDialog')
app._pendingBiometricAuth.qtobject.authCancel() // no fallback to password dialog
}
app._pendingBiometricAuth = null
}
}
function onAuthRequired(method, authMessage) {
handleAuthRequired(Biometrics, method, authMessage)
}
}
property var _opendialog: null
property var _opendialog_startup: true
function showOpenWalletDialog(name, path) {
if (!_opendialog) {
_opendialog = openWalletDialog.createObject(app, {
name: name,
path: path,
isStartup: _opendialog_startup,
})
_opendialog.closed.connect(function() {
_opendialog = null
app._loadingWalletContext = null // dialog closed, we can allow trying biometric auth again
_opendialog_startup = false
})
_opendialog.open()
}
}
Connections {
target: Daemon
function onWalletRequiresPassword(name, path) {
console.log('wallet requires password')
if (Biometrics.isAvailable && Biometrics.isEnabled && !app._loadingWalletContext) {
app._pendingBiometricAuth = {
action: 'load_wallet',
name: name,
path: path
}
Biometrics.unlock()
} else {
showOpenWalletDialog(name, path)
}
}
function onWalletOpenError(error) {
console.log('wallet open error')
var dialog = app.messageDialog.createObject(app, {
title: qsTr('Error'),
iconSource: Qt.resolvedUrl('../../icons/warning.png'),
text: error
})
dialog.open()
}
function onAuthRequired(method, authMessage) {
handleAuthRequired(Daemon, method, authMessage)
}
function onLoadingChanged() {
if (!Daemon.loading)
return
console.log('wallet loading')
var dialog = loadingWalletDialog.createObject(app, { allowClose: false } )
dialog.open()
}
function onWalletLoaded() {
app._loadingWalletContext = null // either biometric auth or manual auth was successful
}
}
Connections {
target: AppController
function onUserNotify(wallet_name, message) {
notificationPopup.show(wallet_name, message)
}
function onShowException(crash_data) {
if (app._exceptionDialog)
return
app._exceptionDialog = crashDialog.createObject(app, {
crashData: crash_data
})
app._exceptionDialog.onClosed.connect(function() {
app._exceptionDialog = null
})
app._exceptionDialog.open()
}
function onPluginLoaded(name) {
console.log('plugin ' + name + ' loaded')
var loader = AppController.plugin(name).loader
if (loader == undefined)
return
var url = Qt.resolvedUrl('../../../plugins/' + name + '/qml/' + loader)
var comp = Qt.createComponent(url)
if (comp.status == Component.Error) {
console.log('Could not find/parse PluginLoader for plugin ' + name)
console.log(comp.errorString())
return
}
var obj = comp.createObject(app)
if (obj != null)
app.pluginobjects[name] = obj
}
function onUriReceived(uri) {
console.log('uri received (main): ' + uri)
app.pendingIntent = uri
}
}
function pluginsComponentsByName(comp_name) {
// return named QML components from plugins
var plugins = AppController.plugins
var result = []
for (var i=0; i < plugins.length; i++) {
if (!plugins[i].enabled)
continue
var pluginobject = app.pluginobjects[plugins[i].name]
if (!pluginobject)
continue
if (!(comp_name in pluginobject))
continue
var comp = pluginobject[comp_name]
if (!comp)
continue
result.push(comp)
}
return result
}
Connections {
target: Daemon.currentWallet
function onAuthRequired(method, authMessage) {
handleAuthRequired(Daemon.currentWallet, method, authMessage)
}
// TODO: add to notification queue instead of barging through
function onPaymentSucceeded(key) {
notificationPopup.show(Daemon.currentWallet.name, qsTr('Payment succeeded'))
}
function onPaymentFailed(key, reason) {
notificationPopup.show(Daemon.currentWallet.name, qsTr('Payment failed') + ': ' + reason)
}
}
Connections {
target: Config
function onAuthRequired(method, authMessage) {
handleAuthRequired(Config, method, authMessage)
}
}
function handleAuthRequired(qtobject, method, authMessage) {
console.log('auth using method ' + method)
if (method === 'payment_auth') {
if (Config.paymentAuthentication) {
// treat like a wallet auth request
method = 'wallet'
} else {
handleAuthConfirmationOnly(qtobject, authMessage)
return
}
}
if (Daemon.currentWallet.verifyPassword('')) {
// wallet has no password
qtobject.authProceed()
return
}
if (method !== 'wallet_password_only') {
if (Biometrics.isAvailable && Biometrics.isEnabled) {
app._pendingBiometricAuth = {
qtobject: qtobject,
method: method,
authMessage: authMessage
}
Biometrics.unlock(authMessage)
return
}
}
handleManualAuth(qtobject, method, authMessage)
}
function handleManualAuth(qtobject, method, authMessage) {
// 'payment_auth' should have been converted to 'wallet' at this point
if (method === 'wallet' || method === 'wallet_password_only') {
var dialog = app.passwordDialog.createObject(app, authMessage ? {'title': authMessage} : {})
dialog.passwordEntered.connect(function(password) {
if (Daemon.currentWallet.verifyPassword(password)) {
dialog.close()
qtobject.authProceed()
} else {
dialog.clearPassword()
dialog.errorMessage = qsTr("Invalid Password")
}
})
dialog.rejected.connect(function() {
qtobject.authCancel()
})
dialog.open()
} else {
console.log('unknown auth method ' + method)
qtobject.authCancel()
}
}
function handleAuthConfirmationOnly(qtobject, authMessage) {
if (!authMessage) {
qtobject.authProceed()
return
}
var dialog = app.messageDialog.createObject(app, {
title: authMessage,
yesno: true
})
dialog.accepted.connect(function() {
qtobject.authProceed()
})
dialog.rejected.connect(function() {
qtobject.authCancel()
})
dialog.open()
}
function startSwap() {
var swapdialog = swapDialog.createObject(app)
swapdialog.open()
}
property var _lastActive: 0 // record time of last activity
property bool _lockDialogShown: false
}