# -*- 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 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", "notizen": "Sprachaufnahmen und Notizen\nfür den Praxisalltag", "translator": "Medizinische Fachtexte übersetzen\nund Begriffe nachschlagen", "medwork_chat": "Kollegialer Austausch mit\nÄrzten und Fachpersonal", "praxis_chat": "Nachrichten und Aufgaben\nim eigenen Praxisteam", } _MODULE_ICON_COLORS = { "ki": "#0984E3", "kg": "#00B894", "notizen": "#6C5CE7", "translator": "#0078D7", "medwork_chat": "#2D3436", "praxis_chat": "#636E72", } _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="") 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("", self._show, add="+") widget.bind("", 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__() self.title("AZA \u2013 Digitales Praxis-Cockpit") self.configure(bg=BG) self.resizable(True, True) self.minsize(_WIN_MIN_W, _WIN_MIN_H) self.attributes("-topmost", True) self._logo_img = None try: logo_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), "logo.png" ) if os.path.exists(logo_path): from PIL import Image, ImageTk img = Image.open(logo_path).resize((44, 44), Image.Resampling.LANCZOS) self._logo_img = ImageTk.PhotoImage(img) 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: logo_lbl = tk.Label(title_row, image=self._logo_img, bg=BG, cursor="hand2") logo_lbl.pack(side="left", padx=(0, 14)) logo_lbl.bind("", self._open_admin) 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("", 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) for i, mod_key in enumerate(LAUNCHER_MODULES): row, col = divmod(i, _GRID_COLS) card = self._create_card(grid, mod_key) card.grid(row=row, column=col, padx=SPACING // 2, pady=SPACING // 2, sticky="nsew") # ── Auto-Open Bereich ── self._build_auto_open_section(outer) # ── 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("", 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("", 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("", 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 ──────────────────────────────────────────────────────── def _create_card(self, parent, mod_key: str) -> tk.Frame: label = LAUNCHER_MODULE_LABELS.get(mod_key, mod_key) desc = _MODULE_DESCRIPTIONS.get(mod_key, "") icon_color = _MODULE_ICON_COLORS.get(mod_key, ACCENT) card = tk.Frame(parent, bg=CARD_BG, cursor="hand2", highlightthickness=1, highlightbackground=CARD_BORDER) inner = tk.Frame(card, bg=CARD_BG, cursor="hand2") inner.pack(fill="both", expand=True, padx=20, pady=18) top_row = tk.Frame(inner, bg=CARD_BG, cursor="hand2") 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"), fg=TEXT, bg=CARD_BG, anchor="w", cursor="hand2" ).pack(anchor="w") if desc: tk.Label(inner, text=desc, font=(FONT_FAMILY, 9), fg=SUBTLE, bg=CARD_BG, anchor="w", 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("", on_enter) w.bind("", on_leave) w.bind("", 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]: """Prüft ob der Launcher übersprungen werden soll.""" prefs = load_launcher_prefs() mod = prefs.get("default_module", "") auto = prefs.get("auto_open", False) if auto and mod and mod in LAUNCHER_MODULES: return True, mod return False, ""