diff --git a/docs/custom-dashboard-guide.md b/docs/custom-dashboard-guide.md new file mode 100644 index 0000000000..cb4d0cb8ac --- /dev/null +++ b/docs/custom-dashboard-guide.md @@ -0,0 +1,314 @@ +# QZ Custom Dashboard — Developer & LLM Guide + +This document is written for an LLM (or human developer) who wants to create a custom web dashboard for QZ Fitness. Read it top-to-bottom before writing any code. + +--- + +## What a custom dashboard is + +QZ already runs a local HTTP + WebSocket server (the "inner QZWS" server, default port 6666). A custom dashboard is a folder of static web files (`index.html`, CSS, JS, images) that QZ serves through this existing server. When the user enables the feature in **Settings → General Options → Custom Dashboard**, QZ replaces the standard tile-based home screen with a full-screen `WebView` that loads your `index.html`. The top toolbar (Bluetooth status, start/stop/lap buttons) always stays visible above the dashboard. + +--- + +## File layout + +``` +src/inner_templates// + index.html ← required entry point + style.css ← optional, referenced from index.html + app.js ← optional, referenced from index.html + ... ← any other assets (images, fonts, etc.) +``` + +Built-in dashboards live in `src/inner_templates/` and are compiled into the Qt resource system (`src/qml.qrc`). User-supplied dashboards live in `/dashboards//` and are served directly from the filesystem — no recompile needed. + +**For built-in dashboards** (shipping with QZ): add every file to `src/qml.qrc`: +```xml +inner_templates//index.html +inner_templates//style.css +inner_templates//app.js +``` + +**For user/community dashboards**: just drop the folder into `/dashboards/` — no code changes needed. The folder name becomes the selectable name in Settings. + +--- + +## Accessing shared assets + +The `chartjs/` folder is always available at the same origin as your dashboard. Reference Chart.js without bundling it: + +```html + +``` + +Other available libraries in `../chartjs/`: +- `chartjs-adapter-moment.js` +- `chartjs-plugin-annotation.min.js` +- `moment.js` +- `jquery-3.6.0.min.js` + +--- + +## WebSocket connection + +QZ sends live metrics once per second over a WebSocket at: + +``` +ws://localhost:/ +``` + +The port is stored in QSettings under the key `template_inner_QZWS_port`. The easiest way to discover it at runtime is to read the page's own port (since the HTTP server and WS server share the same port), or probe common fallbacks: + +```js +function connectWS() { + const port = parseInt(location.port, 10) || 6666; + const ws = new WebSocket(`ws://localhost:${port}/`); + ws.onopen = () => console.log('connected'); + ws.onclose = () => setTimeout(connectWS, 2000); // auto-reconnect + ws.onmessage = (ev) => { + const msg = JSON.parse(ev.data); + if (msg.msg === 'workout') handleMetrics(msg.content); + }; +} +``` + +Always implement auto-reconnect with a 2-second delay — the server may not be ready when the page first loads. + +--- + +## Message format + +Every second QZ sends one JSON message: + +```json +{ + "msg": "workout", + "content": { ...all metric fields... } +} +``` + +Only process messages where `msg.msg === "workout"`. Other `msg` values are internal QZ protocol messages; ignore them unless you need specific features (see "Sending commands" below). + +--- + +## Metric fields reference (`msg.content`) + +### Universal (all device types) + +| Field | Type | Description | +|---|---|---| +| `deviceType` | int | 0 = treadmill, 1 = bike, 2 = elliptical, 3 = rower | +| `deviceName` | string | Bluetooth device name | +| `devicePaused` | bool | `true` while the workout is paused | +| `elapsed_h` | int | Elapsed hours | +| `elapsed_m` | int | Elapsed minutes (0–59) | +| `elapsed_s` | int | Elapsed seconds (0–59) | +| `lapelapsed_h/m/s` | int | Elapsed time for current lap | +| `moving_h/m/s` | int | Moving time (excludes pauses) | +| `remaining_time_h/m/s` | int | Time remaining in training program | +| `row_remaining_time_h/m/s` | int | Time remaining in current training row | +| `speed` | float | Current speed (unit depends on user setting) | +| `speed_avg` | float | Session average speed | +| `speed_lapavg` | float | Lap average speed | +| `calories` | float | Total calories burned | +| `distance` | float | Total distance (odometer) | +| `heart` | float | Current heart rate (BPM) | +| `heart_avg` | float | Session average HR | +| `heart_max` | float | Session max HR | +| `watts` | float | Current power output (W) | +| `watts_avg` | float | Session average watts | +| `watts_max` | float | Session max watts | +| `kgwatts` | float | Power-to-weight (W/kg) | +| `jouls` | float | Total energy in joules | +| `elevation` | float | Total elevation gain | +| `difficult` | float | Current difficulty multiplier | +| `latitude` / `longitude` / `altitude` | float | GPS coordinates | +| `workoutName` | string | Name of the loaded training program | +| `workoutStartDate` | string | ISO date when workout started | +| `instructorName` | string | Instructor name (Peloton integration) | +| `nickName` | string | User nickname from Settings | +| `autoresistance` | bool | ERG/auto-resistance mode active | +| `nextrow` | int | Next row index in training program | +| `pace_s/m/h` | int | Current pace | +| `avgpace_s/m/h` | int | Average pace | +| `maxpace_s/m/h` | int | Max pace | + +### Bike-only fields + +| Field | Type | Description | +|---|---|---| +| `cadence` | float | Pedalling cadence (RPM) | +| `cadence_avg` | float | Session average cadence | +| `resistance` | float | Current resistance level | +| `inclination` | float | Simulated road grade (%) | +| `peloton_resistance` | float | Peloton-mapped resistance (0–100) | +| `power_zone` | float | Current power zone (1–7) | +| `target_power` | float | ERG target power (W) | +| `target_cadence` | float | Target cadence | +| `target_resistance` | float | Target resistance level | +| `target_power_zone` | float | Target power zone | +| `req_power` | float | Last requested power | +| `req_cadence` | float | Last requested cadence | +| `gears` | int | Virtual gear position | +| `cranks` | int | Cumulative crank revolutions | + +### Treadmill-only fields + +| Field | Type | Description | +|---|---|---| +| `cadence` | float | Step cadence (steps/min) | +| `inclination` | float | Current treadmill incline (%) | +| `inclination_avg` | float | Average incline | +| `target_speed` | float | Target speed | +| `target_inclination` | float | Target incline | +| `stridelength` | float | Stride length (cm) | +| `groundcontact` | float | Ground contact time (ms) | +| `verticaloscillation` | float | Vertical oscillation (mm) | + +### Rower-only fields + +| Field | Type | Description | +|---|---|---| +| `cadence` | float | Stroke rate (strokes/min) | +| `strokescount` | float | Total stroke count | +| `strokeslength` | float | Stroke length | +| `resistance` | float | Resistance level | +| `target_pace_s/m/h` | int | Target pace | + +### Elliptical-only fields + +| Field | Type | Description | +|---|---|---| +| `cadence` | float | Stride cadence (RPM) | +| `resistance` | float | Resistance level | +| `inclination` | float | Ramp angle (%) | + +--- + +## Sending commands to QZ + +Your dashboard can send JSON commands back over the same WebSocket connection to control the device. All commands follow the pattern `{msg: "", ...params}`. + +### Control commands + +```js +// Set resistance (bike/rower/elliptical) +ws.send(JSON.stringify({ msg: "setresistance", peloton_resistance: 42 })); + +// Set power target (ERG mode) +ws.send(JSON.stringify({ msg: "setpower", power: 250 })); + +// Set cadence target +ws.send(JSON.stringify({ msg: "setcadence", cadence: 90 })); + +// Set speed (treadmill) +ws.send(JSON.stringify({ msg: "setspeed", speed: 10.5 })); + +// Set incline (treadmill) +// (uses inclination field — value in %) +ws.send(JSON.stringify({ msg: "setinclination", inclination: 5.0 })); + +// Set difficulty multiplier +ws.send(JSON.stringify({ msg: "setdifficult", difficult: 1.2 })); + +// Set fan speed +ws.send(JSON.stringify({ msg: "setfanspeed", fanspeed: 3 })); +``` + +### Data request commands + +```js +// Request current settings object (response: msg = "R_getsettings") +ws.send(JSON.stringify({ msg: "getsettings" })); + +// Request session history array (response: msg = "R_getsessionarray") +ws.send(JSON.stringify({ msg: "getsessionarray" })); + +// Request training program list (response: msg = "R_loadtrainingprograms") +ws.send(JSON.stringify({ msg: "loadtrainingprograms" })); +``` + +Response messages arrive as `{msg: "R_", content: ...}`. + +--- + +## Power zones reference + +QZ reports `power_zone` as a float (1.0–7.0). If you want to compute zones yourself from raw watts, use the standard 7-zone model relative to FTP: + +| Zone | % of FTP | Name | +|---|---|---| +| 1 | < 55% | Recovery | +| 2 | 55–75% | Endurance | +| 3 | 75–90% | Tempo | +| 4 | 90–105% | Threshold | +| 5 | 105–120% | VO₂max | +| 6 | 120–150% | Anaerobic | +| 7 | > 150% | Sprint | + +The user's FTP is stored in QSettings under the key `ftp` (default 200 W). You can retrieve it from the `settings` object sent by QZ in the `R_getsettings` response. + +--- + +## Minimal working example + +A complete dashboard in a single file, no dependencies: + +```html + + + + + + + + +
Power
+
+
watts
+ + + + +``` + +--- + +## Checklist before shipping a dashboard + +- [ ] `index.html` exists at the root of the dashboard folder +- [ ] WebSocket connects to `ws://localhost:${location.port}/` (no hardcoded port) +- [ ] Auto-reconnect implemented (server may not be up when page first loads) +- [ ] Only `msg.msg === "workout"` messages are processed for live metrics +- [ ] All values guarded against `undefined` / `null` / `0` (device may not report all fields) +- [ ] `user-scalable=no` in viewport meta (prevents unwanted pinch-zoom in WebView) +- [ ] No `overflow: auto` on `body` / `html` — use `overflow: hidden` to avoid scroll bouncing on iOS +- [ ] External CDN links avoided — QZ may run offline; bundle assets or use `../chartjs/` shared libs +- [ ] Tested with simulated data before connecting to a real device + +--- + +## Distributing a community dashboard + +A dashboard is a plain folder. To share: +1. Zip the folder: `zip -r my-dashboard.zip my-dashboard/` +2. The recipient unzips it into `/dashboards/` +3. The name appears automatically in **Settings → General Options → Custom Dashboard** + +No restart required — the picker reads the filesystem at open time. diff --git a/src/Home.qml b/src/Home.qml index 18d64627f3..bfe553b91b 100644 --- a/src/Home.qml +++ b/src/Home.qml @@ -6,6 +6,7 @@ import QtQuick.Window 2.12 import Qt.labs.settings 1.0 import Qt.labs.platform 1.1 import QtMultimedia 5.15 +import QtWebView 1.1 HomeForm { objectName: "home" @@ -38,6 +39,78 @@ HomeForm { property string theme_tile_shadow_color: "#9C27B0" property int theme_tile_secondline_textsize: 12 property bool skipLocationServicesDialog: false + property bool ui_custom_dashboard_enabled: false + property string ui_custom_dashboard_name: "bike-pro" + } + + // Custom web dashboard overlay — sits below the toolbar, covers the tile area + Loader { + id: customDashboardLoader + active: settings.ui_custom_dashboard_enabled + visible: !window.sideBarVisible + anchors.fill: parent + anchors.topMargin: 0 + z: 10 + + sourceComponent: Item { + anchors.fill: parent + + property bool pageLoaded: false + + Settings { + id: dashboardQSettings + } + + Timer { + id: dashboardPortPoller + interval: 500 + repeat: true + running: !parent.pageLoaded + onTriggered: { + var p = dashboardQSettings.value("template_inner_QZWS_port", 0) + if (!p) return + var target = "http://localhost:" + p + "/" + settings.ui_custom_dashboard_name + "/index.html" + if (dashboardWebView.url !== target) + dashboardWebView.url = target + } + } + + WebView { + id: dashboardWebView + anchors.fill: parent + visible: parent.pageLoaded + onLoadingChanged: { + if (loadRequest.status === WebView.LoadSucceededStatus) { + parent.pageLoaded = true + dashboardBusy.visible = false + dashboardBusy.running = false + dashboardPortPoller.stop() + } else if (loadRequest.status === WebView.LoadFailedStatus) { + parent.pageLoaded = false + dashboardBusy.visible = true + dashboardBusy.running = true + dashboardPortPoller.start() + } + } + Screen.orientationUpdateMask: Qt.LandscapeOrientation | Qt.PortraitOrientation + Screen.onPrimaryOrientationChanged: { + if (url != "") { + var saved = url + url = "" + url = saved + } + } + } + + BusyIndicator { + id: dashboardBusy + anchors.centerIn: parent + visible: !parent.pageLoaded + running: !parent.pageLoaded + } + + Component.onCompleted: dashboardPortPoller.start() + } } MessageDialog { diff --git a/src/homeform.cpp b/src/homeform.cpp index 1da7f9f7a3..7a3f363ea7 100644 --- a/src/homeform.cpp +++ b/src/homeform.cpp @@ -388,7 +388,11 @@ homeform::homeform(QQmlApplicationEngine *engine, bluetooth *bl) { settings.setValue(sKey + QStringLiteral("type"), TEMPLATE_TYPE_WEBSERVER); settings.setValue(sKey + QStringLiteral("port"), 0); this->innerTemplateManager = - TemplateInfoSenderBuilder::getInstance(innerId, QStringList({QStringLiteral(":/inner_templates/")}), this); + TemplateInfoSenderBuilder::getInstance( + innerId, + QStringList({QStringLiteral(":/inner_templates/"), + getWritableAppDir() + QStringLiteral("dashboards")}), + this); speed = new DataObject(tr("Speed (%1/h)").arg(unit), QStringLiteral("icons/icons/speed.png"), QStringLiteral("0.0"), true, @@ -10561,6 +10565,29 @@ void homeform::clearFiles() { } } +QStringList homeform::availableDashboards() { + static const QStringList excluded = { + QStringLiteral("chartjs"), QStringLiteral("googlemaps"), + QStringLiteral("maps2d"), QStringLiteral("floating"), + QStringLiteral("previewchart"), QStringLiteral("workouteditor"), + QStringLiteral("workoutpreview"),QStringLiteral("trainingbrowser"), + }; + QStringList result; + QDir resDir(QStringLiteral(":/inner_templates")); + for (const QString &name : resDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { + if (!excluded.contains(name) && + QFile::exists(QStringLiteral(":/inner_templates/") + name + QStringLiteral("/index.html"))) + result << name; + } + QDir userDir(getWritableAppDir() + QStringLiteral("dashboards")); + for (const QString &name : userDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { + if (!excluded.contains(name) && !result.contains(name) && + QFile::exists(userDir.filePath(name + QStringLiteral("/index.html")))) + result << name; + } + return result; +} + int homeform::preview_workout_points() { if (previewTrainProgram) { QTime d = previewTrainProgram->duration(); diff --git a/src/homeform.h b/src/homeform.h index 6cb2136a36..b78a0c0460 100644 --- a/src/homeform.h +++ b/src/homeform.h @@ -639,6 +639,7 @@ class homeform : public QObject { Q_INVOKABLE static QString getWritableAppDir(); Q_INVOKABLE static QString getProfileDir(); Q_INVOKABLE static void clearFiles(); + Q_INVOKABLE static QStringList availableDashboards(); Q_INVOKABLE bool startTrainingProgramFromFile(const QString &filePath); Q_INVOKABLE bool deleteTrainingProgramFile(const QString &fileUrl); diff --git a/src/inner_templates/bike-pro/app.js b/src/inner_templates/bike-pro/app.js new file mode 100644 index 0000000000..4b5dfada6f --- /dev/null +++ b/src/inner_templates/bike-pro/app.js @@ -0,0 +1,257 @@ +'use strict'; + +// ── Zone definitions (% of FTP, assuming FTP ≈ 200W as default) ── +const ZONES = [ + { name: 'Z1 Recovery', max: 0.55, color: '#5ac8fa', label: 'Z1 · Recovery' }, + { name: 'Z2 Endurance', max: 0.75, color: '#30d158', label: 'Z2 · Endurance' }, + { name: 'Z3 Tempo', max: 0.90, color: '#ffd60a', label: 'Z3 · Tempo' }, + { name: 'Z4 Threshold', max: 1.05, color: '#ff9f0a', label: 'Z4 · Threshold' }, + { name: 'Z5 VO2max', max: 1.20, color: '#ff453a', label: 'Z5 · VO₂max' }, + { name: 'Z6 Anaerobic', max: 1.50, color: '#bf5af2', label: 'Z6 · Anaerobic' }, + { name: 'Z7 Sprint', max: Infinity, color: '#ff375f', label: 'Z7 · Sprint' }, +]; + +// ── State ── +const MAX_POINTS = 60; // 60 seconds of history +const powerHistory = new Array(MAX_POINTS).fill(null); +const cadenceHistory = new Array(MAX_POINTS).fill(null); +let ftpWatts = 200; +let chart = null; +let wsPort = 0; +let wsSocket = null; +let wsReconnectTimer = null; +let elapsedSeconds = 0; +let elapsedTimer = null; +let lastDataTime = 0; + +// ── DOM refs ── +const elElapsed = document.getElementById('elapsed'); +const elConnStatus = document.getElementById('connection-status'); +const elPower = document.getElementById('val-power'); +const elCadence = document.getElementById('val-cadence'); +const elSpeed = document.getElementById('val-speed'); +const elHr = document.getElementById('val-hr'); +const elDist = document.getElementById('val-dist'); +const elKcal = document.getElementById('val-kcal'); +const elZoneLabel = document.getElementById('zone-label'); +const elZoneFill = document.getElementById('zone-bar-fill'); +const elZoneSegs = document.querySelectorAll('.zone-seg'); + +// ── Elapsed timer ── +function startElapsedTimer() { + if (elapsedTimer) return; + elapsedTimer = setInterval(() => { + elapsedSeconds++; + const h = Math.floor(elapsedSeconds / 3600); + const m = Math.floor((elapsedSeconds % 3600) / 60); + const s = elapsedSeconds % 60; + elElapsed.textContent = h > 0 + ? `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}` + : `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`; + }, 1000); +} + +// ── Chart setup ── +function initChart() { + const ctx = document.getElementById('chart-canvas').getContext('2d'); + const labels = Array.from({ length: MAX_POINTS }, (_, i) => i - MAX_POINTS + 1); + + chart = new Chart(ctx, { + type: 'line', + data: { + labels, + datasets: [ + { + label: 'Power', + data: [...powerHistory], + borderColor: '#ff9f0a', + backgroundColor: 'rgba(255,159,10,0.12)', + borderWidth: 2, + pointRadius: 0, + fill: true, + tension: 0.4, + yAxisID: 'yPower', + spanGaps: true, + }, + { + label: 'Cadence', + data: [...cadenceHistory], + borderColor: '#0a84ff', + backgroundColor: 'rgba(10,132,255,0.08)', + borderWidth: 1.5, + pointRadius: 0, + fill: true, + tension: 0.4, + yAxisID: 'yCadence', + spanGaps: true, + }, + ], + }, + options: { + animation: false, + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { legend: { display: false }, tooltip: { enabled: false } }, + scales: { + x: { display: false }, + yPower: { + position: 'left', + display: false, + min: 0, + suggestedMax: 400, + }, + yCadence: { + position: 'right', + display: false, + min: 0, + suggestedMax: 120, + }, + }, + }, + }); +} + +// ── Zone logic ── +function updateZone(watts) { + const ratio = watts / ftpWatts; + let zoneIdx = ZONES.findIndex(z => ratio < z.max); + if (zoneIdx < 0) zoneIdx = ZONES.length - 1; + + const z = ZONES[zoneIdx]; + const pctOfNext = Math.min(ratio / (ZONES[zoneIdx]?.max ?? 1.5), 1); + const barPct = Math.min((ratio / 1.5) * 100, 100); + + elZoneLabel.textContent = z.label; + elZoneLabel.style.color = z.color; + elZoneFill.style.width = barPct + '%'; + elZoneFill.style.background = z.color; + + elZoneSegs.forEach((seg, i) => { + seg.classList.toggle('active', i <= zoneIdx); + }); +} + +// ── Heart rate color ── +function hrClass(bpm) { + if (!bpm || bpm < 60) return ''; + if (bpm < 120) return 'hr-low'; + if (bpm < 150) return 'hr-normal'; + if (bpm < 170) return 'hr-high'; + return 'hr-max'; +} + +// ── Data update ── +function applyData(d) { + lastDataTime = Date.now(); + + const power = Math.round(d.watts ?? 0); + const cadence = Math.round(d.cadence ?? 0); + const speed = parseFloat(d.speed ?? 0).toFixed(1); + const hr = Math.round(d.heart ?? 0); + const dist = parseFloat(d.distance ?? 0).toFixed(2); + const kcal = Math.round(d.calories ?? 0); + + // QZ sends elapsed time as separate h/m/s fields + if (d.elapsed_h !== undefined || d.elapsed_m !== undefined || d.elapsed_s !== undefined) { + elapsedSeconds = (d.elapsed_h ?? 0) * 3600 + (d.elapsed_m ?? 0) * 60 + (d.elapsed_s ?? 0); + } + + elPower.textContent = power || '–'; + elCadence.textContent = cadence || '–'; + elSpeed.textContent = speed !== '0.0' ? speed : '–'; + elKcal.textContent = kcal || '–'; + elDist.textContent = dist !== '0.00' ? dist : '–'; + + if (hr > 0) { + elHr.textContent = hr; + elHr.className = 'stat-value ' + hrClass(hr); + } else { + elHr.textContent = '–'; + elHr.className = 'stat-value'; + } + + // zone bar + if (power > 0) updateZone(power); + + // push to history ring + powerHistory.shift(); powerHistory.push(power || null); + cadenceHistory.shift(); cadenceHistory.push(cadence || null); + + if (chart) { + chart.data.datasets[0].data = [...powerHistory]; + chart.data.datasets[1].data = [...cadenceHistory]; + chart.update('none'); + } +} + +// ── WebSocket ── +function connectWS(port) { + if (wsSocket) { try { wsSocket.close(); } catch(_){} } + wsPort = port; + const url = `ws://localhost:${port}/`; + wsSocket = new WebSocket(url); + + wsSocket.onopen = () => { + setStatus(true); + clearTimeout(wsReconnectTimer); + startElapsedTimer(); + }; + + wsSocket.onmessage = (ev) => { + try { + const msg = JSON.parse(ev.data); + // QZ sends {msg: "workout", content: {...metrics...}} + if (msg && msg.msg === 'workout' && msg.content) { + applyData(msg.content); + } + } catch(_) {} + }; + + wsSocket.onclose = () => { setStatus(false); scheduleReconnect(); }; + wsSocket.onerror = () => { setStatus(false); }; +} + +function scheduleReconnect() { + if (wsReconnectTimer) return; + wsReconnectTimer = setTimeout(() => { + wsReconnectTimer = null; + if (wsPort) connectWS(wsPort); + }, 2000); +} + +function setStatus(connected) { + elConnStatus.textContent = connected ? 'CONNECTED' : 'CONNECTING…'; + elConnStatus.className = connected ? 'connected' : ''; +} + +// ── Port discovery — same strategy as WorkoutEditor.qml ── +// The port is stored in QSettings as "template_inner_QZWS_port". +// We poll the page URL for ?port= query param (set by the QML WebView url). +// If not present, we try common ports. +function discoverPort() { + const params = new URLSearchParams(location.search); + const qp = parseInt(params.get('port'), 10); + if (qp) { connectWS(qp); return; } + + // Try the URL origin port first (same server), then fallback 6666/6667/6668 + const originPort = parseInt(location.port, 10); + const candidates = [originPort, 6666, 6667, 6668].filter(Boolean); + + let i = 0; + function tryNext() { + if (i >= candidates.length) { i = 0; } + const port = candidates[i++]; + const ws = new WebSocket(`ws://localhost:${port}/`); + ws.onopen = () => { ws.close(); connectWS(port); }; + ws.onerror = () => { setTimeout(tryNext, 500); }; + } + tryNext(); +} + +// ── Init ── +document.addEventListener('DOMContentLoaded', () => { + initChart(); + setStatus(false); + discoverPort(); +}); diff --git a/src/inner_templates/bike-pro/index.html b/src/inner_templates/bike-pro/index.html new file mode 100644 index 0000000000..5bc863f50b --- /dev/null +++ b/src/inner_templates/bike-pro/index.html @@ -0,0 +1,106 @@ + + + + + + Bike Pro · QZ Dashboard + + + +
+ + +
+
+
+ LIVE +
+
00:00
+
CONNECTING…
+
+ + +
+
+
Power
+
+
watts
+
+
+
Cadence
+
+
rpm
+
+
+
Speed
+
+
km/h
+
+
+ + +
+
+
Power Zone
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
Last 60 s
+
+
+
+ Power +
+
+
+ Cadence +
+
+
+
+ +
+
+ + +
+
+
+
+
bpm
+
+
+
📍
+
+
km
+
+
+
🔥
+
+
kcal
+
+
+ +
+ + + + + + diff --git a/src/inner_templates/bike-pro/style.css b/src/inner_templates/bike-pro/style.css new file mode 100644 index 0000000000..5d463648c6 --- /dev/null +++ b/src/inner_templates/bike-pro/style.css @@ -0,0 +1,296 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #1c1c1e; + --surface: #2c2c2e; + --surface2: #3a3a3c; + --label: #8e8e93; + --text: #ffffff; + --blue: #0a84ff; + --green: #30d158; + --orange: #ff9f0a; + --red: #ff453a; + --yellow: #ffd60a; + --pink: #ff375f; + --radius: 18px; + --font: -apple-system, "SF Pro Display", "Helvetica Neue", Arial, sans-serif; +} + +html, body { + width: 100%; height: 100%; + background: var(--bg); + color: var(--text); + font-family: var(--font); + overflow: hidden; + -webkit-font-smoothing: antialiased; +} + +#app { + display: grid; + grid-template-rows: auto 1fr auto; + height: 100dvh; + gap: 10px; + padding: 12px; +} + +/* ── TOP STATUS BAR ── */ +#statusbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 4px; +} + +#live-badge { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--red); +} + +#live-dot { + width: 8px; height: 8px; + border-radius: 50%; + background: var(--red); + animation: pulse 1.4s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.7); } +} + +#elapsed { + font-size: 28px; + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; +} + +#connection-status { + font-size: 12px; + color: var(--label); + letter-spacing: 0.03em; +} + +#connection-status.connected { color: var(--green); } + +/* ── METRICS GRID ── */ +#metrics { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 10px; +} + +.metric-card { + background: var(--surface); + border-radius: var(--radius); + padding: 16px 14px 14px; + display: flex; + flex-direction: column; + gap: 4px; + position: relative; + overflow: hidden; + transition: background 0.3s ease; +} + +.metric-card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 3px; + border-radius: var(--radius) var(--radius) 0 0; + background: var(--accent, var(--blue)); + opacity: 0.9; +} + +.metric-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--label); +} + +.metric-value { + font-size: 40px; + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: -0.03em; + line-height: 1; + color: var(--text); + transition: color 0.3s ease; +} + +.metric-unit { + font-size: 11px; + font-weight: 500; + color: var(--label); + margin-top: 2px; +} + +/* individual card accents */ +#card-power { --accent: var(--orange); } +#card-cadence { --accent: var(--blue); } +#card-speed { --accent: var(--green); } + +/* ── POWER ZONE BAR ── */ +#zone-section { + background: var(--surface); + border-radius: var(--radius); + padding: 12px 16px; +} + +#zone-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +#zone-title { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--label); +} + +#zone-label { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--orange); + transition: color 0.4s ease; +} + +#zone-bar-track { + position: relative; + height: 8px; + border-radius: 4px; + background: var(--surface2); + overflow: hidden; +} + +#zone-bar-fill { + height: 100%; + border-radius: 4px; + background: var(--orange); + width: 0%; + transition: width 0.6s cubic-bezier(0.4,0,0.2,1), background 0.4s ease; +} + +/* zone segment markers */ +#zone-segments { + display: flex; + gap: 2px; + margin-top: 6px; +} + +.zone-seg { + flex: 1; + height: 3px; + border-radius: 2px; + opacity: 0.35; + transition: opacity 0.4s ease; +} + +.zone-seg.active { opacity: 1; } +.z1 { background: #5ac8fa; } +.z2 { background: #30d158; } +.z3 { background: #ffd60a; } +.z4 { background: #ff9f0a; } +.z5 { background: #ff453a; } +.z6 { background: #bf5af2; } +.z7 { background: #ff375f; } + +/* ── CHART ── */ +#chart-section { + background: var(--surface); + border-radius: var(--radius); + padding: 12px 14px 10px; +} + +#chart-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +#chart-title { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--label); +} + +#chart-legend { + display: flex; + gap: 12px; +} + +.legend-item { + display: flex; + align-items: center; + gap: 5px; + font-size: 10px; + color: var(--label); +} + +.legend-dot { + width: 8px; height: 8px; + border-radius: 50%; +} + +#chart-canvas-wrap { + position: relative; + height: 90px; +} + +/* ── BOTTOM STATS ── */ +#bottom-stats { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 10px; +} + +.stat-pill { + background: var(--surface); + border-radius: 12px; + padding: 10px 14px; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.stat-icon { + font-size: 14px; + line-height: 1; +} + +.stat-value { + font-size: 20px; + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; +} + +.stat-label { + font-size: 9px; + font-weight: 600; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--label); +} + +/* heart rate color coding */ +.hr-low { color: #5ac8fa; } +.hr-normal { color: var(--green); } +.hr-high { color: var(--orange); } +.hr-max { color: var(--red); } diff --git a/src/inner_templates/treadmill-pro/app.js b/src/inner_templates/treadmill-pro/app.js new file mode 100644 index 0000000000..b20b55876d --- /dev/null +++ b/src/inner_templates/treadmill-pro/app.js @@ -0,0 +1,231 @@ +'use strict'; + +// ── DOM ─────────────────────────────────────────────────────────────────────── +const elElapsed = document.getElementById('elapsed'); +const elConn = document.getElementById('conn-status'); +const elSegName = document.getElementById('segment-name'); +const elDist = document.getElementById('val-dist'); +const elTime = document.getElementById('val-time'); +const elAvgSpeed = document.getElementById('val-avg-speed'); +const elProgressFill= document.getElementById('progress-fill'); +const elSpeed = document.getElementById('val-speed'); +const elTargetSpeed = document.getElementById('val-target-speed'); +const elIncline = document.getElementById('val-incline'); +const elPace = document.getElementById('val-pace'); +const elHr = document.getElementById('val-hr'); +const elHrItem = document.querySelector('.lap-hr'); +const elCadence = document.getElementById('val-cadence'); +const elPauseBtn = document.getElementById('pause-btn'); +const canvas = document.getElementById('sparkline'); + +// ── State ───────────────────────────────────────────────────────────────────── +let ws = null, wsPort = 0, wsReconnectTimer = null; +let elapsedSec = 0, elapsedTimer = null, paused = false; +let speedMax = 0, speedSum = 0, speedCount = 0; +const HIST = 60; +const speedHist = []; +const DPR = window.devicePixelRatio || 1; + +// ── Helpers ─────────────────────────────────────────────────────────────────── +const pad = n => String(n).padStart(2, '0'); + +function fmtTime(s) { + const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60; + return h > 0 ? `${h}:${pad(m)}:${pad(ss)}` : `${pad(m)}:${pad(ss)}`; +} + +function fmtPace(kmh) { + if (!kmh || kmh < 0.5) return '–'; + const s = 3600 / kmh; + return `${Math.floor(s / 60)}:${pad(Math.round(s % 60))}`; +} + +function fmtDist(km, miles) { + return miles ? km.toFixed(2) + 'MI' : km.toFixed(2) + 'KM'; +} + +function hrZone(bpm) { + if (!bpm || bpm < 50) return ''; + if (bpm < 100) return 'hr-z1'; + if (bpm < 130) return 'hr-z2'; + if (bpm < 155) return 'hr-z3'; + if (bpm < 175) return 'hr-z4'; + return 'hr-z5'; +} + +// ── Elapsed timer ───────────────────────────────────────────────────────────── +function startElapsedTimer() { + if (elapsedTimer) return; + elapsedTimer = setInterval(() => { + if (!paused) { elapsedSec++; elElapsed.textContent = fmtTime(elapsedSec); } + }, 1000); +} + +// ── Sparkline ───────────────────────────────────────────────────────────────── +function resizeCanvas() { + const r = canvas.getBoundingClientRect(); + canvas.width = r.width * DPR; + canvas.height = r.height * DPR; +} + +function drawSparkline(targetSpeed) { + const ctx = canvas.getContext('2d'); + const W = canvas.width, H = canvas.height; + ctx.clearRect(0, 0, W, H); + if (speedHist.length < 2) return; + + const maxV = Math.max(targetSpeed * 1.2, Math.max(...speedHist) * 1.1, 3); + const toY = v => H * 0.9 - (v / maxV) * H * 0.82; + const toX = i => (i / (HIST - 1)) * W; + + const grad = ctx.createLinearGradient(0, 0, 0, H); + grad.addColorStop(0, 'rgba(0,188,212,.30)'); + grad.addColorStop(1, 'rgba(0,188,212,0)'); + + ctx.beginPath(); + ctx.moveTo(toX(0), toY(speedHist[0])); + for (let i = 1; i < speedHist.length; i++) { + const cx = (toX(i - 1) + toX(i)) / 2; + ctx.bezierCurveTo(cx, toY(speedHist[i-1]), cx, toY(speedHist[i]), toX(i), toY(speedHist[i])); + } + ctx.lineTo(toX(speedHist.length - 1), H); + ctx.lineTo(0, H); + ctx.closePath(); + ctx.fillStyle = grad; + ctx.fill(); + + ctx.beginPath(); + ctx.moveTo(toX(0), toY(speedHist[0])); + for (let i = 1; i < speedHist.length; i++) { + const cx = (toX(i - 1) + toX(i)) / 2; + ctx.bezierCurveTo(cx, toY(speedHist[i-1]), cx, toY(speedHist[i]), toX(i), toY(speedHist[i])); + } + ctx.strokeStyle = '#00bcd4'; + ctx.lineWidth = 2 * DPR; + ctx.shadowColor = '#00bcd4'; + ctx.shadowBlur = 6 * DPR; + ctx.stroke(); + ctx.shadowBlur = 0; + + if (targetSpeed > 0) { + const ty = toY(targetSpeed); + ctx.setLineDash([5 * DPR, 4 * DPR]); + ctx.beginPath(); ctx.moveTo(0, ty); ctx.lineTo(W, ty); + ctx.strokeStyle = 'rgba(255,112,67,.75)'; + ctx.lineWidth = 1.5 * DPR; + ctx.stroke(); + ctx.setLineDash([]); + } +} + +// ── Send command ────────────────────────────────────────────────────────────── +function sendCmd(cmd) { + if (ws && ws.readyState === WebSocket.OPEN) + ws.send(JSON.stringify({ msg: 'cmd', content: { command: cmd } })); +} + +function togglePause() { + paused = !paused; + elPauseBtn.textContent = paused ? '▶ Resume' : '⏸ Pause'; + sendCmd(paused ? 'pause' : 'start'); +} + +// ── Data update ─────────────────────────────────────────────────────────────── +function applyData(d) { + const speed = parseFloat(d.speed ?? 0); + const targetSpd = parseFloat(d.target_speed ?? 0); + const incline = parseFloat(d.inclination ?? 0); + const hr = Math.round(d.heart ?? 0); + const cadence = Math.round(d.cadence ?? 0); + const dist = parseFloat(d.distance ?? 0); + const kcal = Math.round(d.calories ?? 0); + + // elapsed + if (d.elapsed_h !== undefined || d.elapsed_m !== undefined || d.elapsed_s !== undefined) { + elapsedSec = (d.elapsed_h ?? 0) * 3600 + (d.elapsed_m ?? 0) * 60 + (d.elapsed_s ?? 0); + elElapsed.textContent = fmtTime(elapsedSec); + } + startElapsedTimer(); + + // segment name + const seg = d.row_name ?? d.workoutName ?? ''; + elSegName.textContent = seg || '–'; + + // stats row + elDist.textContent = dist > 0 ? dist.toFixed(2) + 'km' : '–'; + elTime.textContent = fmtTime(elapsedSec); + speedHist.push(speed); + if (speedHist.length > HIST) speedHist.shift(); + if (speed > speedMax) speedMax = speed; + if (speed > 0) { speedSum += speed; speedCount++; } + elAvgSpeed.textContent = speedCount > 0 ? (speedSum / speedCount).toFixed(1) + 'km/h' : '–'; + + // progress + const remain = (d.remaining_time_h ?? 0) * 3600 + (d.remaining_time_m ?? 0) * 60 + (d.remaining_time_s ?? 0); + if (remain > 0 && elapsedSec > 0) { + const pct = Math.min(elapsedSec / (elapsedSec + remain) * 100, 100); + elProgressFill.style.width = pct.toFixed(1) + '%'; + } + + // speed control + elSpeed.textContent = speed.toFixed(1); + if (targetSpd > 0) { + elTargetSpeed.textContent = 'TARGET ' + targetSpd.toFixed(1) + 'km/h'; + } else { + elTargetSpeed.textContent = 'km/h'; + } + + // incline control + elIncline.textContent = incline.toFixed(1); + + // lap / middle + elPace.textContent = fmtPace(speed); + elHr.textContent = hr > 0 ? hr : '–'; + const zone = hrZone(hr); + elHrItem.className = 'lap-item lap-hr' + (zone ? ' ' + zone : ''); + elCadence.textContent = cadence || '–'; + + drawSparkline(targetSpd); +} + +// ── WebSocket ───────────────────────────────────────────────────────────────── +function connect(port) { + if (ws) { try { ws.close(); } catch (_) {} } + wsPort = port; + ws = new WebSocket(`ws://localhost:${port}/`); + ws.onopen = () => { setConn(true); clearTimeout(wsReconnectTimer); wsReconnectTimer = null; }; + ws.onmessage = ev => { try { const m = JSON.parse(ev.data); if (m?.msg === 'workout' && m.content) applyData(m.content); } catch (_) {} }; + ws.onclose = () => { setConn(false); scheduleReconnect(); }; + ws.onerror = () => { setConn(false); }; +} + +function scheduleReconnect() { + if (wsReconnectTimer) return; + wsReconnectTimer = setTimeout(() => { wsReconnectTimer = null; if (wsPort) connect(wsPort); }, 2000); +} + +function setConn(ok) { + elConn.textContent = ok ? 'CONNECTED' : 'CONNECTING…'; + elConn.className = ok ? 'connected' : ''; +} + +function discoverPort() { + const fromUrl = parseInt(location.port, 10); + const cands = [fromUrl, 6666, 6667, 6668].filter(Boolean); + let i = 0; + (function tryNext() { + if (i >= cands.length) i = 0; + const p = cands[i++]; + const t = new WebSocket(`ws://localhost:${p}/`); + t.onopen = () => { t.close(); connect(p); }; + t.onerror = () => setTimeout(tryNext, 500); + })(); +} + +window.addEventListener('resize', () => { resizeCanvas(); drawSparkline(0); }); + +document.addEventListener('DOMContentLoaded', () => { + resizeCanvas(); + setConn(false); + discoverPort(); +}); diff --git a/src/inner_templates/treadmill-pro/index.html b/src/inner_templates/treadmill-pro/index.html new file mode 100644 index 0000000000..5edf6ec9ac --- /dev/null +++ b/src/inner_templates/treadmill-pro/index.html @@ -0,0 +1,100 @@ + + + + + + Treadmill Pro · QZ + + + +
+ + +
+ CONNECTING… + 00:00 + +
+ + +
+
CURRENT
+
+
+ + +
+
+
+
DISTANCE
+
+
+
+
00:00
+
TIME
+
+
+
+
+
AVG SPEED
+
+
+ + +
+
+
+
+
+
+ + +
+ +
NEXT LAP
+
+
+
+
PACE min/km
+
+
+
+
HEART RATE
+
+
+
+
CADENCE spm
+
+
+
+ + +
+ +
+
0.0
+
+
+ +
+ + +
+ +
+
0.0
+
INCLINE
+
+ +
+ + +
+ + +
+ +
+ + + diff --git a/src/inner_templates/treadmill-pro/style.css b/src/inner_templates/treadmill-pro/style.css new file mode 100644 index 0000000000..7e618ed9bc --- /dev/null +++ b/src/inner_templates/treadmill-pro/style.css @@ -0,0 +1,301 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0d0d0d; + --surface: #181818; + --border: rgba(255,255,255,0.10); + --text: #ffffff; + --dim: rgba(255,255,255,0.45); + --green: #00e676; + --teal: #00bcd4; + --red: #f44336; + --orange: #ff7043; + --font: -apple-system, "Helvetica Neue", Arial, sans-serif; + --mono: "SF Mono", "Roboto Mono", monospace; +} + +html, body { width:100%; height:100%; background:var(--bg); color:var(--text); + font-family:var(--font); overflow:hidden; -webkit-font-smoothing:antialiased; } + +#app { + display: flex; + flex-direction: column; + height: 100dvh; + max-width: 480px; + margin: 0 auto; +} + +/* ── TOP BAR ───────────────────────────────────────── */ +#topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px 4px; + flex-shrink: 0; + font-size: 11px; + letter-spacing: .06em; + color: var(--dim); +} +#elapsed { + font-family: var(--mono); + font-size: 14px; + font-weight: 700; + color: var(--text); + letter-spacing: .08em; +} +#conn-status.connected { color: var(--green); } + +/* ── CURRENT SEGMENT ───────────────────────────────── */ +#segment-section { + padding: 4px 16px 6px; + flex-shrink: 0; +} +#segment-label { + font-size: 10px; + font-weight: 700; + letter-spacing: .18em; + color: var(--dim); + margin-bottom: 2px; +} +#segment-name { + font-size: clamp(22px, 6vw, 34px); + font-weight: 800; + color: var(--teal); + letter-spacing: -.01em; + line-height: 1.1; + text-shadow: 0 0 20px rgba(0,188,212,.35); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── STATS ROW ─────────────────────────────────────── */ +#stats-row { + display: flex; + align-items: center; + justify-content: space-around; + padding: 6px 8px; + flex-shrink: 0; +} +.stat-item { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; +} +.stat-value { + font-size: clamp(16px, 4.5vw, 22px); + font-weight: 800; + font-variant-numeric: tabular-nums; + letter-spacing: -.02em; +} +.stat-label { + font-size: 9px; + font-weight: 700; + letter-spacing: .12em; + color: var(--dim); + margin-top: 1px; +} +.stat-sep { + width: 1px; height: 28px; + background: var(--border); + flex-shrink: 0; +} + +/* ── PROGRESS BAR ──────────────────────────────────── */ +#progress-section { + padding: 4px 16px 6px; + flex-shrink: 0; +} +#progress-track { + position: relative; + height: 6px; + border-radius: 3px; + background: rgba(255,255,255,0.08); + overflow: hidden; +} +#progress-fill { + position: absolute; + left: 0; top: 0; bottom: 0; + width: 0%; + background: linear-gradient(90deg, var(--teal), var(--green)); + border-radius: 3px; + transition: width 1s linear; + box-shadow: 0 0 8px rgba(0,230,118,.4); +} +#progress-dots { + position: absolute; + inset: 0; + background-image: repeating-linear-gradient( + 90deg, transparent, transparent 8px, + rgba(255,255,255,.06) 8px, rgba(255,255,255,.06) 9px + ); +} + +/* ── MIDDLE / SPARKLINE + LAP STATS ───────────────── */ +#middle-section { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + justify-content: center; + padding: 4px 16px; +} +#sparkline { + width: 100%; + height: 60px; + display: block; + flex-shrink: 0; +} +#next-lap-label { + font-size: 10px; + font-weight: 700; + letter-spacing: .18em; + color: var(--dim); + text-align: center; + margin: 8px 0 4px; +} +#lap-stats { + display: flex; + justify-content: space-around; + align-items: flex-start; +} +.lap-item { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; +} +.lap-value { + font-size: clamp(20px, 5.5vw, 30px); + font-weight: 800; + font-variant-numeric: tabular-nums; + letter-spacing: -.02em; +} +.lap-label { + font-size: 9px; + font-weight: 700; + letter-spacing: .10em; + color: var(--dim); + margin-top: 2px; + text-align: center; +} +.hr-icon { color: var(--red); animation: hrbeat .9s ease-in-out infinite; display:inline-block; } +@keyframes hrbeat { + 0%,100% { transform: scale(1); } + 40% { transform: scale(1.25); } +} +.lap-hr .lap-value { color: var(--red); } + +/* HR zone colours */ +.hr-z1 .lap-value { color: #80cbc4 !important; } +.hr-z2 .lap-value { color: var(--teal) !important; } +.hr-z3 .lap-value { color: var(--green) !important; } +.hr-z4 .lap-value { color: var(--orange) !important; } +.hr-z5 .lap-value { color: var(--red) !important; } + +/* ── CONTROL ROWS ──────────────────────────────────── */ +.control-row { + display: flex; + align-items: center; + margin: 0 12px 8px; + border-radius: 14px; + padding: 10px 8px; + flex-shrink: 0; +} +.control-speed { background: rgba(0,188,212,.10); border: 1.5px solid rgba(0,188,212,.30); } +.control-incline { background: rgba(244,67,54,.08); border: 1.5px solid rgba(244,67,54,.25); } + +.ctrl-btn { + width: 52px; height: 52px; + border-radius: 12px; + border: 1.5px solid var(--border); + background: rgba(255,255,255,.05); + color: var(--text); + font-size: 26px; + font-weight: 300; + cursor: pointer; + flex-shrink: 0; + display: flex; align-items: center; justify-content: center; + -webkit-tap-highlight-color: transparent; + transition: background .15s, transform .1s; +} +.ctrl-btn:active { background: rgba(255,255,255,.12); transform: scale(.93); } +.control-speed .ctrl-btn { border-color: rgba(0,188,212,.4); } +.control-incline .ctrl-btn { border-color: rgba(244,67,54,.4); } + +.ctrl-center { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; +} +.ctrl-value { + font-size: clamp(28px, 8vw, 40px); + font-weight: 800; + font-variant-numeric: tabular-nums; + letter-spacing: -.02em; + line-height: 1; +} +.control-speed .ctrl-value { color: var(--teal); text-shadow: 0 0 16px rgba(0,188,212,.5); } +.control-incline .ctrl-value { color: var(--orange); text-shadow: 0 0 16px rgba(244,67,54,.4); } + +.ctrl-sub { + font-size: 10px; + font-weight: 700; + letter-spacing: .10em; + color: var(--dim); + margin-top: 2px; +} +.control-speed .ctrl-sub { color: var(--teal); opacity: .7; } +.control-incline .ctrl-sub { color: var(--orange); opacity: .7; } + +/* ── ACTION BUTTONS ────────────────────────────────── */ +#action-row { + display: flex; + gap: 10px; + padding: 0 12px 12px; + flex-shrink: 0; +} +.action-btn { + flex: 1; + padding: 14px 0; + border-radius: 14px; + font-size: 15px; + font-weight: 700; + letter-spacing: .04em; + cursor: pointer; + border: none; + -webkit-tap-highlight-color: transparent; + transition: opacity .15s, transform .1s; +} +.action-btn:active { opacity: .8; transform: scale(.97); } +.action-secondary { + background: var(--surface); + color: var(--text); + border: 1.5px solid var(--border); +} +.action-primary { + background: #ffffff; + color: #000000; +} + +/* ── RESPONSIVE LANDSCAPE ──────────────────────────── */ +@media (orientation: landscape) { + #app { flex-direction: row; flex-wrap: wrap; max-width: 100%; } + + #topbar { width: 100%; order: 0; padding: 4px 16px; } + #segment-section { width: 48%; order: 1; padding: 4px 12px; } + #stats-row { width: 52%; order: 2; padding: 4px 8px; } + #progress-section { width: 100%; order: 3; padding: 2px 16px 4px; } + #middle-section{ width: 50%; order: 4; flex: none; min-height: 0; + height: calc(100dvh - 200px); padding: 4px 12px; } + #speed-control { width: 50%; order: 5; margin: 4px 6px 4px 0; } + #incline-control{ width: 50%; order: 6; margin: 4px 0 4px 6px; } + #action-row { width: 100%; order: 7; padding: 0 12px 8px; } + + #sparkline { height: 40px; } + .ctrl-value { font-size: 28px; } + .ctrl-btn { width: 44px; height: 44px; font-size: 22px; } + .action-btn { padding: 10px 0; font-size: 14px; } +} diff --git a/src/main.qml b/src/main.qml index 7fc1062be4..922bfdfdc6 100644 --- a/src/main.qml +++ b/src/main.qml @@ -132,6 +132,7 @@ ApplicationWindow { property bool lockTiles: false property bool settings_restart_to_apply: false property bool gymModePopupDismissed: false + property bool sideBarVisible: false Settings { id: settings @@ -1107,6 +1108,8 @@ ApplicationWindow { leftPadding: getLeftPadding() rightPadding: getRightPadding() Accessible.ignored: !drawer.opened + onOpened: window.sideBarVisible = true + onClosed: window.sideBarVisible = false ScrollView { contentWidth: -1 diff --git a/src/qml.qrc b/src/qml.qrc index 3376fb2ad8..e7082d40b1 100644 --- a/src/qml.qrc +++ b/src/qml.qrc @@ -135,5 +135,11 @@ WebPelotonAuth.qml inner_templates/floating/hfloating.htm WebIntervalsICUAuth.qml + inner_templates/bike-pro/index.html + inner_templates/bike-pro/style.css + inner_templates/bike-pro/app.js + inner_templates/treadmill-pro/index.html + inner_templates/treadmill-pro/style.css + inner_templates/treadmill-pro/app.js diff --git a/src/qzsettings.cpp b/src/qzsettings.cpp index d8bdce9f6e..c075493385 100644 --- a/src/qzsettings.cpp +++ b/src/qzsettings.cpp @@ -1230,10 +1230,13 @@ const QString QZSettings::shortcut_lap = QStringLiteral("shortcut_lap"); const QString QZSettings::default_shortcut_lap = QStringLiteral(""); const QString QZSettings::shortcut_start_stop = QStringLiteral("shortcut_start_stop"); const QString QZSettings::default_shortcut_start_stop = QStringLiteral(""); +const QString QZSettings::ui_custom_dashboard_enabled = QStringLiteral("ui_custom_dashboard_enabled"); +const QString QZSettings::ui_custom_dashboard_name = QStringLiteral("ui_custom_dashboard_name"); +const QString QZSettings::default_ui_custom_dashboard_name = QStringLiteral("bike-pro"); const QString QZSettings::shortcut_stop = QStringLiteral("shortcut_stop"); const QString QZSettings::default_shortcut_stop = QStringLiteral(""); -const uint32_t allSettingsCount = 962; +const uint32_t allSettingsCount = 964; QVariant allSettings[allSettingsCount][2] = { {QZSettings::cryptoKeySettingsProfiles, QZSettings::default_cryptoKeySettingsProfiles}, @@ -2220,6 +2223,8 @@ QVariant allSettings[allSettingsCount][2] = { {QZSettings::proform_treadmill_cst_505_pftl59420_0, QZSettings::default_proform_treadmill_cst_505_pftl59420_0}, {QZSettings::applewatch_as_treadmill_speed, QZSettings::default_applewatch_as_treadmill_speed}, {QZSettings::horizon_treadmill_omega_z, QZSettings::default_horizon_treadmill_omega_z}, + {QZSettings::ui_custom_dashboard_enabled, QZSettings::default_ui_custom_dashboard_enabled}, + {QZSettings::ui_custom_dashboard_name, QZSettings::default_ui_custom_dashboard_name}, }; void QZSettings::qDebugAllSettings(bool showDefaults) { diff --git a/src/qzsettings.h b/src/qzsettings.h index 494a115e75..24bda0b69d 100644 --- a/src/qzsettings.h +++ b/src/qzsettings.h @@ -3188,6 +3188,12 @@ class QZSettings { static const QString horizon_treadmill_omega_z; static constexpr bool default_horizon_treadmill_omega_z = false; + static const QString ui_custom_dashboard_enabled; + static constexpr bool default_ui_custom_dashboard_enabled = false; + + static const QString ui_custom_dashboard_name; + static const QString default_ui_custom_dashboard_name; + /** * @brief Write the QSettings values using the constants from this namespace. * @param showDefaults Optionally indicates if the default should be shown with the key. diff --git a/src/settings-catalog.json b/src/settings-catalog.json index 16a8ef7c1c..9a38e16044 100644 --- a/src/settings-catalog.json +++ b/src/settings-catalog.json @@ -2,7 +2,7 @@ "$schema": "https://qdomyos-zwift.local/settings-catalog.schema.json", "schemaVersion": 1, "format": "qdomyos-zwift-settings-catalog", - "settingCount": 950, + "settingCount": 952, "pages": [ { "key": "page_custom_gear_table", @@ -13520,6 +13520,32 @@ "source": "expression", "expression": "appLanguageOptions" } + }, + { + "key": "ui_custom_dashboard_enabled", + "name": "Custom Dashboard Enabled", + "description": null, + "parent": "General", + "type": "boolean", + "qmlType": "bool", + "control": "switch", + "visible": false, + "defaultValue": false, + "defaultExpression": "false", + "options": null + }, + { + "key": "ui_custom_dashboard_name", + "name": "Custom Dashboard Name", + "description": null, + "parent": "General", + "type": "string", + "qmlType": "string", + "control": "text", + "visible": false, + "defaultValue": "bike-pro", + "defaultExpression": "\"bike-pro\"", + "options": null } ] } diff --git a/src/settings.qml b/src/settings.qml index bf7bcf62cd..12275a49e9 100644 --- a/src/settings.qml +++ b/src/settings.qml @@ -1689,8 +1689,10 @@ import Qt.labs.platform 1.1 property string app_language: "auto" property bool garmin_download_workouts_on_start: true - property bool trainprogram_clipboard_workout_enabled: false + property bool trainprogram_clipboard_workout_enabled: false property string shortcut_stop: "" + property bool ui_custom_dashboard_enabled: false + property string ui_custom_dashboard_name: "bike-pro" } @@ -6293,6 +6295,69 @@ import Qt.labs.platform 1.1 } } } + + IndicatorOnlySwitch { + text: qsTr("Custom Web Dashboard") + spacing: 0 + bottomPadding: 0 + topPadding: 0 + rightPadding: 0 + leftPadding: 0 + clip: false + checked: settings.ui_custom_dashboard_enabled + Layout.alignment: Qt.AlignLeft | Qt.AlignTop + Layout.fillWidth: true + onClicked: settings.ui_custom_dashboard_enabled = checked + } + + Label { + text: qsTr("Replace the home screen with a custom web dashboard. Place your dashboard folder inside the 'dashboards' folder in the QZ data directory, or pick one of the built-in dashboards.") + font.bold: true + font.italic: true + font.pixelSize: Qt.application.font.pixelSize - 2 + textFormat: Text.PlainText + wrapMode: Text.WordWrap + verticalAlignment: Text.AlignVCenter + Layout.alignment: Qt.AlignLeft | Qt.AlignTop + Layout.fillWidth: true + color: Material.color(Material.Lime) + } + + RowLayout { + spacing: 10 + Label { + text: qsTr("Dashboard:") + Layout.fillWidth: true + } + ComboBox { + id: customDashboardCombo + model: rootItem.availableDashboards() + Layout.fillHeight: false + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + onActivated: displayText = currentText + Component.onCompleted: { + var dashboards = rootItem.availableDashboards() + var current = settings.ui_custom_dashboard_name + for (var i = 0; i < dashboards.length; i++) { + if (dashboards[i] === current) { + currentIndex = i + return + } + } + currentIndex = 0 + } + } + Button { + text: qsTr("OK") + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + onClicked: { + var dashboards = rootItem.availableDashboards() + if (customDashboardCombo.currentIndex >= 0 && customDashboardCombo.currentIndex < dashboards.length) + settings.ui_custom_dashboard_name = dashboards[customDashboardCombo.currentIndex] + toast.show(qsTr("Setting saved!")) + } + } + } } }