diff --git a/src/auth/ha-authorize.ts b/src/auth/ha-authorize.ts index deadcf765e..91fed8ef07 100644 --- a/src/auth/ha-authorize.ts +++ b/src/auth/ha-authorize.ts @@ -182,6 +182,10 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) { display: block; margin-top: 24px; } + p { + font-size: 14px; + line-height: 20px; + } `; } } diff --git a/src/components/map/ha-locations-editor.ts b/src/components/map/ha-locations-editor.ts index 9995094c95..76edc2606f 100644 --- a/src/components/map/ha-locations-editor.ts +++ b/src/components/map/ha-locations-editor.ts @@ -1,8 +1,9 @@ -import { +import type { Circle, DivIcon, DragEndEvent, LatLng, + LatLngExpression, Marker, MarkerOptions, } from "leaflet"; @@ -22,6 +23,8 @@ import type { HomeAssistant } from "../../types"; import "../ha-input-helper-text"; import "./ha-map"; import type { HaMap } from "./ha-map"; +import { HaIcon } from "../ha-icon"; +import { HaSvgIcon } from "../ha-svg-icon"; declare global { // for fire event @@ -40,6 +43,7 @@ export interface MarkerLocation { name?: string; id: string; icon?: string; + iconPath?: string; radius_color?: string; location_editable?: boolean; radius_editable?: boolean; @@ -81,11 +85,21 @@ export class HaLocationsEditor extends LitElement { ); } - public fitMap(): void { - this.map.fitMap(); + public fitMap(options?: { zoom?: number; pad?: number }): void { + this.map.fitMap(options); } - public async fitMarker(id: string): Promise { + public fitBounds( + boundingbox: LatLngExpression[], + options?: { zoom?: number; pad?: number } + ) { + this.map.fitBounds(boundingbox, options); + } + + public async fitMarker( + id: string, + options?: { zoom?: number } + ): Promise { if (!this.Leaflet) { await this._loadPromise; } @@ -104,7 +118,10 @@ export class HaLocationsEditor extends LitElement { if (circle) { this.map.leafletMap.fitBounds(circle.getBounds()); } else { - this.map.leafletMap.setView(marker.getLatLng(), this.zoom); + this.map.leafletMap.setView( + marker.getLatLng(), + options?.zoom || this.zoom + ); } } } @@ -199,15 +216,21 @@ export class HaLocationsEditor extends LitElement { this.locations.forEach((location: MarkerLocation) => { let icon: DivIcon | undefined; - if (location.icon) { + if (location.icon || location.iconPath) { // create icon const el = document.createElement("div"); el.className = "named-icon"; - if (location.name) { + if (location.name !== undefined) { el.innerText = location.name; } - const iconEl = document.createElement("ha-icon"); - iconEl.setAttribute("icon", location.icon); + let iconEl: HaIcon | HaSvgIcon; + if (location.icon) { + iconEl = document.createElement("ha-icon"); + iconEl.setAttribute("icon", location.icon); + } else { + iconEl = document.createElement("ha-svg-icon"); + iconEl.setAttribute("path", location.iconPath!); + } el.prepend(iconEl); icon = this.Leaflet!.divIcon({ diff --git a/src/components/map/ha-map.ts b/src/components/map/ha-map.ts index 507657ce15..8ff0b4275c 100644 --- a/src/components/map/ha-map.ts +++ b/src/components/map/ha-map.ts @@ -1,7 +1,8 @@ -import { +import type { Circle, CircleMarker, LatLngTuple, + LatLngExpression, Layer, Map, Marker, @@ -162,7 +163,7 @@ export class HaMap extends ReactiveElement { this._loaded = true; } - public fitMap(): void { + public fitMap(options?: { zoom?: number; pad?: number }): void { if (!this.leafletMap || !this.Leaflet || !this.hass) { return; } @@ -173,7 +174,7 @@ export class HaMap extends ReactiveElement { this.hass.config.latitude, this.hass.config.longitude ), - this.zoom + options?.zoom || this.zoom ); return; } @@ -196,11 +197,22 @@ export class HaMap extends ReactiveElement { ); }); - if (!this.layers) { - bounds = bounds.pad(0.5); - } + bounds = bounds.pad(options?.pad ?? 0.5); - this.leafletMap.fitBounds(bounds, { maxZoom: this.zoom }); + this.leafletMap.fitBounds(bounds, { maxZoom: options?.zoom || this.zoom }); + } + + public fitBounds( + boundingbox: LatLngExpression[], + options?: { zoom?: number; pad?: number } + ) { + if (!this.leafletMap || !this.Leaflet || !this.hass) { + return; + } + const bounds = this.Leaflet.latLngBounds(boundingbox).pad( + options?.pad ?? 0.5 + ); + this.leafletMap.fitBounds(bounds, { maxZoom: options?.zoom || this.zoom }); } private _drawLayers(prevLayers: Layer[] | undefined): void { diff --git a/src/data/currency.ts b/src/data/currency.ts new file mode 100644 index 0000000000..a263641615 --- /dev/null +++ b/src/data/currency.ts @@ -0,0 +1,254 @@ +// From http://country.io/currency.json + +export const countryCurrency = { + BD: "BDT", + BE: "EUR", + BF: "XOF", + BG: "BGN", + BA: "BAM", + BB: "BBD", + WF: "XPF", + BL: "EUR", + BM: "BMD", + BN: "BND", + BO: "BOB", + BH: "BHD", + BI: "BIF", + BJ: "XOF", + BT: "BTN", + JM: "JMD", + BV: "NOK", + BW: "BWP", + WS: "WST", + BQ: "USD", + BR: "BRL", + BS: "BSD", + JE: "GBP", + BY: "BYN", + BZ: "BZD", + RU: "RUB", + RW: "RWF", + RS: "RSD", + TL: "USD", + RE: "EUR", + TM: "TMT", + TJ: "TJS", + RO: "RON", + TK: "NZD", + GW: "XOF", + GU: "USD", + GT: "GTQ", + GS: "GBP", + GR: "EUR", + GQ: "XAF", + GP: "EUR", + JP: "JPY", + GY: "GYD", + GG: "GBP", + GF: "EUR", + GE: "GEL", + GD: "XCD", + GB: "GBP", + GA: "XAF", + SV: "USD", + GN: "GNF", + GM: "GMD", + GL: "DKK", + GI: "GIP", + GH: "GHS", + OM: "OMR", + TN: "TND", + JO: "JOD", + HR: "EUR", + HT: "HTG", + HU: "HUF", + HK: "HKD", + HN: "HNL", + HM: "AUD", + VE: "VEF", + PR: "USD", + PS: "ILS", + PW: "USD", + PT: "EUR", + SJ: "NOK", + PY: "PYG", + IQ: "IQD", + PA: "PAB", + PF: "XPF", + PG: "PGK", + PE: "PEN", + PK: "PKR", + PH: "PHP", + PN: "NZD", + PL: "PLN", + PM: "EUR", + ZM: "ZMK", + EH: "MAD", + EE: "EUR", + EG: "EGP", + ZA: "ZAR", + EC: "USD", + IT: "EUR", + VN: "VND", + SB: "SBD", + ET: "ETB", + SO: "SOS", + ZW: "ZWL", + SA: "SAR", + ES: "EUR", + ER: "ERN", + ME: "EUR", + MD: "MDL", + MG: "MGA", + MF: "EUR", + MA: "MAD", + MC: "EUR", + UZ: "UZS", + MM: "MMK", + ML: "XOF", + MO: "MOP", + MN: "MNT", + MH: "USD", + MK: "MKD", + MU: "MUR", + MT: "EUR", + MW: "MWK", + MV: "MVR", + MQ: "EUR", + MP: "USD", + MS: "XCD", + MR: "MRO", + IM: "GBP", + UG: "UGX", + TZ: "TZS", + MY: "MYR", + MX: "MXN", + IL: "ILS", + FR: "EUR", + IO: "USD", + SH: "SHP", + FI: "EUR", + FJ: "FJD", + FK: "FKP", + FM: "USD", + FO: "DKK", + NI: "NIO", + NL: "EUR", + NO: "NOK", + NA: "NAD", + VU: "VUV", + NC: "XPF", + NE: "XOF", + NF: "AUD", + NG: "NGN", + NZ: "NZD", + NP: "NPR", + NR: "AUD", + NU: "NZD", + CK: "NZD", + XK: "EUR", + CI: "XOF", + CH: "CHF", + CO: "COP", + CN: "CNY", + CM: "XAF", + CL: "CLP", + CC: "AUD", + CA: "CAD", + CG: "XAF", + CF: "XAF", + CD: "CDF", + CZ: "CZK", + CY: "EUR", + CX: "AUD", + CR: "CRC", + CW: "ANG", + CV: "CVE", + CU: "CUP", + SZ: "SZL", + SY: "SYP", + SX: "ANG", + KG: "KGS", + KE: "KES", + SS: "SSP", + SR: "SRD", + KI: "AUD", + KH: "KHR", + KN: "XCD", + KM: "KMF", + ST: "STD", + SK: "EUR", + KR: "KRW", + SI: "EUR", + KP: "KPW", + KW: "KWD", + SN: "XOF", + SM: "EUR", + SL: "SLL", + SC: "SCR", + KZ: "KZT", + KY: "KYD", + SG: "SGD", + SE: "SEK", + SD: "SDG", + DO: "DOP", + DM: "XCD", + DJ: "DJF", + DK: "DKK", + VG: "USD", + DE: "EUR", + YE: "YER", + DZ: "DZD", + US: "USD", + UY: "UYU", + YT: "EUR", + UM: "USD", + LB: "LBP", + LC: "XCD", + LA: "LAK", + TV: "AUD", + TW: "TWD", + TT: "TTD", + TR: "TRY", + LK: "LKR", + LI: "CHF", + LV: "EUR", + TO: "TOP", + LT: "EUR", + LU: "EUR", + LR: "LRD", + LS: "LSL", + TH: "THB", + TF: "EUR", + TG: "XOF", + TD: "XAF", + TC: "USD", + LY: "LYD", + VA: "EUR", + VC: "XCD", + AE: "AED", + AD: "EUR", + AG: "XCD", + AF: "AFN", + AI: "XCD", + VI: "USD", + IS: "ISK", + IR: "IRR", + AM: "AMD", + AL: "ALL", + AO: "AOA", + AQ: "", + AS: "USD", + AR: "ARS", + AU: "AUD", + AT: "EUR", + AW: "AWG", + IN: "INR", + AX: "EUR", + AZ: "AZN", + IE: "EUR", + ID: "IDR", + UA: "UAH", + QA: "QAR", + MZ: "MZN", +}; diff --git a/src/data/openstreetmap.ts b/src/data/openstreetmap.ts new file mode 100644 index 0000000000..3428d46f74 --- /dev/null +++ b/src/data/openstreetmap.ts @@ -0,0 +1,69 @@ +import { HomeAssistant } from "../types"; + +export interface OpenStreetMapPlace { + place_id: number; + licence: string; + osm_type: string; + osm_id: number; + lat: string; + lon: string; + place_rank: number; + category: string; + type: string; + importance: number; + addresstype: string; + name: string | null; + display_name: string; + address: { + house_number?: string; + road?: string; + neighbourhood?: string; + city?: string; + municipality?: string; + state?: string; + country?: string; + postcode?: string; + country_code: string; + [key: string]: string | undefined; + }; + boundingbox: number[]; +} + +export const searchPlaces = ( + address: string, + hass: HomeAssistant, + addressdetails?: boolean, + limit?: number +): Promise => + fetch( + `https://nominatim.openstreetmap.org/search.php?q=${address}&format=jsonv2${ + limit ? `&limit=${limit}` : "" + }${addressdetails ? "&addressdetails=1" : ""}&accept-language=${ + hass.locale.language + }&email=abuse@home-assistant.io`, + { headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } } + ).then((res) => { + if (res.ok) { + return res.json(); + } + throw new Error(res.statusText); + }); + +export const reverseGeocode = ( + location: [number, number], + hass: HomeAssistant, + zoom?: number +): Promise => + fetch( + `https://nominatim.openstreetmap.org/reverse.php?lat=${location[0]}&lon=${ + location[1] + }&accept-language=${hass.locale.language}&zoom=${ + zoom ?? 18 + }&format=jsonv2&email=abuse@home-assistant.io`, + { headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } } + ).then((res) => { + if (res.ok) { + return res.json(); + } + throw new Error(res.statusText); + }); diff --git a/src/html/onboarding.html.template b/src/html/onboarding.html.template index 7e34e0bc7a..1824f4727b 100644 --- a/src/html/onboarding.html.template +++ b/src/html/onboarding.html.template @@ -11,13 +11,14 @@ } body { height: auto; + padding: 64px 0; } .content { box-sizing: border-box; padding: 20px 16px; border-radius: var(--ha-card-border-radius, 12px); max-width: 432px; - margin: 64px auto 0; + margin: 0 auto; box-shadow: var( --ha-card-box-shadow, rgba(0, 0, 0, 0.25) 0px 54px 55px, diff --git a/src/onboarding/onboarding-analytics.ts b/src/onboarding/onboarding-analytics.ts index 09c19a6f98..aeddcb4cc7 100644 --- a/src/onboarding/onboarding-analytics.ts +++ b/src/onboarding/onboarding-analytics.ts @@ -82,10 +82,13 @@ class OnboardingAnalytics extends LitElement { static get styles(): CSSResultGroup { return css` + p { + font-size: 14px; + line-height: 20px; + } .error { color: var(--error-color); } - .footer { margin-top: 16px; display: flex; @@ -93,7 +96,6 @@ class OnboardingAnalytics extends LitElement { align-items: center; flex-direction: row-reverse; } - a { color: var(--primary-color); } diff --git a/src/onboarding/onboarding-core-config.ts b/src/onboarding/onboarding-core-config.ts index 46621294f4..3ea32cd556 100644 --- a/src/onboarding/onboarding-core-config.ts +++ b/src/onboarding/onboarding-core-config.ts @@ -5,10 +5,10 @@ import { html, LitElement, nothing, + PropertyValues, TemplateResult, } from "lit"; -import { customElement, property, query, state } from "lit/decorators"; -import memoizeOne from "memoize-one"; +import { customElement, property, state } from "lit/decorators"; import { fireEvent } from "../common/dom/fire_event"; import type { LocalizeFunc } from "../common/translations/localize"; import "../components/ha-alert"; @@ -22,22 +22,13 @@ import "../components/ha-textfield"; import type { HaTextField } from "../components/ha-textfield"; import "../components/ha-timezone-picker"; import "../components/map/ha-locations-editor"; -import type { - HaLocationsEditor, - MarkerLocation, -} from "../components/map/ha-locations-editor"; -import { - ConfigUpdateValues, - detectCoreConfig, - saveCoreConfig, -} from "../data/core"; +import { ConfigUpdateValues, saveCoreConfig } from "../data/core"; +import { countryCurrency } from "../data/currency"; import { onboardCoreConfigStep } from "../data/onboarding"; import type { HomeAssistant, ValueChangedEvent } from "../types"; import { getLocalLanguage } from "../util/common-translation"; - -const amsterdam: [number, number] = [52.3731339, 4.8903147]; -const mql = matchMedia("(prefers-color-scheme: dark)"); -const locationMarkerId = "location"; +import "./onboarding-location"; +import "./onboarding-name"; @customElement("onboarding-core-config") class OnboardingCoreConfig extends LitElement { @@ -57,19 +48,29 @@ class OnboardingCoreConfig extends LitElement { @state() private _currency?: ConfigUpdateValues["currency"]; - @state() private _timeZone? = - Intl.DateTimeFormat?.().resolvedOptions?.().timeZone; + @state() private _timeZone?: ConfigUpdateValues["time_zone"]; - @state() private _language: ConfigUpdateValues["language"] = - getLocalLanguage(); + @state() private _language: ConfigUpdateValues["language"]; @state() private _country?: ConfigUpdateValues["country"]; @state() private _error?: string; - @query("ha-locations-editor", true) private map!: HaLocationsEditor; - protected render(): TemplateResult { + if (!this._name) { + return html``; + } + if (!this._location) { + return html``; + } return html` ${ this._error @@ -78,55 +79,11 @@ class OnboardingCoreConfig extends LitElement { }

