UI/UX pass: readability, feedback, safer actions, DRK branding

Items 1–12 of the interface review:

- Larger UI font (UI_FONT_SIZE) across widgets, menus, dialogs, Treeview rows.
- DRK-red header bar; window title shows entry count; runtime-drawn red-cross
  icon (also written to data/icon.png for the launcher).
- Status bar colour-coded by severity; transient messages auto-clear after 8 s,
  queue-count messages stay.
- Tab 1: more form spacing, digits-only PLZ, explicit "Auswahl entfernen" +
  Delete key for queue rows, confirm on "Warteschlange leeren", modal on
  duplicate save.
- Tab 2: raw lat/lon columns replaced by one "Karte" column (checkmark / red
  "fehlt"); ▲/▼ arrow on the active sort column; hint line always shows the
  saved-entry count.
- "Karte öffnen" with no coordinates shows a dialog, not just a status line.
- Window size/position remembered between sessions (data/window.json).

Verified by building and driving the window under Homebrew Python 3.12 / Tk 9;
non-GUI smoke tests still pass. Docs updated (changelog, overview, improvements,
data-model, setup, dev-notes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 18:51:17 +02:00
parent 37c9642877
commit caecc60c7b
9 changed files with 349 additions and 103 deletions
+234 -70
View File
@@ -6,6 +6,7 @@ Visualisiert Einsatzorte auf einer interaktiven Karte.
from __future__ import annotations
import json
import logging
import math
import os
@@ -26,6 +27,7 @@ from geopy.geocoders import Nominatim
from ttkbootstrap.constants import *
import tkinter as tk
import tkinter.font as tkfont
from tkinter import messagebox
# ── Pfade ────────────────────────────────────────────────────────────────────
@@ -36,11 +38,19 @@ CSV_PATH = DATA_DIR / "orte.csv"
MAP_PATH = DATA_DIR / "karte.html"
BACKUP_DIR = DATA_DIR / "backups"
LOG_PATH = DATA_DIR / "app.log"
ICON_PATH = DATA_DIR / "icon.png"
WINDOW_STATE_PATH = DATA_DIR / "window.json"
CSV_COLUMNS = ["date", "city", "postal_code", "lat", "lon"]
#: Wie viele automatische Backups von orte.csv aufbewahrt werden.
MAX_BACKUPS = 20
#: Schriftgröße der Oberfläche (größer = besser lesbar auf einem Laptop).
UI_FONT_SIZE = 11
#: DRK-Rot für Akzente in der Oberfläche und die Kartenpunkte.
DRK_RED = "#CC0000"
#: Regionen, die bei der Geokodierung bevorzugt durchsucht werden (Reihenfolge zählt).
GEOCODE_REGIONS = ["Baden-Württemberg", "Hessen"]
@@ -419,9 +429,9 @@ def generate_map(store: DataStore) -> Path:
f"<b>{row['city']}</b><br>{count} Einsatz/Einsätze", max_width=200
),
tooltip=str(row["city"]),
color="#CC0000",
color=DRK_RED,
fill=True,
fill_color="#CC0000",
fill_color=DRK_RED,
fill_opacity=0.65,
weight=2,
).add_to(m)
@@ -579,10 +589,17 @@ class EditDialog(tk.Toplevel):
class App(ttk.Window):
#: Statusmeldungs-Art → ttkbootstrap-Style der Statusleiste.
_STATUS_STYLES = {
"info": "secondary", "success": "success",
"warning": "warning", "error": "danger",
}
def __init__(self) -> None:
super().__init__(themename="litera")
self.title("DRK Blutspende Arbeitsorte")
self.minsize(760, 560)
self.minsize(820, 620)
self._scale_fonts()
self._install_icon()
self.store = DataStore()
self.geocoder = GeocoderWorker(
@@ -593,17 +610,82 @@ class App(ttk.Window):
self._queue: list[dict] = []
self._next_id = 0
self._status_after_id: str | None = None
self._build_ui()
self._set_status(f"Bereit. Gesamt: {self.store.total_count()} Einträge.")
self._update_title()
self._restore_window_state()
self._reset_status()
self.protocol("WM_DELETE_WINDOW", self._on_close)
log.info("App gestartet (%d Einträge).", self.store.total_count())
# ── Aussehen ──────────────────────────────────────────────────────────────
def _scale_fonts(self) -> None:
"""Vergrößert die Standard-Schriftarten deutlich besser lesbar."""
for name in ("TkDefaultFont", "TkTextFont", "TkMenuFont", "TkHeadingFont"):
try:
tkfont.nametofont(name).configure(size=UI_FONT_SIZE)
except tk.TclError:
pass
self._ui_family = tkfont.nametofont("TkDefaultFont").actual("family")
self.style.configure(".", font=(self._ui_family, UI_FONT_SIZE))
self.style.configure(
"Treeview", font=(self._ui_family, UI_FONT_SIZE),
rowheight=UI_FONT_SIZE * 2 + 8,
)
self.style.configure(
"Treeview.Heading", font=(self._ui_family, UI_FONT_SIZE, "bold")
)
def _install_icon(self) -> None:
"""Zeichnet ein einfaches rotes Kreuz als Fenster-Icon (ohne Extra-Abhängigkeit)."""
try:
size = 64
img = tk.PhotoImage(width=size, height=size)
img.put("white", to=(0, 0, size, size))
arm = size // 3
img.put(DRK_RED, to=(arm, 7, size - arm, size - 7))
img.put(DRK_RED, to=(7, arm, size - 7, size - arm))
self.iconphoto(True, img)
self._icon_img = img # Referenz halten
if not ICON_PATH.exists():
try:
img.write(str(ICON_PATH), format="png")
except tk.TclError:
pass # Tk < 8.6 kann kein PNG schreiben nicht kritisch
except tk.TclError:
log.warning("Fenster-Icon konnte nicht gesetzt werden.")
def _update_title(self) -> None:
n = self.store.total_count()
self.title(f"DRK Blutspende Arbeitsorte · {n} Einsätze")
# ── Fensterposition merken ────────────────────────────────────────────────
def _restore_window_state(self) -> None:
try:
geom = json.loads(WINDOW_STATE_PATH.read_text()).get("geometry")
except (OSError, ValueError):
return
if isinstance(geom, str) and re.match(r"^\d+x\d+\+-?\d+\+-?\d+$", geom):
try:
self.geometry(geom)
except tk.TclError:
pass
def _save_window_state(self) -> None:
try:
WINDOW_STATE_PATH.write_text(json.dumps({"geometry": self.geometry()}))
except OSError:
log.warning("Fensterposition konnte nicht gespeichert werden.")
def _on_geocode_service_error(self) -> None:
"""Einmalige Warnung, wenn der Standortdienst wiederholt nicht erreichbar ist."""
self._set_status(
"Standortdienst nicht erreichbar Koordinaten werden übersprungen. "
"Bitte Internetverbindung prüfen."
"Bitte Internetverbindung prüfen.",
"error",
)
if not self._service_error_shown:
self._service_error_shown = True
@@ -618,12 +700,22 @@ class App(ttk.Window):
# ── UI-Aufbau ─────────────────────────────────────────────────────────────
def _build_ui(self) -> None:
# Kopfzeile mit DRK-Rot
header = ttk.Frame(self, bootstyle="danger", padding=(14, 8))
header.pack(fill=X, side=TOP)
ttk.Label(
header, text="DRK Blutspende Arbeitsorte",
bootstyle="inverse-danger",
font=(self._ui_family, UI_FONT_SIZE + 4, "bold"),
).pack(side=LEFT)
# Statusleiste (ganz unten, vor Notebook packen)
self._status_var = ttk.StringVar()
ttk.Label(
self._status_label = ttk.Label(
self, textvariable=self._status_var, bootstyle="secondary",
anchor=W, padding=(12, 4),
).pack(fill=X, side=BOTTOM)
anchor=W, padding=(12, 5),
)
self._status_label.pack(fill=X, side=BOTTOM)
# Notebook mit zwei Tabs
self.notebook = ttk.Notebook(self)
@@ -646,31 +738,33 @@ class App(ttk.Window):
input_frame = ttk.LabelFrame(parent, text=" Eintrag ", padding=10)
input_frame.pack(fill=X, padx=4, pady=(4, 6))
ttk.Label(input_frame, text="Datum:").grid(row=0, column=0, sticky=W, padx=(0, 4))
self._date_entry = ttk.DateEntry(input_frame, dateformat="%Y-%m-%d", width=12)
self._date_entry.grid(row=0, column=1, sticky=W, padx=(0, 14))
ttk.Label(input_frame, text="(JJJJ-MM-TT)", bootstyle="secondary").grid(
row=1, column=1, sticky=W
)
plz_ok = (self.register(lambda P: P == "" or (P.isdigit() and len(P) <= 5)), "%P")
ttk.Label(input_frame, text="Ort:").grid(row=0, column=2, sticky=W, padx=(0, 4))
ttk.Label(input_frame, text="Datum:").grid(row=0, column=0, sticky=W, padx=(0, 4), pady=4)
self._date_entry = ttk.DateEntry(input_frame, dateformat="%Y-%m-%d", width=12)
self._date_entry.grid(row=0, column=1, sticky=W, padx=(0, 16), pady=4)
ttk.Label(input_frame, text="Ort:").grid(row=0, column=2, sticky=W, padx=(0, 4), pady=4)
self._city_var = ttk.StringVar()
self.city_cb = ttk.Combobox(input_frame, textvariable=self._city_var, width=24)
self.city_cb["values"] = self.store.get_autocomplete_strings()
self.city_cb.grid(row=0, column=3, sticky=W, padx=(0, 14))
self.city_cb.grid(row=0, column=3, sticky=W, padx=(0, 16), pady=4)
self.city_cb.bind("<KeyRelease>", self._on_city_key)
self.city_cb.bind("<<ComboboxSelected>>", self._on_city_selected)
ttk.Label(input_frame, text="PLZ:").grid(row=0, column=4, sticky=W, padx=(0, 4))
ttk.Label(input_frame, text="PLZ:").grid(row=0, column=4, sticky=W, padx=(0, 4), pady=4)
self._plz_var = ttk.StringVar()
plz_entry = ttk.Entry(input_frame, textvariable=self._plz_var, width=8)
plz_entry.grid(row=0, column=5, sticky=W, padx=(0, 14))
plz_entry = ttk.Entry(
input_frame, textvariable=self._plz_var, width=8,
validate="key", validatecommand=plz_ok,
)
plz_entry.grid(row=0, column=5, sticky=W, padx=(0, 16), pady=4)
add_btn = ttk.Button(
input_frame, text="+ Hinzufügen", bootstyle="success-outline",
input_frame, text="+ Hinzufügen", bootstyle="success",
command=self._on_add_row,
)
add_btn.grid(row=0, column=6)
add_btn.grid(row=0, column=6, pady=4)
for w in (self._date_entry.entry, self.city_cb, plz_entry):
w.bind("<Return>", lambda _e: self._on_add_row())
@@ -699,13 +793,16 @@ class App(ttk.Window):
self.tree.bind("<ButtonRelease-1>", self._on_tree_click)
self.tree.bind("<Button-3>", self._on_queue_right_click)
self.tree.bind("<Delete>", lambda _e: self._delete_selected_queue_rows())
# Aktionsleiste
action_frame = ttk.Frame(parent, padding=(4, 4))
action_frame = ttk.Frame(parent, padding=(4, 6))
action_frame.pack(fill=X)
ttk.Button(action_frame, text="Alle speichern", bootstyle="success",
command=self._on_save_all).pack(side=LEFT, padx=(0, 6))
ttk.Button(action_frame, text="Leeren", bootstyle="secondary-outline",
ttk.Button(action_frame, text="Auswahl entfernen", bootstyle="secondary-outline",
command=self._delete_selected_queue_rows).pack(side=LEFT, padx=(0, 6))
ttk.Button(action_frame, text="Warteschlange leeren", bootstyle="secondary-outline",
command=self._on_clear_queue).pack(side=LEFT, padx=(0, 6))
ttk.Button(action_frame, text="Karte öffnen", bootstyle="info",
command=self._on_open_map).pack(side=RIGHT)
@@ -735,36 +832,34 @@ class App(ttk.Window):
hist_frame = ttk.Frame(parent, padding=(4, 2))
hist_frame.pack(fill=BOTH, expand=True)
cols = ("date", "city", "postal_code", "lat", "lon")
self._hist_headings = {
"date": "Datum", "city": "Ort", "postal_code": "PLZ", "coords": "Karte",
}
cols = tuple(self._hist_headings)
self.hist_tree = ttk.Treeview(hist_frame, columns=cols, show="headings", height=16)
self.hist_tree.heading("date", text="Datum",
command=lambda: self._sort_history("date"))
self.hist_tree.heading("city", text="Ort",
command=lambda: self._sort_history("city"))
self.hist_tree.heading("postal_code", text="PLZ",
command=lambda: self._sort_history("postal_code"))
self.hist_tree.heading("lat", text="Breitengrad",
command=lambda: self._sort_history("lat"))
self.hist_tree.heading("lon", text="Längengrad",
command=lambda: self._sort_history("lon"))
self.hist_tree.column("date", width=105, minwidth=90, stretch=False)
self.hist_tree.column("city", width=200, minwidth=120)
self.hist_tree.column("postal_code", width=65, minwidth=50, stretch=False)
self.hist_tree.column("lat", width=110, minwidth=80, stretch=False)
self.hist_tree.column("lon", width=110, minwidth=80, stretch=False)
for key in cols:
self.hist_tree.heading(
key, text=self._hist_headings[key],
command=lambda k=key: self._sort_history(k),
)
self.hist_tree.column("date", width=115, minwidth=95, stretch=False)
self.hist_tree.column("city", width=260, minwidth=140)
self.hist_tree.column("postal_code", width=80, minwidth=55, stretch=False, anchor=CENTER)
self.hist_tree.column("coords", width=90, minwidth=70, stretch=False, anchor=CENTER)
sb2 = ttk.Scrollbar(hist_frame, orient=VERTICAL, command=self.hist_tree.yview)
self.hist_tree.configure(yscrollcommand=sb2.set)
self.hist_tree.pack(side=LEFT, fill=BOTH, expand=True)
sb2.pack(side=RIGHT, fill=Y)
self.hist_tree.tag_configure("missing", foreground="#CC0000")
self.hist_tree.tag_configure("missing", foreground=DRK_RED)
self.hist_tree.bind("<Double-1>", self._on_hist_double_click)
self.hist_tree.bind("<Button-3>", self._on_hist_right_click)
self._sort_col = "date"
self._sort_asc = False # neueste zuerst
self._apply_sort_headings()
# Aktionsleiste
action_frame = ttk.Frame(parent, padding=(4, 4))
@@ -773,7 +868,7 @@ class App(ttk.Window):
command=self._on_hist_edit).pack(side=LEFT, padx=(0, 6))
ttk.Button(action_frame, text="Löschen", bootstyle="danger-outline",
command=self._on_hist_delete).pack(side=LEFT)
self._hist_hint_var = ttk.StringVar(value="(Doppelklick zum Bearbeiten)")
self._hist_hint_var = ttk.StringVar()
ttk.Label(action_frame, textvariable=self._hist_hint_var,
bootstyle="secondary").pack(side=RIGHT)
@@ -788,6 +883,7 @@ class App(ttk.Window):
df = self.store.df.copy()
missing = df["lat"].map(_is_blank) | df["lon"].map(_is_blank)
total = len(df)
total_missing = int(missing.sum())
query = self._search_var.get().strip().lower()
@@ -801,7 +897,13 @@ class App(ttk.Window):
if self._only_missing_var.get():
df = df[missing.reindex(df.index, fill_value=False)]
df = df.sort_values(self._sort_col, ascending=self._sort_asc, na_position="last")
if self._sort_col == "coords":
order = missing.reindex(df.index, fill_value=True)
df = df.assign(_ord=order).sort_values("_ord", ascending=self._sort_asc)
else:
df = df.sort_values(
self._sort_col, ascending=self._sort_asc, na_position="last"
)
for idx, row in df.iterrows():
row_missing = _is_blank(row.get("lat", "")) or _is_blank(row.get("lon", ""))
@@ -812,18 +914,26 @@ class App(ttk.Window):
row.get("date", ""),
row.get("city", ""),
row.get("postal_code", ""),
row.get("lat", ""),
row.get("lon", ""),
"fehlt" if row_missing else "",
),
)
parts = [f"{total} Einsätze gespeichert"]
if total_missing:
self._hist_hint_var.set(
f"{total_missing} Eintrag/Einträge ohne Koordinaten (rot) "
f"Rechtsklick Koordinaten suchen"
parts.append(
f"{total_missing} ohne Koordinaten (rot) Rechtsklick Koordinaten suchen"
)
else:
self._hist_hint_var.set("(Doppelklick zum Bearbeiten)")
parts.append("Doppelklick zum Bearbeiten")
self._hist_hint_var.set(" · ".join(parts))
def _apply_sort_headings(self) -> None:
"""Zeigt einen Pfeil an der aktuell sortierten Spalte."""
arrow = "" if self._sort_asc else ""
for key, label in self._hist_headings.items():
self.hist_tree.heading(
key, text=label + (arrow if key == self._sort_col else "")
)
def _sort_history(self, col: str) -> None:
if self._sort_col == col:
@@ -831,6 +941,7 @@ class App(ttk.Window):
else:
self._sort_col = col
self._sort_asc = True
self._apply_sort_headings()
self._refresh_history()
def _on_hist_double_click(self, event) -> None:
@@ -907,8 +1018,8 @@ class App(ttk.Window):
new_vals["lon"] = dlg.result["lon"]
self.store.update_row(df_idx, new_vals)
self._set_status(
f"Eintrag aktualisiert: {new_vals['date']} {new_vals['city']}. "
f"Gesamt: {self.store.total_count()} Einträge."
f"Eintrag aktualisiert: {new_vals['date']} {new_vals['city']}.",
"success",
)
self.city_cb["values"] = self.store.get_autocomplete_strings()
@@ -923,11 +1034,12 @@ class App(ttk.Window):
"lat": round(coords[0], 5),
"lon": round(coords[1], 5),
})
self._set_status(
f"Koordinaten aktualisiert. Gesamt: {self.store.total_count()} Einträge."
)
self._set_status("Koordinaten aktualisiert.", "success")
else:
self._set_status("Koordinaten konnten nicht gefunden werden.")
self._set_status(
"Koordinaten konnten nicht gefunden werden ggf. manuell eintragen.",
"warning",
)
self._refresh_history()
def _on_hist_delete(self) -> None:
@@ -945,9 +1057,11 @@ class App(ttk.Window):
return
indices = [int(iid) for iid in sel]
self.store.delete_rows(indices)
self._update_title()
self._refresh_history()
self._set_status(
f"{count} Eintrag/Einträge gelöscht. Gesamt: {self.store.total_count()} Einträge."
f"{count} Eintrag/Einträge gelöscht · {self.store.total_count()} insgesamt.",
"warning",
)
# ── Autovervollständigung ─────────────────────────────────────────────────
@@ -971,10 +1085,10 @@ class App(ttk.Window):
date_str = parse_date(self._date_entry.entry.get())
if not city:
self._set_status("Bitte einen Ort eingeben.")
self._set_status("Bitte einen Ort eingeben.", "warning")
return
if date_str is None:
self._set_status("Bitte ein gültiges Datum (JJJJ-MM-TT) eingeben.")
self._set_status("Bitte ein gültiges Datum (JJJJ-MM-TT) eingeben.", "warning")
messagebox.showwarning(
"Ungültiges Datum",
"Bitte ein gültiges Datum im Format JJJJ-MM-TT eingeben "
@@ -996,7 +1110,9 @@ class App(ttk.Window):
self._city_var.set("")
self._plz_var.set("")
self.city_cb.focus_set()
self._set_status(f"{len(self._queue)} Einträge in der Warteschlange.")
self._set_status(
f"{len(self._queue)} Einträge in der Warteschlange.", sticky=True
)
def _geocode_done(self, row_id: str, coords: tuple | None) -> None:
self.after(0, self._apply_geocode_result, row_id, coords)
@@ -1044,7 +1160,17 @@ class App(ttk.Window):
self._queue = [r for r in self._queue if r["_id"] != row_id]
if self.tree.exists(row_id):
self.tree.delete(row_id)
self._set_status(f"{len(self._queue)} Einträge in der Warteschlange.")
self._set_status(
f"{len(self._queue)} Einträge in der Warteschlange.", sticky=True
)
def _delete_selected_queue_rows(self) -> None:
sel = self.tree.selection()
if not sel:
self._set_status("Bitte zuerst eine Zeile in der Warteschlange auswählen.")
return
for row_id in sel:
self._delete_queue_row(row_id)
def _retry_geocode(self, row_id: str) -> None:
for row in self._queue:
@@ -1058,6 +1184,12 @@ class App(ttk.Window):
break
def _on_clear_queue(self) -> None:
if self._queue and not messagebox.askyesno(
"Warteschlange leeren",
f"{len(self._queue)} noch nicht gespeicherte Einträge verwerfen?",
parent=self,
):
return
self._queue.clear()
for item in self.tree.get_children():
self.tree.delete(item)
@@ -1072,11 +1204,16 @@ class App(ttk.Window):
dupes = self.store.find_duplicates(self._queue)
if dupes:
names = ", ".join(f"{d['date']} {d['city']}" for d in dupes[:3])
suffix = " u.w." if len(dupes) > 3 else ""
self._set_status(
f"Hinweis: Mögliche Duplikate ({names}{suffix}). Trotzdem gespeichert."
)
listed = "\n".join(f"{d['date']} {d['city']}" for d in dupes[:8])
more = f"\n … und {len(dupes) - 8} weitere" if len(dupes) > 8 else ""
if not messagebox.askyesno(
"Mögliche Duplikate",
f"{len(dupes)} Eintrag/Einträge scheint es schon zu geben:\n\n"
f"{listed}{more}\n\nTrotzdem alle speichern?",
parent=self,
):
self._set_status("Speichern abgebrochen Warteschlange unverändert.")
return
self.store.append_rows(self._queue)
count = len(self._queue)
@@ -1085,9 +1222,11 @@ class App(ttk.Window):
self.tree.delete(item)
self.city_cb["values"] = self.store.get_autocomplete_strings()
self._update_title()
self._refresh_history() # Tab 2 sofort aktualisieren
self._set_status(
f"{count} Einträge gespeichert. Gesamt: {self.store.total_count()} Einträge."
f"{count} Eintrag/Einträge gespeichert · {self.store.total_count()} insgesamt.",
"success",
)
# ── Karte ─────────────────────────────────────────────────────────────────
@@ -1096,25 +1235,50 @@ class App(ttk.Window):
try:
path = generate_map(self.store)
except ValueError as e:
self._set_status(str(e))
messagebox.showinfo("Karte", str(e), parent=self)
self._set_status(str(e), "warning")
return
except Exception as e:
log.exception("Karte konnte nicht erstellt werden.")
self._set_status(f"Fehler beim Erstellen der Karte: {e}")
messagebox.showerror(
"Karte", f"Die Karte konnte nicht erstellt werden:\n{e}", parent=self
)
self._set_status(f"Fehler beim Erstellen der Karte: {e}", "error")
return
try:
webbrowser.open(path.as_uri())
except Exception:
log.exception("Karte konnte nicht im Browser geöffnet werden.")
self._set_status(f"Karte geöffnet: {path}")
self._set_status("Karte im Browser geöffnet.", "success")
# ── Hilfsmethoden ─────────────────────────────────────────────────────────
def _set_status(self, msg: str) -> None:
def _set_status(self, msg: str, kind: str = "info", *, sticky: bool = False) -> None:
"""Zeigt eine Statusmeldung, farblich nach Art, und blendet sie nach 8 s aus."""
self._status_var.set(msg)
self._status_label.configure(
bootstyle=self._STATUS_STYLES.get(kind, "secondary")
)
if self._status_after_id is not None:
self.after_cancel(self._status_after_id)
self._status_after_id = None
if not sticky:
self._status_after_id = self.after(8000, self._reset_status)
def _reset_status(self) -> None:
"""Setzt die Statusleiste auf den neutralen Dauerzustand zurück."""
self._status_after_id = None
self._status_label.configure(bootstyle="secondary")
if self._queue:
self._status_var.set(
f"{len(self._queue)} Einträge in der Warteschlange noch nicht gespeichert."
)
else:
self._status_var.set("Bereit.")
def _on_close(self) -> None:
log.info("App wird beendet.")
self._save_window_state()
self.geocoder.stop()
self.destroy()