Files
pay/guis/desktop/main.qml
T
tomFlowee e5e4252764 Show sync status in single wallet setup
In multi wallet setups the account list item has an indicator of
sync status, but there is nothing to indicate we are behind on a
single wallet setup.
This adds a simple indicator of blockheight and changes that to
'Up to date' when we're all good
2026-01-16 23:25:00 +01:00

890 lines
36 KiB
QML

/*
* This file is part of the Flowee project
* Copyright (C) 2020-2026 Tom Zander <tom@flowee.org>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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;
}
}
}
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
model: isLoading ? 0 : portfolio.current.transactions
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 "";
}
}
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
}
}
}
}