Files
quickdots/bar/modules/Crypto.qml

1440 lines
73 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import Quickshell.Io
import "../../config" as Cfg
import "../../components"
ModuleChip {
id: root
readonly property var cfg: Cfg.Config
ConfigWriter {
id: configWriter
}
width: chipRow.implicitWidth + 24
radius: panelRadius
chipColor: t.cryptoBg
chipColorHovered: t.cryptoBgHover
chipBorderColor: t.cryptoBorder
chipBorderColorHovered: t.cryptoBorderHover
chipHoverEnabled: true
chipTapAction: () => cryptoPopup.toggle()
popupOpen: cryptoPopup.popupOpen
chipAnimWidth: true
property var localPairs: [{ coin: "bitcoin", currency: "usd" }]
property int activeCoinIndex: 0
readonly property string localCoinId: localPairs.length > 0
? localPairs[activeCoinIndex % localPairs.length].coin
: "bitcoin"
readonly property string localActiveCurrency: localPairs.length > 0
? localPairs[activeCoinIndex % localPairs.length].currency
: "usd"
property string localVsCur: "usd"
property int localDecimals: cfg.cryptoDecimals
property int localRefreshSec: cfg.cryptoRefreshSec
property int localRotateSec: cfg.cryptoRotateSec
property bool localShowIcon: cfg.cryptoShowIcon
function resolveWithPersistence() {
root.localPairs = Array.isArray(cfg.cryptoPairs) && cfg.cryptoPairs.length > 0 ? cfg.cryptoPairs : [{ coin: "bitcoin", currency: "usd" }]
root.localVsCur = root.localPairs[0].currency
root.displayCurrency = root.localPairs[0].currency
root.activeCoinIndex = 0
root.localDecimals = cfg.cryptoDecimals
root.localRefreshSec = cfg.cryptoRefreshSec
root.localRotateSec = cfg.cryptoRotateSec
root.localShowIcon = cfg.cryptoShowIcon
}
function persistSettings() {
const pairs = root.localPairs
const decimals = Math.round(root.localDecimals)
const refreshSec = Math.round(root.localRefreshSec)
const rotateSec = Math.round(root.localRotateSec)
const showIcon = root.localShowIcon
const pairsQml = "[" + pairs.map(p =>
'{ coin: "' + p.coin + '", currency: "' + p.currency + '" }'
).join(", ") + "]"
configWriter.writeInts([
{ key: "cryptoDecimals", value: decimals },
{ key: "cryptoRefreshSec", value: refreshSec },
{ key: "cryptoRotateSec", value: rotateSec }
])
configWriter.writeBool("cryptoShowIcon", showIcon)
configWriter.writeArray("cryptoPairs", pairsQml)
}
property real price: -1
property real change24h: 0
property real high24h: 0
property real low24h: 0
property real marketCap: 0
property real volume24h: 0
property int rank: 0
property real circulatingSupply: 0
property real ath: 0
property real athChange: 0
property string coinSymbol: localCoinId.toUpperCase().slice(0, 4)
property string coinName: ""
property string lastUpdated: ""
property string displayCurrency: "usd"
property bool loading: false
property bool loadingVisible: false
property bool hasError: false
property var sparklineData: []
property real change7d: 0
property var _priceCache: ({})
property int _fetchGen: 0
Timer {
id: minLoadTimer
interval: 2000
repeat: false
onTriggered: { if (!root.loading) root.loadingVisible = false }
}
onLoadingChanged: {
if (loading) {
loadingVisible = true
minLoadTimer.restart()
} else {
if (!minLoadTimer.running)
loadingVisible = false
}
}
function compactPrice(val) {
if (val < 0) return "—"
const factor = Math.pow(10, localDecimals)
if (val >= 1_000_000) {
const n = Math.trunc(val * factor / 1_000_000)
return (n / factor).toFixed(localDecimals) + "m"
}
if (val >= 1_000) {
const n = Math.trunc(val * factor / 1_000)
return (n / factor).toFixed(localDecimals) + "k"
}
return val.toFixed(Math.max(localDecimals, 2))
}
function fullPrice(val) {
if (val < 0) return "—"
return val.toLocaleString(Qt.locale(), "f", 2)
}
function compactLarge(val) {
if (val <= 0) return "—"
if (val >= 1_000_000_000) return (val / 1_000_000_000).toFixed(2) + "B"
if (val >= 1_000_000) return (val / 1_000_000).toFixed(2) + "M"
if (val >= 1_000) return (val / 1_000).toFixed(2) + "k"
return val.toFixed(2)
}
function safeNumber(val, fallback) {
const n = Number(val)
return (typeof n === "number" && isFinite(n)) ? n : fallback
}
function safeString(val, maxLen, fallback) {
if (typeof val !== "string") return fallback
return val.slice(0, maxLen).replace(/[\x00-\x1f\x7f-\x9f]/g, "")
}
function rotatePrev() {
if (root.localPairs.length <= 1) return
root.activeCoinIndex = (root.activeCoinIndex - 1 + root.localPairs.length) % root.localPairs.length
root._applyFromCache()
rotateTimer.restart()
}
function rotateNext() {
if (root.localPairs.length <= 1) return
root.activeCoinIndex = (root.activeCoinIndex + 1) % root.localPairs.length
root._applyFromCache()
rotateTimer.restart()
}
function _applyFromCache() {
var key = root.localCoinId + "_" + root.localActiveCurrency
var entry = root._priceCache[key]
if (!entry) { console.log("Crypto: no cache entry for", key); return }
console.log("Crypto: applying", key, "price=" + entry.price)
price = entry.price
change24h = entry.change24h
high24h = entry.high24h
low24h = entry.low24h
marketCap = entry.marketCap
volume24h = entry.volume24h
rank = entry.rank
circulatingSupply = entry.circulatingSupply
ath = entry.ath
athChange = entry.athChange
coinSymbol = safeString(entry.symbol, 8, root.localCoinId).toUpperCase()
coinName = safeString(entry.name, 50, "")
lastUpdated = new Date().toLocaleTimeString(Qt.locale(), "HH:mm:ss")
root.displayCurrency = root.localActiveCurrency
sparklineData = entry.sparkline || []
change7d = entry.change7d || 0
hasError = false
}
function fetchPrice() {
loading = true
hasError = false
_fetchGen++
var myGen = _fetchGen
var coinGroups = {}
for (var i = 0; i < root.localPairs.length; i++) {
var p = root.localPairs[i]
if (!coinGroups[p.currency]) coinGroups[p.currency] = []
var coinList = coinGroups[p.currency]
if (coinList.indexOf(p.coin) === -1) coinList.push(p.coin)
}
var currencies = Object.keys(coinGroups)
var pending = currencies.length
if (pending === 0) { loading = false; hasError = true; return }
for (var ci = 0; ci < currencies.length; ci++) {
var cur = currencies[ci]
var ids = coinGroups[cur].join(",")
var url =
"https://api.coingecko.com/api/v3/coins/markets" +
"?vs_currency=" + encodeURIComponent(cur || "usd") +
"&ids=" + encodeURIComponent(ids) +
"&order=market_cap_desc&per_page=250&page=1" +
"&sparkline=true&price_change_percentage=24h,7d"
var xhr = new XMLHttpRequest()
xhr._currency = cur
xhr.timeout = 15000
xhr.onreadystatechange = (function(xhr, cur, myGen) {
return function() {
if (myGen !== root._fetchGen) return
if (xhr.readyState !== XMLHttpRequest.DONE) return
if (xhr.status !== 200) { hasError = true }
else {
try {
var text = xhr.responseText
if (text.length <= 65536) {
var data = JSON.parse(text)
if (Array.isArray(data)) {
for (var di = 0; di < data.length; di++) {
var coin = data[di]
var key = coin.id + "_" + cur
console.log("Crypto: cache", key, "price=" + coin.current_price)
root._priceCache[key] = {
price: safeNumber(coin.current_price, -1),
change24h: safeNumber(coin.price_change_percentage_24h, 0),
high24h: safeNumber(coin.high_24h, 0),
low24h: safeNumber(coin.low_24h, 0),
marketCap: safeNumber(coin.market_cap, 0),
volume24h: safeNumber(coin.total_volume, 0),
rank: Math.trunc(safeNumber(coin.market_cap_rank, 0)),
circulatingSupply: safeNumber(coin.circulating_supply, 0),
ath: safeNumber(coin.ath, 0),
athChange: safeNumber(coin.ath_change_percentage, 0),
symbol: safeString(coin.symbol, 8, coin.id),
name: safeString(coin.name, 50, ""),
sparkline: (coin.sparkline_in_7d && Array.isArray(coin.sparkline_in_7d.price))
? coin.sparkline_in_7d.price : [],
change7d: safeNumber(coin.price_change_percentage_7d_in_currency, 0)
}
}
}
}
} catch (_) {}
}
if (--pending <= 0) {
loading = false
root._applyFromCache()
}
}
})(xhr, cur, myGen)
xhr.open("GET", url)
xhr.setRequestHeader("Accept", "application/json")
xhr.send()
}
}
Component.onCompleted: { resolveWithPersistence(); fetchPrice() }
Timer {
id: rotateTimer
interval: root.localRotateSec * 1000
running: root.localRotateSec > 0 && root.localPairs.length > 1
repeat: true
onTriggered: root.rotateNext()
}
Timer {
id: refreshTimer
interval: root.localRefreshSec * 1000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.fetchPrice()
}
onLocalRefreshSecChanged: refreshTimer.restart()
onLocalRotateSecChanged: rotateTimer.restart()
RowLayout {
id: chipRow
anchors.centerIn: parent
spacing: 0
SequentialAnimation on opacity {
running: root.loadingVisible
loops: Animation.Infinite
NumberAnimation { from: 1; to: 0.25; duration: 500; easing.type: Easing.InOutSine }
NumberAnimation { from: 0.25; to: 1; duration: 500; easing.type: Easing.InOutSine }
}
Connections {
target: root
function onLoadingVisibleChanged() {
if (!root.loadingVisible) chipRow.opacity = 1
}
}
Text {
visible: root.localShowIcon
text: "󰿤"
color: t.cryptoIcon
font.pixelSize: 14
Layout.alignment: Qt.AlignVCenter
Layout.rightMargin: 7
}
Text {
text: root.coinSymbol + "/" + root.displayCurrency.toUpperCase() + ": "
color: t.cryptoLabel
font.pixelSize: 12
font.bold: true
verticalAlignment: Text.AlignVCenter
}
Text {
text: root.hasError ? "err" : root.price < 0 ? "…" : root.compactPrice(root.price)
color: t.cryptoValue
font.pixelSize: 13
font.bold: true
verticalAlignment: Text.AlignVCenter
}
Text {
visible: root.price >= 0
text: root.change24h >= 0 ? "▲" : "▼"
color: root.change24h >= 0 ? (t.cryptoUp) : (t.cryptoDown)
font.pixelSize: 9
font.bold: true
renderType: Text.NativeRendering
verticalAlignment: Text.AlignVCenter
Layout.alignment: Qt.AlignVCenter
Layout.leftMargin: 4
Behavior on color { ColorAnimation { duration: 200 } }
}
}
StandardPopup {
id: cryptoPopup
ns: "crypto"
chipRoot: root
cardWidth: 300
hasKeyboardFocus: true
cardColor: t.cryptoPopupBg
cardBorderColor: t.cryptoPopupBorder
cardHeight: cardCol.implicitHeight + 24
layerEnabled: true
ColumnLayout {
id: cardCol
anchors { left: parent.left; right: parent.right; top: parent.top }
anchors.margins: 16
anchors.topMargin: 16
spacing: 10
Rectangle {
Layout.fillWidth: true
height: 36
radius: 12
color: t.cryptoFieldBg
RowLayout {
anchors.fill: parent
anchors.margins: 4
spacing: 4
component TabBtn: Rectangle {
required property string label
required property int tabIndex
property string icon: ""
Layout.fillWidth: true
height: 28
radius: 9
readonly property bool active: cryptoPopup.activeTab === tabIndex
color: active
? (t.accent)
: (tm.containsMouse ? (t.cryptoFieldBgFocus) : "transparent")
Behavior on color { ColorAnimation { duration: 150 } }
border.color: active ? Qt.rgba(1, 1, 1, 0.18) : "transparent"
border.width: 1
Behavior on border.color { ColorAnimation { duration: 150 } }
scale: tm.pressed ? 0.93 : 1.0
Behavior on scale { NumberAnimation { duration: 90; easing.type: Easing.OutQuad } }
Row {
anchors.centerIn: parent
spacing: 5
Text {
anchors.verticalCenter: parent.verticalCenter
visible: parent.parent.icon !== ""
text: parent.parent.icon
color: parent.parent.active ? (t.cryptoTextOnAccent) : (t.cryptoPopupDim)
font.pixelSize: 11
opacity: parent.parent.active ? 1.0 : 0.7
Behavior on color { ColorAnimation { duration: 150 } }
Behavior on opacity { NumberAnimation { duration: 150 } }
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: parent.parent.label
color: parent.parent.active ? (t.cryptoTextOnAccent) : (t.cryptoPopupDim)
font.pixelSize: 12
font.bold: parent.parent.active
font.letterSpacing: parent.parent.active ? 0.4 : 0.0
Behavior on color { ColorAnimation { duration: 150 } }
Behavior on font.letterSpacing { NumberAnimation { duration: 150 } }
}
}
MouseArea {
id: tm
anchors.fill: parent; hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: cryptoPopup.activeTab = tabIndex
}
}
TabBtn { label: "Price"; icon: "󰕾"; tabIndex: 0 }
TabBtn { label: "Settings"; icon: "󰒓"; tabIndex: 1 }
}
}
Item {
id: tabContainer
Layout.fillWidth: true
clip: true
Layout.preferredHeight: Math.max(priceContent.implicitHeight,
settingsContent.implicitHeight)
ColumnLayout {
id: priceContent
width: tabContainer.width
x: cryptoPopup.activeTab === 0 ? 0 : -tabContainer.width
opacity: cryptoPopup.activeTab === 0 ? 1.0 : 0.0
Behavior on x { NumberAnimation { duration: 260; easing.type: Easing.OutCubic } }
Behavior on opacity { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } }
spacing: 10
RowLayout {
Layout.fillWidth: true
Text {
text: "󰿤"
color: t.cryptoIcon
font.pixelSize: 22
Layout.alignment: Qt.AlignVCenter
}
ColumnLayout {
spacing: 1
Layout.fillWidth: true
Layout.leftMargin: 8
Text {
text: root.coinSymbol + " / " + root.displayCurrency.toUpperCase()
color: t.cryptoPopupHeader
font.pixelSize: 15
font.bold: true
}
Text {
text: root.coinName !== "" ? root.coinName : (root.localCoinId.charAt(0).toUpperCase() + root.localCoinId.slice(1))
color: t.cryptoPopupDim
font.pixelSize: 11
}
}
Rectangle {
width: badge.implicitWidth + 12
height: 22; radius: 11
color: root.change24h >= 0 ? (t.cryptoUpBg) : (t.cryptoDownBg)
Text {
id: badge
anchors.centerIn: parent
text: (root.change24h >= 0 ? "▲ +" : "▼ ") + root.change24h.toFixed(2) + "%"
color: root.change24h >= 0 ? (t.cryptoUp) : (t.cryptoDown)
font.pixelSize: 11
font.bold: true
renderType: Text.NativeRendering
}
}
}
Text {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
text: root.price < 0 ? "Loading…" : root.displayCurrency.toUpperCase() + " " + root.fullPrice(root.price)
color: t.cryptoPopupText
font.pixelSize: 24
font.bold: true
font.family: "monospace"
}
Rectangle { Layout.fillWidth: true; height: 1; color: t.calSeparator }
component StatRow: RowLayout {
property string label: ""
property string value: ""
Layout.fillWidth: true
Text { text: label; color: t.cryptoPopupDim; font.pixelSize: 12; Layout.fillWidth: true }
Text { text: value; color: t.cryptoPopupText; font.pixelSize: 12; font.bold: true; font.family: "monospace"; renderType: Text.NativeRendering }
}
StatRow { label: "Rank"; value: root.rank > 0 ? "#" + root.rank : "—" }
StatRow { label: "24h High"; value: root.high24h > 0 ? root.fullPrice(root.high24h) : "—" }
StatRow { label: "24h Low"; value: root.low24h > 0 ? root.fullPrice(root.low24h) : "—" }
StatRow { label: "Market Cap"; value: root.compactLarge(root.marketCap) }
StatRow { label: "24h Volume"; value: root.compactLarge(root.volume24h) }
StatRow { label: "Circ. Supply"; value: root.circulatingSupply > 0 ? root.compactLarge(root.circulatingSupply) + " " + root.coinSymbol : "—" }
RowLayout {
Layout.fillWidth: true
Text { text: "ATH"; color: t.cryptoPopupDim; font.pixelSize: 12; Layout.fillWidth: true }
Text {
text: root.ath > 0 ? root.fullPrice(root.ath) : "—"
color: t.cryptoPopupText
font.pixelSize: 12; font.bold: true; font.family: "monospace"
}
Text {
visible: root.ath > 0
text: "(" + root.athChange.toFixed(2) + "%)"
color: t.cryptoDown
font.pixelSize: 11; font.bold: true; font.family: "monospace"
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 5
RowLayout {
Layout.fillWidth: true
Text {
text: "24h Range"
color: t.cryptoPopupDim
font.pixelSize: 12
renderType: Text.NativeRendering
Layout.fillWidth: true
}
Text {
visible: root.high24h > root.low24h && root.price >= 0
text: ((root.price - root.low24h) / (root.high24h - root.low24h) * 100).toFixed(1) + "% of range"
color: t.cryptoPopupDim
font.pixelSize: 11
renderType: Text.NativeRendering
}
}
Item {
Layout.fillWidth: true
height: 6
Rectangle {
anchors.fill: parent
radius: 3
color: t.cryptoRangeTrack
}
Rectangle {
height: parent.height
radius: 3
color: root.change24h >= 0
? (t.cryptoUp)
: (t.cryptoDown)
width: (root.high24h > root.low24h && root.price >= 0)
? Math.max(radius * 2,
Math.min(parent.width,
(root.price - root.low24h) / (root.high24h - root.low24h)
* parent.width))
: 0
Behavior on width { NumberAnimation { duration: 500; easing.type: Easing.OutCubic } }
}
}
RowLayout {
Layout.fillWidth: true
Text {
text: root.low24h > 0 ? root.fullPrice(root.low24h) : "—"
color: t.cryptoDown
font.pixelSize: 10
font.family: "monospace"
renderType: Text.NativeRendering
}
Item { Layout.fillWidth: true }
Text {
text: root.high24h > 0 ? root.fullPrice(root.high24h) : "—"
color: t.cryptoUp
font.pixelSize: 10
font.family: "monospace"
renderType: Text.NativeRendering
}
}
}
Rectangle { Layout.fillWidth: true; height: 1; color: t.calSeparator }
ColumnLayout {
Layout.fillWidth: true
spacing: 5
RowLayout {
Layout.fillWidth: true
Text {
text: "7d Chart"
color: t.cryptoPopupDim
font.pixelSize: 12
Layout.fillWidth: true
}
Rectangle {
visible: root.sparklineData.length > 1
width: badge7d.implicitWidth + 12
height: 20; radius: 10
color: root.change7d >= 0 ? (t.cryptoUpBg) : (t.cryptoDownBg)
Text {
id: badge7d
anchors.centerIn: parent
text: (root.change7d >= 0 ? "▲ +" : "▼ ") + root.change7d.toFixed(2) + "%"
color: root.change7d >= 0 ? (t.cryptoUp) : (t.cryptoDown)
font.pixelSize: 10
font.bold: true
}
}
}
Canvas {
id: sparklineCanvas
Layout.fillWidth: true
height: 52
property var prices: root.sparklineData
property bool isUp: root.change7d >= 0
property string upColor: t.cryptoUp || "#4ade80"
property string downColor: t.cryptoDown || "#f87171"
onPricesChanged: requestPaint()
onIsUpChanged: requestPaint()
onUpColorChanged: requestPaint()
onDownColorChanged:requestPaint()
onPaint: {
var ctx = getContext("2d")
ctx.clearRect(0, 0, width, height)
var pts = prices
if (!pts || pts.length < 2) {
ctx.fillStyle = Qt.rgba(1, 1, 1, 0.06)
ctx.beginPath()
ctx.roundedRect(0, 0, width, height, 6, 6)
ctx.fill()
return
}
var minP = pts[0], maxP = pts[0]
for (var i = 1; i < pts.length; i++) {
if (pts[i] < minP) minP = pts[i]
if (pts[i] > maxP) maxP = pts[i]
}
var range = maxP - minP
if (range === 0) range = 1
var pad = 4, pw = width - pad * 2, ph = height - pad * 2
var lineColor = isUp ? upColor : downColor
ctx.beginPath()
for (var j = 0; j < pts.length; j++) {
var x = pad + (j / (pts.length - 1)) * pw
var y = pad + ph - ((pts[j] - minP) / range) * ph
if (j === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.lineTo(pad + pw, pad + ph)
ctx.lineTo(pad, pad + ph)
ctx.closePath()
var grad = ctx.createLinearGradient(0, 0, 0, height)
grad.addColorStop(0, Qt.rgba(
isUp ? 0.29 : 0.97,
isUp ? 0.87 : 0.44,
isUp ? 0.50 : 0.44, 0.22))
grad.addColorStop(1, Qt.rgba(0, 0, 0, 0))
ctx.fillStyle = grad
ctx.fill()
ctx.beginPath()
for (var k = 0; k < pts.length; k++) {
var lx = pad + (k / (pts.length - 1)) * pw
var ly = pad + ph - ((pts[k] - minP) / range) * ph
if (k === 0) ctx.moveTo(lx, ly)
else ctx.lineTo(lx, ly)
}
ctx.strokeStyle = lineColor
ctx.lineWidth = 1.5
ctx.lineJoin = "round"
ctx.stroke()
var lx2 = pad + pw
var ly2 = pad + ph - ((pts[pts.length - 1] - minP) / range) * ph
ctx.beginPath()
ctx.arc(lx2, ly2, 3, 0, Math.PI * 2)
ctx.fillStyle = lineColor
ctx.fill()
}
}
}
Rectangle { Layout.fillWidth: true; height: 1; color: t.calSeparator }
RowLayout {
Layout.fillWidth: true
Text {
text: root.hasError ? "⚠ fetch error" : "Updated " + (root.lastUpdated || "—")
color: t.cryptoPopupDim
font.pixelSize: 11
Layout.fillWidth: true
}
Rectangle {
visible: root.localPairs.length > 1
width: 26; height: 26; radius: 8
color: prevMouse.containsMouse
? (prevMouse.pressed ? (t.accent) : (t.cryptoFieldBgFocus))
: "transparent"
Behavior on color { ColorAnimation { duration: 120 } }
Text {
anchors.centerIn: parent
text: ""
color: t.cryptoIcon
font.pixelSize: 14
}
MouseArea {
id: prevMouse
anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor
onClicked: root.rotatePrev()
}
}
Rectangle {
visible: root.localPairs.length > 1
width: 26; height: 26; radius: 8
color: nextMouse.containsMouse
? (nextMouse.pressed ? (t.accent) : (t.cryptoFieldBgFocus))
: "transparent"
Behavior on color { ColorAnimation { duration: 120 } }
Text {
anchors.centerIn: parent
text: ""
color: t.cryptoIcon
font.pixelSize: 14
}
MouseArea {
id: nextMouse
anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor
onClicked: root.rotateNext()
}
}
Rectangle {
id: refreshBtn
width: 26; height: 26; radius: 8
color: rMouse.containsMouse
? (rMouse.pressed ? (t.accent) : (t.cryptoFieldBgFocus))
: "transparent"
Behavior on color { ColorAnimation { duration: 120 } }
Text {
id: refreshIcon
anchors.centerIn: parent
text: "󰑐"
color: t.cryptoIcon
font.pixelSize: 14
RotationAnimator {
id: spinAnim
target: refreshIcon
from: 0; to: 360
duration: 600
easing.type: Easing.OutCubic
running: false
}
}
MouseArea {
id: rMouse
anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor
onClicked: {
spinAnim.restart()
root.fetchPrice()
}
}
}
}
}
ColumnLayout {
id: settingsContent
width: tabContainer.width
height: tabContainer.height
x: cryptoPopup.activeTab === 1 ? 0 : tabContainer.width
opacity: cryptoPopup.activeTab === 1 ? 1.0 : 0.0
Behavior on x { NumberAnimation { duration: 260; easing.type: Easing.OutCubic } }
Behavior on opacity { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } }
spacing: 12
component FieldRow: ColumnLayout {
property string label: ""
property string placeholder: ""
property alias fieldText: fi.text
spacing: 4
Layout.fillWidth: true
Text {
text: label
color: t.cryptoPopupDim
font.pixelSize: 11
font.capitalization: Font.AllUppercase
}
Rectangle {
Layout.fillWidth: true
height: 30
radius: 8
color: fi.activeFocus ? (t.cryptoFieldBgFocus) : (t.cryptoFieldBg)
border.color: fi.activeFocus ? (t.accent) : "transparent"
border.width: 1
Behavior on color { ColorAnimation { duration: 120 } }
Behavior on border.color { ColorAnimation { duration: 120 } }
TextInput {
id: fi
anchors { fill: parent; leftMargin: 10; rightMargin: 10; topMargin: 7; bottomMargin: 7 }
color: t.cryptoPopupText
font.pixelSize: 12
font.family: "monospace"
selectionColor: t.accent
clip: true
Text {
anchors.fill: parent
text: parent.parent.parent.placeholder
color: t.cryptoPlaceholder
font.pixelSize: 12
font.family: "monospace"
visible: fi.text === "" && !fi.activeFocus
verticalAlignment: Text.AlignVCenter
}
}
}
}
component StepRow: RowLayout {
property string label: ""
property int value: 1
property int minVal: 0
property int maxVal: 999
signal stepped(int v)
Layout.fillWidth: true
Text { text: label; color: t.cryptoPopupDim; font.pixelSize: 12; Layout.fillWidth: true }
Rectangle {
width: 24; height: 24; radius: 7
color: mm.containsMouse ? (mm.pressed ? (t.accent) : (t.cryptoFieldBgFocus)) : (t.cryptoFieldBg)
Behavior on color { ColorAnimation { duration: 100 } }
Text { anchors.centerIn: parent; text: ""; font.pixelSize: 14; font.bold: true; color: t.cryptoPopupText }
MouseArea { id: mm; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: if (value > minVal) stepped(value - 1) }
}
Rectangle {
width: 54; height: 24; radius: 6; color: t.cryptoFieldBg
Text { anchors.centerIn: parent; text: value; color: t.cryptoPopupText; font.pixelSize: 12; font.bold: true; font.family: "monospace" }
}
Rectangle {
width: 24; height: 24; radius: 7
color: pm.containsMouse ? (pm.pressed ? (t.accent) : (t.cryptoFieldBgFocus)) : (t.cryptoFieldBg)
Behavior on color { ColorAnimation { duration: 100 } }
Text { anchors.centerIn: parent; text: "+"; font.pixelSize: 14; font.bold: true; color: t.cryptoPopupText }
MouseArea { id: pm; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: if (value < maxVal) stepped(value + 1) }
}
}
component ToggleRow: RowLayout {
property string label: ""
property bool checked: false
signal toggled(bool v)
Layout.fillWidth: true
Text { text: label; color: t.cryptoPopupDim; font.pixelSize: 12; Layout.fillWidth: true }
Rectangle {
width: 36; height: 20; radius: 10
color: checked ? (t.accent) : (t.cryptoFieldBgFocus)
Behavior on color { ColorAnimation { duration: 150 } }
Rectangle {
width: 14; height: 14; radius: 7; color: "#ffffffff"
anchors.verticalCenter: parent.verticalCenter
x: checked ? parent.width - 17 : 3
Behavior on x { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } }
}
MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: toggled(!checked) }
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "Pairs to cycle"
color: t.cryptoPopupDim
font.pixelSize: 11
font.capitalization: Font.AllUppercase
}
ColumnLayout {
Layout.fillWidth: true
spacing: 8
Item {
Layout.fillWidth: true
height: 28
clip: true
Rectangle {
anchors { left: parent.left; top: parent.top; bottom: parent.bottom }
width: 12; z: 2
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: t.cryptoPopupBg }
GradientStop { position: 1.0; color: "transparent" }
}
visible: chipScroll.contentX > 0
}
Rectangle {
anchors { right: parent.right; top: parent.top; bottom: parent.bottom }
width: 12; z: 2
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: "transparent" }
GradientStop { position: 1.0; color: t.cryptoPopupBg }
}
visible: chipScroll.contentX < chipScroll.contentWidth - chipScroll.width - 1
}
Flickable {
id: chipScroll
anchors.fill: parent
contentWidth: chipRow2.implicitWidth
contentHeight: height
clip: true
flickableDirection: Flickable.HorizontalFlick
boundsBehavior: Flickable.StopAtBounds
Row {
id: chipRow2
spacing: 8
Repeater {
model: root.localPairs
delegate: Item {
readonly property bool isActive:
index === root.activeCoinIndex % Math.max(1, root.localPairs.length)
height: 24
width: chipLabel.implicitWidth + dotSpace.width + 6
+ (root.localPairs.length > 1 ? 18 : 0)
Rectangle {
id: dotSpace
anchors.verticalCenter: parent.verticalCenter
x: 0; width: 5; height: 5; radius: 3
color: isActive ? (t.accent) : "#33ffffff"
Behavior on color { ColorAnimation { duration: 150 } }
}
Text {
id: chipLabel
anchors.verticalCenter: parent.verticalCenter
x: dotSpace.width + 5
text: (modelData.coin ?? "?") + "/" + (modelData.currency ?? "?").toUpperCase()
color: isActive
? (t.accent)
: (t.cryptoPopupText)
font.pixelSize: 12
font.bold: isActive
font.family: "monospace"
Behavior on color { ColorAnimation { duration: 150 } }
}
Rectangle {
visible: root.localPairs.length > 1
anchors { right: parent.right
verticalCenter: parent.verticalCenter }
width: 14; height: 14; radius: 4
color: rmChipMouse.containsMouse ? (t.cryptoChipRemoveBg) : "transparent"
Behavior on color { ColorAnimation { duration: 100 } }
Text {
anchors.centerIn: parent
text: "×"; font.pixelSize: 11; font.bold: true
color: t.cryptoDown
}
MouseArea {
id: rmChipMouse
anchors.fill: parent; hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
let arr = root.localPairs.slice()
arr.splice(index, 1)
root.localPairs = arr
if (root.activeCoinIndex >= arr.length)
root.activeCoinIndex = 0
root.fetchPrice()
}
}
}
}
}
}
}
}
Item {
Layout.fillWidth: true
height: 3
visible: chipScroll.contentWidth > chipScroll.width
Rectangle {
anchors.fill: parent
radius: 2
color: t.cryptoFieldBg
}
Rectangle {
height: parent.height
radius: 2
color: t.accent
opacity: 0.7
readonly property real ratio: chipScroll.width / Math.max(1, chipScroll.contentWidth)
width: Math.max(16, parent.width * ratio)
x: (parent.width - width) *
(chipScroll.contentWidth > chipScroll.width
? chipScroll.contentX / (chipScroll.contentWidth - chipScroll.width)
: 0)
Behavior on x { NumberAnimation { duration: 80 } }
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text {
text: "Coin ID"
color: t.cryptoPopupDim
font.pixelSize: 11
font.capitalization: Font.AllUppercase
}
Rectangle {
Layout.fillWidth: true
height: 30; radius: 8
color: addCoinInput.activeFocus ? (t.cryptoFieldBgFocus) : (t.cryptoFieldBg)
border.color: addCoinInput.activeFocus ? (t.accent) : "transparent"
border.width: 1
Behavior on color { ColorAnimation { duration: 120 } }
Behavior on border.color { ColorAnimation { duration: 120 } }
TextInput {
id: addCoinInput
anchors { fill: parent; leftMargin: 10; rightMargin: 10; topMargin: 7; bottomMargin: 7 }
color: t.cryptoPopupText
font.pixelSize: 12; font.family: "monospace"
selectionColor: t.accent
clip: true
Text {
anchors.fill: parent
text: "e.g. bitcoin, monero, solana"
color: t.cryptoPlaceholder
font.pixelSize: 12; font.family: "monospace"
visible: parent.text === "" && !parent.activeFocus
verticalAlignment: Text.AlignVCenter
}
onAccepted: addCurrencyInput.forceActiveFocus()
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text {
text: "Currency"
color: t.cryptoPopupDim
font.pixelSize: 11
font.capitalization: Font.AllUppercase
}
RowLayout {
Layout.fillWidth: true
spacing: 6
Rectangle {
Layout.fillWidth: true
height: 30; radius: 8
color: addCurrencyInput.activeFocus ? (t.cryptoFieldBgFocus) : (t.cryptoFieldBg)
border.color: addCurrencyInput.activeFocus ? (t.accent) : "transparent"
border.width: 1
Behavior on color { ColorAnimation { duration: 120 } }
Behavior on border.color { ColorAnimation { duration: 120 } }
TextInput {
id: addCurrencyInput
anchors { fill: parent; leftMargin: 10; rightMargin: 10; topMargin: 7; bottomMargin: 7 }
color: t.cryptoPopupText
font.pixelSize: 12; font.family: "monospace"
selectionColor: t.accent
clip: true
Text {
anchors.fill: parent
text: "e.g. usd, eur, brl"
color: t.cryptoPlaceholder
font.pixelSize: 12; font.family: "monospace"
visible: parent.text === "" && !parent.activeFocus
verticalAlignment: Text.AlignVCenter
}
onAccepted: {
const c = addCoinInput.text.trim().toLowerCase()
const cur = addCurrencyInput.text.trim().toLowerCase() || root.localVsCur
if (c !== "") {
root.localPairs = root.localPairs.concat([{ coin: c, currency: cur }])
addCoinInput.text = ""
addCurrencyInput.text = ""
root.fetchPrice()
}
}
}
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.topMargin: 4
height: 32; radius: 9
color: addPairMouse.containsMouse
? (addPairMouse.pressed ? (t.accent) : (t.cryptoFieldBgFocus))
: (t.cryptoApplyIdleBg)
Behavior on color { ColorAnimation { duration: 130 } }
Text {
anchors.centerIn: parent
text: "+ Add Pair"
color: addPairMouse.containsMouse ? (t.cryptoTextOnAccent) : (t.cryptoPopupText)
font.pixelSize: 12; font.bold: true
Behavior on color { ColorAnimation { duration: 130 } }
}
MouseArea {
id: addPairMouse
anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor
onClicked: {
const c = addCoinInput.text.trim().toLowerCase()
const cur = addCurrencyInput.text.trim().toLowerCase() || root.localVsCur
if (c !== "") {
root.localPairs = root.localPairs.concat([{ coin: c, currency: cur }])
addCoinInput.text = ""
addCurrencyInput.text = ""
root.fetchPrice()
}
}
}
}
}
Item { Layout.fillHeight: true }
Rectangle { Layout.fillWidth: true; height: 1; color: t.calSeparator }
StepRow {
label: "Decimals in label"
value: root.localDecimals
minVal: 0
maxVal: 8
onStepped: (v) => root.localDecimals = v
}
RowLayout {
Layout.fillWidth: true
Text {
text: "Refresh (seconds)"
color: t.cryptoPopupDim
font.pixelSize: 12
Layout.fillWidth: true
}
Rectangle {
width: 24; height: 24; radius: 7
color: decMouse.containsMouse
? (decMouse.pressed ? (t.accent) : (t.cryptoFieldBgFocus))
: (t.cryptoFieldBg)
Behavior on color { ColorAnimation { duration: 100 } }
Text { anchors.centerIn: parent; text: ""; font.pixelSize: 14; font.bold: true; color: t.cryptoPopupText }
MouseArea {
id: decMouse
anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor
onClicked: if (root.localRefreshSec > 10) root.localRefreshSec -= 1
}
}
Rectangle {
width: 54; height: 24; radius: 6
color: refreshInput.activeFocus ? (t.cryptoFieldBgFocus) : (t.cryptoFieldBg)
border.color: refreshInput.activeFocus ? (t.accent) : "transparent"
border.width: 1
Behavior on color { ColorAnimation { duration: 120 } }
Behavior on border.color { ColorAnimation { duration: 120 } }
TextInput {
id: refreshInput
anchors { fill: parent; leftMargin: 6; rightMargin: 6; topMargin: 4; bottomMargin: 4 }
text: root.localRefreshSec.toString()
color: t.cryptoPopupText
font.pixelSize: 12
font.bold: true
font.family: "monospace"
selectionColor: t.accent
horizontalAlignment: TextInput.AlignHCenter
inputMethodHints: Qt.ImhDigitsOnly
validator: IntValidator { bottom: 10; top: 3600 }
clip: true
onActiveFocusChanged: if (!activeFocus) {
const v = parseInt(text)
if (!isNaN(v) && v >= 10 && v <= 3600)
root.localRefreshSec = v
else
text = root.localRefreshSec.toString()
}
onAccepted: {
const v = parseInt(text)
if (!isNaN(v) && v >= 10 && v <= 3600)
root.localRefreshSec = v
else
text = root.localRefreshSec.toString()
focus = false
}
Connections {
target: root
function onLocalRefreshSecChanged() {
if (!refreshInput.activeFocus)
refreshInput.text = root.localRefreshSec.toString()
}
}
}
}
Rectangle {
width: 24; height: 24; radius: 7
color: incMouse.containsMouse
? (incMouse.pressed ? (t.accent) : (t.cryptoFieldBgFocus))
: (t.cryptoFieldBg)
Behavior on color { ColorAnimation { duration: 100 } }
Text { anchors.centerIn: parent; text: "+"; font.pixelSize: 14; font.bold: true; color: t.cryptoPopupText }
MouseArea {
id: incMouse
anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor
onClicked: if (root.localRefreshSec < 3600) root.localRefreshSec += 1
}
}
}
RowLayout {
Layout.fillWidth: true
Text {
text: "Rotate (seconds)"
color: t.cryptoPopupDim
font.pixelSize: 12
Layout.fillWidth: true
}
Rectangle {
width: 24; height: 24; radius: 7
color: rotDecMouse.containsMouse
? (rotDecMouse.pressed ? (t.accent) : (t.cryptoFieldBgFocus))
: (t.cryptoFieldBg)
Behavior on color { ColorAnimation { duration: 100 } }
Text { anchors.centerIn: parent; text: ""; font.pixelSize: 14; font.bold: true; color: t.cryptoPopupText }
MouseArea {
id: rotDecMouse
anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor
onClicked: if (root.localRotateSec > 1) root.localRotateSec -= 1
}
}
Rectangle {
width: 54; height: 24; radius: 6
color: rotateInput.activeFocus ? (t.cryptoFieldBgFocus) : (t.cryptoFieldBg)
border.color: rotateInput.activeFocus ? (t.accent) : "transparent"
border.width: 1
Behavior on color { ColorAnimation { duration: 120 } }
Behavior on border.color { ColorAnimation { duration: 120 } }
TextInput {
id: rotateInput
anchors { fill: parent; leftMargin: 6; rightMargin: 6; topMargin: 4; bottomMargin: 4 }
text: root.localRotateSec.toString()
color: t.cryptoPopupText
font.pixelSize: 12
font.bold: true
font.family: "monospace"
selectionColor: t.accent
horizontalAlignment: TextInput.AlignHCenter
inputMethodHints: Qt.ImhDigitsOnly
validator: IntValidator { bottom: 1; top: 600 }
clip: true
onActiveFocusChanged: if (!activeFocus) {
const v = parseInt(text)
if (!isNaN(v) && v >= 1 && v <= 600)
root.localRotateSec = v
else
text = root.localRotateSec.toString()
}
onAccepted: {
const v = parseInt(text)
if (!isNaN(v) && v >= 1 && v <= 600)
root.localRotateSec = v
else
text = root.localRotateSec.toString()
focus = false
}
Connections {
target: root
function onLocalRotateSecChanged() {
if (!rotateInput.activeFocus)
rotateInput.text = root.localRotateSec.toString()
}
}
}
}
Rectangle {
width: 24; height: 24; radius: 7
color: rotIncMouse.containsMouse
? (rotIncMouse.pressed ? (t.accent) : (t.cryptoFieldBgFocus))
: (t.cryptoFieldBg)
Behavior on color { ColorAnimation { duration: 100 } }
Text { anchors.centerIn: parent; text: "+"; font.pixelSize: 14; font.bold: true; color: t.cryptoPopupText }
MouseArea {
id: rotIncMouse
anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor
onClicked: if (root.localRotateSec < 600) root.localRotateSec += 1
}
}
}
ToggleRow {
label: "Show icon on bar"
checked: root.localShowIcon
onToggled: (v) => root.localShowIcon = v
}
Rectangle { Layout.fillWidth: true; height: 1; color: t.calSeparator }
Rectangle {
Layout.fillWidth: true
height: 32
radius: 9
color: applyMouse.containsMouse
? (applyMouse.pressed
? Qt.darker(t.accent, 1.15)
: (t.accent))
: (t.cryptoApplyIdleBg)
Behavior on color { ColorAnimation { duration: 130 } }
Text {
anchors.centerIn: parent
text: "Apply & Refresh"
color: applyMouse.containsMouse ? (t.cryptoTextOnAccent) : (t.cryptoPopupText)
font.pixelSize: 12
font.bold: true
Behavior on color { ColorAnimation { duration: 130 } }
}
MouseArea {
id: applyMouse
anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor
onClicked: {
root.activeCoinIndex = 0
root.persistSettings()
root.fetchPrice()
cryptoPopup.activeTab = 0
}
}
}
Item { height: 2 }
}
}
}
}
}