Dashboard Studio 2.6.0 and Haven 2.3.0: easier to use on a phone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+88
-17
@@ -301,8 +301,8 @@
|
||||
const p = (t, cls = "") => `<p${cls ? ` class="${cls}"` : ""}>${esc(t)}</p>`;
|
||||
let body, actions = `<button class="btn" data-x="cancel">${tr("Close")}</button>`;
|
||||
if (IN_APP) {
|
||||
body = p(tr("This is Haven, connected to {0}.", location.host));
|
||||
actions += `<button class="btn" data-x="reset">${tr("Change address")}</button>`;
|
||||
body = p(tr("This is Haven, connected to {0}. You can add another home, such as a holiday home, or switch between them.", location.host));
|
||||
actions += `<button class="btn" data-x="reset">${tr("Homes and addresses")}</button>`;
|
||||
} else if (android) {
|
||||
// A home screen app from the browser needs https, and Home Assistant's own app can't make one at all: Haven always works.
|
||||
body = p(tr("Get Haven, the Android app for your dashboards. It opens full screen with its own icon and keeps you signed in."))
|
||||
@@ -325,7 +325,7 @@
|
||||
if (browserInstall) actions += `<button class="btn primary" data-x="install">${tr("Install")}</button>`;
|
||||
}
|
||||
if (!IN_APP && !android && isAdmin()) body += p(tr("For Android phones there is Haven: see Admin, Haven app."), "muted small");
|
||||
const choice = await openModal(`<h3>${tr("Install as an app")}</h3>${body}
|
||||
const choice = await openModal(`<h3>${IN_APP ? "Haven" : tr("Install as an app")}</h3>${body}
|
||||
${IN_APP || ios ? "" : p(tr("It opens on the dashboard chosen for that device. Mark buttons as a shortcut to use them from the app icon."), "muted small")}
|
||||
<div class="modal-actions">${actions}</div>`,
|
||||
(e, close) => { const x = e.target.closest("[data-x], a.btn"); if (x) close(x.dataset.x || "link"); });
|
||||
@@ -2238,15 +2238,34 @@
|
||||
|
||||
// ------------------------------------------------------------ dialogs
|
||||
let modalClose = null;
|
||||
// An open sheet is a step in the history, so going back (Android's back gesture in Haven, the browser's
|
||||
// back button) closes it instead of leaving the app.
|
||||
let modalInHistory = false, closingByBack = false, ownBacks = 0;
|
||||
window.addEventListener("popstate", () => {
|
||||
if (ownBacks) { ownBacks--; return; } // the step back a closing sheet took itself (another may have opened since)
|
||||
if (!modalInHistory) return;
|
||||
modalInHistory = false;
|
||||
closingByBack = true;
|
||||
modalClose?.(null);
|
||||
closingByBack = false;
|
||||
});
|
||||
/** Haven's back gesture asks first: close what is open here (a sheet, the top bar) and say so. */
|
||||
window.havenBack = () => {
|
||||
if (modalClose) { modalClose(null); return true; }
|
||||
if (document.body.classList.contains("peek")) { document.body.classList.remove("peek"); return true; }
|
||||
return false;
|
||||
};
|
||||
function openModal(html, onClick) {
|
||||
const card = $("#modal .modal-card");
|
||||
card.innerHTML = html;
|
||||
$("#modal").hidden = false;
|
||||
if (!modalInHistory) { history.pushState({ dsSheet: true }, "", location.href); modalInHistory = true; }
|
||||
const prevFocus = document.activeElement;
|
||||
return new Promise((resolve) => {
|
||||
modalClose = (result) => {
|
||||
$("#modal").hidden = true;
|
||||
modalClose = null;
|
||||
if (modalInHistory && !closingByBack) { modalInHistory = false; ownBacks++; history.back(); }
|
||||
prevFocus?.focus?.();
|
||||
resolve(result);
|
||||
};
|
||||
@@ -2467,23 +2486,56 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** The details of one entity: what it is now in words, a day's history for a sensor, the controls,
|
||||
* and the technical details (its ID and attributes) folded away for whoever needs them. */
|
||||
function moreInfo(eid) {
|
||||
const s = ha.states[eid];
|
||||
if (!s) return;
|
||||
if (domainOf(eid) === "light") { lightPopup(eid); return; }
|
||||
if (domainOf(eid) === "climate") { climatePopup(eid); return; }
|
||||
const toggle = window.HAFmt.TOGGLE_DOMAINS.has(domainOf(eid));
|
||||
const actionLabel = domainOf(eid) === "lock" ? (s.state === "locked" ? tr("Unlock") : tr("Lock")) : ["scene", "script"].includes(domainOf(eid)) ? tr("Run") : isOn(s) ? tr("Turn off") : tr("Turn on");
|
||||
openModal(`<h3>${esc(friendly(s))}</h3><div class="muted small">${esc(eid)}</div>
|
||||
<div class="num" style="font-size:34px;font-weight:700;margin:14px 0 2px">${esc(fmtState(s))}</div>
|
||||
const d = domainOf(eid);
|
||||
if (d === "light") { lightPopup(eid); return; }
|
||||
if (d === "climate") { climatePopup(eid); return; }
|
||||
const toggle = window.HAFmt.TOGGLE_DOMAINS.has(d) && !["climate", "vacuum", "media_player", "cover"].includes(d);
|
||||
const actionLabel = d === "lock" ? (s.state === "locked" ? tr("Unlock") : tr("Lock")) : ["scene", "script"].includes(d) ? tr("Run") : ["button", "input_button"].includes(d) ? tr("Press") : isOn(s) ? tr("Turn off") : tr("Turn on");
|
||||
const numeric = ["sensor", "input_number", "number", "counter"].includes(d) && s.state !== "" && isFinite(Number(s.state));
|
||||
// a few domains get their own buttons
|
||||
const svc = (service, ico, label) => `<button class="btn" data-svc="${service}">${Icons.svg(ico)}${esc(label)}</button>`;
|
||||
const extra = d === "cover" ? svc("cover.open_cover", "arrow-up", tr("Open")) + svc("cover.stop_cover", "stop", tr("Stop")) + svc("cover.close_cover", "arrow-down", tr("Close"))
|
||||
: d === "media_player" ? svc("media_player.media_previous_track", "prev", tr("Previous track")) + svc("media_player.media_play_pause", s.state === "playing" ? "pause" : "play", s.state === "playing" ? tr("Pause") : tr("Play")) + svc("media_player.media_next_track", "next", tr("Next track"))
|
||||
: d === "vacuum" ? svc(s.state === "cleaning" ? "vacuum.pause" : "vacuum.start", s.state === "cleaning" ? "pause" : "play", s.state === "cleaning" ? tr("Pause") : tr("Start")) + svc("vacuum.return_to_base", "home", tr("Dock")) : "";
|
||||
const attrs = Object.entries(s.attributes).filter(([k]) => !["friendly_name", "icon", "entity_picture", "supported_features"].includes(k));
|
||||
const done = openModal(`<div class="mi-head"><span class="mi-ico">${window.HAFmt.icon(s)}</span><h3>${esc(friendly(s))}</h3></div>
|
||||
<div class="mi-state num">${esc(fmtState(s))}</div>
|
||||
<div class="muted small">${esc(tr("Changed {0}", relTime(s.last_changed)))}</div>
|
||||
<table class="attr-table">${Object.entries(s.attributes).map(([k, v]) => `<tr><td>${esc(cap(k))}</td><td>${esc(typeof v === "object" ? JSON.stringify(v) : v)}</td></tr>`).join("")}</table>
|
||||
${numeric ? `<div class="mi-graph" aria-label="${esc(tr("The last 24 hours"))}"><div class="muted small">${tr("Loading history…")}</div></div>` : ""}
|
||||
${extra ? `<div class="mi-actions">${extra}</div>` : ""}
|
||||
<details class="mi-more"><summary>${tr("Technical details")}</summary>
|
||||
<table class="attr-table"><tr><td>${tr("Entity ID")}</td><td>${esc(eid)}</td></tr>${attrs.map(([k, v]) => `<tr><td>${esc(cap(k))}</td><td>${esc(typeof v === "object" ? JSON.stringify(v) : v)}</td></tr>`).join("")}</table></details>
|
||||
<div class="modal-actions"><button class="btn" data-x="close">${tr("Close")}</button>${toggle ? `<button class="btn primary" data-x="toggle">${actionLabel}</button>` : ""}</div>`,
|
||||
(e, close) => {
|
||||
const x = e.target.closest("[data-x]")?.dataset.x;
|
||||
if (x === "toggle") defaultAction(eid);
|
||||
if (x) close();
|
||||
const b = e.target.closest("[data-x], [data-svc]");
|
||||
if (!b) return;
|
||||
if (b.dataset.svc) { const [dom, service] = b.dataset.svc.split("."); ha.callService(dom, service, {}, eid); haptic(); return; }
|
||||
if (b.dataset.x === "toggle") defaultAction(eid);
|
||||
close();
|
||||
});
|
||||
if (numeric) drawDetailGraph(eid, done);
|
||||
}
|
||||
|
||||
/** A day of a sensor's history as a line, with its lowest and highest value. */
|
||||
async function drawDetailGraph(eid, done) {
|
||||
let pts = [];
|
||||
try { pts = ((await ha.history([eid], 24))[eid] || []).filter(([, v]) => isFinite(v)); } catch { /* shown as unavailable */ }
|
||||
const box = $("#modal:not([hidden]) .mi-graph");
|
||||
if (!box) return;
|
||||
if (pts.length < 2) { box.innerHTML = `<div class="muted small">${tr("No history for the last 24 hours.")}</div>`; return; }
|
||||
const W_ = 320, H = 90, t0 = Date.now() - 24 * 3600e3, t1 = Date.now();
|
||||
const vs = pts.map(([, v]) => v), lo = Math.min(...vs), hi = Math.max(...vs), span = hi - lo || 1;
|
||||
const x = (t) => ((Math.max(t, t0) - t0) / (t1 - t0)) * W_, y = (v) => H - 6 - ((v - lo) / span) * (H - 12);
|
||||
const line = pts.map(([t, v], i) => `${i ? "L" : "M"}${x(t).toFixed(1)} ${y(v).toFixed(1)}`).join("") + `L${W_} ${y(vs[vs.length - 1]).toFixed(1)}`;
|
||||
const unit = ha.states[eid]?.attributes.unit_of_measurement || "";
|
||||
const f = (v) => `${Number(v).toLocaleString(LOCALE, { maximumFractionDigits: 1 })}${unit ? ` ${unit}` : ""}`;
|
||||
box.innerHTML = `<svg viewBox="0 0 ${W_} ${H}" preserveAspectRatio="none" aria-hidden="true"><path d="${line}" fill="none" stroke="var(--accent)" stroke-width="2" vector-effect="non-scaling-stroke" stroke-linejoin="round"/></svg>
|
||||
<div class="mi-graph-legend muted small"><span>${tr("24 hours ago")}</span><span>${esc(tr("Low {0} · high {1}", f(lo), f(hi)))}</span><span>${tr("Now")}</span></div>`;
|
||||
}
|
||||
|
||||
/** The changelog in a dialog. A small Markdown subset: ## headings, - bullets, **bold** and *italic*. */
|
||||
@@ -2599,7 +2651,7 @@
|
||||
const others = lockedKiosk ? store.dashboards.filter((d) => d.id !== current.id) : [];
|
||||
const choice = await openModal(lockedKiosk
|
||||
? `<h3>${tr("Dashboards")}</h3>${me?.guest && me.until ? `<p class="muted small">${esc(tr("This invite ends {0}.", new Date(me.until * 1000).toLocaleString(LOCALE, { weekday: "long", hour: "2-digit", minute: "2-digit" })))}</p>` : ""}${others.length ? `<div class="kp-dashes">${others.map((d) => `<button class="btn" data-x="dash:${d.id}">${Icons.svg("layout")}${esc(d.name)}</button>`).join("")}</div>` : `<p>${tr("This is the only dashboard you can see. An admin can add more for you.")}</p>`}
|
||||
<div class="modal-actions">${IN_APP ? `<button class="btn" data-x="haven">${tr("Change address")}</button>` : ""}<button class="btn guest-hide" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn primary" data-x="stay" autofocus>${tr("Close")}</button></div>`
|
||||
<div class="modal-actions">${IN_APP ? `<button class="btn" data-x="haven">${tr("Homes and addresses")}</button>` : ""}<button class="btn guest-hide" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn primary" data-x="stay" autofocus>${tr("Close")}</button></div>`
|
||||
: `<h3>${tr("Leave kiosk mode?")}</h3><p>${tr("This closes by itself in a few seconds.")}</p>
|
||||
<div class="modal-actions"><button class="btn" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn" data-x="stay" autofocus>${tr("Stay")}</button><button class="btn primary" data-x="exit">${tr("Leave kiosk")}</button></div>`,
|
||||
(e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x); });
|
||||
@@ -3003,11 +3055,23 @@
|
||||
if (on) peekTimer = setTimeout(() => document.body.classList.remove("peek"), 15000);
|
||||
}
|
||||
$("#phone-menu").onclick = () => peek(!document.body.classList.contains("peek"));
|
||||
// Scrolling down, the ⋯ button steps aside so it doesn't sit on a card; scrolling up brings it back.
|
||||
let lastScrollY = 0;
|
||||
document.addEventListener("scroll", (e) => {
|
||||
if (!document.body.classList.contains("phone-ui")) return;
|
||||
const el = e.target === document ? document.scrollingElement : e.target;
|
||||
if (!el || el.closest?.(".w, #modal")) return; // a list inside a card, or a sheet: not the page
|
||||
const y = el.scrollTop || 0;
|
||||
if (Math.abs(y - lastScrollY) < 6) return;
|
||||
document.body.classList.toggle("menu-away", y > lastScrollY && y > 40);
|
||||
lastScrollY = y;
|
||||
}, { capture: true, passive: true });
|
||||
|
||||
// ------------------------------------------------------------ chrome wiring
|
||||
function setCurrent(id) {
|
||||
current = store.dashboards.find((d) => d.id === id) || store.dashboards[0];
|
||||
lsSet("ds.current", current.id);
|
||||
if (phoneScreen.matches) lsSet("ds.currentPhone", current.id);
|
||||
activeTabId = lsGet(`ds.tab.${current.id}`);
|
||||
guestOn = false; guestCache = null; awayState = null; awayFrom = null;
|
||||
undoStack.length = redoStack.length = 0;
|
||||
@@ -3721,10 +3785,14 @@
|
||||
document.querySelectorAll("#menu [data-icon], #mode-seg [data-icon]").forEach((b) => b.insertAdjacentHTML("afterbegin", Icons.svg(b.dataset.icon)));
|
||||
}
|
||||
|
||||
let everConnected = false;
|
||||
ha.onStatus = (ok) => {
|
||||
const c = $("#conn");
|
||||
if (ok) everConnected = true;
|
||||
c.classList.toggle("ok", ok);
|
||||
c.textContent = ok ? tr("Connected") : offlineText();
|
||||
// the first moments after opening are connecting, not offline
|
||||
c.classList.toggle("connecting", !ok && !everConnected && !lastLive);
|
||||
c.textContent = ok ? tr("Connected") : !everConnected && !lastLive ? tr("Connecting…") : offlineText();
|
||||
showOffline(!ok);
|
||||
// An update restarts the app, which drops the connection: check right after reconnecting.
|
||||
if (ok) checkVersion();
|
||||
@@ -3824,9 +3892,12 @@
|
||||
const exists = (id) => id && store.dashboards.some((d) => d.id === id);
|
||||
// The screen it was on last time; people who aren't admins, and Haven, only get the dashboard.
|
||||
const lastMode = lsGet("ds.mode") || (lsGet("ds.edit") === "1" ? "builder" : "dashboard");
|
||||
mode = isAdmin() && !IN_APP ? lastMode : "dashboard";
|
||||
// the builder is for bigger screens: a phone always opens on the dashboard
|
||||
mode = isAdmin() && !IN_APP && !phoneScreen.matches ? lastMode : "dashboard";
|
||||
editMode = mode === "builder";
|
||||
setCurrent([hashId, dev?.dashboard, home, lsGet("ds.current")].find(exists));
|
||||
// On a phone: the one last used on this phone, else the first dashboard designed for phones.
|
||||
const phone = phoneScreen.matches ? [lsGet("ds.currentPhone"), store.dashboards.find((d) => d.mobile)?.id] : [];
|
||||
setCurrent([hashId, dev?.dashboard, home, ...phone, lsGet("ds.current")].find(exists));
|
||||
if (!isAdmin()) { lockedKiosk = true; enterKiosk({ fullscreen: false }); }
|
||||
else if (location.hash.includes("kiosk") || shared.get("ds_kiosk") === "1" || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
|
||||
else setMode(mode);
|
||||
|
||||
Reference in New Issue
Block a user