Files
aza/backup 24.2.26 - Kopie/aza_launcher.py
2026-03-25 22:03:39 +01:00

578 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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("<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__()
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("<Double-Button-1>", 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("<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)
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("<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 ────────────────────────────────────────────────────────
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("<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]:
"""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, ""