added two new themes, reworked Crypto.qml and Stats.qml, standardized the buttons for the modules, added a new Sidebar widget which contains new 3 modules

This commit is contained in:
SomeElse
2026-05-28 08:24:03 +00:00
parent b9708ddfd0
commit 1b9ee3bbb3
39 changed files with 3652 additions and 267 deletions

View File

@@ -40,6 +40,7 @@ ModuleChip {
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() {
@@ -49,6 +50,7 @@ ModuleChip {
root.activeCoinIndex = 0
root.localDecimals = cfg.cryptoDecimals
root.localRefreshSec = cfg.cryptoRefreshSec
root.localRotateSec = cfg.cryptoRotateSec
root.localShowIcon = cfg.cryptoShowIcon
}
@@ -56,6 +58,7 @@ ModuleChip {
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 =>
@@ -64,7 +67,8 @@ ModuleChip {
configWriter.writeInts([
{ key: "cryptoDecimals", value: decimals },
{ key: "cryptoRefreshSec", value: refreshSec }
{ key: "cryptoRefreshSec", value: refreshSec },
{ key: "cryptoRotateSec", value: rotateSec }
])
configWriter.writeBool("cryptoShowIcon", showIcon)
configWriter.writeArray("cryptoPairs", pairsQml)
@@ -87,6 +91,10 @@ ModuleChip {
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
@@ -140,66 +148,148 @@ ModuleChip {
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() {
if (loading) return
loading = true
hasError = false
_fetchGen++
var myGen = _fetchGen
const url =
"https://api.coingecko.com/api/v3/coins/markets" +
"?vs_currency=" + encodeURIComponent(localActiveCurrency || "usd") +
"&ids=" + encodeURIComponent(localCoinId || "bitcoin") +
"&order=market_cap_desc&per_page=1&page=1" +
"&sparkline=false&price_change_percentage=24h"
const xhr = new XMLHttpRequest()
xhr.timeout = 15000
xhr.onreadystatechange = function () {
if (xhr.readyState !== XMLHttpRequest.DONE) return
loading = false
if (xhr.status !== 200) { hasError = true; return }
try {
const text = xhr.responseText
if (text.length > 65536) { hasError = true; return }
const data = JSON.parse(text)
if (!Array.isArray(data) || data.length === 0) { hasError = true; return }
const coin = data[0]
if (typeof coin !== "object" || coin === null) { hasError = true; return }
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)
coinSymbol = safeString(coin.symbol, 8, localCoinId).toUpperCase()
coinName = safeString(coin.name, 50, "")
lastUpdated = new Date().toLocaleTimeString(Qt.locale(), "HH:mm:ss")
root.displayCurrency = root.localActiveCurrency
root.activeCoinIndex = (root.activeCoinIndex + 1) % Math.max(1, root.localPairs.length)
} catch (_) {
hasError = true
}
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()
}
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
@@ -277,36 +367,64 @@ ModuleChip {
Rectangle {
Layout.fillWidth: true
height: 30
radius: 10
height: 36
radius: 12
color: t.cryptoFieldBg
RowLayout {
anchors.fill: parent
anchors.margins: 3
spacing: 3
anchors.margins: 4
spacing: 4
component TabBtn: Rectangle {
required property string label
required property int tabIndex
property string icon: ""
Layout.fillWidth: true
height: 24
radius: 8
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: 130 } }
Behavior on color { ColorAnimation { duration: 150 } }
Text {
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
text: parent.label
color: parent.active ? (t.cryptoTextOnAccent) : (t.cryptoPopupDim)
font.pixelSize: 11
font.bold: parent.active
Behavior on color { ColorAnimation { duration: 130 } }
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
@@ -315,8 +433,8 @@ ModuleChip {
}
}
TabBtn { label: "Price"; tabIndex: 0 }
TabBtn { label: "Settings"; tabIndex: 1 }
TabBtn { label: "Price"; icon: "󰕾"; tabIndex: 0 }
TabBtn { label: "Settings"; icon: "󰒓"; tabIndex: 1 }
}
}
@@ -490,6 +608,113 @@ ModuleChip {
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
@@ -500,6 +725,50 @@ ModuleChip {
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
@@ -540,6 +809,7 @@ ModuleChip {
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 } }
@@ -650,7 +920,7 @@ ModuleChip {
ColumnLayout {
Layout.fillWidth: true
spacing: 4
spacing: 8
Text {
text: "Pairs to cycle"
@@ -661,11 +931,11 @@ ModuleChip {
ColumnLayout {
Layout.fillWidth: true
spacing: 4
spacing: 8
Item {
Layout.fillWidth: true
height: 24
height: 28
clip: true
Rectangle {
@@ -756,6 +1026,7 @@ ModuleChip {
root.localPairs = arr
if (root.activeCoinIndex >= arr.length)
root.activeCoinIndex = 0
root.fetchPrice()
}
}
}
@@ -885,41 +1156,51 @@ ModuleChip {
root.localPairs = root.localPairs.concat([{ coin: c, currency: cur }])
addCoinInput.text = ""
addCurrencyInput.text = ""
}
}
}
}
Rectangle {
width: 30; height: 30; radius: 8
color: addPairMouse.containsMouse
? (addPairMouse.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: 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()
}
}
}
}
}
}
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
@@ -1015,6 +1296,93 @@ ModuleChip {
}
}
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
@@ -1047,9 +1415,7 @@ ModuleChip {
id: applyMouse
anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor
onClicked: {
root.price = -1
root.activeCoinIndex = 0
root.coinSymbol = root.localCoinId.toUpperCase().slice(0, 4)
root.persistSettings()
root.fetchPrice()
cryptoPopup.activeTab = 0
@@ -1057,30 +1423,6 @@ ModuleChip {
}
}
Text {
Layout.alignment: Qt.AlignHCenter
text: "Reset to defaults"
color: t.cryptoPopupDim
font.pixelSize: 11
font.underline: true
MouseArea {
anchors.fill: parent; cursorShape: Qt.PointingHandCursor
onClicked: {
root.localPairs = Array.isArray(cfg.cryptoPairs) && cfg.cryptoPairs.length > 0 ? cfg.cryptoPairs : [{ coin: "bitcoin", currency: "usd" }]
root.localVsCur = "usd"
root.displayCurrency = root.localPairs.length > 0 ? root.localPairs[0].currency : "usd"
root.activeCoinIndex = 0
root.localDecimals = cfg.cryptoDecimals
root.localRefreshSec = cfg.cryptoRefreshSec
root.localShowIcon = cfg.cryptoShowIcon
root.persistSettings()
root.price = -1
cryptoPopup.activeTab = 0
}
}
}
Item { height: 2 }
}
}