/* * 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) // 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; }); } } } } } } 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; }); } } } 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"); } }