HA Widgets — Eigene Home-Assistant-Apps mit Vue 3 (Teil 7: Phone und Desktop)

- Veröffentlicht unter Makerspace von

Hinweis: Der Beitrag wurde mit KI-Unterstützung (Claude Code) verfasst und manuell nachbearbeitet.

HA Widgets — alle Teile: Teil 1: Überblick · Teil 2: Wie fängt man an? · Teil 3: Architektur · Teil 4: Architektur-Checks · Teil 5: HA-Infrastruktur · Teil 6: LCARS-Design · Teil 7: Phone und Desktop · Teil 8: Claude Code richtig briefen

Teil 6 hat sich komplett mit der Tablet-App beschäftigt — LCARS, CSS-Tricks, wiederverwendbare Komponenten. Jetzt die anderen beiden: die Handy-App für unterwegs und die Desktop-App als Verwaltungs-Interface.


Handy: eine andere Ästhetik

Das Tablet hat einen eigenwilligen Star-Trek-Stil. Das Handy nicht — es soll sich wie eine normale Android-App anfühlen. Material 3 Dark war die naheliegende Wahl: das aktuelle Android-Designsystem, dunkel, vertraut.

Das CSS-System der Handy-App orientiert sich an Material-3-Tokens. Keine Bibliothek — einfach CSS-Variablen, die die Semantik abbilden:

:root {
  --md-bg:          #121212;
  --md-surface:     #1E1E1E;
  --md-surface-2:   #242424;
  --md-primary:     #82B1FF;
  --md-on-bg:       #E1E1E1;
  --md-secondary:   #9E9E9E;
  --md-error:       #CF6679;
  --md-success:     #66BB6A;
  --md-divider:     #2E2E2E;
}

Dazu semantische Aliasse — --success, --warning, --error, --accent — damit Composables aus shared/ (die beide Apps verwenden) nicht wissen müssen ob sie auf Material oder LCARS laufen.


Bottom Navigation

Drei Tabs: Übersicht, Vorräte, Kochen. Sidebar gibt es nicht — auf dem Handy wäre sie falsch platziert.

const tabs: Tab[] = [
  { path: '/uebersicht', label: 'Übersicht', icon: ICON_HOME   },
  { path: '/einkaufen',  label: 'Vorräte',   icon: ICON_VORRAT },
  { path: '/rezepte',    label: 'Kochen',    icon: ICON_FORK   },
]

Die SVG-Icons sind als Path-Daten direkt in App.vue inline — keine Icon-Bibliothek, kein zusätzliches Bundle. Drei Strings, keine Dependency.

<keep-alive include="ViewRezepte"> erhält die Rezeptbuch-Instanz beim Tab-Wechsel: wer ein Rezept aufklappt, dann kurz die Vorräte checkt, findet das Rezept noch offen vor.

Handy-App Bottom Navigation mit 3 Tabs


Touch-Feinheiten

Drei CSS-Details, die auf dem Handy spürbar sind:

Scrollbar ausblenden. Auf einem Touchscreen ist keine Scrollbar nötig — sie würde nur Platz wegnehmen.

* { scrollbar-width: none; }
*::-webkit-scrollbar { display: none; }

Global in style.css. Funktioniert in Firefox und Webkit.

Tap-Highlight unterdrücken. Android zeigt beim Antippen von Buttons standardmäßig einen grauem Schimmer. Bei Custom-Buttons stört das:

-webkit-tap-highlight-color: transparent;

Steht auf allen .md-btn, .md-toggle, .md-chip — die Farbgebung beim Antippen kommt dann explizit über :active { opacity: 0.7 }.

Number-Inputs ohne Spinner. Browser zeigen bei <input type="number"> Auf/Ab-Pfeile. Im Layout sehen die schief aus:

.md-input[type=number]::-webkit-inner-spin-button,
.md-input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; }
.md-input[type=number] { -moz-appearance: textfield; }

md-toggle: das Haupt-Button-Muster

