From 4f9ca3b1739c7e2911827aa40a032e0bc5b246a3 Mon Sep 17 00:00:00 2001 From: Wendelin Date: Wed, 12 Feb 2025 15:30:15 +0100 Subject: [PATCH] Add save theme for user functionality --- src/data/ws-themes.ts | 19 +++ src/panels/profile/ha-pick-theme-row.ts | 39 +++-- .../profile/ha-profile-section-general.ts | 138 ++++++++++++++++-- src/state/themes-mixin.ts | 72 +++++++-- src/translations/en.json | 11 +- src/util/ha-pref-storage.ts | 8 + 6 files changed, 254 insertions(+), 33 deletions(-) diff --git a/src/data/ws-themes.ts b/src/data/ws-themes.ts index 0c04a2382c..e030906df4 100644 --- a/src/data/ws-themes.ts +++ b/src/data/ws-themes.ts @@ -1,5 +1,11 @@ import type { Connection } from "home-assistant-js-websocket"; import { createCollection } from "home-assistant-js-websocket"; +import type { HomeAssistant, ThemeSettings } from "../types"; +import { + fetchFrontendUserData, + saveFrontendUserData, + subscribeFrontendUserData, +} from "./frontend"; export interface ThemeVars { // Incomplete @@ -50,3 +56,16 @@ export const subscribeThemes = ( conn, onChange ); + +export const SELECTED_THEME_KEY = "selectedTheme"; + +export const saveSelectedTheme = (hass: HomeAssistant, data?: ThemeSettings) => + saveFrontendUserData(hass.connection, SELECTED_THEME_KEY, data); + +export const subscribeSelectedTheme = ( + hass: HomeAssistant, + callback: (selectedTheme?: ThemeSettings | null) => void +) => subscribeFrontendUserData(hass.connection, SELECTED_THEME_KEY, callback); + +export const fetchSelectedTheme = (hass: HomeAssistant) => + fetchFrontendUserData(hass.connection, SELECTED_THEME_KEY); diff --git a/src/panels/profile/ha-pick-theme-row.ts b/src/panels/profile/ha-pick-theme-row.ts index d37471f938..2e761ee766 100644 --- a/src/panels/profile/ha-pick-theme-row.ts +++ b/src/panels/profile/ha-pick-theme-row.ts @@ -16,6 +16,7 @@ import { } from "../../resources/styles-data"; import type { HomeAssistant } from "../../types"; import { documentationUrl } from "../../util/documentation-url"; +import type { StorageLocation } from "../../state/themes-mixin"; const USE_DEFAULT_THEME = "__USE_DEFAULT_THEME__"; const HOME_ASSISTANT_THEME = "default"; @@ -26,6 +27,9 @@ export class HaPickThemeRow extends LitElement { @property({ type: Boolean }) public narrow = false; + @property({ attribute: false }) + public storageLocation: StorageLocation = "browser"; + @state() _themeNames: string[] = []; protected render(): TemplateResult { @@ -171,13 +175,19 @@ export class HaPickThemeRow extends LitElement { private _handleColorChange(ev: CustomEvent) { const target = ev.target as any; - fireEvent(this, "settheme", { [target.name]: target.value }); + fireEvent(this, "settheme", { + settings: { [target.name]: target.value }, + storageLocation: this.storageLocation, + }); } private _resetColors() { fireEvent(this, "settheme", { - primaryColor: undefined, - accentColor: undefined, + settings: { + primaryColor: undefined, + accentColor: undefined, + }, + storageLocation: this.storageLocation, }); } @@ -198,7 +208,10 @@ export class HaPickThemeRow extends LitElement { dark = true; break; } - fireEvent(this, "settheme", { dark }); + fireEvent(this, "settheme", { + settings: { dark }, + storageLocation: this.storageLocation, + }); } private _handleThemeSelection(ev) { @@ -210,17 +223,23 @@ export class HaPickThemeRow extends LitElement { if (theme === USE_DEFAULT_THEME) { if (this.hass.selectedTheme?.theme) { fireEvent(this, "settheme", { - theme: "", - primaryColor: undefined, - accentColor: undefined, + settings: { + theme: "", + primaryColor: undefined, + accentColor: undefined, + }, + storageLocation: this.storageLocation, }); } return; } fireEvent(this, "settheme", { - theme, - primaryColor: undefined, - accentColor: undefined, + settings: { + theme, + primaryColor: undefined, + accentColor: undefined, + }, + storageLocation: this.storageLocation, }); } diff --git a/src/panels/profile/ha-profile-section-general.ts b/src/panels/profile/ha-profile-section-general.ts index 4cd71d7ed7..e75b4bdaa3 100644 --- a/src/panels/profile/ha-profile-section-general.ts +++ b/src/panels/profile/ha-profile-section-general.ts @@ -1,18 +1,23 @@ -import "@material/mwc-button"; +import { mdiAlertCircleOutline } from "@mdi/js"; import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import type { CSSResultGroup, TemplateResult } from "lit"; -import { css, html, LitElement } from "lit"; +import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { fireEvent } from "../../common/dom/fire_event"; import "../../components/ha-card"; +import "../../components/ha-button"; +import "../../components/ha-expansion-panel"; +import "../../components/ha-switch"; +import "../../components/ha-svg-icon"; import "../../layouts/hass-tabs-subpage"; import { profileSections } from "./ha-panel-profile"; import { isExternal } from "../../data/external"; +import { SELECTED_THEME_KEY } from "../../data/ws-themes"; import type { CoreFrontendUserData } from "../../data/frontend"; import { getOptimisticFrontendUserDataCollection } from "../../data/frontend"; import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box"; import { haStyle } from "../../resources/styles"; -import type { HomeAssistant, Route } from "../../types"; +import type { HomeAssistant, Route, ThemeSettings } from "../../types"; import "./ha-advanced-mode-row"; import "./ha-enable-shortcuts-row"; import "./ha-force-narrow-row"; @@ -27,6 +32,8 @@ import "./ha-pick-time-zone-row"; import "./ha-push-notifications-row"; import "./ha-set-suspend-row"; import "./ha-set-vibrate-row"; +import { storage } from "../../common/decorators/storage"; +import type { HaSwitch } from "../../components/ha-switch"; @customElement("ha-profile-section-general") class HaProfileSectionGeneral extends LitElement { @@ -38,6 +45,15 @@ class HaProfileSectionGeneral extends LitElement { @property({ attribute: false }) public route!: Route; + @storage({ + key: SELECTED_THEME_KEY, + state: true, + subscribe: true, + }) + private _browserThemeSettings?: ThemeSettings; + + @state() private _browserThemeActivated = false; + private _unsubCoreData?: UnsubscribeFunc; private _getCoreData() { @@ -91,9 +107,9 @@ class HaProfileSectionGeneral extends LitElement { : ""}
- + ${this.hass.localize("ui.panel.profile.logout")} - +
+ ${this._browserThemeSettings || this._browserThemeActivated + ? html` + + + ${this.hass.localize("ui.panel.profile.themes.header")} + + + + ${this.hass.localize( + "ui.panel.profile.themes.device.user_theme_info" + )} + + + ` + : html``} ${this.hass.user!.is_admin ? html` ${this.hass.localize("ui.panel.profile.client_settings_detail")} - + + + ${this.hass.localize( + isExternal + ? "ui.panel.profile.themes.device.mobile_app_header" + : "ui.panel.profile.themes.device.browser_header" + )} + + + ${this.hass.localize( + "ui.panel.profile.themes.device.description" + )} + + + + ${this._browserThemeSettings || this._browserThemeActivated + ? html` + + + + ` + : nothing} - + ${this.hass.localize( "ui.panel.profile.customize_sidebar.button" )} - + ${this.hass.dockedSidebar !== "auto" || !this.narrow ? html` @@ -225,6 +293,40 @@ class HaProfileSectionGeneral extends LitElement { }); } + private async _toggleBrowserTheme(ev: Event) { + const switchElement = ev.target as HaSwitch; + const enabled = switchElement.checked; + + if (!enabled) { + if (!this._browserThemeSettings && this._browserThemeActivated) { + // no changed have made, disable without confirmation + this._browserThemeActivated = false; + } else { + const confirm = await showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.profile.themes.device.delete_header" + ), + text: this.hass.localize( + "ui.panel.profile.themes.device.delete_description" + ), + confirmText: this.hass.localize("ui.common.delete"), + destructive: true, + }); + + if (confirm) { + this._browserThemeActivated = false; + this._browserThemeSettings = undefined; + fireEvent(this, "resetBrowserTheme"); + } else { + // revert switch + switchElement.click(); + } + } + } else { + this._browserThemeActivated = true; + } + } + static get styles(): CSSResultGroup { return [ haStyle, @@ -251,6 +353,20 @@ class HaProfileSectionGeneral extends LitElement { text-align: center; color: var(--secondary-text-color); } + + ha-expansion-panel { + margin: 0 8px 8px; + } + .theme-warning { + color: var(--warning-color); + display: flex; + align-items: center; + gap: 8px; + } + .device-theme { + display: block; + padding-bottom: 16px; + } `, ]; } diff --git a/src/state/themes-mixin.ts b/src/state/themes-mixin.ts index adfa0cecb5..92dad7267e 100644 --- a/src/state/themes-mixin.ts +++ b/src/state/themes-mixin.ts @@ -3,18 +3,36 @@ import { invalidateThemeCache, } from "../common/dom/apply_themes_on_element"; import type { HASSDomEvent } from "../common/dom/fire_event"; -import { subscribeThemes } from "../data/ws-themes"; -import type { Constructor, HomeAssistant } from "../types"; -import { storeState } from "../util/ha-pref-storage"; +import { + fetchSelectedTheme, + saveSelectedTheme, + SELECTED_THEME_KEY, + subscribeSelectedTheme, + subscribeThemes, +} from "../data/ws-themes"; +import type { Constructor, HomeAssistant, ThemeSettings } from "../types"; +import { clearStateKey, storeState } from "../util/ha-pref-storage"; import type { HassBaseEl } from "./hass-base-mixin"; +export type StorageLocation = "user" | "browser"; + +interface SetThemeSettings { + settings: Partial; + storageLocation: StorageLocation; +} + declare global { // for add event listener interface HTMLElementEventMap { - settheme: HASSDomEvent>; + settheme: HASSDomEvent; } interface HASSDomEvents { - settheme: Partial; + settheme: SetThemeSettings; + resetBrowserTheme: undefined; + } + + interface FrontendUserData { + selectedTheme?: ThemeSettings; } } @@ -27,16 +45,34 @@ export default >(superClass: T) => protected firstUpdated(changedProps) { super.firstUpdated(changedProps); this.addEventListener("settheme", (ev) => { + const selectedTheme = { + ...this.hass!.selectedTheme!, + ...ev.detail.settings, + }; this._updateHass({ - selectedTheme: { - ...this.hass!.selectedTheme!, - ...ev.detail, - }, + selectedTheme, }); this._applyTheme(mql.matches); - storeState(this.hass!); + + if (ev.detail.storageLocation === "browser") { + storeState(this.hass!); + } else { + clearStateKey(SELECTED_THEME_KEY); + saveSelectedTheme(this.hass!, selectedTheme); + } }); - mql.addListener((ev) => this._applyTheme(ev.matches)); + + this.addEventListener("resetBrowserTheme", async () => { + clearStateKey(SELECTED_THEME_KEY); + const selectedTheme = await fetchSelectedTheme(this.hass!); + this._updateHass({ + selectedTheme, + }); + this._applyTheme(mql.matches); + }); + + mql.addEventListener("change", (ev) => this._applyTheme(ev.matches)); + if (!this._themeApplied && mql.matches) { applyThemesOnElement( document.documentElement, @@ -63,6 +99,20 @@ export default >(superClass: T) => invalidateThemeCache(); this._applyTheme(mql.matches); }); + + subscribeSelectedTheme( + this.hass!, + (selectedTheme?: ThemeSettings | null) => { + if ( + !window.localStorage.getItem(SELECTED_THEME_KEY) && + selectedTheme + ) { + this._themeApplied = true; + this._updateHass({ selectedTheme }); + this._applyTheme(mql.matches); + } + } + ); } private _applyTheme(darkPreferred: boolean) { diff --git a/src/translations/en.json b/src/translations/en.json index b1462e406b..e8a303a107 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -7575,7 +7575,16 @@ "primary_color": "Primary color", "accent_color": "Accent color", "reset": "Reset", - "use_default": "Use default theme" + "use_default": "Use default theme", + "device": { + "browser_header": "Browser theme", + "mobile_app_header": "Mobile app theme", + "custom_theme": "Custom theme", + "description": "Overwrite user theme with custom device settings", + "delete_header": "Delete device theme", + "delete_description": "Are you sure you want to delete the device specific theme?", + "user_theme_info": "Device theme is active. Delete it to edit the user theme." + } }, "dashboard": { "header": "Dashboard", diff --git a/src/util/ha-pref-storage.ts b/src/util/ha-pref-storage.ts index 4ef259ff0c..73be8f54e4 100644 --- a/src/util/ha-pref-storage.ts +++ b/src/util/ha-pref-storage.ts @@ -11,6 +11,8 @@ const STORED_STATE = [ "defaultPanel", ]; +const CLEARABLE_STATE = ["selectedTheme"]; + export function storeState(hass: HomeAssistant) { try { STORED_STATE.forEach((key) => { @@ -55,3 +57,9 @@ export function getState() { export function clearState() { window.localStorage.clear(); } + +export function clearStateKey(key: string) { + if (CLEARABLE_STATE.includes(key)) { + window.localStorage.removeItem(key); + } +}