- ${this.onboardingLocalize( - "ui.panel.page-onboarding.core-config.intro", - "name", - this.hass.user!.name - )} + ${this.onboardingLocalize( + "ui.panel.page-onboarding.core-config.intro_core_config" + )}

- - -
-

- ${this.onboardingLocalize( - "ui.panel.page-onboarding.core-config.intro_location" - )} -

- -
-
- ${this.onboardingLocalize( - "ui.panel.page-onboarding.core-config.intro_location_detect" - )} -
- - ${this.onboardingLocalize( - "ui.panel.page-onboarding.core-config.button_detect" - )} - -
-
- -
- -
-
this.renderRoot.querySelector("ha-textfield")!.focus(), - 100 - ); - this.addEventListener("keypress", (ev) => { - if (ev.key === "Enter") { + this.addEventListener("keyup", (ev) => { + if (this._location && ev.key === "Enter") { this._save(ev); } }); } - private get _nameValue() { - return this._name !== undefined - ? this._name - : this.onboardingLocalize( - "ui.panel.page-onboarding.core-config.location_name_default" - ); - } - - private get _locationValue() { - return this._location || amsterdam; - } - private get _elevationValue() { return this._elevation !== undefined ? this._elevation : 0; } @@ -324,17 +279,6 @@ class OnboardingCoreConfig extends LitElement { return this._currency !== undefined ? this._currency : ""; } - private _markerLocation = memoizeOne( - (location: [number, number]): MarkerLocation[] => [ - { - id: locationMarkerId, - latitude: location[0], - longitude: location[1], - location_editable: true, - }, - ] - ); - private _handleValueChanged(ev: ValueChangedEvent) { const target = ev.currentTarget as HTMLElement; this[`_${target.getAttribute("name")}`] = ev.detail.value; @@ -345,8 +289,25 @@ class OnboardingCoreConfig extends LitElement { this[`_${target.name}`] = target.value; } - private _locationChanged(ev) { - this._location = ev.detail.location; + private _nameChanged(ev: CustomEvent) { + this._name = ev.detail.value; + } + + private async _locationChanged(ev) { + this._location = ev.detail.value.location; + this._country = ev.detail.value.country; + this._elevation = ev.detail.value.elevation; + this._currency = ev.detail.value.currency; + this._language = ev.detail.value.language || getLocalLanguage(); + this._timeZone = + ev.detail.value.timezone || + Intl.DateTimeFormat?.().resolvedOptions?.().timeZone; + this._unitSystem = ev.detail.value.unit_system; + await this.updateComplete; + setTimeout( + () => this.renderRoot.querySelector("ha-textfield")!.focus(), + 100 + ); } private _unitSystemChanged(ev: CustomEvent) { @@ -355,55 +316,17 @@ class OnboardingCoreConfig extends LitElement { | "us_customary"; } - private async _detect() { - this._working = true; - try { - const values = await detectCoreConfig(this.hass); - - if (values.latitude && values.longitude) { - this.map.addEventListener( - "markers-updated", - () => { - this.map.fitMarker(locationMarkerId); - }, - { - once: true, - } - ); - this._location = [Number(values.latitude), Number(values.longitude)]; - } - if (values.elevation) { - this._elevation = String(values.elevation); - } - if (values.unit_system) { - this._unitSystem = values.unit_system; - } - if (values.time_zone) { - this._timeZone = values.time_zone; - } - if (values.currency) { - this._currency = values.currency; - } - if (values.country) { - this._country = values.country; - } - this._language = getLocalLanguage(); - } catch (err: any) { - this._error = `Failed to detect location information: ${err.message}`; - } finally { - this._working = false; - } - } - private async _save(ev) { + if (!this._location) { + return; + } ev.preventDefault(); this._working = true; try { - const location = this._locationValue; await saveCoreConfig(this.hass, { - location_name: this._nameValue, - latitude: location[0], - longitude: location[1], + location_name: this._name, + latitude: this._location[0], + longitude: this._location[1], elevation: Number(this._elevationValue), unit_system: this._unitSystemValue, time_zone: this._timeZoneValue || "UTC", @@ -436,12 +359,13 @@ class OnboardingCoreConfig extends LitElement { color: var(--secondary-text-color); } - ha-textfield { - display: block; + p { + font-size: 14px; + line-height: 20px; } - ha-locations-editor { - height: 200px; + ha-textfield { + display: block; } .flex { diff --git a/src/onboarding/onboarding-integrations.ts b/src/onboarding/onboarding-integrations.ts index 56622a750f..c2c145359a 100644 --- a/src/onboarding/onboarding-integrations.ts +++ b/src/onboarding/onboarding-integrations.ts @@ -211,6 +211,10 @@ class OnboardingIntegrations extends SubscribeMixin(LitElement) { static get styles(): CSSResultGroup { return css` + p { + font-size: 14px; + line-height: 20px; + } .badges { margin-top: 24px; display: flex; diff --git a/src/onboarding/onboarding-location.ts b/src/onboarding/onboarding-location.ts new file mode 100644 index 0000000000..e75a64b100 --- /dev/null +++ b/src/onboarding/onboarding-location.ts @@ -0,0 +1,542 @@ +import "@material/mwc-button/mwc-button"; +import { mdiCrosshairsGps, mdiMapMarker, mdiMapSearchOutline } from "@mdi/js"; +import { + css, + CSSResultGroup, + html, + LitElement, + nothing, + TemplateResult, +} from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import type { LocalizeFunc } from "../common/translations/localize"; +import "../components/ha-alert"; +import "../components/ha-formfield"; +import "../components/ha-radio"; +import "../components/ha-textfield"; +import type { HaTextField } from "../components/ha-textfield"; +import "../components/map/ha-locations-editor"; +import type { + HaLocationsEditor, + MarkerLocation, +} from "../components/map/ha-locations-editor"; +import { ConfigUpdateValues, detectCoreConfig } from "../data/core"; +import { showConfirmationDialog } from "../dialogs/generic/show-dialog-box"; +import type { HomeAssistant } from "../types"; +import { fireEvent } from "../common/dom/fire_event"; +import { + OpenStreetMapPlace, + reverseGeocode, + searchPlaces, +} from "../data/openstreetmap"; + +const AMSTERDAM: [number, number] = [52.3731339, 4.8903147]; +const mql = matchMedia("(prefers-color-scheme: dark)"); +const LOCATION_MARKER_ID = "location"; + +@customElement("onboarding-location") +class OnboardingLocation extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public onboardingLocalize!: LocalizeFunc; + + @state() private _working = false; + + @state() private _location?: [number, number]; + + @state() private _places?: OpenStreetMapPlace[] | null; + + @state() private _error?: string; + + @state() private _search = false; + + @state() private _highlightedMarker?: number; + + private _elevation?: string; + + private _unitSystem?: ConfigUpdateValues["unit_system"]; + + private _currency?: ConfigUpdateValues["currency"]; + + private _timeZone?: ConfigUpdateValues["time_zone"]; + + private _country?: ConfigUpdateValues["country"]; + + @query("ha-locations-editor", true) private map!: HaLocationsEditor; + + protected render(): TemplateResult { + const addressAttribution = this.onboardingLocalize( + "ui.panel.page-onboarding.core-config.location_address", + { + openstreetmap: html`OpenStreetMap`, + osm_privacy_policy: html`${this.onboardingLocalize( + "ui.panel.page-onboarding.core-config.osm_privacy_policy" + )}`, + } + ); + + return html` + ${this._error + ? html`${this._error}` + : nothing} + +

+ ${this.onboardingLocalize( + "ui.panel.page-onboarding.core-config.intro_location" + )} +

+ + + ${this._working + ? html` + + ` + : html` + + `} + + ${this._places !== undefined + ? html` + + ${this._places?.length + ? this._places.map((place) => { + const primary = [ + place.name || place.address[place.category], + place.address.house_number, + place.address.road || place.address.waterway, + place.address.village || place.address.town, + place.address.suburb || place.address.subdivision, + place.address.city || place.address.municipality, + ] + .filter(Boolean) + .join(", "); + const secondary = [ + place.address.county || + place.address.state_district || + place.address.region, + place.address.state, + place.address.country, + ] + .filter(Boolean) + .join(", "); + return html` + ${primary || secondary} + ${primary ? secondary : ""} + `; + }) + : html`${this._places === null ? "" : "No results"}`} + + ` + : nothing} +

${addressAttribution}

+ + + + `; + } + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + setTimeout( + () => this.renderRoot.querySelector("ha-textfield")!.focus(), + 100 + ); + this.addEventListener("keyup", (ev) => { + if (ev.key === "Enter") { + this._save(ev); + } + }); + } + + protected updated(changedProps) { + if (changedProps.has("_highlightedMarker") && this._highlightedMarker) { + const place = this._places?.find( + (plc) => plc.place_id === this._highlightedMarker + ); + if (place?.boundingbox?.length === 4) { + this.map.fitBounds( + [ + [place.boundingbox[0], place.boundingbox[2]], + [place.boundingbox[1], place.boundingbox[3]], + ], + { zoom: 16, pad: 0 } + ); + } else { + this.map.fitMarker(String(this._highlightedMarker), { zoom: 16 }); + } + } + } + + private _markerLocations = memoizeOne( + ( + location?: [number, number], + places?: OpenStreetMapPlace[] | null, + highlightedMarker?: number + ): MarkerLocation[] => { + if (!places) { + return [ + { + id: LOCATION_MARKER_ID, + latitude: (location || AMSTERDAM)[0], + longitude: (location || AMSTERDAM)[1], + location_editable: true, + }, + ]; + } + return places?.length + ? places.map((place) => ({ + id: String(place.place_id), + iconPath: + place.place_id === highlightedMarker ? undefined : mdiMapMarker, + latitude: + location && place.place_id === highlightedMarker + ? location[0] + : Number(place.lat), + longitude: + location && place.place_id === highlightedMarker + ? location[1] + : Number(place.lon), + location_editable: place.place_id === highlightedMarker, + })) + : []; + } + ); + + private _locationChanged(ev) { + this._location = ev.detail.location; + if (ev.detail.id !== LOCATION_MARKER_ID) { + this._reverseGeocode(); + } + } + + private _markerClicked(ev) { + if (ev.detail.id === LOCATION_MARKER_ID) { + return; + } + this._highlightedMarker = ev.detail.id; + const place = this._places!.find((plc) => plc.place_id === ev.detail.id)!; + this._location = [Number(place.lat), Number(place.lon)]; + this._country = place.address.country_code.toUpperCase(); + } + + private _itemClicked(ev) { + this._highlightedMarker = ev.currentTarget.placeId; + const place = this._places!.find( + (plc) => plc.place_id === ev.currentTarget.placeId + )!; + this._location = [Number(place.lat), Number(place.lon)]; + this._country = place.address.country_code.toUpperCase(); + } + + private async _addressSearch(ev: KeyboardEvent) { + ev.stopPropagation(); + this._search = (ev.currentTarget as HaTextField).value.length > 0; + if (ev.key !== "Enter") { + return; + } + this._searchAddress((ev.currentTarget as HaTextField).value); + } + + private async _searchAddress(address: string) { + this._working = true; + this._location = undefined; + this._highlightedMarker = undefined; + this._error = undefined; + this._places = null; + this.map.addEventListener( + "markers-updated", + () => { + setTimeout(() => { + if ((this._places?.length || 0) > 2) { + this.map.fitMap({ pad: 0.5 }); + } + }, 500); + }, + { + once: true, + } + ); + try { + this._places = await searchPlaces(address, this.hass, true, 3); + if (this._places?.length === 1) { + this._highlightedMarker = this._places[0].place_id; + this._location = [ + Number(this._places[0].lat), + Number(this._places[0].lon), + ]; + this._country = this._places[0].address.country_code.toUpperCase(); + } + } catch (e: any) { + this._places = undefined; + this._error = e.message; + } finally { + this._working = false; + } + } + + private async _reverseGeocode() { + if (!this._location) { + return; + } + this._places = null; + const reverse = await reverseGeocode(this._location, this.hass); + this._country = reverse.address.country_code.toUpperCase(); + this._places = [reverse]; + this._highlightedMarker = reverse.place_id; + } + + private async _handleButtonClick(ev) { + if (this._search) { + this._searchAddress(ev.target.parentElement.value); + return; + } + this._detectLocation(); + } + + private _detectLocation() { + if (window.isSecureContext && navigator.geolocation) { + this._working = true; + const options = { + enableHighAccuracy: true, + timeout: 5000, + maximumAge: 0, + }; + navigator.geolocation.getCurrentPosition( + async (result) => { + this.map.addEventListener( + "markers-updated", + () => { + this.map.fitMarker(LOCATION_MARKER_ID); + }, + { + once: true, + } + ); + this._location = [result.coords.latitude, result.coords.longitude]; + if (result.coords.altitude) { + this._elevation = String(result.coords.altitude); + } + try { + await this._reverseGeocode(); + } finally { + this._working = false; + } + }, + () => { + // GPS is not available, get location based on IP + this._working = false; + this._whoAmI(); + }, + options + ); + } else { + this._whoAmI(); + } + } + + private async _whoAmI() { + const confirm = await showConfirmationDialog(this, { + title: this.onboardingLocalize( + "ui.panel.page-onboarding.core-config.title_location_detect" + ), + text: this.onboardingLocalize( + "ui.panel.page-onboarding.core-config.intro_location_detect" + ), + }); + if (!confirm) { + return; + } + this._working = true; + try { + const values = await detectCoreConfig(this.hass); + + if (values.latitude && values.longitude) { + this.map.addEventListener( + "markers-updated", + () => { + this.map.fitMarker(LOCATION_MARKER_ID); + }, + { + once: true, + } + ); + this._location = [Number(values.latitude), Number(values.longitude)]; + } + if (values.elevation) { + this._elevation = String(values.elevation); + } + if (values.unit_system) { + this._unitSystem = values.unit_system; + } + if (values.time_zone) { + this._timeZone = values.time_zone; + } + if (values.currency) { + this._currency = values.currency; + } + if (values.country) { + this._country = values.country; + } + } catch (err: any) { + this._error = `Failed to detect location information: ${err.message}`; + } finally { + this._working = false; + } + } + + private async _save(ev) { + if (!this._location) { + return; + } + ev.preventDefault(); + fireEvent(this, "value-changed", { + value: { + location: this._location!, + country: this._country, + elevation: this._elevation, + unit_system: this._unitSystem, + time_zone: this._timeZone, + currency: this._currency, + }, + }); + } + + static get styles(): CSSResultGroup { + return css` + p { + font-size: 14px; + line-height: 20px; + } + ha-textfield { + display: block; + } + ha-textfield > ha-icon-button { + position: absolute; + top: 10px; + right: 10px; + --mdc-icon-button-size: 36px; + --mdc-icon-size: 20px; + color: var(--secondary-text-color); + inset-inline-start: initial; + inset-inline-end: 10px; + direction: var(--direction); + } + ha-textfield > ha-circular-progress { + position: relative; + left: 12px; + } + ha-locations-editor { + display: block; + height: 300px; + margin-top: 8px; + border-radius: var(--mdc-shape-small, 4px); + overflow: hidden; + } + mwc-list { + width: 100%; + border: 1px solid var(--divider-color); + box-sizing: border-box; + border-top-width: 0; + border-bottom-left-radius: var(--mdc-shape-small, 4px); + border-bottom-right-radius: var(--mdc-shape-small, 4px); + --mdc-list-vertical-padding: 0; + } + ha-list-item { + height: 72px; + } + .footer { + margin-top: 16px; + text-align: right; + } + .attribution { + /* textfield helper style */ + margin: 0; + padding: 4px 16px 12px 16px; + color: var(--mdc-text-field-label-ink-color, rgba(0, 0, 0, 0.6)); + font-family: var( + --mdc-typography-caption-font-family, + var(--mdc-typography-font-family, Roboto, sans-serif) + ); + font-size: var(--mdc-typography-caption-font-size, 0.75rem); + font-weight: var(--mdc-typography-caption-font-weight, 400); + letter-spacing: var( + --mdc-typography-caption-letter-spacing, + 0.0333333333em + ); + text-decoration: var(--mdc-typography-caption-text-decoration, inherit); + text-transform: var(--mdc-typography-caption-text-transform, inherit); + } + .attribution a { + color: inherit; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "onboarding-location": OnboardingLocation; + } +} diff --git a/src/onboarding/onboarding-name.ts b/src/onboarding/onboarding-name.ts new file mode 100644 index 0000000000..6d8d023137 --- /dev/null +++ b/src/onboarding/onboarding-name.ts @@ -0,0 +1,111 @@ +import "@material/mwc-button/mwc-button"; +import { CSSResultGroup, LitElement, TemplateResult, css, html } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../common/dom/fire_event"; +import type { LocalizeFunc } from "../common/translations/localize"; +import "../components/ha-alert"; +import "../components/ha-formfield"; +import "../components/ha-radio"; +import "../components/ha-textfield"; +import "../components/map/ha-locations-editor"; +import { ConfigUpdateValues } from "../data/core"; +import type { HomeAssistant } from "../types"; + +@customElement("onboarding-name") +class OnboardingName extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public onboardingLocalize!: LocalizeFunc; + + private _name?: ConfigUpdateValues["location_name"]; + + protected render(): TemplateResult { + return html` +

+ ${this.onboardingLocalize( + "ui.panel.page-onboarding.core-config.intro", + { name: this.hass.user!.name } + )} +

+ + + +

+ ${this.onboardingLocalize( + "ui.panel.page-onboarding.core-config.intro_core" + )} +

+ + + `; + } + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + setTimeout( + () => this.renderRoot.querySelector("ha-textfield")!.focus(), + 100 + ); + this.addEventListener("keyup", (ev) => { + if (ev.key === "Enter") { + this._save(ev); + } + }); + } + + private get _nameValue() { + return this._name !== undefined + ? this._name + : this.onboardingLocalize( + "ui.panel.page-onboarding.core-config.location_name_default" + ); + } + + private _nameChanged(ev) { + this._name = ev.target.value; + } + + private async _save(ev) { + ev.preventDefault(); + fireEvent(this, "value-changed", { + value: this._nameValue, + }); + } + + static get styles(): CSSResultGroup { + return css` + ha-textfield { + display: block; + } + p { + font-size: 14px; + line-height: 20px; + } + .footer { + margin-top: 16px; + text-align: right; + } + a { + color: var(--primary-color); + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "onboarding-name": OnboardingName; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index d7673cff1e..04ba3cae2e 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -5589,10 +5589,16 @@ }, "core-config": { "intro": "Hello {name}, welcome to Home Assistant. How would you like to name your home?", - "intro_location": "We would like to know where you live. This information will help with displaying information and setting up sun-based automations. This data is never shared outside of your network.", - "intro_location_detect": "We can help you fill in this information by making a one-time request to an external service.", + "intro_core": "We will set up the basics together. You can always change this later in the settings.", + "intro_location": "Let's set up the location of your home so that you can display information such as the local weather and use sun-based or presence-based automations. This data is never shared outside of your network.", + "location_address": "Powered by {openstreetmap} ({osm_privacy_policy}).", + "osm_privacy_policy": "Privacy policy", + "title_location_detect": "Do you want us to detect your location?", + "intro_location_detect": "We can detect your location by making a one-time request to an external service.", + "intro_core_config": "We filled out some details about your location. Please check if they are correct and continue.", "location_name": "Name of your Home Assistant installation", "location_name_default": "Home", + "address_label": "Search address", "button_detect": "Detect", "finish": "Next" },