Auf dem Handy gibt es viele Toggles — Lichter, Schalter, Aktionen. Alle sehen gleich aus:

.md-toggle {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 11px 14px;
  border-radius: 10px;
  background: var(--md-surface-2);
  width: 100%;
}
.md-toggle.on { background: var(--md-primary-dim); color: var(--md-primary); }

Rechts im Button ein kleiner LED-Dot:

<button class="md-toggle" :class="{ on: lightOn }" @click="toggle(E.lights.wohnzimmer)">
  <span>Wohnzimmer</span>
  <div class="md-led" :class="{ on: lightOn }"></div>
</button>

Der LED-Dot (9×9px, Vollkreis) leuchtet bei on mit box-shadow in der Akzentfarbe — minimales visuelles Feedback, kein Icon-Swap.


PC-Button: Tap und Hold

Der PC-Button hat zwei Funktionen: kurz tippen schaltet den Switch, lang halten (600 ms) bricht ein laufendes Herunterfahren ab. Das steckt im shared Composable usePCVater.

function startHold(): void {
  holdTimer = setTimeout(() => {
    runScript(E.scripts.pcAbbrechen)
    holdTimer = null
  }, 600)
}
function cancelHold(): void {
  if (holdTimer) { clearTimeout(holdTimer); holdTimer = null }
}

Im Template:

<button class="md-toggle"
  @click="togglePC"
  @pointerdown="startHold"
  @pointerup="cancelHold"
  @pointerleave="cancelHold">

@pointerdown/@pointerup statt Touch-Events — funktioniert auf Touch und Mouse gleich, kein preventDefault-Konflikt.

Außerdem gibt es einen pcPending-State: nach dem Klick dreht ein kleiner Spinner im Button, bis HA die Zustandsänderung zurückmeldet. Kein Timeout-Blindwait — ein watch auf den Entity-State löst pcPending = false aus sobald sich etwas ändert:

watch(() => getState(E.switches.pcVater), (newVal) => {
  if (pcPending.value && newVal !== pcStateAtClick) {
    pcPending.value = false
  }
})

Standorte via Nominatim

Die Übersicht zeigt wo sich Personen gerade befinden. Die HA Companion-App sendet GPS-Koordinaten; daraus soll eine lesbare Adresse werden.

usePersonLocation watcht Koordinaten-Attribute der Person-Entities und ruft bei Änderung den Nominatim Reverse-Geocoding-Endpunkt auf:

const res = await fetch(
  `${NOMINATIM_REVERSE_URL}?format=json&lat=${lat}&lon=${lon}&accept-language=de`
)

Die URL steht in shared/config/apiConfig.ts — nicht im Composable.

Drei Fälle: - State home → direkt "Zuhause", kein API-Aufruf - State ist ein bekannter Zone-Name (z.B. "Schule") → direkt anzeigen - not_home mit Koordinaten → Geocoding

Damit das nicht bei jedem GPS-Update eine API-Anfrage auslöst, gibt es einen Modul-Level-Cache:

const geocodeCache = new Map<string, string>()

const key = `${lat.toFixed(3)},${lon.toFixed(3)}`
if (geocodeCache.has(key)) return geocodeCache.get(key)!

Koordinaten werden auf drei Dezimalstellen gerundet — das entspricht ungefähr 100 Meter. Feinere Änderungen lösen keinen neuen Geocoding-Aufruf aus.


Desktop: Verwaltung am großen Bildschirm

Das Tablet bedient, das Handy schaut nach — der Desktop verwaltet. Rezepte anlegen und bearbeiten, Lagerkatalog pflegen, Artikel verschieben. Das sind Aufgaben für Maus und großes Display, nicht für einen Touchscreen.

Die Desktop-App hat einen Hash-Router mit fünf Views:

Route View Funktion
/ Dashboard Übersicht SmartHome, Wetter, Gauges
/lager Lager Vorrats-Kategorien + Einkaufsliste
/katalog Katalog Alle Artikel als sortierbare Tabelle
/smarthome SmartHome Lichter, Heizung, Geräte
/rezepte Rezeptbuch Rezeptliste, Editor, KI-Rezept

