# -*- 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 sys import tkinter as tk from aza_config import ( LAUNCHER_MODULES, LAUNCHER_MODULE_LABELS, LAUNCHER_DISABLED_MODULES, ) 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", "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", "praxis_chat": "Nachrichten und Aufgaben\nim eigenen Praxisteam", } _ICON_BLUE = "#5B8DB3" _MODULE_ICON_COLORS = { "ki": _ICON_BLUE, "kg": _ICON_BLUE, "empfang": _ICON_BLUE, "notizen": _ICON_BLUE, "translator": _ICON_BLUE, "medwork_chat": _ICON_BLUE, "praxis_chat": _ICON_BLUE, } _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_text(m, m, text="AzA", font=("Segoe UI", 11, "bold"), fill=fg) 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 == "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) 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-Cockpit") self.configure(bg=BG) self.resizable(True, True) self.minsize(_WIN_MIN_W, _WIN_MIN_H) # 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 self.attributes("-topmost", True) self._logo_img = None try: 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: from PIL import Image, ImageTk img = Image.open(logo_path).resize((44, 44), Image.Resampling.LANCZOS) self._logo_img = ImageTk.PhotoImage(img, master=self) 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: 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("", self._open_admin) except tk.TclError: self._logo_img = None 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) _CARD_GRAYS = ["#f2f4f6", "#f5f7f9", "#f7f8fa", "#f9fafb", "#fafbfc", "#fbfcfd"] for i, mod_key in enumerate(LAUNCHER_MODULES): row, col = divmod(i, _GRID_COLS) card_bg = _CARD_GRAYS[min(i, len(_CARD_GRAYS) - 1)] card = self._create_card(grid, mod_key, card_bg=card_bg) card.grid(row=row, column=col, padx=SPACING // 2, pady=SPACING // 2, sticky="nsew") # Auto-Open-Bereich vorlaeufig deaktiviert (Endlosschlaufen-Problem) # ── 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, card_bg: str = None) -> tk.Frame: label = LAUNCHER_MODULE_LABELS.get(mod_key, mod_key) desc = _MODULE_DESCRIPTIONS.get(mod_key, "") is_disabled = mod_key in LAUNCHER_DISABLED_MODULES icon_color = "#B0BEC5" if is_disabled else _MODULE_ICON_COLORS.get(mod_key, ACCENT) _cbg = "#ECEFF1" if is_disabled else (card_bg or CARD_BG) _cursor = "arrow" if is_disabled else "hand2" _text_fg = "#90A4AE" if is_disabled else TEXT _desc_fg = "#B0BEC5" if is_disabled else SUBTLE card = tk.Frame(parent, bg=_cbg, cursor=_cursor, highlightthickness=1, highlightbackground=CARD_BORDER) inner = tk.Frame(card, bg=_cbg, cursor=_cursor) inner.pack(fill="both", expand=True, padx=20, pady=18) top_row = tk.Frame(inner, bg=_cbg, cursor=_cursor) top_row.pack(fill="x", pady=(0, 10)) if mod_key == "kg" and self._logo_img: icon_lbl = tk.Label(top_row, image=self._logo_img, bg=_cbg, cursor=_cursor) icon_lbl.image = self._logo_img icon_lbl.pack(side="left") else: icon_cv = tk.Canvas(top_row, width=_ICON_SZ, height=_ICON_SZ, bg=icon_color, highlightthickness=0, cursor=_cursor) icon_cv.pack(side="left") _draw_module_icon(icon_cv, mod_key) lbl_title = tk.Label(inner, text=label, font=(FONT_FAMILY, 12, "bold"), fg=_text_fg, bg=_cbg, anchor="w", cursor=_cursor) lbl_title.pack(anchor="w") if desc: lbl_desc = tk.Label(inner, text=desc, font=(FONT_FAMILY, 9), fg=_desc_fg, bg=_cbg, anchor="w", justify="left", cursor=_cursor) lbl_desc.pack(anchor="w", pady=(4, 0)) if is_disabled: tk.Label(inner, text="Bald verf\u00fcgbar", font=(FONT_FAMILY, 8, "italic"), fg="#B0BEC5", bg=_cbg).pack(anchor="w", pady=(4, 0)) return card 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]: """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 return False, ""