/* * This file is part of the Flowee project * Copyright (C) 2020-2026 Tom Zander * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import QtQuick import QtQuick.Controls.Basic as QQC2 import QtQuick.Templates as T import QtQuick.Layouts import "../Flowee" as Flowee import "../ControlColors.js" as ControlColors QQC2.ApplicationWindow { id: mainWindow visible: true width: Pay.windowWidth height: Pay.windowHeight minimumWidth: 400 minimumHeight: 300 title: "Flowee Pay" onWidthChanged: Pay.windowWidth = width onHeightChanged: Pay.windowHeight = height onVisibleChanged: if (visible) ControlColors.applySkin(mainWindow) property bool isLoading: typeof net === "undefined" Component.onCompleted: updateFontSize(mainWindow) function updateFontSize(window) { // 75% = > 14.25, 100% => 19, 200% => 28 window.font.pixelSize = 17 + (11 * (Pay.fontScaling-100) / 100) } Connections { target: Pay function onFontScalingChanged() { updateFontSize(mainWindow) if (txDetailsWindow.status === Loader.Ready) updateFontSize(txDetailsWindow.item) if (netView.status === Loader.Ready) updateFontSize(netView.item) } function onUseDarkSkinChanged() { ControlColors.applySkin(mainWindow) if (txDetailsWindow.status === Loader.Ready) ControlColors.applySkin(txDetailsWindow.item) if (netView.status === Loader.Ready) ControlColors.applySkin(netView.item) } } onIsLoadingChanged: { if (!isLoading) { // delay loading to avoid errors due to not having a portfolio // portfolio is only initialized after a second or so. receivePane.source = "./ReceiveTransactionPane.qml" sendTransactionPane.source = "./SendTransactionPane.qml" if (intent.paymentUrl !== "") { // respond to payment intent is prio one. tabbar.currentIndex = 1 } else if (!portfolio.current.isUserOwned) { // Open on receive tab if the wallet is effectively empty tabbar.currentIndex = 2 } else { tabbar.currentIndex = 0 } activityView.model = portfolio.current.transactions } } property color floweeSalmon: "#ff9d94" property color floweeBlue: "#0b1088" property color floweeGreen: "#90e4b5" property color errorRed: Pay.useDarkSkin ? "#ff6568" : "#940000" property color errorRedBg: Pay.useDarkSkin ? "#671314" : "#9f1d1f" Item { id: mainScreen anchors.fill: parent focus: true Loader { id: effects source: "./BlurComponents.qml" } Rectangle { id: header color: Pay.useDarkSkin ? "#00000000" : mainWindow.floweeBlue width: parent.width height: 90 Rectangle { color: mainWindow.floweeBlue opacity: Pay.useDarkSkin ? 1 : 0 width: 60 height: 60 radius: 30 x: 3 y: 11 Behavior on opacity { NumberAnimation { duration: 300 } } } Image { id: appLogo anchors.verticalCenter: parent.verticalCenter x: 17 smooth: true source: "qrc:/FloweePay-light.svg" // ratio: 77 / 449 height: 40 width: height * 449 / 77 } Item { id: balanceInHeader visible: { if (mainWindow.isLoading) return false if (portfolio.accounts.length <= 1) return false // If there is not enough space here (only for quite long balances), move to the left bar var minX = appLogo.width + totalFiatLabel.width + 50 // 50 is spacing return x > minX } width: totalBalance.width height: totalBalance.height anchors.bottom: parent.bottom anchors.bottomMargin: tabbar.headerHeight + 5 anchors.right: parent.right anchors.rightMargin: 10 baselineOffset: totalBalance.baselineOffset Flowee.BitcoinAmountLabel { id: totalBalance value: { if (isLoading) return 0 if (Pay.hideBalance) return 88888888 return portfolio.totalBalance } colorize: false color: "white" showFiat: false fontPixelSize: 28 layer.enabled: effects.loaded && Pay.hideBalance layer.effect: effects.item.biggerBlur } } Flowee.Label { id: totalFiatLabel anchors.baseline: balanceInHeader.baseline anchors.right: balanceInHeader.left anchors.rightMargin: 10 color: "white" font.pixelSize: 15 text: { if (Pay.hideBalance && Pay.isMainChain) return Fiat.formattedPrice(100000000, Fiat.price) if (!Fiat.hasPrice && Pay.isMainChain) return "?" return Fiat.formattedPrice(totalBalance.value, Fiat.price) } visible: balanceInHeader.visible opacity: 0.6 layer.enabled: effects.loaded && Pay.hideBalance layer.effect: effects.item.simpleBlur } } Item { id: tabbedPane width: Math.max(parent.width - 270, parent.width * 65 / 100) anchors.right: parent.right anchors.top: header.bottom anchors.topMargin: -1 * tabbar.headerHeight anchors.bottom: parent.bottom Rectangle { anchors.fill: parent opacity: 0.2 anchors.topMargin: -5 anchors.leftMargin: -5 anchors.rightMargin: 5 radius: 5 color: Pay.useDarkSkin ? "black" : "white" } TabBarWidget { id: tabbar anchors.fill: parent Rectangle { id: activityTab property string title: qsTr("Activity") property string icon: "qrc:/activityIcon-light.png" anchors.fill: parent color: palette.light Column { id: activityHeader width: parent.width - 20 spacing: 6 x: 10 y: -2 Item { width: parent.width - 4 height: 52 x: 2 Loader { source: isLoading ? "" : "./ActivityConfigBar.qml" width: parent.width } } Rectangle { width: parent.width height: visible ? (warn.height + unarchiveButton.height + 26) : 0 color: Pay.useDarkSkin ? "#c1ba58" : "#f6e992" // yellow visible: !isLoading && portfolio.current.isArchived radius: 7 Flowee.Label { id: warn y: 6 x: 6 width: parent.width - 10 horizontalAlignment: Text.AlignHCenter color: "black" font.bold: true wrapMode: Text.WordWrap text: qsTr("Archived wallets do not check for activities. Balance may be out of date.") } Flowee.Button { id: unarchiveButton text: qsTr("Unarchive") anchors.right: warn.right anchors.top: warn.bottom anchors.topMargin: 6 onClicked: portfolio.current.isArchived = false } } Rectangle { id: needsDecryptPane width: parent.width height: visible ? (decryptText.height + decryptPwd.height + decryptButton.height + 36): 0 color: Pay.useDarkSkin ? "#c1ba58" : "#f6e992" // yellow visible: !isLoading && portfolio.current.needsPinToOpen && !portfolio.current.isDecrypted radius: 7 onVisibleChanged: { decryptError.visible = false decryptPwd.text = "" } Flowee.Label { id: decryptText y: 6 x: 6 width: parent.width - 20 horizontalAlignment: Text.AlignHCenter color: "black" // font.pixelSize: activityTab.font.pixelSize font.bold: true wrapMode: Text.WordWrap text: qsTr("This wallet needs a password to open.") } Flowee.Label { id: decryptLabel anchors.left: decryptText.left anchors.verticalCenter: decryptPwd.verticalCenter color: decryptText.color // font: activityTab.font text: qsTr("Password:") } Flowee.TextField { id: decryptPwd focus: needsDecryptPane.visible anchors.top: decryptText.bottom anchors.left: decryptLabel.right anchors.right: parent.right anchors.margins: 6 echoMode: TextInput.Password onAccepted: decryptButton.clicked() } Flowee.Label { id: decryptError anchors.left: decryptPwd.left anchors.verticalCenter: decryptButton.verticalCenter color: mainWindow.errorRed font.bold: true text: qsTr("Invalid password") visible: false } Flowee.Button { id: decryptButton text: qsTr("Open") anchors.right: decryptText.right anchors.top: decryptPwd.bottom anchors.topMargin: 6 enabled: decryptPwd.text !== "" onClicked: { portfolio.current.decrypt(decryptPwd.text) if (portfolio.current.isDecrypted) { decryptPwd.text = "" decryptError.visible = false } else { decryptPwd.selectAll() decryptError.visible = true decryptPwd.forceActiveFocus() } } } } } ListView { id: activityView clip: true delegate: Loader { width: activityView.width source: { if (model.isTransaction) return "./Transaction.qml" if (model.isUnseenRow) return "./UnseenDelegate.qml" if (model.isCollapsedRow) return "./HiddenItemsDelegate.qml" return "" } } property bool isActive: tabbar.currentIndex === 0 && accountOverlay.item == null && newAccountPane.item == null && mainWindow.active Timer { id: markSeenTimer interval: 80000 onTriggered: portfolio.current.transactions.markSeen() running: !isLoading && activityView.isActive } Connections { target: isLoading ? null : portfolio function onCurrentChanged() { markSeenTimer.restart() // starting in Qt6.10 this is needed to work around // a crash in Qt. Changing the model while not at the top // would crash. So we move to the top before switching. // last known-crashing version: 6.10.2 activityView.positionViewAtBeginning() activityView.model = portfolio.current.transactions } } anchors.top: activityHeader.bottom anchors.left: parent.left anchors.leftMargin: 10 anchors.right: parent.right anchors.rightMargin: 10 anchors.bottom: parent.bottom QQC2.ScrollBar.vertical: Flowee.ScrollThumb { id: thumb minimumSize: 20 / activityView.height visible: size < 0.9 preview: Rectangle { width: label.width + 12 height: label.height + 12 radius: 5 color: palette.window Flowee.Label { id: label anchors.centerIn: parent color: palette.dark text: isLoading || activityView.model === null ? "" : activityView.model.dateForItem(thumb.position) } } } } Keys.forwardTo: Flowee.ListViewKeyHandler { target: activityView } } Loader { id: sendTransactionPane // Disable these tabs for archived accounts enabled: !mainWindow.isLoading && !portfolio.current.isArchived && (!portfolio.current.needsPinToOpen || portfolio.current.isDecrypted) anchors.fill: parent property string title: qsTr("Send") property string icon: "qrc:/sendIcon-light.png" } Loader { id: receivePane enabled: sendTransactionPane.enabled && (!portfolio.current.needSpinToOpen || portfolio.current.isDecrypted) anchors.fill: parent property string icon: "qrc:/receiveIcon.png" property string title: qsTr("Receive") } SettingsPane { anchors.fill: parent } } Behavior on opacity { NumberAnimation { } } visible: opacity > 0 } Rectangle { anchors.fill: accountOverlay anchors.topMargin: tabbar.headerHeight color: palette.window opacity: accountOverlay.opacity } Loader { // This overlays the tabbed pane id: accountOverlay anchors.bottom: parent.bottom anchors.left: overviewPane.right anchors.right: parent.right anchors.top: header.bottom anchors.topMargin: -1 * tabbar.headerHeight opacity: 0 Behavior on opacity { NumberAnimation { } } states: [ State { name: "showTransactions" PropertyChanges { target: tabbedPane; opacity: 1 } PropertyChanges { target: tabbar; focus: true } PropertyChanges { target: accountOverlay opacity: 0 source: "" } }, State { name: "accountDetails" PropertyChanges { target: accountOverlay source: "./AccountDetails.qml" opacity: 1 focus: true } PropertyChanges { target: tabbedPane; opacity: 0 } }, State { name: "startWalletEncryption" PropertyChanges { target: accountOverlay source: "./WalletEncryption.qml" opacity: 1 focus: true } PropertyChanges { target: tabbedPane; opacity: 0 } } ] state: "showTransactions" } Item { // the whole area left of the tabbed panels. id: overviewPane anchors.left: parent.left anchors.leftMargin: 6 anchors.right: tabbedPane.left anchors.rightMargin: 12 anchors.bottom: parent.bottom anchors.bottomMargin: 6 anchors.top: header.bottom Column { id: balances spacing: 3 width: parent.width Item { height: balanceLabel.height width: parent.width Flowee.Label { id: balanceLabel text: qsTr("Balance") height: implicitHeight / 10 * 7 } Image { id: showBalanceButton anchors.right: parent.right source: { var state = Pay.hideBalance ? "closed" : "open" var skin = Pay.useDarkSkin ? "-light" : "" return "qrc:/eye-" + state + skin + ".png" } smooth: true opacity: 0.5 height: 14 width: 14 MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: { Pay.hideBalance = !Pay.hideBalance balanceDetailsPane.showDetails = false } } } } Item { id: balanceDetailsPane property bool showDetails: false width: parent.width clip: !Pay.hideBalance // on to avoid the balance overlapping the tabbar. height: balance.height + (showDetails ? extraBalances.height + 10 : 0) Flowee.BitcoinAmountLabel { id: balance value: { if (isLoading) return 0 var account = portfolio.current if (account === null) return 0 if (Pay.hideBalance) return 88888888 return account.balanceConfirmed + account.balanceUnconfirmed } colorize: false showFiat: false color: palette.windowText fontPixelSize: { if (leftColumn.width < 240) // max width is 252 return leftColumn.width / 9 return 27 } layer.enabled: effects.loaded && Pay.hideBalance layer.effect: effects.item.biggerBlur } GridLayout { id: extraBalances visible: parent.showDetails width: parent.width / 0.9 anchors.top: balance.bottom anchors.topMargin: 5 columns: 2 scale: 0.9 transformOrigin: Item.TopLeft clip: true property QtObject account: mainWindow.isLoading ? null : portfolio.current Flowee.Label { text: qsTr("Main", "balance (money), non specified") + ":" Layout.alignment: Qt.AlignRight } Flowee.BitcoinAmountLabel { value: extraBalances.account == null ? 0 : extraBalances.account.balanceConfirmed colorize: false showFiat: false Layout.fillWidth: true } Flowee.Label { text: qsTr("Unconfirmed", "balance (money)") + ":" Layout.alignment: Qt.AlignRight } Flowee.BitcoinAmountLabel { value: extraBalances.account == null ? 0 : extraBalances.account.balanceUnconfirmed colorize: false showFiat: false } Flowee.Label { text: qsTr("Immature", "balance (money)") + ":" Layout.alignment: Qt.AlignRight } Flowee.BitcoinAmountLabel { value: extraBalances.account == null ? 0 : extraBalances.account.balanceImmature colorize: false showFiat: false } } MouseArea { enabled: priceCover.visible === false anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: { if (!Pay.hideBalance) parent.showDetails = !parent.showDetails } } Rectangle { id: priceCover // this covers the prices while the wallet is encrypted. color: palette.window opacity: 0.8 anchors.fill: parent anchors.topMargin: 2 visible: !mainWindow.isLoading && portfolio.current.needsPinToOpen && !portfolio.current.isDecrypted } Behavior on height { NumberAnimation {} } } Flowee.Label { text: { if (mainWindow.isLoading || !Pay.isMainChain) return "" if (Pay.hideBalance || (portfolio.current.needsPinToOpen && !portfolio.current.isDecrypted)) return "-- " + Fiat.currencyName return Fiat.formattedPrice(balance.value, Fiat.price) } opacity: 0.6 layer.enabled: effects.loaded && Pay.hideBalance layer.effect: effects.item.simpleBlur } Item { width: 1; height: fiatValue.visible ? 10 : 0 } // spacer Item { width: parent.width height: fiatValue.height Flowee.Label { id: fiatValue property double prevPrice: 0 text: { if (Fiat.hasPrice) var price = Fiat.formattedPrice(100000000, Fiat.price) else price = "?" qsTr("1 BCH is: %1").arg(price) } visible: Pay.isMainChain Behavior on color { ColorAnimation { duration: 300 } } onTextChanged: { animTimer.start() if (prevPrice > Fiat.price) color = Pay.useDarkSkin ? "#5f1414" : "#ff3636" // red else color = Pay.useDarkSkin ? "#154822" : "#4aff77" // green prevPrice = Fiat.price } Timer { id: animTimer interval: 305 onTriggered: fiatValue.color = palette.windowText } } AccountConfigMenu { anchors.right: parent.right visible: isLoading ? false : portfolio.singleAccountSetup account: isLoading ? null : portfolio.current anchors.bottom: fiatValue.baseline } } Item { width: 1; height: syncStatus.visible ? 10 : 0 } // spacer Flowee.Label { text: qsTr("Sync Status") + ":" id: syncStatus visible: isLoading ? false : portfolio.singleAccountSetup } Flowee.Label { visible: syncStatus.visible opacity: 0.6 text: { if (!visible) return "" let account = portfolio.current if (account.uptodate) return qsTr("Up to date") let cur = account.lastBlockSynched if (cur < 1) return "" return cur + " / " + Pay.chainHeight } } } Flickable { anchors { left: balances.left right: balances.right top: balances.bottom topMargin: 8 bottom: parent.bottom bottomMargin: 8 } contentWidth: leftColumn.width contentHeight: leftColumn.height flickableDirection: Flickable.VerticalFlick clip: true Column { id: leftColumn width: balances.width Flowee.Label { id: netStatus text: qsTr("Network status") opacity: 0.6 visible: isLoading } Flowee.Label { visible: netStatus.visible id: syncIndicator text: { if (isLoading) return "" var account = portfolio.current if (account === null) return "" if (account.needsPinToOpen && !account.isDecrypted) return qsTr("Offline") return account.timeBehind } font.italic: true } Item { // spacer width: 1 height: 20 visible: netStatus.visible } Repeater { // the portfolio listing our accounts width: parent.width model: mainWindow.isLoading ? 0 : portfolio.accounts delegate: AccountListItem { width: leftColumn.width account: modelData } } Item { // spacer width: 1 height: 20 } Flowee.BigButton { // button 'add bitcoin cash wallet' text: qsTr("Add Bitcoin Cash wallet") onClicked: newAccountPane.source = "./NewAccountPane.qml" isMainButton: true width: leftColumn.width } Item { // spacer width: 1 height: 20 } Item { // archived wallets label with hide button visible: !isLoading && portfolio.archivedAccounts.length > 0 height: archivedLabel.height width: leftColumn.width Flowee.ArrowPoint { id: showArchivedWalletsList property bool on: false color: Pay.useDarkSkin ? "white" : "black" rotation: on ? 90 : 0 x: 4 y: 6 transformOrigin: Item.Center Behavior on rotation { NumberAnimation {} } } Flowee.Label { id: archivedLabel x: showArchivedWalletsList.width + 6 text: { if (isLoading) return "" var walletCount = portfolio.archivedAccounts.length return qsTr("Archived wallets [%1]", "Arg is wallet count", walletCount).arg(walletCount) } } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: showArchivedWalletsList.on = !showArchivedWalletsList.on } } Repeater { // the archived accounts width: parent.width model: isLoading ? 0 : portfolio.archivedAccounts delegate: AccountListItem { width: leftColumn.width height: showArchivedWalletsList.on ? implicitHeight : 0 account: modelData // archived accounts don't have access to anything but the activity tab onClicked: tabbar.currentIndex = 0 // change to the 'activity' tab opacity: showArchivedWalletsList.on ? 1 : 0 Behavior on height { NumberAnimation { } } Behavior on opacity { NumberAnimation { } } } } } } } Rectangle { id: splashScreen color: palette.window anchors.fill: parent Flowee.Label { text: qsTr("Preparing...") anchors.centerIn: parent font.pointSize: 20 } opacity: mainWindow.isLoading ? 1 : 0 Behavior on opacity { NumberAnimation { duration: 300 } } } Keys.onPressed: (event)=> { if ((event.modifiers & Qt.ControlModifier) !== 0) { if (event.key === Qt.Key_Q) { mainWindow.close() } } } // NetView (reachable from settings) Loader { id: netView onLoaded: { ControlColors.applySkin(item) netViewHandler.target = item item.font = mainWindow.font } Connections { id: netViewHandler function onVisibleChanged() { if (!netView.item.visible) netView.source = "" } } } Loader { id: txDetailsWindow function openTab(walletIndex) { source = "./TransactionDetails.qml" if (item.visible) item.requestActivate() item.openTab(walletIndex) } onLoaded: { ControlColors.applySkin(item) txDetailsHandler.target = item item.font = mainWindow.font item.show() } Connections { id: txDetailsHandler function onVisibleChanged() { if (!txDetailsWindow.item.visible) txDetailsWindow.source = "" } } } // new accounts pane, corresponding to the big green button Loader { id: newAccountPane anchors.fill: parent onLoaded: { tabbar.enabled = false // avoid it taking focus on tab newAccountHandler.target = item // to unload on hide } Connections { id: newAccountHandler function onVisibleChanged() { if (!newAccountPane.item.visible) { newAccountPane.source = "" tabbar.enabled = true // reenable tabbar.focus = true } } } } // QRScanner window QQC2.ApplicationWindow { id: scannerWindow visible: CameraController.visible minimumWidth: 300 minimumHeight: 600 width: 400 height: 700 title: qsTr("QR-Scan") flags: Qt.Dialog onClosing: CameraController.abort() Flowee.QRScanner { anchors.fill: parent } } } }