Die Topbar zeigt Breadcrumb-Navigation: auf der Startseite "Smart|Home", auf allen anderen Views "← Übersicht / Seitenname". Der meta.title der Route bestimmt den angezeigten Namen.

Desktop-App Dashboard

Theme-Toggle

Die Desktop-App hat einen Light/Dark-Toggle in der Topbar. Der Mechanismus ist simpel:

function toggleTheme(): void {
  isDark.value = !isDark.value
  document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
}

CSS-Variablen werden im Light-Modus mit [data-theme=light] überschrieben. Default ist dunkel — weil das Entwickler so wollen. 😅


Rezept-Editor

Der Rezepteditor öffnet sich als Modal über BaseModal. Titel, Hauptkategorie, Filter-Tags, Beschreibung, URL, Zutaten, Schritte — alles bearbeitbar.

Der interessanteste Teil: der -Button für die kcal-Berechnung.

async function berechneKcal() {
  const km = Object.keys(kcalMap.value).length
    ? kcalMap.value
    : getKcalMap(await loadKatalog())
  let total = 0, treffer = 0
  for (const z of form.zutaten) {
    if (!z.name.trim()) continue
    const info = km[z.name.trim().toLowerCase()]
    const beitrag = kalkuliereKcal(z.menge, info)
    if (beitrag === null) continue
    total += beitrag
    treffer++
  }
  if (treffer > 0) form.kcal = Math.round(total / (form.portionen || 2))
}

kalkuliereKcal() kommt aus shared/utils/kcalUtils.ts und wertet den Mengeneintrag ("200g", "3 Stück", "100ml") gegen den Nährwert im Katalog aus. Pro Zutat ein Beitrag, Summe durch Portionen — Näherungswert, kein Labor.

Der Wert kann überschrieben und mit ! suffixiert werden ("540!") — das markiert ihn als manuell fixiert und kalkuliereKcal überspringt ihn beim nächsten Aufruf.

Ein weiterer Button im Detail: "Auf Tablet anzeigen". Er sendet ein IPC-Event und weckt gleichzeitig den Tablet-Bildschirm:

function zeigeAufTablet(slug: string, portionen?: number): void {
  ipcSend('navigate_rezept', { slug, portionen })
  callAction('notify', E.notify.tablet, { message: 'command_screen_on' })
}

Das Tablet empfängt navigate_rezept in seiner App.vue — unabhängig von der gerade aktiven View — und navigiert direkt zum Rezept. Beschrieben in Teil 6.

Desktop-App Rezept-Editor

Lagerverwaltung: Drag&Drop

Das Lager zeigt alle Vorratskategorien als Karten, jeweils mit den enthaltenen Artikeln. Artikel können per Drag&Drop zwischen Kategorien verschoben werden.

Die Logik steckt in useLagerDragDrop. Standardmäßiges HTML5-Drag-API:

function onDragStart(itemName: string): void {
  dragItem.value = itemName
  setTimeout(() => { isDragging.value = true }, 0)
}

async function onDrop(targetName: string): Promise<void> {
  const name = dragItem.value
  if (!name) return
  // Item aus der alten Kategorie entfernen, in die neue eintragen
  const newKat = JSON.parse(JSON.stringify(katalog.value)) as VorratKatalog
  const katEntry = Object.entries(newKat).find(([, k]) => name in (k.items ?? {}))
  if (!katEntry) return
  const [currentKat] = katEntry
  const meta = newKat[currentKat].items[name]
  delete newKat[currentKat].items[name]
  newKat[targetName].items[name] = meta
  katalog.value = newKat
  await saveKatalog(newKat)
}

Das setTimeout(() => isDragging.value = true, 0) im onDragStart ist ein kleines Detail: der isDragging-State setzt eine CSS-Klasse .drag-mode auf dem Grid, die alle Karten leicht abdunkelt außer der Drop-Target-Karte. Würde man das synchron setzen, könnte der Browser die initiale Ghost-Darstellung falsch berechnen — ein Frame Verzögerung verhindert das.

