Files
aza/AzA march 2026/aza_launcher.py

621 lines
22 KiB
Python
Raw Normal View History

2026-03-25 22:03:39 +01:00
# -*- coding: utf-8 -*-
"""
AZA Desktop Launcher / Startseite.
Premium-Medizinprodukt-Design mit 6 Modulkacheln, KI-Kapazitätsanzeige
und verstecktem Admin-Zugang (Doppelklick auf Logo).
"""
import os
2026-04-19 20:41:37 +02:00
import sys
2026-03-25 22:03:39 +01:00
import tkinter as tk
from aza_config import (
LAUNCHER_MODULES,
LAUNCHER_MODULE_LABELS,
)
from aza_persistence import (
load_launcher_prefs,
save_launcher_prefs,
get_remaining_tokens,
get_capacity_fraction,
estimated_reports_remaining,
is_capacity_low,
)
from aza_ui_helpers import save_toplevel_geometry, load_toplevel_geometry
from aza_style import (
BG, CARD_BG, CARD_HOVER_BG, CARD_BORDER, CARD_HOVER_BORDER,
ACCENT, TEXT, SUBTLE,
CAPACITY_BLUE, TURQUOISE, WARNING_AMBER, DANGER,
FONT_FAMILY, SPACING,
format_number_de,
)
_MODULE_DESCRIPTIONS = {
"ki": "Medizinische Fragen stellen,\nBefunde besprechen, Zweitmeinung einholen",
"kg": "Diktat aufnehmen, transkribieren\nund Krankengeschichte erstellen",
2026-04-19 20:41:37 +02:00
"empfang": "Empfangs-Chat, Aufgaben\nund Praxis-Kommunikation",
"notizen": "Sprachaufnahmen und Notizen\nfuer den Praxisalltag",
"translator": "Medizinische Fachtexte uebersetzen\nund Begriffe nachschlagen",
"medwork_chat": "Kollegialer Austausch mit\nAerzten und Fachpersonal",
2026-03-25 22:03:39 +01:00
"praxis_chat": "Nachrichten und Aufgaben\nim eigenen Praxisteam",
}
2026-04-19 20:41:37 +02:00
_ICON_BLUE = "#5B8DB3"
2026-03-25 22:03:39 +01:00
_MODULE_ICON_COLORS = {
2026-04-19 20:41:37 +02:00
"ki": _ICON_BLUE,
"kg": _ICON_BLUE,
"empfang": _ICON_BLUE,
"notizen": _ICON_BLUE,
"translator": _ICON_BLUE,
"medwork_chat": _ICON_BLUE,
"praxis_chat": _ICON_BLUE,
2026-03-25 22:03:39 +01:00
}
_ICON_SZ = 38
def _draw_module_icon(c: tk.Canvas, key: str):
"""Draw a minimal white line-art icon (38×38) for the given module."""
s = _ICON_SZ
m = s // 2
fg = "#FFFFFF"
if key == "ki":
c.create_polygon(m, 7, m + 5, m, m, s - 7, m - 5, m,
fill=fg, outline="")
c.create_polygon(7, m, m, m - 5, s - 7, m, m, m + 5,
fill=fg, outline="")
elif key == "kg":
c.create_rectangle(10, 12, s - 10, s - 7, outline=fg, width=2)
c.create_rectangle(14, 8, s - 14, 15, outline=fg, width=1.5)
c.create_line(m, 18, m, s - 10, fill=fg, width=2.5)
c.create_line(14, m + 2, s - 14, m + 2, fill=fg, width=2.5)
elif key == "notizen":
c.create_oval(m - 5, 7, m + 5, 19, outline=fg, width=2)
c.create_arc(m - 9, 13, m + 9, 27, start=180, extent=180,
outline=fg, width=2, style="arc")
c.create_line(m, 27, m, s - 9, fill=fg, width=2)
c.create_line(m - 5, s - 9, m + 5, s - 9, fill=fg, width=2)
elif key == "translator":
c.create_rectangle(7, 8, m + 1, s - 10, outline=fg, width=1.5)
c.create_rectangle(m - 1, 10, s - 7, s - 8, outline=fg, width=1.5)
c.create_text(14, m, text="A",
font=("Segoe UI", 9, "bold"), fill=fg)
c.create_text(s - 14, m + 1, text="\u6587",
font=("Segoe UI", 8), fill=fg)
elif key == "medwork_chat":
nodes = [(m, 9), (9, s - 11), (s - 9, s - 11)]
for i in range(3):
for j in range(i + 1, 3):
c.create_line(*nodes[i], *nodes[j], fill=fg, width=1.5)
for x, y in nodes:
c.create_oval(x - 4, y - 4, x + 4, y + 4, fill=fg, outline="")
2026-04-19 20:41:37 +02:00
elif key == "empfang":
c.create_rectangle(8, 10, s - 8, s - 8, outline=fg, width=2)
c.create_line(8, 10, m, m + 2, fill=fg, width=2)
c.create_line(s - 8, 10, m, m + 2, fill=fg, width=2)
2026-03-25 22:03:39 +01:00
elif key == "praxis_chat":
c.create_oval(8, 7, s - 8, s - 13, outline=fg, width=2)
bg = c.cget("bg")
c.create_polygon(12, s - 14, 10, s - 7, 18, s - 14,
fill=bg, outline=bg)
c.create_line(12, s - 14, 10, s - 7, fill=fg, width=2)
c.create_line(10, s - 7, 17, s - 14, fill=fg, width=2)
_GRID_COLS = 2
_BAR_H = 6
_WIN_W = 620
_WIN_MIN_W = 520
_WIN_MIN_H = 500
class _Tooltip:
"""Dezentes Hover-Tooltip im Glas-Stil."""
def __init__(self, widget, text: str):
self._widget = widget
self._text = text
self._tip = None
widget.bind("<Enter>", self._show, add="+")
widget.bind("<Leave>", self._hide, add="+")
def _show(self, event):
if self._tip:
return
x = self._widget.winfo_rootx() + 20
y = self._widget.winfo_rooty() + self._widget.winfo_height() + 4
self._tip = tw = tk.Toplevel(self._widget)
tw.wm_overrideredirect(True)
tw.wm_geometry(f"+{x}+{y}")
tw.configure(bg="#2D3436")
tk.Label(
tw, text=self._text,
font=(FONT_FAMILY, 9), fg="#F0F0F0", bg="#2D3436",
padx=12, pady=8, justify="left",
).pack()
def _hide(self, event):
if self._tip:
self._tip.destroy()
self._tip = None
def update_text(self, text: str):
self._text = text
class AzaLauncher(tk.Tk):
"""Premium-Startseite mit Modulauswahl, KI-Kapazität und verstecktem Admin."""
def __init__(self):
super().__init__()
2026-04-19 20:41:37 +02:00
self.title("AzA-Cockpit")
2026-03-25 22:03:39 +01:00
self.configure(bg=BG)
self.resizable(True, True)
self.minsize(_WIN_MIN_W, _WIN_MIN_H)
2026-04-19 20:41:37 +02:00
# AppUserModelID bereits im __main__ gesetzt (vor Fenster-Erstellung)
for _d in [os.path.dirname(os.path.abspath(__file__)),
getattr(sys, "_MEIPASS", ""),
os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else ""]:
if _d:
_ip = os.path.join(_d, "logo.ico")
if os.path.isfile(_ip):
try:
self.iconbitmap(_ip)
except Exception:
pass
break
2026-03-25 22:03:39 +01:00
self.attributes("-topmost", True)
self._logo_img = None
try:
2026-04-16 13:32:32 +02:00
import sys as _sys
_search = []
if getattr(_sys, "frozen", False):
_exe = os.path.dirname(os.path.abspath(_sys.executable))
_search.append(_exe)
_search.append(os.path.join(_exe, "_internal"))
_search.append(os.path.dirname(os.path.abspath(__file__)))
logo_path = None
for _d in _search:
_p = os.path.join(_d, "logo.png")
if os.path.isfile(_p):
logo_path = _p
break
if logo_path:
2026-03-25 22:03:39 +01:00
from PIL import Image, ImageTk
img = Image.open(logo_path).resize((44, 44), Image.Resampling.LANCZOS)
2026-04-16 13:32:32 +02:00
self._logo_img = ImageTk.PhotoImage(img, master=self)
2026-03-25 22:03:39 +01:00
except Exception:
pass
self._selected_module = None
prefs = load_launcher_prefs()
self._auto_open_var = tk.BooleanVar(value=prefs.get("auto_open", False))
self._build_ui()
self._apply_geometry()
self.protocol("WM_DELETE_WINDOW", self._on_close)
def _apply_geometry(self):
saved_geom = load_toplevel_geometry("launcher")
if saved_geom:
try:
self.geometry(saved_geom)
return
except Exception:
pass
self.update_idletasks()
req_w = max(self.winfo_reqwidth(), _WIN_W)
req_h = self.winfo_reqheight() + 20
sw = self.winfo_screenwidth()
sh = self.winfo_screenheight()
win_w = min(req_w, sw - 40)
win_h = min(req_h, sh - 80)
self.minsize(_WIN_MIN_W, min(win_h, _WIN_MIN_H))
x = (sw - win_w) // 2
y = max(20, (sh - win_h) // 2)
self.geometry(f"{win_w}x{win_h}+{x}+{y}")
# ── UI Aufbau ──────────────────────────────────────────────────────────────
def _build_ui(self):
outer = tk.Frame(self, bg=BG)
outer.pack(fill="both", expand=True, padx=36, pady=(28, 20))
# ── Header ──
header = tk.Frame(outer, bg=BG)
header.pack(fill="x")
title_row = tk.Frame(header, bg=BG)
title_row.pack(fill="x")
if self._logo_img:
2026-04-16 13:32:32 +02:00
try:
logo_lbl = tk.Label(title_row, image=self._logo_img, bg=BG, cursor="hand2")
logo_lbl.image = self._logo_img
logo_lbl.pack(side="left", padx=(0, 14))
logo_lbl.bind("<Double-Button-1>", self._open_admin)
except tk.TclError:
self._logo_img = None
2026-03-25 22:03:39 +01:00
title_block = tk.Frame(title_row, bg=BG)
title_block.pack(side="left", anchor="w")
aza_lbl = tk.Label(title_block, text="AZA",
font=(FONT_FAMILY, 24, "bold"), fg=ACCENT, bg=BG,
cursor="hand2")
aza_lbl.pack(anchor="w")
aza_lbl.bind("<Double-Button-1>", self._open_admin)
tk.Label(title_block, text="Medizinischer KI-Arbeitsplatz",
font=(FONT_FAMILY, 10), fg=SUBTLE, bg=BG
).pack(anchor="w")
self._build_capacity_bar(header)
# ── Separator ──
tk.Frame(outer, bg="#E2E8F0", height=1).pack(fill="x", pady=(14, 16))
# ── Card Grid ──
grid = tk.Frame(outer, bg=BG)
grid.pack(fill="both", expand=True)
for c in range(_GRID_COLS):
grid.columnconfigure(c, weight=1, uniform="col")
num_rows = (len(LAUNCHER_MODULES) + _GRID_COLS - 1) // _GRID_COLS
for r in range(num_rows):
grid.rowconfigure(r, weight=1)
2026-04-19 20:41:37 +02:00
_CARD_GRAYS = ["#f2f4f6", "#f5f7f9", "#f7f8fa", "#f9fafb", "#fafbfc", "#fbfcfd"]
2026-03-25 22:03:39 +01:00
for i, mod_key in enumerate(LAUNCHER_MODULES):
row, col = divmod(i, _GRID_COLS)
2026-04-19 20:41:37 +02:00
card_bg = _CARD_GRAYS[min(i, len(_CARD_GRAYS) - 1)]
card = self._create_card(grid, mod_key, card_bg=card_bg)
2026-03-25 22:03:39 +01:00
card.grid(row=row, column=col,
padx=SPACING // 2, pady=SPACING // 2,
sticky="nsew")
2026-04-19 20:41:37 +02:00
# Auto-Open-Bereich vorlaeufig deaktiviert (Endlosschlaufen-Problem)
2026-03-25 22:03:39 +01:00
# ── Footer ──
_FOOTER_BG = "#EDF2F7"
footer_wrap = tk.Frame(outer, bg=_FOOTER_BG, highlightthickness=0)
footer_wrap.pack(fill="x", pady=(16, 0), ipady=8)
footer = tk.Frame(footer_wrap, bg=_FOOTER_BG)
footer.pack(fill="x", padx=12)
lbl_status = tk.Label(
footer, text="Systemstatus",
font=(FONT_FAMILY, 9, "bold"), fg=ACCENT, bg=_FOOTER_BG,
cursor="hand2",
)
lbl_status.pack(side="left")
lbl_status.bind("<Button-1>", self._open_systemstatus)
self._build_key_status(footer, _FOOTER_BG)
try:
from aza_version import APP_VERSION
tk.Label(
footer, text=f"v{APP_VERSION}",
font=(FONT_FAMILY, 8), fg="#A0AEC0", bg=_FOOTER_BG,
).pack(side="right")
except Exception:
pass
# ── Auto-Open Bereich ──────────────────────────────────────────────────────
def _build_auto_open_section(self, parent):
prefs = load_launcher_prefs()
default_mod = prefs.get("default_module", "")
section = tk.Frame(parent, bg=BG)
section.pack(fill="x", pady=(14, 0))
tk.Frame(section, bg="#E2E8F0", height=1).pack(fill="x", pady=(0, 10))
row1 = tk.Frame(section, bg=BG)
row1.pack(fill="x")
tk.Checkbutton(
row1,
text="Nächste Auswahl als Standardstart merken",
variable=self._auto_open_var,
font=(FONT_FAMILY, 9), fg=TEXT, bg=BG,
activebackground=BG, selectcolor=CARD_BG,
command=self._on_auto_open_toggle,
).pack(side="left")
row2 = tk.Frame(section, bg=BG)
row2.pack(fill="x", pady=(4, 0))
self._auto_open_status = tk.Label(row2, font=(FONT_FAMILY, 9), bg=BG)
self._auto_open_status.pack(side="left")
self._auto_open_reset = tk.Label(
row2, text="Zurücksetzen",
font=(FONT_FAMILY, 8, "underline"), fg=ACCENT, bg=BG,
cursor="hand2",
)
self._auto_open_reset.bind("<Button-1>", self._reset_auto_open)
self._auto_open_help = tk.Label(
section, font=(FONT_FAMILY, 8), fg=SUBTLE, bg=BG,
)
self._auto_open_help.pack(anchor="w", pady=(2, 0))
self._update_auto_open_display(default_mod)
def _update_auto_open_display(self, mod_key: str):
if mod_key and mod_key in LAUNCHER_MODULES:
label = LAUNCHER_MODULE_LABELS.get(mod_key, mod_key)
self._auto_open_status.configure(
text=f"Standardstart: {label}",
fg=ACCENT,
)
self._auto_open_reset.pack(side="left", padx=(10, 0))
self._auto_open_help.configure(
text="Dieses Modul öffnet sich beim nächsten Programmstart automatisch.",
)
else:
self._auto_open_status.configure(
text="Kein Standardstart festgelegt",
fg=SUBTLE,
)
self._auto_open_reset.pack_forget()
self._auto_open_help.configure(
text="Aktivieren Sie die Option und wählen Sie ein Modul, um einen Standardstart festzulegen.",
)
def _on_auto_open_toggle(self):
if not self._auto_open_var.get():
save_launcher_prefs("", False)
self._update_auto_open_display("")
def _reset_auto_open(self, event=None):
self._auto_open_var.set(False)
save_launcher_prefs("", False)
self._update_auto_open_display("")
# ── Admin-Zugang ──────────────────────────────────────────────────────────
def _open_systemstatus(self, event=None):
try:
from aza_systemstatus import show_systemstatus
show_systemstatus(self)
except Exception:
pass
def _open_admin(self, event=None):
try:
from aza_admin import show_admin_login, show_admin_panel
if show_admin_login(self):
show_admin_panel(self)
self._refresh_capacity()
except Exception:
pass
def _refresh_capacity(self):
"""Aktualisiert Kapazitätsanzeige nach Admin-Aktion."""
try:
remaining = get_remaining_tokens()
pct = get_capacity_fraction()
low = is_capacity_low()
est = estimated_reports_remaining()
self._cap_label.configure(
text=f"Ihre KI-Kapazität: {format_number_de(remaining)} Einheiten verbleibend",
fg=WARNING_AMBER if low else SUBTLE,
)
self._cap_canvas.delete("all")
self._cap_canvas.update_idletasks()
self._draw_gradient_bar(self._cap_canvas, pct, _BAR_H)
except Exception:
pass
# ── KI-Kapazitätsanzeige ──────────────────────────────────────────────────
def _build_capacity_bar(self, parent):
frame = tk.Frame(parent, bg=BG)
frame.pack(fill="x", pady=(12, 0))
remaining = get_remaining_tokens()
pct = get_capacity_fraction()
low = is_capacity_low()
est = estimated_reports_remaining()
label_text = f"KI-Kapazität: {format_number_de(remaining)} Einheiten"
color = WARNING_AMBER if low else "#A0AEC0"
self._cap_label = tk.Label(
frame, text=label_text,
font=(FONT_FAMILY, 8), fg=color, bg=BG, anchor="e",
)
self._cap_label.pack(anchor="e", pady=(0, 3))
self._cap_canvas = canvas = tk.Canvas(
frame, height=_BAR_H, bg=BG, highlightthickness=0,
)
canvas.pack(fill="x")
canvas.bind("<Configure>", lambda e: self._draw_gradient_bar(canvas, pct, _BAR_H))
tooltip_text = (
f"Entspricht ca. {est} weiteren Berichten\n"
"(Basierend auf Ihrem Durchschnittsverbrauch)"
)
if low:
tooltip_text += (
"\n\n\u26A0 Kapazität fast aufgebraucht.\n"
"Guthaben unter aza-medwork.ch nachfüllen."
)
_Tooltip(canvas, tooltip_text)
_Tooltip(self._cap_label, tooltip_text)
@staticmethod
def _draw_gradient_bar(canvas, pct: float, bar_h: int):
canvas.delete("all")
w = canvas.winfo_width()
if w <= 1:
return
filled_w = max(0, min(w, int(w * pct)))
canvas.create_rectangle(0, 0, w, bar_h, fill="#EDF2F7", outline="")
if filled_w <= 0:
return
if pct <= 0.02:
canvas.create_rectangle(0, 0, filled_w, bar_h, fill=DANGER, outline="")
elif pct <= 0.10:
canvas.create_rectangle(0, 0, filled_w, bar_h, fill=WARNING_AMBER, outline="")
else:
r1, g1, b1 = 0x00, 0x78, 0xD7
r2, g2, b2 = 0x00, 0xCE, 0xC9
steps = min(filled_w, 120)
step_w = filled_w / steps
for i in range(steps):
t = i / max(1, steps - 1)
r = int(r1 + (r2 - r1) * t)
g = int(g1 + (g2 - g1) * t)
b = int(b1 + (b2 - b1) * t)
x1 = int(i * step_w)
x2 = int((i + 1) * step_w)
canvas.create_rectangle(
x1, 0, x2, bar_h,
fill=f"#{r:02x}{g:02x}{b:02x}", outline="",
)
# ── Key-Status ────────────────────────────────────────────────────────────
def _build_key_status(self, parent, bg_color=None):
bg = bg_color or BG
try:
from security_vault import has_vault_key, get_masked_key
if has_vault_key():
masked = get_masked_key()
tk.Label(
parent,
text=f"Schlüssel aktiv ({masked})",
font=(FONT_FAMILY, 8), fg="#00B894", bg=bg,
).pack(side="left", padx=(12, 0))
except Exception:
pass
# ── Card Rendering ────────────────────────────────────────────────────────
2026-04-19 20:41:37 +02:00
def _create_card(self, parent, mod_key: str, card_bg: str = None) -> tk.Frame:
2026-03-25 22:03:39 +01:00
label = LAUNCHER_MODULE_LABELS.get(mod_key, mod_key)
desc = _MODULE_DESCRIPTIONS.get(mod_key, "")
icon_color = _MODULE_ICON_COLORS.get(mod_key, ACCENT)
2026-04-19 20:41:37 +02:00
_cbg = card_bg or CARD_BG
2026-03-25 22:03:39 +01:00
2026-04-19 20:41:37 +02:00
card = tk.Frame(parent, bg=_cbg, cursor="hand2",
2026-03-25 22:03:39 +01:00
highlightthickness=1, highlightbackground=CARD_BORDER)
2026-04-19 20:41:37 +02:00
inner = tk.Frame(card, bg=_cbg, cursor="hand2")
2026-03-25 22:03:39 +01:00
inner.pack(fill="both", expand=True, padx=20, pady=18)
2026-04-19 20:41:37 +02:00
top_row = tk.Frame(inner, bg=_cbg, cursor="hand2")
2026-03-25 22:03:39 +01:00
top_row.pack(fill="x", pady=(0, 10))
icon_cv = tk.Canvas(top_row, width=_ICON_SZ, height=_ICON_SZ,
bg=icon_color, highlightthickness=0, cursor="hand2")
icon_cv.pack(side="left")
_draw_module_icon(icon_cv, mod_key)
tk.Label(inner, text=label, font=(FONT_FAMILY, 12, "bold"),
2026-04-19 20:41:37 +02:00
fg=TEXT, bg=_cbg, anchor="w", cursor="hand2"
2026-03-25 22:03:39 +01:00
).pack(anchor="w")
if desc:
tk.Label(inner, text=desc, font=(FONT_FAMILY, 9),
2026-04-19 20:41:37 +02:00
fg=SUBTLE, bg=_cbg, anchor="w",
2026-03-25 22:03:39 +01:00
justify="left", cursor="hand2"
).pack(anchor="w", pady=(4, 0))
def on_enter(e):
card.configure(highlightbackground=CARD_HOVER_BORDER, highlightthickness=2)
for w in _deep_children(card):
try:
if isinstance(w, tk.Canvas):
continue
w.configure(bg=CARD_HOVER_BG)
except tk.TclError:
pass
def on_leave(e):
card.configure(highlightbackground=CARD_BORDER, highlightthickness=1)
for w in _deep_children(card):
try:
if isinstance(w, tk.Canvas):
continue
w.configure(bg=CARD_BG)
except tk.TclError:
pass
def on_click(e, key=mod_key):
self._select(key)
for w in _deep_children(card):
w.bind("<Enter>", on_enter)
w.bind("<Leave>", on_leave)
w.bind("<Button-1>", on_click)
return card
# ── Modul-Auswahl / Schliessen ────────────────────────────────────────────
def _save_geom(self):
try:
save_toplevel_geometry("launcher", self.geometry())
except Exception:
pass
def _select(self, mod_key: str):
self._selected_module = mod_key
if self._auto_open_var.get():
save_launcher_prefs(mod_key, True)
else:
save_launcher_prefs("", False)
self._save_geom()
self.destroy()
def _on_close(self):
self._selected_module = None
self._save_geom()
self.destroy()
def run(self) -> str | None:
self.mainloop()
return self._selected_module
def _deep_children(widget):
"""Widget + alle verschachtelten Kinder."""
result = [widget]
for child in widget.winfo_children():
result.extend(_deep_children(child))
return result
def should_skip_launcher() -> tuple[bool, str]:
2026-04-19 20:41:37 +02:00
"""Standardstart vorlaeufig deaktiviert (Endlosschlaufen-Problem).
Alte gespeicherte Werte werden ignoriert."""
try:
prefs = load_launcher_prefs()
if prefs.get("auto_open"):
prefs["auto_open"] = False
prefs["default_module"] = ""
save_launcher_prefs("", False)
except Exception:
pass
2026-03-25 22:03:39 +01:00
return False, ""