import QtQuick 2.13 import QtQuick.Layouts 1.13 import QtQuick.Controls 2.14 import QtQuick.Window 2.12 import StatusQ 0.1 import StatusQ.Components 0.1 import StatusQ.Core.Theme 0.1 import StatusQ.Core.Utils 0.1 as SQUtils import StatusQ.Core 0.1 import StatusQ.Controls 0.1 import utils 1.0 import shared.views 1.0 import shared.controls 1.0 import shared.stores 1.0 import SortFilterProxyModel 0.2 import "../controls" /// \beware: heavy shortcuts here, refactor to match the requirements when touching this again /// \todo split into token history and balance views; they have different requirements that introduce unnecessary complexity /// \todo take a declarative approach, move logic into the typed backend and remove multiple source of truth (e.g. time ranges) Item { id: root property var token: ({}) property var currencyStore property var networkConnectionStore property var allNetworksModel property var networkFilters onNetworkFiltersChanged: d.forceRefreshBalanceStore = true /*required*/ property string address: "" property TokenBalanceHistoryStore balanceStore: TokenBalanceHistoryStore {} QtObject { id: d property var marketValueStore : RootStore.marketValueStore readonly property string symbol: !!root.token? root.token.symbol?? "" : "" property bool marketDetailsLoading: !!root.token? root.token.marketDetailsLoading?? false : false property bool tokenDetailsLoading: !!root.token? root.token.detailsLoading?? false: false property bool isCommunityAsset: !!root.token && !!token.communityId readonly property LeftJoinModel addressPerChainModel: LeftJoinModel { leftModel: token && token.addressPerChain ? token.addressPerChain: null rightModel: root.allNetworksModel joinRole: "chainId" } property bool forceRefreshBalanceStore: false } Connections { target: walletSectionAllTokens function onTokenHistoricalDataReady(tokenDetails: string) { let response = JSON.parse(tokenDetails) if (response === null) { console.debug("error parsing json message for tokenHistoricalDataReady") return } if(response.historicalData === null || response.historicalData <= 0) return d.marketValueStore.setTimeAndValueData(response.historicalData, response.range) } } AssetsDetailsHeader { id: tokenDetailsHeader anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right asset.name: { if (!token) return "" if (token.image) return token.image if (token.symbol) return Style.png("tokens/%1".arg(token.symbol)) return "" } asset.isImage: true primaryText: token && token.name ? token.name : Constants.dummyText secondaryText: token ? token.balanceText : Constants.dummyText tertiaryText: { if (!d.isCommunityAsset) { let totalCurrencyBalance = token ? token.balance * token.marketPrice : 0 return currencyStore.formatCurrencyAmount(totalCurrencyBalance, currencyStore.currentCurrency) } return "" } decimals: token && token.decimals ? token.decimals : 4 balances: token && token.balances ? token.balances: null allNetworksModel: root.allNetworksModel isLoading: d.marketDetailsLoading address: root.address errorTooltipText: token && token.balances ? networkConnectionStore.getBlockchainNetworkDownTextForToken(token.balances): "" formatBalance: function(balance){ return LocaleUtils.currencyAmountToLocaleString(currencyStore.getCurrencyAmount(balance, token.symbol)) } communityTag.visible: d.isCommunityAsset communityTag.tagPrimaryLabel.text: d.isCommunityAsset ? token.communityName: "" communityTag.asset.name: d.isCommunityAsset ? token && !!token.communityImage ? token.communityImage : "" : "" communityTag.asset.isImage: true } enum GraphType { Price = 0, Balance } StatusScrollView { id: scrollView anchors.top: tokenDetailsHeader.bottom anchors.bottom: parent.bottom anchors.topMargin: 47 width: parent.width contentWidth: availableWidth padding: 0 ColumnLayout { width: scrollView.availableWidth spacing: 40 Loader { id: graphDetailLoader Layout.fillWidth: true Layout.preferredHeight: 290 active: root.visible sourceComponent: StatusChartPanel { id: graphDetail property int selectedGraphType: AssetsDetailView.GraphType.Price property var selectedStore: d.marketValueStore function dataReady() { return typeof selectedStore != "undefined" } function timeRangeSelected() { return dataReady() && graphDetail.timeRangeTabBarIndex >= 0 && graphDetail.selectedTimeRange.length > 0 } readonly property var labelsData: { return timeRangeSelected() ? selectedStore.timeRange[graphDetail.timeRangeTabBarIndex][graphDetail.selectedTimeRange] : [] } readonly property var dataRange: { return timeRangeSelected() ? selectedStore.dataRange[graphDetail.timeRangeTabBarIndex][graphDetail.selectedTimeRange] : [] } readonly property var maxTicksLimit: { return timeRangeSelected() && typeof selectedStore.maxTicks != "undefined" ? selectedStore.maxTicks[graphDetail.timeRangeTabBarIndex][graphDetail.selectedTimeRange] : 0 } graphsModel: [ {text: qsTr("Price"), enabled: true, id: AssetsDetailView.GraphType.Price, visible: !d.isCommunityAsset}, {text: qsTr("Balance"), enabled: true, id: AssetsDetailView.GraphType.Balance, visible: true}, ] defaultTimeRangeIndexShown: ChartStoreBase.TimeRange.All timeRangeModel: dataReady() && selectedStore.timeRangeTabsModel onHeaderTabClicked: (privateIdentifier, isTimeRange) => { if(!isTimeRange && graphDetail.selectedGraphType !== privateIdentifier) { graphDetail.selectedGraphType = privateIdentifier } if(graphDetail.selectedGraphType === AssetsDetailView.GraphType.Balance) { graphDetail.updateBalanceStore() } if(!isTimeRange) { graphDetail.selectedStore = graphDetail.selectedGraphType === AssetsDetailView.GraphType.Price ? d.marketValueStore : balanceStore } chart.refresh() } readonly property var dateToShortLabel: function (value) { const range = balanceStore.timeRangeStrToEnum(graphDetail.selectedTimeRange) return range === ChartStoreBase.TimeRange.Weekly || range === ChartStoreBase.TimeRange.Monthly ? LocaleUtils.getDayMonth(value) : LocaleUtils.getMonthYear(value) } chart.type: 'line' chart.labels: RootStore.marketHistoryIsLoading ? [] : graphDetail.labelsData chart.datasets: { return [{ xAxisId: 'x-axis-1', yAxisId: 'y-axis-1', backgroundColor: (Theme.palette.name === "dark") ? 'rgba(136, 176, 255, 0.2)' : 'rgba(67, 96, 223, 0.2)', borderColor: (Theme.palette.name === "dark") ? 'rgba(136, 176, 255, 1)' : 'rgba(67, 96, 223, 1)', borderWidth: graphDetail.selectedGraphType === AssetsDetailView.GraphType.Price ? 3 : 2, pointRadius: 0, data: RootStore.marketHistoryIsLoading ? [] : graphDetail.dataRange, parsing: false, }] } chart.options: { return { maintainAspectRatio: false, responsive: true, legend: { display: false }, elements: { line: { cubicInterpolationMode: 'monotone' // without it interpolation makes the line too curvy that can extend horizontally farther then data points } }, //TODO enable zoom //zoom: { // enabled: true, // drag: true, // speed: 0.1, // threshold: 2 //}, //pan:{enabled:true,mode:'x'}, tooltips: { format: { enabled: graphDetail.selectedGraphType === AssetsDetailView.GraphType.Balance, callback: function (value) { return graphDetail.dateToShortLabel(value) }, valueCallback: function(value) { return LocaleUtils.currencyAmountToLocaleString({ amount: value, symbol: RootStore.currencyStore.currentCurrencySymbol, displayDecimals: 2 }) } }, intersect: false, displayColors: false, callbacks: { label: function(tooltipItem, data) { let label = data.datasets[tooltipItem.datasetIndex].label || ''; if (label) { label += ': '; } if (graphDetail.selectedGraphType === AssetsDetailView.GraphType.Balance) return label + tooltipItem.yLabel // already formatted in tooltips.value.callback const value = LocaleUtils.currencyAmountToLocaleString({ amount: tooltipItem.yLabel, symbol: RootStore.currencyStore.currentCurrencySymbol, displayDecimals: 2 }) return label + value } } }, scales: { labelFormat: { callback: function (value) { return graphDetail.dateToShortLabel(value) }, enabled: graphDetail.selectedGraphType === AssetsDetailView.GraphType.Balance, }, xAxes: [{ id: 'x-axis-1', position: 'bottom', type: graphDetail.selectedGraphType === AssetsDetailView.GraphType.Price ? 'category' : 'time', gridLines: { drawOnChartArea: false, drawBorder: false, drawTicks: false, }, ticks: { fontSize: 10, fontColor: (Theme.palette.name === "dark") ? '#909090' : '#939BA1', padding: 16, maxRotation: 0, minRotation: 0, maxTicksLimit: graphDetail.maxTicksLimit, }, time: { minUnit: 'day' // for '7days' timeframe, otherwise labels are '10PM', '10AM', '10PM', etc } }], yAxes: [{ position: 'left', id: 'y-axis-1', gridLines: { borderDash: [8, 4], drawBorder: false, drawTicks: false, color: (Theme.palette.name === "dark") ? '#909090' : '#939BA1' }, beforeDataLimits: (axis) => { axis.paddingTop = 25; axis.paddingBottom = 0; }, afterDataLimits: (axis) => { if(axis.min < 0) axis.min = 0; }, ticks: { fontSize: 10, fontColor: (Theme.palette.name === "dark") ? '#909090' : '#939BA1', padding: 8, callback: function(value, index, ticks) { return LocaleUtils.numberToLocaleString(value) }, } }] } } } LoadingGraphView { anchors.fill: chart active: RootStore.marketHistoryIsLoading } function updateBalanceStore() { let selectedTimeRangeEnum = balanceStore.timeRangeStrToEnum(graphDetail.selectedTimeRange) let currencySymbol = RootStore.currencyStore.currentCurrency if(!balanceStore.hasData(root.address, token.symbol, currencySymbol, selectedTimeRangeEnum) || d.forceRefreshBalanceStore) { RootStore.fetchHistoricalBalanceForTokenAsJson(root.address, token.symbol, currencySymbol, selectedTimeRangeEnum) } } Connections { target: balanceStore function onNewDataReady(address, tokenSymbol, currencySymbol, timeRange) { d.forceRefreshBalanceStore = false if (timeRange === balanceStore.timeRangeStrToEnum(graphDetail.selectedTimeRange)) { chart.refresh() } } } Connections { target: root function onAddressChanged() { graphDetail.updateBalanceStore() } } Connections { target: d function onSymbolChanged() { if (d.symbol) graphDetail.updateBalanceStore() } } } } RowLayout { Layout.fillWidth: true visible: !d.isCommunityAsset InformationTile { maxWidth: parent.width objectName: "marketCapInformationTile" primaryText: qsTr("Market Cap") secondaryText: token && token.marketDetails && token.marketDetails.marketCap ? LocaleUtils.currencyAmountToLocaleString(token.marketDetails.marketCap) : Constants.dummyText isLoading: d.marketDetailsLoading } InformationTile { maxWidth: parent.width objectName: "dayLowInformationTile" primaryText: qsTr("Day Low") secondaryText: token && token.marketDetails && token.marketDetails.lowDay ? LocaleUtils.currencyAmountToLocaleString(token.marketDetails.lowDay) : Constants.dummyText isLoading: d.marketDetailsLoading } InformationTile { maxWidth: parent.width objectName: "dayHighInformationTile" primaryText: qsTr("Day High") secondaryText: token && token.marketDetails && token.marketDetails.highDay ? LocaleUtils.currencyAmountToLocaleString(token.marketDetails.highDay) : Constants.dummyText isLoading: d.marketDetailsLoading } Item { Layout.fillWidth: true } InformationTile { readonly property double changePctHour: token && token.marketDetails ? token.marketDetails.changePctHour : 0 maxWidth: parent.width objectName: "hourInformationTile" primaryText: qsTr("Hour") secondaryText: "%1%".arg(LocaleUtils.numberToLocaleString(changePctHour, 2)) secondaryLabel.customColor: changePctHour === 0 ? Theme.palette.directColor1 : changePctHour < 0 ? Theme.palette.dangerColor1 : Theme.palette.successColor1 isLoading: d.marketDetailsLoading } InformationTile { readonly property double changePctDay: token && token.marketDetails ? token.marketDetails.changePctDay : 0 maxWidth: parent.width primaryText: qsTr("Day") objectName: "dayInformationTile" secondaryText: "%1%".arg(LocaleUtils.numberToLocaleString(changePctDay, 2)) secondaryLabel.customColor: changePctDay === 0 ? Theme.palette.directColor1 : changePctDay < 0 ? Theme.palette.dangerColor1 : Theme.palette.successColor1 isLoading: d.marketDetailsLoading } InformationTile { readonly property double changePct24hour: token && token.marketDetails ? token.marketDetails.changePct24hour : 0 maxWidth: parent.width primaryText: qsTr("24 Hours") objectName: "24HoursInformationTile" secondaryText: "%1%".arg(LocaleUtils.numberToLocaleString(changePct24hour, 2)) secondaryLabel.customColor: changePct24hour === 0 ? Theme.palette.directColor1 : changePct24hour < 0 ? Theme.palette.dangerColor1 : Theme.palette.successColor1 isLoading: d.marketDetailsLoading } } Flow { id: detailsFlow readonly property bool isOverflowing: detailsFlow.width - websiteBlock.width - tokenDescriptionText.width < 24 Layout.fillWidth: true spacing: 24 StatusTabBar { width: parent.width StatusTabButton { leftPadding: 0 width: implicitWidth text: qsTr("Overview") } visible: tokenDescriptionText.visible } StatusTextWithLoadingState { id: tokenDescriptionText width: Math.max(536 , scrollView.availableWidth - websiteBlock.width - 24) font.pixelSize: 15 lineHeight: 22 lineHeightMode: Text.FixedHeight text: token && token.description ? token.description : d.tokenDetailsLoading ? Constants.dummyText: "" customColor: Theme.palette.directColor1 elide: Text.ElideRight wrapMode: Text.Wrap textFormat: Qt.RichText loading: d.tokenDetailsLoading visible: !!text } GridLayout{ columnSpacing: 10 rowSpacing: 10 flow: detailsFlow.isOverflowing ? GridLayout.LeftToRight: GridLayout.TopToBottom InformationTileAssetDetails { id: websiteBlock Layout.preferredWidth: 272 visible: !d.isCommunityAsset primaryText: qsTr("Website") content: InformationTag { asset.name : "browser" tagPrimaryLabel.text: SQUtils.Utils.stripHttpsAndwwwFromUrl(token.websiteUrl) visible: typeof token != "undefined" && token && token.websiteUrl !== "" customBackground: Component { Rectangle { color: Theme.palette.baseColor2 border.width: 1 border.color: "transparent" radius: 36 } } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: Global.openLink(token.websiteUrl) } } } InformationTileAssetDetails { Layout.preferredWidth: 272 visible: d.isCommunityAsset primaryText: qsTr("Minted by") content: InformationTag { tagPrimaryLabel.text: token && token.communityName ? token.communityName : "" asset.name: token && token.communityImage ? token.communityImage : "" asset.isImage: true customBackground: Component { Rectangle { color: Theme.palette.baseColor2 border.width: 1 border.color: "transparent" radius: 36 } } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: Global.switchToCommunity(token.communityId) } } } InformationTileAssetDetails { Layout.minimumWidth: 272 Layout.preferredWidth: implicitWidth primaryText: qsTr("Contract") content: GridLayout { columnSpacing: 10 rowSpacing: 10 flow: detailsFlow.isOverflowing ? GridLayout.LeftToRight : GridLayout.TopToBottom Repeater { model: SortFilterProxyModel { sourceModel: d.addressPerChainModel filters: ExpressionFilter { expression: root.networkFilters.split(":").includes(model.chainId+"") } } delegate: InformationTag { asset.name: Style.svg("tiny/" + model.iconUrl) asset.isImage: true tagPrimaryLabel.text: model.chainName tagSecondaryLabel.text: SQUtils.Utils.elideText(model.address, 2,4) customBackground: Component { Rectangle { color: Theme.palette.baseColor2 border.width: 1 border.color: "transparent" radius: 36 } } } } } } } } } } }