Es gibt zwei Ansichten: Kategorie-View (Standardsicht) und Abteilungs-View. In der Abteilungs-View sind Artikel nach Einkaufsgängen (Kühlregal, Gemüseabteilung, …) gruppiert — ein Drag verschiebt dort nicht die Kategorie, sondern setzt das abteilung-Feld am Item.


ViewKatalog: die Tabellen-Ansicht

Direkt neben der Lager-View gibt es den Katalog — eine sortierbare Tabelle aller Vorrats-Artikel mit Name, Kategorie, kcal/Einheit, Einheit und Flags.

Jede Spalte ist klickbar für Sortierung. Inline-Editing direkt in der Tabelle: ein Klick auf die Aktions-Schaltfläche versetzt die Zeile in den Edit-Modus, alle Felder werden zu Eingabefeldern. Speichern schreibt per wsCommand zurück auf den HA-Server.

function toggleSort(key: string): void {
  if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
  else { sortKey.value = key; sortDir.value = 'asc' }
}

useKatalogTable in der Desktop-eigenen composables/ kapselt Sortier- und Filterzustand — nicht in shared/, weil nur der Desktop diese Ansicht hat.


GaugeRing: eine Prop, zwei Verwendungsarten

GaugeRing.vue aus shared/components zeigt einen SVG-Kreis-Gauge mit Wert und Label. Es gibt eine optionale size-Prop:

defineProps<{
  val: string
  pct: number
  color: string
  label?: string
  size?: number  // px — wenn nicht gesetzt: responsive (100% des Containers)
}>()

Ohne size füllt der Gauge seinen Container aus — gut für das Handy, wo die Gauges in einem Flex-Grid gleichmäßig verteilt sind. Mit size bekommt der Gauge eine feste Pixelgröße — gut für den Desktop, wo mehrere Gauges nebeneinander im Dashboard stehen und nicht auf Containerbreite reagieren sollen.

   <GaugeRing v-bind="g" />

   <GaugeRing v-bind="g" :size="48" />

Gleiche Komponente, dasselbe Template, eine Prop steuert das Verhalten.


rangeColor: Schwellenwerte die in beide Richtungen funktionieren

Der naheliegende Ansatz für Farb-Schwellenwerte: "über 80% ist schlecht, über 60% ist eine Warnung". Das stimmt für CPU-Auslastung — aber nicht für Luftfeuchtigkeit (zu trocken ist auch schlecht) oder Filterlebensdauer (niedrig ist schlecht).

rangeColor() aus shared/utils/systemUtils.ts löst das mit expliziten Bereichen:

rangeColor(value, [
  { from: 0, to: 12, color: 'var(--success)' },
  { from: 13, to: 35, color: 'var(--warning)' },
], 'var(--error)')   // Fallback: über 35 → Fehler

Für den Luftreiniger-Filter (niedrig = schlecht):

rangeColor(filterReplace, [
  { from: 0,  to: 20, color: 'var(--error)'   },
  { from: 21, to: 50, color: 'var(--warning)'  },
], 'var(--success)')  // Fallback: über 50 → gut

Dasselbe Schema, andere Bereiche, andere Fallback-Farbe. Kein if/else-Wald in den Views.


useVorratEinkaufBase: nachträgliche Konsolidierung

In Teil 4 hat jscpd Template-Klone aufgedeckt. TypeScript-Logik-Klone findet es nicht — die mussten manuell auffallen.

An drei Stellen gab es fast identische init()-Funktionen: ViewLager (Desktop), ViewEinkauf (Phone), ViewEinkauf (Tablet). Alle drei luden nacheinander Einkaufsliste, Vorratskatalog und Vorrat-State — immer in derselben Reihenfolge, immer mit denselben try/finally-Blöcken.

