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:
+3
-1
@@ -11,10 +11,12 @@ data/karte.html
|
|||||||
# Real data — kept out of version control for now (see docs/data-model.md)
|
# Real data — kept out of version control for now (see docs/data-model.md)
|
||||||
data/orte.csv
|
data/orte.csv
|
||||||
|
|
||||||
# Local backups and logs
|
# Local backups, logs and generated/per-machine files
|
||||||
data/backups/
|
data/backups/
|
||||||
data/app.log
|
data/app.log
|
||||||
data/app.log.*
|
data/app.log.*
|
||||||
|
data/window.json
|
||||||
|
data/icon.png
|
||||||
|
|
||||||
# Editor / OS
|
# Editor / OS
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Visualisiert Einsatzorte auf einer interaktiven Karte.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
@@ -26,6 +27,7 @@ from geopy.geocoders import Nominatim
|
|||||||
from ttkbootstrap.constants import *
|
from ttkbootstrap.constants import *
|
||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
|
import tkinter.font as tkfont
|
||||||
from tkinter import messagebox
|
from tkinter import messagebox
|
||||||
|
|
||||||
# ── Pfade ────────────────────────────────────────────────────────────────────
|
# ── Pfade ────────────────────────────────────────────────────────────────────
|
||||||
@@ -36,11 +38,19 @@ CSV_PATH = DATA_DIR / "orte.csv"
|
|||||||
MAP_PATH = DATA_DIR / "karte.html"
|
MAP_PATH = DATA_DIR / "karte.html"
|
||||||
BACKUP_DIR = DATA_DIR / "backups"
|
BACKUP_DIR = DATA_DIR / "backups"
|
||||||
LOG_PATH = DATA_DIR / "app.log"
|
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"]
|
CSV_COLUMNS = ["date", "city", "postal_code", "lat", "lon"]
|
||||||
|
|
||||||
#: Wie viele automatische Backups von orte.csv aufbewahrt werden.
|
#: Wie viele automatische Backups von orte.csv aufbewahrt werden.
|
||||||
MAX_BACKUPS = 20
|
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).
|
#: Regionen, die bei der Geokodierung bevorzugt durchsucht werden (Reihenfolge zählt).
|
||||||
GEOCODE_REGIONS = ["Baden-Württemberg", "Hessen"]
|
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
|
f"<b>{row['city']}</b><br>{count} Einsatz/Einsätze", max_width=200
|
||||||
),
|
),
|
||||||
tooltip=str(row["city"]),
|
tooltip=str(row["city"]),
|
||||||
color="#CC0000",
|
color=DRK_RED,
|
||||||
fill=True,
|
fill=True,
|
||||||
fill_color="#CC0000",
|
fill_color=DRK_RED,
|
||||||
fill_opacity=0.65,
|
fill_opacity=0.65,
|
||||||
weight=2,
|
weight=2,
|
||||||
).add_to(m)
|
).add_to(m)
|
||||||
@@ -579,10 +589,17 @@ class EditDialog(tk.Toplevel):
|
|||||||
|
|
||||||
|
|
||||||
class App(ttk.Window):
|
class App(ttk.Window):
|
||||||
|
#: Statusmeldungs-Art → ttkbootstrap-Style der Statusleiste.
|
||||||
|
_STATUS_STYLES = {
|
||||||
|
"info": "secondary", "success": "success",
|
||||||
|
"warning": "warning", "error": "danger",
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__(themename="litera")
|
super().__init__(themename="litera")
|
||||||
self.title("DRK Blutspende – Arbeitsorte")
|
self.minsize(820, 620)
|
||||||
self.minsize(760, 560)
|
self._scale_fonts()
|
||||||
|
self._install_icon()
|
||||||
|
|
||||||
self.store = DataStore()
|
self.store = DataStore()
|
||||||
self.geocoder = GeocoderWorker(
|
self.geocoder = GeocoderWorker(
|
||||||
@@ -593,17 +610,82 @@ class App(ttk.Window):
|
|||||||
|
|
||||||
self._queue: list[dict] = []
|
self._queue: list[dict] = []
|
||||||
self._next_id = 0
|
self._next_id = 0
|
||||||
|
self._status_after_id: str | None = None
|
||||||
|
|
||||||
self._build_ui()
|
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)
|
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||||
log.info("App gestartet (%d Einträge).", self.store.total_count())
|
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:
|
def _on_geocode_service_error(self) -> None:
|
||||||
"""Einmalige Warnung, wenn der Standortdienst wiederholt nicht erreichbar ist."""
|
"""Einmalige Warnung, wenn der Standortdienst wiederholt nicht erreichbar ist."""
|
||||||
self._set_status(
|
self._set_status(
|
||||||
"Standortdienst nicht erreichbar – Koordinaten werden übersprungen. "
|
"Standortdienst nicht erreichbar – Koordinaten werden übersprungen. "
|
||||||
"Bitte Internetverbindung prüfen."
|
"Bitte Internetverbindung prüfen.",
|
||||||
|
"error",
|
||||||
)
|
)
|
||||||
if not self._service_error_shown:
|
if not self._service_error_shown:
|
||||||
self._service_error_shown = True
|
self._service_error_shown = True
|
||||||
@@ -618,12 +700,22 @@ class App(ttk.Window):
|
|||||||
# ── UI-Aufbau ─────────────────────────────────────────────────────────────
|
# ── UI-Aufbau ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _build_ui(self) -> None:
|
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)
|
# Statusleiste (ganz unten, vor Notebook packen)
|
||||||
self._status_var = ttk.StringVar()
|
self._status_var = ttk.StringVar()
|
||||||
ttk.Label(
|
self._status_label = ttk.Label(
|
||||||
self, textvariable=self._status_var, bootstyle="secondary",
|
self, textvariable=self._status_var, bootstyle="secondary",
|
||||||
anchor=W, padding=(12, 4),
|
anchor=W, padding=(12, 5),
|
||||||
).pack(fill=X, side=BOTTOM)
|
)
|
||||||
|
self._status_label.pack(fill=X, side=BOTTOM)
|
||||||
|
|
||||||
# Notebook mit zwei Tabs
|
# Notebook mit zwei Tabs
|
||||||
self.notebook = ttk.Notebook(self)
|
self.notebook = ttk.Notebook(self)
|
||||||
@@ -646,31 +738,33 @@ class App(ttk.Window):
|
|||||||
input_frame = ttk.LabelFrame(parent, text=" Eintrag ", padding=10)
|
input_frame = ttk.LabelFrame(parent, text=" Eintrag ", padding=10)
|
||||||
input_frame.pack(fill=X, padx=4, pady=(4, 6))
|
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))
|
plz_ok = (self.register(lambda P: P == "" or (P.isdigit() and len(P) <= 5)), "%P")
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
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_var = ttk.StringVar()
|
||||||
self.city_cb = ttk.Combobox(input_frame, textvariable=self._city_var, width=24)
|
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["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("<KeyRelease>", self._on_city_key)
|
||||||
self.city_cb.bind("<<ComboboxSelected>>", self._on_city_selected)
|
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()
|
self._plz_var = ttk.StringVar()
|
||||||
plz_entry = ttk.Entry(input_frame, textvariable=self._plz_var, width=8)
|
plz_entry = ttk.Entry(
|
||||||
plz_entry.grid(row=0, column=5, sticky=W, padx=(0, 14))
|
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(
|
add_btn = ttk.Button(
|
||||||
input_frame, text="+ Hinzufügen", bootstyle="success-outline",
|
input_frame, text="+ Hinzufügen", bootstyle="success",
|
||||||
command=self._on_add_row,
|
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):
|
for w in (self._date_entry.entry, self.city_cb, plz_entry):
|
||||||
w.bind("<Return>", lambda _e: self._on_add_row())
|
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("<ButtonRelease-1>", self._on_tree_click)
|
||||||
self.tree.bind("<Button-3>", self._on_queue_right_click)
|
self.tree.bind("<Button-3>", self._on_queue_right_click)
|
||||||
|
self.tree.bind("<Delete>", lambda _e: self._delete_selected_queue_rows())
|
||||||
|
|
||||||
# Aktionsleiste
|
# Aktionsleiste
|
||||||
action_frame = ttk.Frame(parent, padding=(4, 4))
|
action_frame = ttk.Frame(parent, padding=(4, 6))
|
||||||
action_frame.pack(fill=X)
|
action_frame.pack(fill=X)
|
||||||
ttk.Button(action_frame, text="Alle speichern", bootstyle="success",
|
ttk.Button(action_frame, text="Alle speichern", bootstyle="success",
|
||||||
command=self._on_save_all).pack(side=LEFT, padx=(0, 6))
|
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))
|
command=self._on_clear_queue).pack(side=LEFT, padx=(0, 6))
|
||||||
ttk.Button(action_frame, text="Karte öffnen", bootstyle="info",
|
ttk.Button(action_frame, text="Karte öffnen", bootstyle="info",
|
||||||
command=self._on_open_map).pack(side=RIGHT)
|
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 = ttk.Frame(parent, padding=(4, 2))
|
||||||
hist_frame.pack(fill=BOTH, expand=True)
|
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 = ttk.Treeview(hist_frame, columns=cols, show="headings", height=16)
|
||||||
self.hist_tree.heading("date", text="Datum",
|
for key in cols:
|
||||||
command=lambda: self._sort_history("date"))
|
self.hist_tree.heading(
|
||||||
self.hist_tree.heading("city", text="Ort",
|
key, text=self._hist_headings[key],
|
||||||
command=lambda: self._sort_history("city"))
|
command=lambda k=key: self._sort_history(k),
|
||||||
self.hist_tree.heading("postal_code", text="PLZ",
|
)
|
||||||
command=lambda: self._sort_history("postal_code"))
|
self.hist_tree.column("date", width=115, minwidth=95, stretch=False)
|
||||||
self.hist_tree.heading("lat", text="Breitengrad",
|
self.hist_tree.column("city", width=260, minwidth=140)
|
||||||
command=lambda: self._sort_history("lat"))
|
self.hist_tree.column("postal_code", width=80, minwidth=55, stretch=False, anchor=CENTER)
|
||||||
self.hist_tree.heading("lon", text="Längengrad",
|
self.hist_tree.column("coords", width=90, minwidth=70, stretch=False, anchor=CENTER)
|
||||||
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)
|
|
||||||
|
|
||||||
sb2 = ttk.Scrollbar(hist_frame, orient=VERTICAL, command=self.hist_tree.yview)
|
sb2 = ttk.Scrollbar(hist_frame, orient=VERTICAL, command=self.hist_tree.yview)
|
||||||
self.hist_tree.configure(yscrollcommand=sb2.set)
|
self.hist_tree.configure(yscrollcommand=sb2.set)
|
||||||
self.hist_tree.pack(side=LEFT, fill=BOTH, expand=True)
|
self.hist_tree.pack(side=LEFT, fill=BOTH, expand=True)
|
||||||
sb2.pack(side=RIGHT, fill=Y)
|
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("<Double-1>", self._on_hist_double_click)
|
||||||
self.hist_tree.bind("<Button-3>", self._on_hist_right_click)
|
self.hist_tree.bind("<Button-3>", self._on_hist_right_click)
|
||||||
|
|
||||||
self._sort_col = "date"
|
self._sort_col = "date"
|
||||||
self._sort_asc = False # neueste zuerst
|
self._sort_asc = False # neueste zuerst
|
||||||
|
self._apply_sort_headings()
|
||||||
|
|
||||||
# Aktionsleiste
|
# Aktionsleiste
|
||||||
action_frame = ttk.Frame(parent, padding=(4, 4))
|
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))
|
command=self._on_hist_edit).pack(side=LEFT, padx=(0, 6))
|
||||||
ttk.Button(action_frame, text="Löschen", bootstyle="danger-outline",
|
ttk.Button(action_frame, text="Löschen", bootstyle="danger-outline",
|
||||||
command=self._on_hist_delete).pack(side=LEFT)
|
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,
|
ttk.Label(action_frame, textvariable=self._hist_hint_var,
|
||||||
bootstyle="secondary").pack(side=RIGHT)
|
bootstyle="secondary").pack(side=RIGHT)
|
||||||
|
|
||||||
@@ -788,6 +883,7 @@ class App(ttk.Window):
|
|||||||
|
|
||||||
df = self.store.df.copy()
|
df = self.store.df.copy()
|
||||||
missing = df["lat"].map(_is_blank) | df["lon"].map(_is_blank)
|
missing = df["lat"].map(_is_blank) | df["lon"].map(_is_blank)
|
||||||
|
total = len(df)
|
||||||
total_missing = int(missing.sum())
|
total_missing = int(missing.sum())
|
||||||
|
|
||||||
query = self._search_var.get().strip().lower()
|
query = self._search_var.get().strip().lower()
|
||||||
@@ -801,7 +897,13 @@ class App(ttk.Window):
|
|||||||
if self._only_missing_var.get():
|
if self._only_missing_var.get():
|
||||||
df = df[missing.reindex(df.index, fill_value=False)]
|
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():
|
for idx, row in df.iterrows():
|
||||||
row_missing = _is_blank(row.get("lat", "")) or _is_blank(row.get("lon", ""))
|
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("date", ""),
|
||||||
row.get("city", ""),
|
row.get("city", ""),
|
||||||
row.get("postal_code", ""),
|
row.get("postal_code", ""),
|
||||||
row.get("lat", ""),
|
"fehlt" if row_missing else "✓",
|
||||||
row.get("lon", ""),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parts = [f"{total} Einsätze gespeichert"]
|
||||||
if total_missing:
|
if total_missing:
|
||||||
self._hist_hint_var.set(
|
parts.append(
|
||||||
f"{total_missing} Eintrag/Einträge ohne Koordinaten (rot) – "
|
f"{total_missing} ohne Koordinaten (rot) – Rechtsklick › Koordinaten suchen"
|
||||||
f"Rechtsklick › Koordinaten suchen"
|
|
||||||
)
|
)
|
||||||
else:
|
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:
|
def _sort_history(self, col: str) -> None:
|
||||||
if self._sort_col == col:
|
if self._sort_col == col:
|
||||||
@@ -831,6 +941,7 @@ class App(ttk.Window):
|
|||||||
else:
|
else:
|
||||||
self._sort_col = col
|
self._sort_col = col
|
||||||
self._sort_asc = True
|
self._sort_asc = True
|
||||||
|
self._apply_sort_headings()
|
||||||
self._refresh_history()
|
self._refresh_history()
|
||||||
|
|
||||||
def _on_hist_double_click(self, event) -> None:
|
def _on_hist_double_click(self, event) -> None:
|
||||||
@@ -907,8 +1018,8 @@ class App(ttk.Window):
|
|||||||
new_vals["lon"] = dlg.result["lon"]
|
new_vals["lon"] = dlg.result["lon"]
|
||||||
self.store.update_row(df_idx, new_vals)
|
self.store.update_row(df_idx, new_vals)
|
||||||
self._set_status(
|
self._set_status(
|
||||||
f"Eintrag aktualisiert: {new_vals['date']} {new_vals['city']}. "
|
f"Eintrag aktualisiert: {new_vals['date']} {new_vals['city']}.",
|
||||||
f"Gesamt: {self.store.total_count()} Einträge."
|
"success",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.city_cb["values"] = self.store.get_autocomplete_strings()
|
self.city_cb["values"] = self.store.get_autocomplete_strings()
|
||||||
@@ -923,11 +1034,12 @@ class App(ttk.Window):
|
|||||||
"lat": round(coords[0], 5),
|
"lat": round(coords[0], 5),
|
||||||
"lon": round(coords[1], 5),
|
"lon": round(coords[1], 5),
|
||||||
})
|
})
|
||||||
self._set_status(
|
self._set_status("Koordinaten aktualisiert.", "success")
|
||||||
f"Koordinaten aktualisiert. Gesamt: {self.store.total_count()} Einträge."
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
self._set_status("Koordinaten konnten nicht gefunden werden.")
|
self._set_status(
|
||||||
|
"Koordinaten konnten nicht gefunden werden – ggf. manuell eintragen.",
|
||||||
|
"warning",
|
||||||
|
)
|
||||||
self._refresh_history()
|
self._refresh_history()
|
||||||
|
|
||||||
def _on_hist_delete(self) -> None:
|
def _on_hist_delete(self) -> None:
|
||||||
@@ -945,9 +1057,11 @@ class App(ttk.Window):
|
|||||||
return
|
return
|
||||||
indices = [int(iid) for iid in sel]
|
indices = [int(iid) for iid in sel]
|
||||||
self.store.delete_rows(indices)
|
self.store.delete_rows(indices)
|
||||||
|
self._update_title()
|
||||||
self._refresh_history()
|
self._refresh_history()
|
||||||
self._set_status(
|
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 ─────────────────────────────────────────────────
|
# ── Autovervollständigung ─────────────────────────────────────────────────
|
||||||
@@ -971,10 +1085,10 @@ class App(ttk.Window):
|
|||||||
date_str = parse_date(self._date_entry.entry.get())
|
date_str = parse_date(self._date_entry.entry.get())
|
||||||
|
|
||||||
if not city:
|
if not city:
|
||||||
self._set_status("Bitte einen Ort eingeben.")
|
self._set_status("Bitte einen Ort eingeben.", "warning")
|
||||||
return
|
return
|
||||||
if date_str is None:
|
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(
|
messagebox.showwarning(
|
||||||
"Ungültiges Datum",
|
"Ungültiges Datum",
|
||||||
"Bitte ein gültiges Datum im Format JJJJ-MM-TT eingeben "
|
"Bitte ein gültiges Datum im Format JJJJ-MM-TT eingeben "
|
||||||
@@ -996,7 +1110,9 @@ class App(ttk.Window):
|
|||||||
self._city_var.set("")
|
self._city_var.set("")
|
||||||
self._plz_var.set("")
|
self._plz_var.set("")
|
||||||
self.city_cb.focus_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:
|
def _geocode_done(self, row_id: str, coords: tuple | None) -> None:
|
||||||
self.after(0, self._apply_geocode_result, row_id, coords)
|
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]
|
self._queue = [r for r in self._queue if r["_id"] != row_id]
|
||||||
if self.tree.exists(row_id):
|
if self.tree.exists(row_id):
|
||||||
self.tree.delete(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:
|
def _retry_geocode(self, row_id: str) -> None:
|
||||||
for row in self._queue:
|
for row in self._queue:
|
||||||
@@ -1058,6 +1184,12 @@ class App(ttk.Window):
|
|||||||
break
|
break
|
||||||
|
|
||||||
def _on_clear_queue(self) -> None:
|
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()
|
self._queue.clear()
|
||||||
for item in self.tree.get_children():
|
for item in self.tree.get_children():
|
||||||
self.tree.delete(item)
|
self.tree.delete(item)
|
||||||
@@ -1072,11 +1204,16 @@ class App(ttk.Window):
|
|||||||
|
|
||||||
dupes = self.store.find_duplicates(self._queue)
|
dupes = self.store.find_duplicates(self._queue)
|
||||||
if dupes:
|
if dupes:
|
||||||
names = ", ".join(f"{d['date']} {d['city']}" for d in dupes[:3])
|
listed = "\n".join(f" • {d['date']} {d['city']}" for d in dupes[:8])
|
||||||
suffix = " u.w." if len(dupes) > 3 else ""
|
more = f"\n … und {len(dupes) - 8} weitere" if len(dupes) > 8 else ""
|
||||||
self._set_status(
|
if not messagebox.askyesno(
|
||||||
f"Hinweis: Mögliche Duplikate ({names}{suffix}). Trotzdem gespeichert."
|
"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)
|
self.store.append_rows(self._queue)
|
||||||
count = len(self._queue)
|
count = len(self._queue)
|
||||||
@@ -1085,9 +1222,11 @@ class App(ttk.Window):
|
|||||||
self.tree.delete(item)
|
self.tree.delete(item)
|
||||||
|
|
||||||
self.city_cb["values"] = self.store.get_autocomplete_strings()
|
self.city_cb["values"] = self.store.get_autocomplete_strings()
|
||||||
|
self._update_title()
|
||||||
self._refresh_history() # Tab 2 sofort aktualisieren
|
self._refresh_history() # Tab 2 sofort aktualisieren
|
||||||
self._set_status(
|
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 ─────────────────────────────────────────────────────────────────
|
# ── Karte ─────────────────────────────────────────────────────────────────
|
||||||
@@ -1096,25 +1235,50 @@ class App(ttk.Window):
|
|||||||
try:
|
try:
|
||||||
path = generate_map(self.store)
|
path = generate_map(self.store)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
self._set_status(str(e))
|
messagebox.showinfo("Karte", str(e), parent=self)
|
||||||
|
self._set_status(str(e), "warning")
|
||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception("Karte konnte nicht erstellt werden.")
|
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
|
return
|
||||||
try:
|
try:
|
||||||
webbrowser.open(path.as_uri())
|
webbrowser.open(path.as_uri())
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("Karte konnte nicht im Browser geöffnet werden.")
|
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 ─────────────────────────────────────────────────────────
|
# ── 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_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:
|
def _on_close(self) -> None:
|
||||||
log.info("App wird beendet.")
|
log.info("App wird beendet.")
|
||||||
|
self._save_window_state()
|
||||||
self.geocoder.stop()
|
self.geocoder.stop()
|
||||||
self.destroy()
|
self.destroy()
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ code) and plots them on an interactive map.
|
|||||||
- **Storage:** one CSV file, `data/orte.csv`
|
- **Storage:** one CSV file, `data/orte.csv`
|
||||||
- **Map:** generated on demand as `data/karte.html` with [Folium](https://python-visualization.github.io/folium/) (Leaflet), opened in the default browser
|
- **Map:** generated on demand as `data/karte.html` with [Folium](https://python-visualization.github.io/folium/) (Leaflet), opened in the default browser
|
||||||
- **Geocoding:** OpenStreetMap Nominatim via [`geopy`](https://geopy.readthedocs.io/), rate-limited to 1 request/second on a background thread
|
- **Geocoding:** OpenStreetMap Nominatim via [`geopy`](https://geopy.readthedocs.io/), rate-limited to 1 request/second on a background thread
|
||||||
- **UI language:** German
|
- **UI language:** German; DRK-red header, enlarged font, colour-coded status bar, window position remembered
|
||||||
- **Persistence:** atomic CSV writes + rotating backups in `data/backups/`; log at `data/app.log`
|
- **Persistence:** atomic CSV writes + rotating backups in `data/backups/`; log at `data/app.log`
|
||||||
- **Tests:** non-GUI smoke checks only (not yet a committed `tests/` suite)
|
- **Tests:** non-GUI smoke checks only (not yet a committed `tests/` suite)
|
||||||
- **Version control:** git initialized 2026-09-07
|
- **Version control:** git initialized 2026-09-07
|
||||||
|
|||||||
+28
-2
@@ -2,6 +2,32 @@
|
|||||||
|
|
||||||
Notable changes to the app. Newest first.
|
Notable changes to the app. Newest first.
|
||||||
|
|
||||||
|
## 2026-09-07 — UI/UX pass
|
||||||
|
|
||||||
|
A round of usability improvements aimed at the non-technical user
|
||||||
|
(see [improvements.md](improvements.md#uiux-review-2026-09-07)).
|
||||||
|
|
||||||
|
- **Larger UI font** (`UI_FONT_SIZE = 11`) applied to all default fonts, menus,
|
||||||
|
dialogs and the Treeview (taller rows).
|
||||||
|
- **DRK-red header bar** with the app name; window title shows the total count
|
||||||
|
("… · 142 Einsätze"); simple red-cross window icon drawn at runtime (also
|
||||||
|
written to `data/icon.png` for the launcher).
|
||||||
|
- **Status bar** is now colour-coded by severity (grey/green/orange/red), and
|
||||||
|
transient messages auto-clear after 8 s back to a neutral state; queue-count
|
||||||
|
messages stay put.
|
||||||
|
- **Tab 1:** more spacing in the form; PLZ field accepts digits only;
|
||||||
|
"+ Hinzufügen" is a solid button; explicit **"Auswahl entfernen"** button and
|
||||||
|
<kbd>Delete</kbd> key for queue rows; **"Warteschlange leeren"** now asks for
|
||||||
|
confirmation; **duplicate save shows a modal** (list + "trotzdem speichern?")
|
||||||
|
instead of a transient hint.
|
||||||
|
- **Tab 2:** the raw `lat`/`lon` columns are replaced by a single **"Karte"**
|
||||||
|
column (`✓` / red `fehlt`); active sort column shows a ▲/▼ arrow; the hint
|
||||||
|
line always shows the saved-entry count.
|
||||||
|
- **"Karte öffnen"** with no coordinates now shows a dialog, not just a status
|
||||||
|
line.
|
||||||
|
- **Window size & position** are remembered between sessions
|
||||||
|
(`data/window.json`).
|
||||||
|
|
||||||
## 2026-09-07 — data safety, robustness, easier input
|
## 2026-09-07 — data safety, robustness, easier input
|
||||||
|
|
||||||
Addressed the top three findings from [improvements.md](improvements.md).
|
Addressed the top three findings from [improvements.md](improvements.md).
|
||||||
@@ -43,5 +69,5 @@ Addressed the top three findings from [improvements.md](improvements.md).
|
|||||||
|
|
||||||
### Verification
|
### Verification
|
||||||
Non-GUI smoke tests (helpers, `DataStore` backup/atomic/recovery, geocoder
|
Non-GUI smoke tests (helpers, `DataStore` backup/atomic/recovery, geocoder
|
||||||
resilience) pass. **The GUI could not be run on the development Mac** — see
|
resilience) pass. The GUI now *can* be built and driven on the dev Mac via a
|
||||||
[dev-notes.md](dev-notes.md).
|
Homebrew Python with Tk 9 — see [dev-notes.md](dev-notes.md).
|
||||||
|
|||||||
@@ -59,3 +59,14 @@ closed.
|
|||||||
|
|
||||||
Rotating log file (512 KB × 3 generations). Git-ignored. Records saves, map
|
Rotating log file (512 KB × 3 generations). Git-ignored. Records saves, map
|
||||||
generation, geocoding failures, and uncaught exceptions.
|
generation, geocoding failures, and uncaught exceptions.
|
||||||
|
|
||||||
|
## `data/window.json`
|
||||||
|
|
||||||
|
`{"geometry": "<w>x<h>+<x>+<y>"}` – the window size and position, saved on close
|
||||||
|
and restored on start. Git-ignored, per-machine. Safe to delete (window opens at
|
||||||
|
its default size).
|
||||||
|
|
||||||
|
## `data/icon.png`
|
||||||
|
|
||||||
|
The red-cross window icon, drawn at first run and written here so the `.desktop`
|
||||||
|
launcher can point at it. Git-ignored (regenerated). Delete to force a redraw.
|
||||||
|
|||||||
+20
-12
@@ -3,23 +3,31 @@
|
|||||||
Running list so the same mistakes aren't repeated. Add to it whenever reality
|
Running list so the same mistakes aren't repeated. Add to it whenever reality
|
||||||
contradicts an assumption.
|
contradicts an assumption.
|
||||||
|
|
||||||
## The GUI does not run on the development Mac (2026-09-07)
|
## The macOS system Python can't run the GUI; use a Homebrew Python (2026-09-07)
|
||||||
|
|
||||||
The macOS **CommandLineTools** Python 3.9 (`/Library/Developer/CommandLineTools/
|
The macOS **CommandLineTools** Python 3.9 (`/Library/Developer/CommandLineTools/
|
||||||
.../python3.9`) ships **Tk 8.5.9**. `ttkbootstrap` 1.10.1 requires **Tk 8.6+**.
|
.../python3.9`) ships **Tk 8.5.9**. `ttkbootstrap` 1.10.1 requires **Tk 8.6+**.
|
||||||
|
Symptoms: `TclError: couldn't recognize image data` (window icon) and
|
||||||
|
`TclError: unknown option "-style"` on `ttk.Scrollbar`.
|
||||||
|
|
||||||
Symptoms when trying to launch `App()` there:
|
**Fix for local GUI testing:** `brew install python-tk@3.12` (pulls `tcl-tk`,
|
||||||
- `_tkinter.TclError: couldn't recognize image data` (ttkbootstrap's window icon
|
gives Python 3.12 + **Tk 9.0**), then:
|
||||||
is a PNG; Tk 8.5 can't decode it), and
|
|
||||||
- `_tkinter.TclError: unknown option "-style"` on `ttk.Scrollbar`.
|
|
||||||
|
|
||||||
Consequences:
|
```bash
|
||||||
- `app.py` **cannot be smoke-tested through the GUI on this machine.** Logic is
|
/opt/homebrew/bin/python3.12 -m venv venv
|
||||||
covered by a non-GUI script (constructs `DataStore`, exercises helpers and the
|
venv/bin/pip install -r requirements.txt
|
||||||
worker with monkey-patched paths).
|
venv/bin/python app.py
|
||||||
- The **Linux Mint target is unaffected** — its system Tk is 8.6.
|
```
|
||||||
- If GUI testing on macOS is ever needed, install a Homebrew
|
|
||||||
`python-tk@3.x` / `tcl-tk` combo, or use `pyenv` with `--with-tcltk`.
|
`ttkbootstrap` 1.10.1 runs fine on Tk 9.0 in practice (verified: window builds,
|
||||||
|
Treeview sort, DateEntry, LabelFrame, custom styles all work).
|
||||||
|
|
||||||
|
- The **Linux Mint target** uses its own `python3-tk` (Tk 8.6+) and is
|
||||||
|
unaffected either way.
|
||||||
|
- `screencapture` from a non-GUI shell fails ("could not create image from
|
||||||
|
display") without Screen-Recording permission — automated screenshots of the
|
||||||
|
running app aren't available here; verify by building the widget tree and
|
||||||
|
driving it programmatically instead.
|
||||||
|
|
||||||
## `pandas` reads everything as strings by design
|
## `pandas` reads everything as strings by design
|
||||||
|
|
||||||
|
|||||||
+24
-1
@@ -188,9 +188,32 @@ tile pack — larger effort).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## UI/UX review (2026-09-07)
|
||||||
|
|
||||||
|
Second review, focused on the interface. Items 1–12 implemented; 13 deferred.
|
||||||
|
|
||||||
|
| # | Change | Status |
|
||||||
|
|---|--------|--------|
|
||||||
|
| 1 | Larger base font + taller Treeview rows (`UI_FONT_SIZE`) | ✅ |
|
||||||
|
| 2 | Colour-coded status bar + 8 s auto-clear to a neutral state | ✅ |
|
||||||
|
| 3 | "Warteschlange leeren" asks for confirmation; more form spacing | ✅ |
|
||||||
|
| 4 | ▲/▼ arrow on the active sort column in Tab 2 | ✅ |
|
||||||
|
| 5 | Dropped the now-redundant `(JJJJ-MM-TT)` hint | ✅ |
|
||||||
|
| 6 | Tab 2: single **"Karte"** column (`✓` / red `fehlt`) instead of raw lat/lon | ✅ |
|
||||||
|
| 7 | Explicit "Auswahl entfernen" button + <kbd>Delete</kbd> key for queue rows | ✅ |
|
||||||
|
| 8 | Duplicate save shows a modal with the list, not a transient hint | ✅ |
|
||||||
|
| 9 | Persistent entry count (window title + Tab 2 hint line) | ✅ |
|
||||||
|
| 10 | "Karte öffnen" with no coordinates shows a dialog | ✅ |
|
||||||
|
| 11 | Remember window size & position (`data/window.json`) | ✅ |
|
||||||
|
| 12 | DRK-red header bar + runtime-drawn red-cross icon (`data/icon.png`) | ✅ |
|
||||||
|
| 13 | One-click "Hinzufügen & sofort speichern" | ⬜ deferred |
|
||||||
|
|
||||||
|
Full custom DRK-red *theme* (recolouring `primary` etc.) was **not** done — the
|
||||||
|
red header bar gives the branding without fighting ttkbootstrap's theme system.
|
||||||
|
|
||||||
## Suggested order of remaining work
|
## Suggested order of remaining work
|
||||||
|
|
||||||
1. Initial git commit (item 4), then decide on committing `orte.csv`.
|
1. Initial git commit (item 4), then decide on committing `orte.csv`.
|
||||||
2. Pinned deps + `run.sh` / `.desktop` launcher in the repo (item 5).
|
2. Pinned deps + `run.sh` / `.desktop` launcher in the repo (item 5).
|
||||||
3. Stable `id` column (item 7) and a modal duplicate prompt (item 8).
|
3. Stable `id` column (item 7 of the first review).
|
||||||
4. Formalize tests (item 10); offline map (item 9) only if offline use becomes real.
|
4. Formalize tests (item 10); offline map (item 9) only if offline use becomes real.
|
||||||
|
|||||||
+23
-12
@@ -1,6 +1,11 @@
|
|||||||
# Overview – what the app does
|
# Overview – what the app does
|
||||||
|
|
||||||
The app is a single window with a status bar at the bottom and two tabs.
|
The app is a single window: a **DRK-red header bar**, two tabs, and a
|
||||||
|
**colour-coded status bar** at the bottom (grey = neutral, green = success,
|
||||||
|
orange = warning, red = error; transient messages fade back to neutral after
|
||||||
|
~8 s). The window title shows the total entry count, and the window remembers
|
||||||
|
its size and position between sessions (`data/window.json`). The UI font is
|
||||||
|
enlarged for readability (`UI_FONT_SIZE`).
|
||||||
|
|
||||||
## Tab 1 – "Neuer Eintrag" (new entry)
|
## Tab 1 – "Neuer Eintrag" (new entry)
|
||||||
|
|
||||||
@@ -12,28 +17,34 @@ then save the whole batch at once.
|
|||||||
`TT.MM.JJJJ` and **validated** – an invalid date is rejected with a dialog),
|
`TT.MM.JJJJ` and **validated** – an invalid date is rejected with a dialog),
|
||||||
city (autocomplete combobox), postal code (optional). Pressing `Return` in any
|
city (autocomplete combobox), postal code (optional). Pressing `Return` in any
|
||||||
field, or the **+ Hinzufügen** button, adds the row to the queue.
|
field, or the **+ Hinzufügen** button, adds the row to the queue.
|
||||||
|
The PLZ field only accepts digits.
|
||||||
2. **Queue table** – shows date, city, PLZ, and a live coordinate column that
|
2. **Queue table** – shows date, city, PLZ, and a live coordinate column that
|
||||||
updates from `⏳ wird gesucht…` to either `lat / lon` or `⚠ nicht gefunden`
|
updates from `⏳ wird gesucht…` to either `lat / lon` or `⚠ nicht gefunden`
|
||||||
as the background geocoder works through the queue.
|
as the background geocoder works through the queue.
|
||||||
- Left-click the `✕` cell to remove a row.
|
- Select a row and press <kbd>Delete</kbd>, use the **"Auswahl entfernen"**
|
||||||
|
button, or click the `✕` cell.
|
||||||
- Right-click a row for *Löschen* / *Koordinaten erneut suchen*.
|
- Right-click a row for *Löschen* / *Koordinaten erneut suchen*.
|
||||||
3. **Action bar**
|
3. **Action bar**
|
||||||
- **Alle speichern** – appends every queued row to `orte.csv`. If a row looks
|
- **Alle speichern** – appends every queued row to `orte.csv`. If any row
|
||||||
like a duplicate of an existing entry (same date + city + PLZ) a hint is
|
looks like a duplicate (same date + city + PLZ), a **modal** lists them and
|
||||||
shown in the status bar, but the row is **still saved**.
|
asks whether to save anyway.
|
||||||
- **Leeren** – discards the queue without saving.
|
- **Warteschlange leeren** – discards the queue (asks for confirmation).
|
||||||
- **Karte öffnen** – regenerates `karte.html` and opens it in the browser.
|
- **Karte öffnen** – regenerates `karte.html` and opens it in the browser.
|
||||||
|
|
||||||
## Tab 2 – "Einträge verwalten" (manage entries)
|
## Tab 2 – "Einträge verwalten" (manage entries)
|
||||||
|
|
||||||
A table view of everything in `orte.csv`.
|
A table view of everything in `orte.csv`.
|
||||||
|
|
||||||
|
- **Columns:** Datum, Ort, PLZ, and **Karte** – a status column showing `✓` when
|
||||||
|
the row has coordinates or a red `fehlt` when it doesn't (the whole row is red
|
||||||
|
too). The raw lat/lon numbers live in the edit dialog, not this table.
|
||||||
- **Search box** – live filter across date, city, and PLZ (substring match).
|
- **Search box** – live filter across date, city, and PLZ (substring match).
|
||||||
- **Sortable columns** – click a header to sort; clicking again reverses.
|
- **Sortable columns** – click a header to sort; clicking again reverses. The
|
||||||
Default sort is by date, newest first.
|
active column shows a ▲/▼ arrow. Default sort is by date, newest first.
|
||||||
|
Sorting by **Karte** groups the rows without coordinates together.
|
||||||
- **"Nur ohne Koordinaten"** toggle – filters to entries that have no
|
- **"Nur ohne Koordinaten"** toggle – filters to entries that have no
|
||||||
coordinates yet. Such rows are also shown in **red** in the full list, and the
|
coordinates yet. The hint line always shows the total count, plus how many are
|
||||||
hint line reports how many there are.
|
missing coordinates.
|
||||||
- **Edit** – double-click a row (or *Bearbeiten*) opens a modal dialog to change
|
- **Edit** – double-click a row (or *Bearbeiten*) opens a modal dialog to change
|
||||||
date (calendar picker, validated) / city / PLZ. Two ways to fix coordinates:
|
date (calendar picker, validated) / city / PLZ. Two ways to fix coordinates:
|
||||||
a "Koordinaten automatisch neu suchen" toggle re-runs geocoding after saving,
|
a "Koordinaten automatisch neu suchen" toggle re-runs geocoding after saving,
|
||||||
@@ -58,8 +69,8 @@ Generated by `generate_map()`:
|
|||||||
(`5 + count / max_count * 20` px), colour is DRK red (`#CC0000`).
|
(`5 + count / max_count * 20` px), colour is DRK red (`#CC0000`).
|
||||||
- Popup shows the city and the visit count; tooltip shows the city.
|
- Popup shows the city and the visit count; tooltip shows the city.
|
||||||
- Rows with missing/blank coordinates are silently excluded.
|
- Rows with missing/blank coordinates are silently excluded.
|
||||||
- If there are no usable coordinates at all, a `ValueError` is raised and shown
|
- If there are no usable coordinates at all, a dialog says so (and the status
|
||||||
in the status bar.
|
bar shows a warning).
|
||||||
|
|
||||||
> The generated HTML pulls Leaflet, jQuery and Bootstrap from CDNs, so the map
|
> The generated HTML pulls Leaflet, jQuery and Bootstrap from CDNs, so the map
|
||||||
> needs an internet connection to render even though the data is local.
|
> needs an internet connection to render even though the data is local.
|
||||||
|
|||||||
+5
-4
@@ -73,14 +73,15 @@ Type=Application
|
|||||||
Name=DRK Blutspende – Arbeitsorte
|
Name=DRK Blutspende – Arbeitsorte
|
||||||
Comment=Einsatzorte protokollieren und auf der Karte anzeigen
|
Comment=Einsatzorte protokollieren und auf der Karte anzeigen
|
||||||
Exec=/home/USER/Apps/DRK_Blutspende_Orte/run.sh
|
Exec=/home/USER/Apps/DRK_Blutspende_Orte/run.sh
|
||||||
Icon=/home/USER/Apps/DRK_Blutspende_Orte/icon.png
|
Icon=/home/USER/Apps/DRK_Blutspende_Orte/data/icon.png
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Categories=Utility;
|
Categories=Utility;
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace `USER` with the real username. Add any PNG as `icon.png` (the DRK logo
|
Replace `USER` with the real username. The app writes a simple red-cross
|
||||||
works well). The entry then shows up in the Mint menu and can be pinned to the
|
`data/icon.png` on first run; drop in the real DRK logo at that path if you have
|
||||||
panel or the desktop.
|
one. The entry then shows up in the Mint menu and can be pinned to the panel or
|
||||||
|
the desktop.
|
||||||
|
|
||||||
## Python / Tk version
|
## Python / Tk version
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user