255 lines
9.5 KiB
QML
255 lines
9.5 KiB
QML
/*
|
|
* nl.a14r.bitcoinprice — KDE Plasma 5 widget
|
|
*
|
|
* Shows the bitcoin price in EUR (CoinGecko API).
|
|
* - Green : price up compared to the previous reading
|
|
* - Red : price down compared to the previous reading
|
|
* - Grey : unchanged (or first reading)
|
|
* - "--" : API failure (network error, 4xx/5xx, 429 rate limit)
|
|
*
|
|
* Display modes: 0 = plain text (trend-colored), 1 = split-flap board,
|
|
* 2 = nixie tubes. In the flip and nixie modes the trend shows on a
|
|
* small LED, the digits themselves stay white / amber.
|
|
*
|
|
* Refresh interval: configurable, minimum 5 minutes
|
|
* (CoinGecko limit: 1 call per 5 minutes).
|
|
*/
|
|
|
|
import QtQuick 2.15
|
|
import QtQuick.Layouts 1.15
|
|
import org.kde.plasma.core 2.0 as PlasmaCore
|
|
import org.kde.plasma.plasmoid 2.0
|
|
|
|
Item {
|
|
id: root
|
|
|
|
readonly property int refreshMinutes: Math.max(5, plasmoid.configuration.refreshMinutes)
|
|
|
|
// Seconds over which the split-flap display cascades to a new price
|
|
readonly property int flapSeconds: Math.max(1, plasmoid.configuration.flapSeconds)
|
|
|
|
// Nixie tube flicker intensity (0 = off, 100 = frequent and deep)
|
|
readonly property int nixieFlicker: Math.max(0, Math.min(100, plasmoid.configuration.nixieFlicker))
|
|
|
|
// 0 = plain text, 1 = split-flap board, 2 = nixie tubes
|
|
property int displayMode: 0
|
|
readonly property string modeSource: displayMode === 1 ? "DisplayFlip.qml"
|
|
: displayMode === 2 ? "DisplayNixie.qml"
|
|
: "DisplayText.qml"
|
|
|
|
// Enum config values arrive either as an int index or as the choice
|
|
// name, depending on the Plasma version — accept both.
|
|
function modeFromConfig(v) {
|
|
if (typeof v === "number") {
|
|
return Math.max(0, Math.min(2, Math.round(v)));
|
|
}
|
|
if (v === "SplitFlap") {
|
|
return 1;
|
|
}
|
|
if (v === "Nixie") {
|
|
return 2;
|
|
}
|
|
return 0;
|
|
}
|
|
readonly property string apiUrl: "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=eur"
|
|
|
|
// -1 = down, 0 = unchanged / no previous reading yet, 1 = up
|
|
property int trend: 0
|
|
property double lastPrice: NaN // last successfully fetched price
|
|
property string state: "init" // init | ok | error
|
|
property string lastUpdated: "" // HH:mm:ss of the last successful update
|
|
|
|
readonly property string displayText: {
|
|
if (state !== "ok" || isNaN(lastPrice)) {
|
|
return "--";
|
|
}
|
|
return Number(lastPrice).toLocaleString(Qt.locale(), "f", 0) + " \u20AC";
|
|
}
|
|
|
|
readonly property color priceColor: {
|
|
if (state !== "ok" || isNaN(lastPrice)) {
|
|
return PlasmaCore.Theme.disabledTextColor; // "--" on error/init
|
|
}
|
|
if (trend > 0) {
|
|
return "#2ecc71"; // green
|
|
}
|
|
if (trend < 0) {
|
|
return "#e74c3c"; // red
|
|
}
|
|
return "#9e9e9e"; // grey
|
|
}
|
|
|
|
readonly property string statusText: {
|
|
if (state === "error") {
|
|
return lastUpdated === ""
|
|
? i18n("API error")
|
|
: i18n("API error — last known price: %1", lastUpdated);
|
|
}
|
|
if (lastUpdated === "") {
|
|
return i18n("Fetching\u2026");
|
|
}
|
|
return i18n("Last updated: %1", lastUpdated);
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
try {
|
|
// live binding: config changes from the dialog apply immediately
|
|
displayMode = Qt.binding(function () {
|
|
return modeFromConfig(plasmoid.configuration.displayMode);
|
|
});
|
|
} catch (e) {
|
|
// outside a real plasmoid shell (tests): keep the default
|
|
}
|
|
console.log("[bitcoinprice] started, refresh interval:", refreshMinutes,
|
|
"minutes, display mode:", displayMode);
|
|
}
|
|
|
|
Plasmoid.backgroundHints: PlasmaCore.Types.StandardBackground
|
|
Plasmoid.preferredRepresentation: Plasmoid.compactRepresentation
|
|
|
|
Plasmoid.toolTipMainText: "Bitcoin / EUR"
|
|
Plasmoid.toolTipSubText: statusText
|
|
|
|
Plasmoid.compactRepresentation: Component {
|
|
Item {
|
|
id: compact
|
|
|
|
Layout.minimumWidth: btcIcon.width + row.spacing
|
|
+ (displayLoader.item ? displayLoader.item.implicitWidth : 0)
|
|
+ PlasmaCore.Units.smallSpacing * 2
|
|
Layout.minimumHeight: PlasmaCore.Units.iconSizes.smallMedium
|
|
|
|
Row {
|
|
id: row
|
|
anchors.centerIn: parent
|
|
spacing: PlasmaCore.Units.smallSpacing
|
|
|
|
Image {
|
|
id: btcIcon
|
|
source: "../images/bitcoin.svg"
|
|
sourceSize.width: PlasmaCore.Units.iconSizes.smallMedium
|
|
sourceSize.height: PlasmaCore.Units.iconSizes.smallMedium
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
fillMode: Image.PreserveAspectFit
|
|
visible: root.state === "ok"
|
|
}
|
|
|
|
Loader {
|
|
id: displayLoader
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
height: compact.height * 0.62
|
|
width: item ? item.implicitWidth : 0
|
|
source: root.modeSource
|
|
onLoaded: {
|
|
item.display = Qt.binding(function () { return root.displayText; });
|
|
item.trendColor = Qt.binding(function () { return root.priceColor; });
|
|
if (item.flapSeconds !== undefined) {
|
|
item.flapSeconds = Qt.binding(function () { return root.flapSeconds; });
|
|
}
|
|
if (item.flickerIntensity !== undefined) {
|
|
item.flickerIntensity = Qt.binding(function () { return root.nixieFlicker; });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Plasmoid.fullRepresentation: Component {
|
|
Item {
|
|
id: fullRep
|
|
|
|
implicitWidth: PlasmaCore.Units.gridUnit * 16
|
|
implicitHeight: PlasmaCore.Units.gridUnit * 10
|
|
|
|
Column {
|
|
anchors.centerIn: parent
|
|
spacing: PlasmaCore.Units.smallSpacing
|
|
|
|
Image {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
source: "../images/bitcoin.svg"
|
|
sourceSize.width: PlasmaCore.Units.iconSizes.large
|
|
sourceSize.height: PlasmaCore.Units.iconSizes.large
|
|
fillMode: Image.PreserveAspectFit
|
|
visible: root.state === "ok"
|
|
}
|
|
|
|
Loader {
|
|
id: fullDisplayLoader
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
height: PlasmaCore.Units.gridUnit * 2.4
|
|
width: item ? item.implicitWidth : 0
|
|
source: root.modeSource
|
|
onLoaded: {
|
|
item.display = Qt.binding(function () { return root.displayText; });
|
|
item.trendColor = Qt.binding(function () { return root.priceColor; });
|
|
if (item.flapSeconds !== undefined) {
|
|
item.flapSeconds = Qt.binding(function () { return root.flapSeconds; });
|
|
}
|
|
if (item.flickerIntensity !== undefined) {
|
|
item.flickerIntensity = Qt.binding(function () { return root.nixieFlicker; });
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: root.statusText
|
|
color: PlasmaCore.Theme.disabledTextColor
|
|
font.family: PlasmaCore.Theme.defaultFont.family
|
|
font.pixelSize: PlasmaCore.Theme.smallestFont.pixelSize
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: refreshTimer
|
|
interval: root.refreshMinutes * 60 * 1000
|
|
running: true
|
|
repeat: true
|
|
triggeredOnStart: true
|
|
onTriggered: root.fetchPrice()
|
|
}
|
|
|
|
function fetchPrice() {
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open("GET", root.apiUrl);
|
|
xhr.setRequestHeader("Accept", "application/json");
|
|
xhr.onreadystatechange = function () {
|
|
if (xhr.readyState !== XMLHttpRequest.DONE) {
|
|
return;
|
|
}
|
|
if (xhr.status === 200) {
|
|
try {
|
|
var price = JSON.parse(xhr.responseText).bitcoin.eur;
|
|
if (typeof price === "number" && !isNaN(price)) {
|
|
console.log("[bitcoinprice] fetched EUR", price);
|
|
root.applyPrice(price);
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
// invalid JSON → fall through to error handling
|
|
}
|
|
}
|
|
console.log("[bitcoinprice] fetch failed, HTTP status:", xhr.status);
|
|
root.state = "error";
|
|
};
|
|
xhr.send();
|
|
}
|
|
|
|
function applyPrice(price) {
|
|
if (isNaN(root.lastPrice) || price === root.lastPrice) {
|
|
root.trend = 0;
|
|
} else if (price > root.lastPrice) {
|
|
root.trend = 1;
|
|
} else {
|
|
root.trend = -1;
|
|
}
|
|
root.lastPrice = price;
|
|
root.state = "ok";
|
|
root.lastUpdated = Qt.formatDateTime(new Date(), "HH:mm:ss");
|
|
}
|
|
}
|