Das Ergebnis: useVorratEinkaufBase in shared/composables/data/. Alle drei Views rufen jetzt einfach init() auf und bekommen katalog, inVorrat, loading und die ganzen Lade-/Speicher-Funktionen zurück. Die eigentliche Logik existiert genau einmal.

Das ist kein Refactoring das jscpd erzwungen hätte — aber nach dem Muster aus Teil 4 war die Frage "haben wir das schon dreimal?" leichter zu stellen.


Ein neues Feature als Beispiel: useAirPurifier

Irgendwann kam ein Luftreiniger hinzu. Typischer Fall: neue Hardware, in alle drei Apps integrieren.

Der Weg war: Composable in shared/composables/ui/useAirPurifier.ts schreiben, dann in drei Views einbauen. Das Composable liest fünf Entities (Fan-State, Lüftergeschwindigkeit, PM2.5-Wert, Allergen-Index, Filterzustand), berechnet GaugeConfig-Objekte für Lüfter, PM2.5 und Allergen-Gauge — und gibt Steuer-Funktionen zurück:

const { airGauges, turnOn, turnOff, setMode } = useAirPurifier()

function airTurboEin(): void { setMode('Turbo'); turnOn() }
function airAus():      void { turnOff() }
function airAutoEin():  void { setMode('Auto');  turnOn() }

Die Views bekommen fertige GaugeConfig[] und Buttons — keine Entity-IDs, keine Logik. Die Farben kommen über rangeColor():

const pm25Gauge = computed((): GaugeConfig => ({
  label: 'PM2.5',
  val:   pm25.value != null ? String(pm25.value) : '--',
  pct:   (pm25.value ?? 0) / 75,
  color: rangeColor(pm25.value ?? 0, [
    { from: 0, to: 12, color: 'var(--success)' },
    { from: 13, to: 35, color: 'var(--warning)' },
  ], 'var(--error)'),
}))

Drei Apps, ein Composable, keine doppelte Entity-ID. Das ist shared-first in der Praxis.


Wie geht es weiter?

Teil 8 schaut auf Claude Code selbst — wie man ein Projekt mit dem Tool startet, was in eine CLAUDE.md gehört, und wie der Pair-Programming-Ansatz in der Praxis aussieht.


Fazit

  • Material 3 Dark: CSS-Variablen spiegeln Material-Tokens; semantische Aliasse (--accent, --error, …) erlauben shared/-Composables ohne Design-Kenntnis
  • Bottom Navigation: 3 Tabs, SVG-Icons als Inline-Path-Data, keep-alive nur für die Rezept-View
  • Touch-Feinheiten: Scrollbar global ausgeblendet, tap-highlight-color: transparent, Number-Inputs ohne Spinner
  • usePCVater: Tap = Toggle, Hold 600ms = Skript; @pointerdown/@pointerup statt Touch-Events; pcPending mit Watch auf State-Change statt Timeout
  • usePersonLocation: Nominatim Reverse-Geocoding mit Modul-Level-Cache auf 100m-Raster; Home/Zone/Koordinaten-Fallback
  • Desktop Hash-Router: 5 Views, Topbar mit Breadcrumb, Light/Dark-Toggle via data-theme-Attribut
  • Rezept-Editor: kcal-Schätzung via kalkuliereKcal() + -Button; "Auf Tablet anzeigen" kombiniert IPC und Bildschirm-Aufwecken
  • Drag&Drop: useLagerDragDrop mit HTML5-API; isDragging mit einem Frame Verzögerung für korrektes Ghost-Rendering
  • GaugeRing size-Prop: ohne size responsiv (Phone), mit size fixe Pixelgröße (Desktop) — gleiche Komponente, zwei Verhaltensmodi
  • rangeColor(): explizite { from, to, color }-Bereiche mit Fallback — funktioniert für Metriken die in beide Richtungen kippen können
  • useVorratEinkaufBase: identische init()-Logik aus drei Views in shared/ konsolidiert
  • useAirPurifier: neues Feature direkt als shared Composable — ein Ort, drei Apps