"""Shipping tab — split screen: USPS/Stamps (left) and FedEx (right). USPS panel: freeform address block + service/package/weight/notes. FedEx panel: individual structured address fields. When Stamps credentials arrive, the USPS panel calls the SERA address-parse endpoint to convert the pasted block into individual fields before creating the label, so the user never has to type each field separately. """ from __future__ import annotations import json import logging import os import re import threading import uuid import customtkinter as ctk from . import theme as T from .fedex import FedExClient, ShipmentResult from .ui_parts_tab import _bind_context_menu, _flash_button log = logging.getLogger(__name__) # ── USPS shipment options ────────────────────────────────────────────────── # (display_name, service_code, package_code, package_display) _PKG_OPTIONS: list[tuple] = [ ("Ground Advantage", "usps_ground_advantage", "package", "Package"), ("Priority Mail", "usps_priority_mail", "package", "Package"), ("First-Class (Large Env./Flat)", "usps_first_class_mail", "large_envelope", "Large Env./Flat"), ] _ENV_SERVICE_CODE = "usps_first_class_mail" _ENV_PACKAGE_CODE = "letter" _ENV_SERVICE_NAME = "First-Class Mail" _ENV_PACKAGE_NAME = "Letter" # ── FedEx options ────────────────────────────────────────────────────────── _FEDEX_PACKAGES = ["FedEx Envelope", "FedEx Pak"] _FEDEX_SERVICES = ["FedEx 2Day", "FedEx 2Day AM"] # ── Widget defaults ──────────────────────────────────────────────────────── _ENTRY_KWARGS = dict( height=32, font=T.FONT_BODY, fg_color=T.CARD_BG, border_color=T.BORDER, border_width=1, corner_radius=6, text_color=T.TEXT_PRIMARY, placeholder_text_color=T.TEXT_SUBTLE, ) _OPTION_KWARGS = dict( height=32, font=T.FONT_BODY, fg_color=T.CARD_BG, button_color=T.ACCENT, button_hover_color=T.ACCENT_HOVER, text_color=T.TEXT_PRIMARY, dropdown_fg_color=T.CARD_BG, dropdown_text_color=T.TEXT_PRIMARY, dropdown_hover_color=T.CARD_BG_HOVER, corner_radius=6, ) def _lbl(parent, text: str, row: int, col: int = 0, colspan: int = 1) -> None: ctk.CTkLabel( parent, text=text.upper(), font=T.FONT_LABEL, text_color=T.TEXT_MUTED, ).grid(row=row, column=col, columnspan=colspan, sticky="w", padx=(16 if col == 0 else 8, 8), pady=(0, 2)) class _PresetManager: """Persists package dimension presets to appdata/package_presets.json.""" _PLACEHOLDER = "— select preset —" def __init__(self) -> None: from .paths import appdata_dir self._path = appdata_dir() / "package_presets.json" self._presets: list[dict] = self._load() def _load(self) -> list[dict]: try: if self._path.exists(): return json.loads(self._path.read_text(encoding="utf-8")) except Exception: pass return [] def _save(self) -> None: self._path.write_text( json.dumps(self._presets, indent=2), encoding="utf-8" ) def dropdown_values(self) -> list[str]: names = [p["name"] for p in self._presets] return [self._PLACEHOLDER] + names def get(self, name: str) -> dict | None: return next((p for p in self._presets if p["name"] == name), None) def save_preset(self, name: str, l: float, w: float, h: float) -> None: self._presets = [p for p in self._presets if p["name"] != name] self._presets.append({"name": name, "l": l, "w": w, "h": h}) self._save() def delete(self, name: str) -> None: self._presets = [p for p in self._presets if p["name"] != name] self._save() class _PresetPopup(ctk.CTkToplevel): """Borderless popup that lists dimension presets below the Presets button.""" def __init__(self, anchor_widget, manager: _PresetManager, on_select, on_save, on_delete): super().__init__(anchor_widget) self._manager = manager self._on_select = on_select self._on_save = on_save self._on_delete = on_delete self.overrideredirect(True) self.configure(fg_color=T.CARD_BG) self.attributes("-topmost", True) # Position below the anchor button self.update_idletasks() ax = anchor_widget.winfo_rootx() ay = anchor_widget.winfo_rooty() + anchor_widget.winfo_height() + 2 self.geometry(f"+{ax}+{ay}") self._root_widget = anchor_widget.winfo_toplevel() self._bind_id: str | None = None self._build() # Bind after a short delay so the opening click doesn't immediately close it self.after(120, self._bind_outside_click) def _bind_outside_click(self) -> None: self._bind_id = self._root_widget.bind( "", self._on_outside_click, add="+" ) def _on_outside_click(self, event) -> None: try: px, py = self.winfo_rootx(), self.winfo_rooty() pw, ph = self.winfo_width(), self.winfo_height() if not (px <= event.x_root <= px + pw and py <= event.y_root <= py + ph): self._close() except Exception: self._close() def _close(self) -> None: try: if self._bind_id: self._root_widget.unbind("", self._bind_id) except Exception: pass try: self.destroy() except Exception: pass def _build(self) -> None: outer = ctk.CTkFrame(self, fg_color=T.CARD_BG, border_color=T.BORDER_STRONG, border_width=1, corner_radius=6) outer.pack(fill="both", expand=True) outer.columnconfigure(0, weight=1) presets = self._manager._presets if presets: for i, p in enumerate(presets): dims = f"{p['l']} × {p['w']} × {p['h']}\"" row = ctk.CTkFrame(outer, fg_color="transparent") row.grid(row=i, column=0, sticky="ew", padx=4, pady=(4 if i == 0 else 1, 1)) row.columnconfigure(0, weight=1) name = p["name"] ctk.CTkButton( row, text=f"{name} — {dims}", anchor="w", font=T.FONT_BODY, height=28, fg_color="transparent", hover_color=T.CARD_BG_HOVER, text_color=T.TEXT_PRIMARY, corner_radius=4, command=lambda n=name: self._select(n), ).grid(row=0, column=0, sticky="ew") ctk.CTkButton( row, text="✕", width=26, height=28, font=T.FONT_LABEL, fg_color="transparent", hover_color=T.CARD_BG_HOVER, text_color=T.TEXT_MUTED, corner_radius=4, command=lambda n=name: self._delete(n), ).grid(row=0, column=1, padx=(2, 0)) ctk.CTkFrame(outer, fg_color=T.DIVIDER, height=1).grid( row=len(presets), column=0, sticky="ew", padx=8, pady=4) save_row = len(presets) + 1 else: ctk.CTkLabel(outer, text="No presets saved yet.", font=T.FONT_LABEL, text_color=T.TEXT_MUTED).grid( row=0, column=0, padx=12, pady=(10, 4)) save_row = 1 ctk.CTkButton( outer, text="Save current dims as preset…", anchor="w", font=T.FONT_BODY, height=28, fg_color="transparent", hover_color=T.CARD_BG_HOVER, text_color=T.ACCENT, corner_radius=4, command=self._save, ).grid(row=save_row, column=0, sticky="ew", padx=4, pady=(0, 4)) def _select(self, name: str) -> None: self._on_select(name) self._close() def _delete(self, name: str) -> None: self._on_delete(name) self._close() def _save(self) -> None: self._close() self._on_save() class _AddressVerifyDialog(ctk.CTkToplevel): """Shows original vs. USPS-corrected address before printing.""" def __init__(self, parent, original: str, suggested: str, on_accept): super().__init__(parent) self._on_accept = on_accept self.title("Address Verification") self.resizable(False, False) self.grab_set() self.attributes("-topmost", True) self.update_idletasks() w, h = 500, 310 px = parent.winfo_rootx() + parent.winfo_width() // 2 - w // 2 py = parent.winfo_rooty() + parent.winfo_height() // 2 - h // 2 self.geometry(f"{w}x{h}+{px}+{py}") self.columnconfigure(0, weight=1) self.columnconfigure(1, weight=1) ctk.CTkLabel(self, text="Address Verification", font=T.FONT_BODY_BOLD, text_color=T.TEXT_PRIMARY).grid( row=0, column=0, columnspan=2, sticky="w", padx=20, pady=(16, 2)) ctk.CTkLabel(self, text="The address was adjusted. Review before printing.", font=T.FONT_LABEL, text_color=T.TEXT_MUTED).grid( row=1, column=0, columnspan=2, sticky="w", padx=20, pady=(0, 10)) for col, label, text, border in [ (0, "AS ENTERED", original, T.BORDER), (1, "USPS SUGGESTED", suggested, T.ACCENT), ]: pad_l = (20, 6) if col == 0 else (6, 20) ctk.CTkLabel(self, text=label, font=T.FONT_LABEL, text_color=T.TEXT_MUTED if col == 0 else T.ACCENT).grid( row=2, column=col, sticky="w", padx=pad_l, pady=(0, 3)) box = ctk.CTkTextbox(self, height=110, font=T.FONT_BODY, fg_color=T.CARD_BG, border_color=border, border_width=1, text_color=T.TEXT_PRIMARY, corner_radius=6) box.grid(row=3, column=col, sticky="nsew", padx=pad_l, pady=(0, 12)) box.insert("1.0", text) box.configure(state="disabled") btn_row = ctk.CTkFrame(self, fg_color="transparent") btn_row.grid(row=4, column=0, columnspan=2, sticky="ew", padx=20, pady=(0, 16)) ctk.CTkButton( btn_row, text="Edit Address", width=110, height=32, font=T.FONT_LABEL, fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER, text_color=T.TEXT_PRIMARY, border_width=1, border_color=T.NEUTRAL_BTN_BORDER, corner_radius=6, command=self.destroy, ).pack(side="left") ctk.CTkButton( btn_row, text="Accept & Print", width=120, height=32, font=T.FONT_LABEL, fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER, text_color=T.ACCENT_TEXT, border_width=1, border_color=T.ACCENT_BORDER, corner_radius=6, command=self._accept, ).pack(side="right") def _accept(self) -> None: self.destroy() self._on_accept() class _AmbiguousAddressDialog(ctk.CTkToplevel): """Shown when USPS returns multiple candidate addresses. If the user picks a candidate that contains a range like [A - D] or [1100 - 1199], an inline input row appears so they can specify the exact unit/number before the dialog closes. """ _RANGE_RE = re.compile(r'\[([^\]]+)\]') def __init__(self, parent, original: str, candidates: list, on_select): super().__init__(parent) self._on_select = on_select self._pending: dict | None = None self._pending_field: str | None = None self._pending_pat: str | None = None self.title("Ambiguous Address") self.resizable(False, False) self.grab_set() self.attributes("-topmost", True) w, h = 500, 460 px = parent.winfo_rootx() + parent.winfo_width() // 2 - w // 2 py = parent.winfo_rooty() + parent.winfo_height() // 2 - h // 2 self.geometry(f"{w}x{h}+{px}+{py}") self.columnconfigure(0, weight=1) self.rowconfigure(4, weight=1) ctk.CTkLabel(self, text="Ambiguous Address", font=T.FONT_BODY_BOLD, text_color=T.TEXT_PRIMARY).grid( row=0, column=0, sticky="w", padx=20, pady=(16, 2)) ctk.CTkLabel(self, text="Multiple addresses matched. Select the most specific one.", font=T.FONT_LABEL, text_color=T.TEXT_MUTED).grid( row=1, column=0, sticky="w", padx=20, pady=(0, 8)) # Original address strip orig = ctk.CTkFrame(self, fg_color=T.CARD_BG, border_color=T.BORDER, border_width=1, corner_radius=6) orig.grid(row=2, column=0, sticky="ew", padx=20, pady=(0, 10)) ctk.CTkLabel(orig, text="ORIGINAL", font=T.FONT_LABEL, text_color=T.TEXT_MUTED).pack(side="left", padx=(10, 6), pady=7) ctk.CTkLabel(orig, text=original.replace("\n", " · "), font=T.FONT_BODY, text_color=T.TEXT_PRIMARY, wraplength=340, justify="left").pack(side="left", pady=7) ctk.CTkLabel(self, text="POSSIBLE ADDRESSES", font=T.FONT_LABEL, text_color=T.TEXT_MUTED).grid( row=3, column=0, sticky="w", padx=20, pady=(0, 4)) # Scrollable candidate list scroll = ctk.CTkScrollableFrame(self, fg_color=T.CARD_BG, border_color=T.BORDER, border_width=1, corner_radius=6) scroll.grid(row=4, column=0, sticky="nsew", padx=20, pady=(0, 8)) scroll.columnconfigure(0, weight=1) for i, cand in enumerate(candidates): parts = [ " ".join(filter(None, [ cand.get("name", ""), cand.get("company_name", "")])), " ".join(filter(None, [ cand.get("address_line1", ""), cand.get("address_line2", "")])), " ".join(filter(None, [ cand.get("city", ""), cand.get("state_province", ""), cand.get("postal_code", "")])), ] display = "\n".join(p for p in parts if p) ctk.CTkButton( scroll, text=display, anchor="w", font=T.FONT_BODY, height=58, fg_color="transparent", hover_color=T.CARD_BG_HOVER, text_color=T.TEXT_PRIMARY, corner_radius=4, command=lambda c=cand: self._pick(c), ).grid(row=i * 2, column=0, sticky="ew", padx=4, pady=(6 if i == 0 else 0, 0)) if i < len(candidates) - 1: ctk.CTkFrame(scroll, fg_color=T.DIVIDER, height=1).grid( row=i * 2 + 1, column=0, sticky="ew", padx=8, pady=3) # Range-input row — hidden until a range candidate is clicked self._range_frame = ctk.CTkFrame(self, fg_color=T.CARD_BG, border_color=T.ACCENT, border_width=1, corner_radius=6) self._range_frame.columnconfigure(1, weight=1) self._range_lbl = ctk.CTkLabel( self._range_frame, text="Enter value:", font=T.FONT_LABEL, text_color=T.TEXT_MUTED) self._range_lbl.grid(row=0, column=0, padx=(12, 8), pady=9) self._range_entry = ctk.CTkEntry( self._range_frame, height=30, font=T.FONT_BODY, fg_color=T.WINDOW_BG, border_color=T.BORDER, border_width=1, text_color=T.TEXT_PRIMARY, corner_radius=6, ) self._range_entry.grid(row=0, column=1, sticky="ew", padx=(0, 8), pady=9) self._range_entry.bind("", lambda _e: self._confirm_range()) ctk.CTkButton( self._range_frame, text="Accept", width=80, height=30, font=T.FONT_LABEL, fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER, text_color=T.ACCENT_TEXT, border_width=1, border_color=T.ACCENT_BORDER, corner_radius=6, command=self._confirm_range, ).grid(row=0, column=2, padx=(0, 10), pady=9) # Cancel btns = ctk.CTkFrame(self, fg_color="transparent") btns.grid(row=6, column=0, sticky="ew", padx=20, pady=(4, 16)) ctk.CTkButton( btns, text="Cancel", width=90, height=32, font=T.FONT_LABEL, fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER, text_color=T.TEXT_PRIMARY, border_width=1, border_color=T.NEUTRAL_BTN_BORDER, corner_radius=6, command=self.destroy, ).pack(side="left") def _pick(self, candidate: dict) -> None: for field in ("address_line1", "address_line2"): m = self._RANGE_RE.search(candidate.get(field, "")) if m: self._pending = candidate self._pending_field = field self._pending_pat = m.group(0) hint = m.group(1).strip() # e.g. "A - D" or "1100 - 1199" self._range_lbl.configure( text=f"Enter specific value ({hint}):") self._range_entry.delete(0, "end") self._range_frame.grid(row=5, column=0, sticky="ew", padx=20, pady=(0, 4)) self._range_entry.focus_set() return # No range — proceed immediately self.destroy() self._on_select(candidate) def _confirm_range(self) -> None: value = self._range_entry.get().strip() if not value: self._range_entry.focus_set() return candidate = dict(self._pending) original_val = candidate.get(self._pending_field, "") candidate[self._pending_field] = re.sub( r'\[[^\]]+\]', value, original_val) self.destroy() self._on_select(candidate) class _ShipmentConfirmDialog(ctk.CTkToplevel): """Review shipment details before committing to label creation and postage charge.""" def __init__(self, parent, parsed: dict, data: dict, rate_text: str, on_confirm): super().__init__(parent) self._on_confirm = on_confirm self.title("Confirm Shipment") self.resizable(False, False) self.grab_set() self.attributes("-topmost", True) self.bind("", lambda _e: self._confirm()) w = 420 self.columnconfigure(0, weight=1) ctk.CTkLabel(self, text="Confirm Shipment", font=T.FONT_BODY_BOLD, text_color=T.TEXT_PRIMARY).grid( row=0, column=0, sticky="w", padx=20, pady=(16, 2)) ctk.CTkLabel(self, text="Postage will be charged once you confirm.", font=T.FONT_LABEL, text_color=T.TEXT_MUTED).grid( row=1, column=0, sticky="w", padx=20, pady=(0, 10)) detail = ctk.CTkFrame(self, fg_color=T.CARD_BG, border_color=T.BORDER, border_width=1, corner_radius=6) detail.grid(row=2, column=0, sticky="ew", padx=20, pady=(0, 12)) detail.columnconfigure(1, weight=1) addr_str = "\n".join(filter(None, [ parsed.get("name", ""), parsed.get("company_name", ""), parsed.get("address_line1", ""), parsed.get("address_line2", ""), " ".join(filter(None, [ parsed.get("city", ""), parsed.get("state_province", ""), parsed.get("postal_code", ""), ])), ])) lbs = data["weight_oz"] // 16 oz_ = data["weight_oz"] % 16 wt_str = (f"{lbs} lb {oz_} oz" if lbs and oz_ else f"{lbs} lb" if lbs else f"{oz_} oz") rows = [ ("TO", addr_str), ("SERVICE", data.get("service_name", data.get("service_code", ""))), ("PACKAGE", data.get("package_name", data.get("package_code", ""))), ("WEIGHT", wt_str), ] if rate_text and rate_text not in ("—", "…", ""): rows.append(("EST. COST", rate_text)) for i, (label, value) in enumerate(rows): top = 12 if i == 0 else 6 bot = 12 if i == len(rows) - 1 else 0 ctk.CTkLabel(detail, text=label, font=T.FONT_LABEL, text_color=T.TEXT_MUTED, anchor="nw").grid( row=i, column=0, sticky="nw", padx=(14, 8), pady=(top, bot)) ctk.CTkLabel(detail, text=value, font=T.FONT_BODY, text_color=T.TEXT_PRIMARY, anchor="w", justify="left", wraplength=260).grid( row=i, column=1, sticky="w", padx=(0, 14), pady=(top, bot)) btns = ctk.CTkFrame(self, fg_color="transparent") btns.grid(row=3, column=0, sticky="ew", padx=20, pady=(0, 16)) ctk.CTkButton( btns, text="Cancel", width=90, height=32, font=T.FONT_LABEL, fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER, text_color=T.TEXT_PRIMARY, border_width=1, border_color=T.NEUTRAL_BTN_BORDER, corner_radius=6, command=self.destroy, ).pack(side="left") ctk.CTkButton( btns, text="Confirm & Create", width=150, height=32, font=T.FONT_LABEL, fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER, text_color=T.ACCENT_TEXT, border_width=1, border_color=T.ACCENT_BORDER, corner_radius=6, command=self._confirm, ).pack(side="right") self.update_idletasks() h = self.winfo_reqheight() px = parent.winfo_rootx() + parent.winfo_width() // 2 - w // 2 py = parent.winfo_rooty() + parent.winfo_height() // 2 - h // 2 self.geometry(f"{w}x{h}+{px}+{py}") def _confirm(self) -> None: self.destroy() self._on_confirm() def _card(parent, **grid_kwargs) -> ctk.CTkFrame: f = ctk.CTkFrame( parent, fg_color=T.PANEL_BG, border_color=T.BORDER, border_width=1, corner_radius=8, ) f.grid(**grid_kwargs) return f # ══════════════════════════════════════════════════════════════════════════════ # Main tab — split screen # ══════════════════════════════════════════════════════════════════════════════ class ShippingTab(ctk.CTkFrame): def __init__(self, master, cfg=None): super().__init__(master, fg_color="transparent") self.columnconfigure(0, weight=1) self.columnconfigure(1, weight=0) self.columnconfigure(2, weight=1) self.rowconfigure(0, weight=1) _UspsPanel(self, cfg=cfg).grid(row=0, column=0, sticky="nsew") ctk.CTkFrame(self, fg_color=T.BORDER, width=1, corner_radius=0).grid(row=0, column=1, sticky="ns") _FedExPanel(self, cfg=cfg).grid(row=0, column=2, sticky="nsew") # ══════════════════════════════════════════════════════════════════════════════ # USPS / Stamps panel — address block input # ══════════════════════════════════════════════════════════════════════════════ class _UspsPanel(ctk.CTkScrollableFrame): def __init__(self, master, cfg=None): super().__init__(master, fg_color="transparent", corner_radius=0) self._cfg = cfg self.columnconfigure(0, weight=1) self._build() def _build(self) -> None: # Header hdr = ctk.CTkFrame(self, fg_color="transparent") hdr.grid(row=0, column=0, sticky="ew", padx=20, pady=(18, 0)) ctk.CTkLabel(hdr, text="USPS", font=T.FONT_HEADER, text_color=T.TEXT_PRIMARY).pack(side="left") ctk.CTkLabel(hdr, text="via ShipEngine", font=T.FONT_BODY, text_color=T.TEXT_MUTED).pack(side="left", padx=(8, 0), pady=(4, 0)) ctk.CTkButton( hdr, text="History", width=80, height=28, font=T.FONT_LABEL, fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER, text_color=T.TEXT_PRIMARY, border_width=1, border_color=T.NEUTRAL_BTN_BORDER, corner_radius=6, command=self._open_history, ).pack(side="right") # Shipment options — compact card opts = _card(self, row=1, column=0, sticky="ew", padx=20, pady=(12, 0)) opts.columnconfigure(1, weight=1) # right side of each inline row expands ctk.CTkLabel(opts, text="Shipment Options", font=T.FONT_BODY_BOLD, text_color=T.TEXT_PRIMARY).grid( row=0, column=0, columnspan=2, sticky="w", padx=16, pady=(12, 8)) # ── Package / Envelope mode toggle ─────────────────────────────── self._mode_var = ctk.StringVar(value="Package") ctk.CTkSegmentedButton( opts, values=["Package", "Envelope"], variable=self._mode_var, command=self._toggle_mode, font=T.FONT_LABEL, fg_color=T.CARD_BG, selected_color=T.ACCENT, selected_hover_color=T.ACCENT_HOVER, unselected_color=T.NEUTRAL_BTN_BG, unselected_hover_color=T.NEUTRAL_BTN_HOVER, text_color=T.TEXT_PRIMARY, ).grid(row=1, column=0, columnspan=2, sticky="ew", padx=16, pady=(0, 8)) ctk.CTkFrame(opts, fg_color=T.DIVIDER, height=1).grid( row=2, column=0, columnspan=2, sticky="ew", padx=16, pady=(0, 8)) # ── Package service radios (Package mode) ───────────────────────── self._pkg_svc_var = ctk.StringVar(value="0") self._pkg_svc_frame = ctk.CTkFrame(opts, fg_color="transparent") self._pkg_svc_frame.grid(row=3, column=0, columnspan=2, sticky="ew", padx=16, pady=(0, 8)) for i, (name, _, _, _) in enumerate(_PKG_OPTIONS): ctk.CTkRadioButton( self._pkg_svc_frame, text=name, variable=self._pkg_svc_var, value=str(i), font=T.FONT_BODY, text_color=T.TEXT_PRIMARY, radiobutton_width=18, radiobutton_height=18, fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER, border_color=T.BORDER_STRONG, command=self._schedule_rate_fetch, ).grid(row=i, column=0, sticky="w", pady=3) # ── Envelope service label (Envelope mode) ──────────────────────── self._env_svc_frame = ctk.CTkFrame(opts, fg_color="transparent") self._env_svc_frame.grid(row=3, column=0, columnspan=2, sticky="ew", padx=16, pady=(4, 8)) ctk.CTkLabel(self._env_svc_frame, text="First-Class Mail · Letter", font=T.FONT_BODY, text_color=T.TEXT_PRIMARY).pack( side="left") self._env_svc_frame.grid_remove() # hidden until Envelope mode selected ctk.CTkFrame(opts, fg_color=T.DIVIDER, height=1).grid( row=4, column=0, columnspan=2, sticky="ew", padx=16, pady=(0, 8)) # ── Weight — single inline row ──────────────────────────────────── wt_row = ctk.CTkFrame(opts, fg_color="transparent") wt_row.grid(row=5, column=0, columnspan=2, sticky="ew", padx=16, pady=(0, 6)) ctk.CTkLabel(wt_row, text="Weight", font=T.FONT_LABEL, text_color=T.TEXT_MUTED, width=62, anchor="w").pack(side="left") _e_kw = {**_ENTRY_KWARGS, "height": 28} self._lbs = ctk.CTkEntry(wt_row, width=52, **_e_kw, placeholder_text="0") self._lbs.pack(side="left") _bind_context_menu(self._lbs) ctk.CTkLabel(wt_row, text="lbs", font=T.FONT_LABEL, text_color=T.TEXT_MUTED).pack(side="left", padx=(4, 10)) self._oz = ctk.CTkEntry(wt_row, width=52, **_e_kw, placeholder_text="0") self._oz.pack(side="left") _bind_context_menu(self._oz) ctk.CTkLabel(wt_row, text="oz", font=T.FONT_LABEL, text_color=T.TEXT_MUTED).pack(side="left", padx=(4, 10)) self._weigh_btn = ctk.CTkButton( wt_row, text="Weigh", width=60, height=28, font=T.FONT_LABEL, fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER, text_color=T.TEXT_PRIMARY, border_width=1, border_color=T.NEUTRAL_BTN_BORDER, corner_radius=6, command=self._weigh_once, ) self._weigh_btn.pack(side="left") self._auto_on = False self._auto_btn = ctk.CTkButton( wt_row, text="Auto", width=56, height=28, font=T.FONT_LABEL, fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER, text_color=T.TEXT_MUTED, border_width=1, border_color=T.NEUTRAL_BTN_BORDER, corner_radius=6, command=self._toggle_auto, ) self._auto_btn.pack(side="left", padx=(6, 0)) from .scale import ScalePoller self._poller = ScalePoller(on_weight=self._on_scale_weight) # ── Dimensions — single inline row with preset picker ───────────── self._dim_row = ctk.CTkFrame(opts, fg_color="transparent") self._dim_row.grid(row=6, column=0, columnspan=2, sticky="ew", padx=16, pady=(0, 12)) dim_row = self._dim_row ctk.CTkLabel(dim_row, text="Dims", font=T.FONT_LABEL, text_color=T.TEXT_MUTED, width=62, anchor="w").pack(side="left") self._dim_l = ctk.CTkEntry(dim_row, width=52, **_e_kw, placeholder_text="0") self._dim_l.pack(side="left") _bind_context_menu(self._dim_l) ctk.CTkLabel(dim_row, text="L", font=T.FONT_LABEL, text_color=T.TEXT_MUTED).pack(side="left", padx=(3, 8)) self._dim_w = ctk.CTkEntry(dim_row, width=52, **_e_kw, placeholder_text="0") self._dim_w.pack(side="left") _bind_context_menu(self._dim_w) ctk.CTkLabel(dim_row, text="W", font=T.FONT_LABEL, text_color=T.TEXT_MUTED).pack(side="left", padx=(3, 8)) self._dim_h = ctk.CTkEntry(dim_row, width=52, **_e_kw, placeholder_text="0") self._dim_h.pack(side="left") _bind_context_menu(self._dim_h) ctk.CTkLabel(dim_row, text="H", font=T.FONT_LABEL, text_color=T.TEXT_MUTED).pack(side="left", padx=(3, 8)) self._presets = _PresetManager() self._preset_btn = ctk.CTkButton( dim_row, text="Presets ▾", width=90, height=28, font=T.FONT_LABEL, fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER, text_color=T.TEXT_PRIMARY, border_width=1, border_color=T.NEUTRAL_BTN_BORDER, corner_radius=6, command=self._open_preset_popup, ) self._preset_btn.pack(side="left") # Delivery Address card addr = _card(self, row=2, column=0, sticky="ew", padx=20, pady=(10, 0)) addr.columnconfigure(0, weight=1) # Header row: title left, Clear button right addr_hdr = ctk.CTkFrame(addr, fg_color="transparent") addr_hdr.grid(row=0, column=0, sticky="ew", padx=16, pady=(12, 4)) addr_hdr.columnconfigure(0, weight=1) ctk.CTkLabel(addr_hdr, text="Delivery Address", font=T.FONT_BODY_BOLD, text_color=T.TEXT_PRIMARY).grid(row=0, column=0, sticky="w") ctk.CTkButton( addr_hdr, text="✕ Clear", width=76, height=26, font=T.FONT_LABEL, fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER, text_color=T.TEXT_MUTED, border_width=1, border_color=T.NEUTRAL_BTN_BORDER, corner_radius=6, command=lambda: self._address_block.delete("1.0", "end"), ).grid(row=0, column=1, sticky="e") self._address_block = ctk.CTkTextbox( addr, height=100, font=T.FONT_BODY, fg_color=T.CARD_BG, border_color=T.BORDER, border_width=1, text_color=T.TEXT_PRIMARY, corner_radius=6, ) self._address_block.grid(row=1, column=0, sticky="ew", padx=16, pady=(0, 8)) _bind_context_menu(self._address_block) # Email Recipient row email_row = ctk.CTkFrame(addr, fg_color="transparent") email_row.grid(row=2, column=0, sticky="ew", padx=16, pady=(0, 12)) email_row.columnconfigure(1, weight=1) self._email_var = ctk.BooleanVar(value=True) ctk.CTkCheckBox( email_row, text="Email Recipient:", variable=self._email_var, font=T.FONT_LABEL, text_color=T.TEXT_PRIMARY, fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER, border_color=T.BORDER_STRONG, checkmark_color=T.ACCENT_TEXT, width=16, height=16, command=self._toggle_email_entry, ).grid(row=0, column=0, sticky="w", padx=(0, 8)) self._email_entry = ctk.CTkEntry( email_row, **{**_ENTRY_KWARGS, "height": 28}, placeholder_text="customer@example.com", ) self._email_entry.grid(row=0, column=1, sticky="ew") _bind_context_menu(self._email_entry) # Notes notes = _card(self, row=3, column=0, sticky="ew", padx=20, pady=(10, 0)) notes.columnconfigure(0, weight=1) ctk.CTkLabel(notes, text="Notes / Invoice Numbers", font=T.FONT_BODY_BOLD, text_color=T.TEXT_PRIMARY).grid( row=0, column=0, sticky="w", padx=16, pady=(14, 6)) self._notes = ctk.CTkTextbox( notes, height=72, font=T.FONT_BODY, fg_color=T.CARD_BG, border_color=T.BORDER, border_width=1, text_color=T.TEXT_PRIMARY, corner_radius=6, ) self._notes.grid(row=1, column=0, sticky="ew", padx=16, pady=(0, 8)) _bind_context_menu(self._notes) ctk.CTkFrame(notes, fg_color=T.DIVIDER, height=1).grid( row=2, column=0, sticky="ew", padx=16, pady=(0, 8)) ctk.CTkLabel(notes, text="Print Message", font=T.FONT_BODY_BOLD, text_color=T.TEXT_PRIMARY).grid( row=3, column=0, sticky="w", padx=16, pady=(0, 4)) ctk.CTkLabel(notes, text="Appears on the label (leave blank if unused)", font=T.FONT_LABEL, text_color=T.TEXT_MUTED).grid( row=4, column=0, sticky="w", padx=16, pady=(0, 4)) self._print_msg = ctk.CTkEntry( notes, **{**_ENTRY_KWARGS, "height": 28}, placeholder_text="e.g. Invoice #12345", ) self._print_msg.grid(row=5, column=0, sticky="ew", padx=16, pady=(0, 14)) _bind_context_menu(self._print_msg) # Actions actions = ctk.CTkFrame(self, fg_color="transparent") actions.grid(row=4, column=0, sticky="ew", padx=20, pady=(12, 0)) self._clear_btn = ctk.CTkButton( actions, text="Clear", width=80, height=34, font=T.FONT_LABEL, fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER, text_color=T.TEXT_PRIMARY, border_width=1, border_color=T.NEUTRAL_BTN_BORDER, corner_radius=6, command=self._clear, ) self._clear_btn.pack(side="left") self._ship_btn = ctk.CTkButton( actions, text="Create Label", width=160, height=34, font=T.FONT_LABEL, fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER, text_color=T.ACCENT_TEXT, border_width=1, border_color=T.ACCENT_BORDER, corner_radius=6, command=self._on_create, ) self._ship_btn.pack(side="right") self._rate_label = ctk.CTkLabel( actions, text="—", font=T.FONT_BODY_BOLD, text_color=T.TEXT_MUTED, ) self._rate_label.pack(side="right", padx=(0, 12)) # Wire rate-fetch triggers self._rate_timer: str | None = None self._pkg_svc_var.trace_add("write", lambda *_: self._schedule_rate_fetch()) for entry in (self._lbs, self._oz, self._dim_l, self._dim_w, self._dim_h): entry.bind("", lambda _e: self._schedule_rate_fetch()) self._address_block.bind("", lambda _e: self._schedule_rate_fetch()) # Result self._result = _result_card(self, "USPS Label Created") self._result.grid(row=5, column=0, sticky="ew", padx=20, pady=(12, 20)) self._result.grid_remove() # ── Actions ──────────────────────────────────────────────────────────── def _toggle_email_entry(self) -> None: state = "normal" if self._email_var.get() else "disabled" self._email_entry.configure(state=state) def _toggle_mode(self, _value: str = "") -> None: is_pkg = self._mode_var.get() == "Package" if is_pkg: self._env_svc_frame.grid_remove() self._pkg_svc_frame.grid() self._dim_row.grid() else: self._pkg_svc_frame.grid_remove() self._env_svc_frame.grid() self._dim_row.grid_remove() self._schedule_rate_fetch() def _get_shipment_type(self) -> tuple: """Returns (service_code, package_code, service_name, package_name).""" if self._mode_var.get() == "Envelope": return (_ENV_SERVICE_CODE, _ENV_PACKAGE_CODE, _ENV_SERVICE_NAME, _ENV_PACKAGE_NAME) display, svc, pkg, pkg_display = _PKG_OPTIONS[int(self._pkg_svc_var.get())] return (svc, pkg, display, pkg_display) # ── Scale ────────────────────────────────────────────────────────────── def _on_scale_weight(self, oz: float) -> None: """Called from background thread — schedule UI update on main thread.""" self.after(0, lambda: self._apply_weight(oz)) def _apply_weight(self, oz: float) -> None: lbs = int(oz // 16) rem = round(oz % 16, 1) self._lbs.delete(0, "end") self._oz.delete(0, "end") if lbs: self._lbs.insert(0, str(lbs)) self._oz.insert(0, str(rem) if rem != int(rem) else str(int(rem))) def _weigh_once(self) -> None: self._weigh_btn.configure(text="…", state="disabled") def _read(): oz = self._poller.read_once() self.after(0, lambda: self._weigh_done(oz)) threading.Thread(target=_read, daemon=True).start() def _weigh_done(self, oz: float | None) -> None: self._weigh_btn.configure(text="Weigh", state="normal") if oz is None: _show_error(self, "Scale", "No scale found or no stable reading.\n" "Make sure the USB scale is connected and the hid package is installed.") return self._apply_weight(oz) def _toggle_auto(self) -> None: if self._auto_on: self._poller.stop() self._auto_on = False self._auto_btn.configure( fg_color=T.NEUTRAL_BTN_BG, text_color=T.TEXT_MUTED, border_color=T.NEUTRAL_BTN_BORDER, ) else: started = self._poller.start() if not started: _show_error(self, "Scale", "No USB scale found.\n" "Make sure the scale is connected and the hid package is installed.") return self._auto_on = True self._auto_btn.configure( fg_color=T.ACCENT, text_color=T.ACCENT_TEXT, border_color=T.ACCENT_BORDER, ) # ── Rate fetch ───────────────────────────────────────────────────────── def _schedule_rate_fetch(self) -> None: if self._rate_timer: self.after_cancel(self._rate_timer) self._rate_timer = self.after(600, self._fetch_rate) def _fetch_rate(self) -> None: self._rate_timer = None cfg = self._cfg if not cfg or not getattr(cfg, "shipengine_api_key", ""): return # credentials not set up yet — stay silent to_zip = self._parse_zip() if not to_zip: return try: lbs = int(self._lbs.get().strip() or 0) oz = int(self._oz.get().strip() or 0) except ValueError: return weight_oz = lbs * 16 + oz if weight_oz <= 0: return try: dim_l = float(self._dim_l.get().strip() or 0) dim_w = float(self._dim_w.get().strip() or 0) dim_h = float(self._dim_h.get().strip() or 0) except ValueError: dim_l = dim_w = dim_h = 0.0 service_code, package_code, _, _ = self._get_shipment_type() self._rate_label.configure(text="…", text_color=T.TEXT_MUTED) def _run() -> None: price = _fetch_usps_rate( cfg, to_zip, weight_oz, service_code, package_code, dim_l, dim_w, dim_h, ) self.after(0, lambda: self._apply_rate(price)) threading.Thread(target=_run, daemon=True).start() def _apply_rate(self, price: float | None) -> None: if price is None: self._rate_label.configure(text="—", text_color=T.TEXT_MUTED) else: self._rate_label.configure( text=f"${price:.2f}", text_color=T.NET_COLOR) def _parse_zip(self) -> str | None: import re text = self._address_block.get("1.0", "end") matches = re.findall(r'\b(\d{5})(?:-\d{4})?\b', text) return matches[-1] if matches else None def _open_preset_popup(self) -> None: if hasattr(self, "_popup") and self._popup.winfo_exists(): self._popup.destroy() return self._popup = _PresetPopup(self._preset_btn, self._presets, on_select=self._apply_preset, on_save=self._save_preset, on_delete=self._presets.delete) def _apply_preset(self, name: str) -> None: p = self._presets.get(name) if not p: return for entry, key in [(self._dim_l, "l"), (self._dim_w, "w"), (self._dim_h, "h")]: entry.delete(0, "end") entry.insert(0, str(p[key])) def _save_preset(self) -> None: l, w, h = (self._dim_l.get().strip(), self._dim_w.get().strip(), self._dim_h.get().strip()) if not l or not w or not h: _show_error(self, "Save Preset", "Enter all three dimensions first.") return try: l_f, w_f, h_f = float(l), float(w), float(h) except ValueError: _show_error(self, "Save Preset", "Dimensions must be numbers.") return dialog = ctk.CTkInputDialog(text="Name this preset:", title="Save Preset") name = (dialog.get_input() or "").strip() if name: self._presets.save_preset(name, l_f, w_f, h_f) def _on_create(self) -> None: cfg = self._cfg if not cfg or not getattr(cfg, "shipengine_api_key", ""): _show_error(self, "USPS Label", "ShipEngine API key is not configured.\n\n" "Add shipengine_api_key to config.json.") return address = self._address_block.get("1.0", "end").strip() if not address: _show_error(self, "USPS Label", "Please paste a shipping address.") return try: lbs = int(self._lbs.get().strip() or 0) oz = int(self._oz.get().strip() or 0) except ValueError: lbs = oz = 0 if lbs * 16 + oz <= 0: _show_error(self, "USPS Label", "Please enter a package weight.") return try: dim_l = float(self._dim_l.get().strip() or 0) dim_w = float(self._dim_w.get().strip() or 0) dim_h = float(self._dim_h.get().strip() or 0) except ValueError: _show_error(self, "USPS Label", "Dimensions must be numbers.") return email = "" if self._email_var.get(): email = self._email_entry.get().strip() svc_code, pkg_code, svc_name, pkg_name = self._get_shipment_type() data = { "address_block": address, "service_code": svc_code, "service_name": svc_name, "package_code": pkg_code, "package_name": pkg_name, "weight_oz": lbs * 16 + oz, "dim_l": dim_l, "dim_w": dim_w, "dim_h": dim_h, "email": email, "notes": self._notes.get("1.0", "end").strip(), "print_message": self._print_msg.get().strip(), } self._result.grid_remove() self._set_busy(True, "Validating address…") def _parse(): try: parsed = _parse_usps_address(cfg, address) self.after(0, lambda: self._after_parse(parsed, data)) except Exception as exc: msg = str(exc) self.after(0, lambda: self._on_error(msg)) threading.Thread(target=_parse, daemon=True).start() def _after_parse(self, parsed: dict, data: dict) -> None: # Multiple candidates → let user pick the specific delivery point candidates = parsed.get("candidates") or parsed.get("addresses") or [] if isinstance(candidates, list) and len(candidates) > 1: self._set_busy(False) _AmbiguousAddressDialog( self, original=data["address_block"], candidates=candidates, on_select=lambda chosen: self._show_confirm(data, chosen), ) return # Single corrected address → confirm the change if _address_changed(data["address_block"], parsed): self._set_busy(False) suggested = "\n".join(filter(None, [ parsed.get("name", ""), parsed.get("company_name", ""), parsed.get("address_line1", ""), parsed.get("address_line2", ""), " ".join(filter(None, [ parsed.get("city", ""), parsed.get("state_province", ""), parsed.get("postal_code", ""), ])), ])) _AddressVerifyDialog( self, original=data["address_block"], suggested=suggested, on_accept=lambda: self._show_confirm(data, parsed), ) return self._show_confirm(data, parsed) def _show_confirm(self, data: dict, parsed: dict) -> None: self._set_busy(False) rate_text = self._rate_label.cget("text") _ShipmentConfirmDialog( self, parsed=parsed, data=data, rate_text=rate_text, on_confirm=lambda: self._do_create_label(data, parsed), ) def _do_create_label(self, data: dict, parsed: dict) -> None: self._set_busy(True) cfg = self._cfg def _run(): try: result = _create_usps_label(cfg, data, parsed_addr=parsed) self.after(0, lambda: self._on_success(result)) except Exception as exc: msg = str(exc) log.exception("USPS label failed") self.after(0, lambda: self._on_error(msg)) threading.Thread(target=_run, daemon=True).start() def _open_history(self) -> None: from .ui_history_window import HistoryWindow win = HistoryWindow(self, cfg=self._cfg, carrier="usps") win.grab_set() def _on_success(self, result: dict) -> None: self._set_busy(False) self._result._tracking_lbl.configure( text=result.get("tracking_number") or "(not returned)") label_path = result.get("label_pdf_path") self._result._label_path = label_path self._result.grid() self._save_to_history(result) if label_path: try: os.startfile(label_path, "print") except Exception: try: os.startfile(label_path) except Exception: pass def _on_error(self, msg: str) -> None: self._set_busy(False) _show_error(self, "USPS Label", msg) def _save_to_history(self, result: dict) -> None: from datetime import datetime, timezone from .label_history import LabelEntry, LabelHistory address = self._address_block.get("1.0", "end").strip() recipient = address.splitlines()[0] if address else "" rate_text = self._rate_label.cget("text") try: amount = float(rate_text.lstrip("$")) if rate_text.startswith("$") else None except ValueError: amount = None entry = LabelEntry( label_id = result.get("label_id", result.get("tracking_number", "")), tracking_number = result.get("tracking_number", ""), date_printed = datetime.now(timezone.utc).isoformat(), recipient = recipient, service = self._get_shipment_type()[2], package_type = self._get_shipment_type()[3], amount = amount, notes = self._notes.get("1.0", "end").strip(), address_block = address, carrier = "usps", ) try: LabelHistory().add(entry) except Exception as exc: log.warning("Could not save label to history: %s", exc) def _clear(self) -> None: self._address_block.delete("1.0", "end") self._notes.delete("1.0", "end") self._print_msg.delete(0, "end") self._email_entry.delete(0, "end") for entry in (self._lbs, self._oz, self._dim_l, self._dim_w, self._dim_h): entry.delete(0, "end") self._mode_var.set("Package") self._pkg_svc_var.set("0") self._toggle_mode() self._result.grid_remove() # Leave Auto running if on — intentional, weight stays live def _set_busy(self, busy: bool, label: str = "") -> None: s = "disabled" if busy else "normal" text = label if (busy and label) else ("Creating…" if busy else "Create Label") self._ship_btn.configure(text=text, state=s) self._clear_btn.configure(state=s) # ══════════════════════════════════════════════════════════════════════════════ # FedEx panel — individual address fields # ══════════════════════════════════════════════════════════════════════════════ class _FedExPanel(ctk.CTkScrollableFrame): def __init__(self, master, cfg=None): super().__init__(master, fg_color="transparent", corner_radius=0) self._cfg = cfg self.columnconfigure(0, weight=1) self._build() def _build(self) -> None: # Header hdr = ctk.CTkFrame(self, fg_color="transparent") hdr.grid(row=0, column=0, sticky="ew", padx=20, pady=(18, 0)) ctk.CTkLabel(hdr, text="FedEx", font=T.FONT_HEADER, text_color=T.TEXT_PRIMARY).pack(side="left") ctk.CTkLabel(hdr, text="One Rate · 2-Day", font=T.FONT_BODY, text_color=T.TEXT_MUTED).pack(side="left", padx=(8, 0), pady=(4, 0)) ctk.CTkButton( hdr, text="History", width=80, height=28, font=T.FONT_LABEL, fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER, text_color=T.TEXT_PRIMARY, border_width=1, border_color=T.NEUTRAL_BTN_BORDER, corner_radius=6, command=self._open_history, ).pack(side="right") # Shipment options opts = _card(self, row=1, column=0, sticky="ew", padx=20, pady=(12, 0)) opts.columnconfigure(0, weight=1) ctk.CTkLabel(opts, text="Shipment Options", font=T.FONT_BODY_BOLD, text_color=T.TEXT_PRIMARY).grid( row=0, column=0, sticky="w", padx=16, pady=(14, 10)) _lbl(opts, "Package Type", row=1) self._pkg_var = ctk.StringVar(value=_FEDEX_PACKAGES[0]) for i, pkg in enumerate(_FEDEX_PACKAGES): ctk.CTkRadioButton( opts, text=pkg, variable=self._pkg_var, value=pkg, font=T.FONT_BODY, text_color=T.TEXT_PRIMARY, radiobutton_width=18, radiobutton_height=18, fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER, border_color=T.BORDER_STRONG, ).grid(row=2 + i, column=0, sticky="w", padx=16, pady=3) ctk.CTkFrame(opts, fg_color=T.DIVIDER, height=1).grid( row=4, column=0, sticky="ew", padx=16, pady=10) _lbl(opts, "Service", row=5) self._svc_var = ctk.StringVar(value=_FEDEX_SERVICES[0]) for i, svc in enumerate(_FEDEX_SERVICES): ctk.CTkRadioButton( opts, text=svc, variable=self._svc_var, value=svc, font=T.FONT_BODY, text_color=T.TEXT_PRIMARY, radiobutton_width=18, radiobutton_height=18, fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER, border_color=T.BORDER_STRONG, ).grid(row=6 + i, column=0, sticky="w", padx=16, pady=3) ctk.CTkFrame(opts, fg_color="transparent", height=8).grid(row=8, column=0) # Recipient recip = _card(self, row=2, column=0, sticky="ew", padx=20, pady=(10, 0)) recip.columnconfigure(0, weight=3) recip.columnconfigure(1, weight=1) recip.columnconfigure(2, weight=2) ctk.CTkLabel(recip, text="Recipient", font=T.FONT_BODY_BOLD, text_color=T.TEXT_PRIMARY).grid( row=0, column=0, columnspan=3, sticky="w", padx=16, pady=(14, 10)) _lbl(recip, "Full Name", row=1, colspan=3) self._name = ctk.CTkEntry(recip, **_ENTRY_KWARGS, placeholder_text="Jane Smith") self._name.grid(row=2, column=0, columnspan=3, sticky="ew", padx=16, pady=(0, 8)) _bind_context_menu(self._name) _lbl(recip, "Company (optional)", row=3, colspan=3) self._company = ctk.CTkEntry(recip, **_ENTRY_KWARGS) self._company.grid(row=4, column=0, columnspan=3, sticky="ew", padx=16, pady=(0, 8)) _bind_context_menu(self._company) _lbl(recip, "Street Address", row=5, colspan=3) self._address1 = ctk.CTkEntry(recip, **_ENTRY_KWARGS, placeholder_text="123 Main St") self._address1.grid(row=6, column=0, columnspan=3, sticky="ew", padx=16, pady=(0, 8)) _bind_context_menu(self._address1) _lbl(recip, "Apt / Suite (optional)", row=7, colspan=3) self._address2 = ctk.CTkEntry(recip, **_ENTRY_KWARGS, placeholder_text="Suite 200") self._address2.grid(row=8, column=0, columnspan=3, sticky="ew", padx=16, pady=(0, 8)) _bind_context_menu(self._address2) _lbl(recip, "City", row=9, col=0) _lbl(recip, "State", row=9, col=1) _lbl(recip, "ZIP", row=9, col=2) self._city = ctk.CTkEntry(recip, **_ENTRY_KWARGS, placeholder_text="Springfield") self._city.grid(row=10, column=0, sticky="ew", padx=(16, 8), pady=(0, 8)) _bind_context_menu(self._city) self._state = ctk.CTkEntry(recip, **_ENTRY_KWARGS, placeholder_text="IL") self._state.grid(row=10, column=1, sticky="ew", padx=(8, 8), pady=(0, 8)) _bind_context_menu(self._state) self._zip = ctk.CTkEntry(recip, **_ENTRY_KWARGS, placeholder_text="62701") self._zip.grid(row=10, column=2, sticky="ew", padx=(8, 16), pady=(0, 8)) _bind_context_menu(self._zip) _lbl(recip, "Phone", row=11, colspan=3) self._phone = ctk.CTkEntry(recip, **_ENTRY_KWARGS, placeholder_text="(555) 123-4567") self._phone.grid(row=12, column=0, columnspan=3, sticky="ew", padx=16, pady=(0, 8)) _bind_context_menu(self._phone) _lbl(recip, "Tracking Email", row=13, colspan=3) self._email = ctk.CTkEntry(recip, **_ENTRY_KWARGS, placeholder_text="customer@example.com") self._email.grid(row=14, column=0, columnspan=3, sticky="ew", padx=16, pady=(0, 16)) _bind_context_menu(self._email) # Actions actions = ctk.CTkFrame(self, fg_color="transparent") actions.grid(row=3, column=0, sticky="ew", padx=20, pady=(12, 0)) self._clear_btn = ctk.CTkButton( actions, text="Clear", width=80, height=34, font=T.FONT_LABEL, fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER, text_color=T.TEXT_PRIMARY, border_width=1, border_color=T.NEUTRAL_BTN_BORDER, corner_radius=6, command=self._clear, ) self._clear_btn.pack(side="left") self._ship_btn = ctk.CTkButton( actions, text="Create Shipment", width=170, height=34, font=T.FONT_LABEL, fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER, text_color=T.ACCENT_TEXT, border_width=1, border_color=T.ACCENT_BORDER, corner_radius=6, command=self._on_create, ) self._ship_btn.pack(side="right") # Result self._result = _result_card(self, "FedEx Shipment Created") self._result.grid(row=4, column=0, sticky="ew", padx=20, pady=(12, 20)) self._result.grid_remove() # ── Actions ──────────────────────────────────────────────────────────── def _on_create(self) -> None: d = { "package_type": self._pkg_var.get(), "service": self._svc_var.get(), "name": self._name.get().strip(), "company": self._company.get().strip(), "address1": self._address1.get().strip(), "address2": self._address2.get().strip(), "city": self._city.get().strip(), "state": self._state.get().strip().upper(), "zip": self._zip.get().strip(), "phone": self._phone.get().strip(), "email": self._email.get().strip(), } missing = [label for field, label in [ ("name", "Full Name"), ("address1", "Street Address"), ("city", "City"), ("state", "State"), ("zip", "ZIP"), ("phone", "Phone"), ] if not d[field]] if missing: _show_error(self, "FedEx Shipment", "Please fill in: " + ", ".join(missing)) return cfg = self._cfg if not cfg or not cfg.fedex_api_key: _show_error(self, "FedEx Shipment", "FedEx credentials are not configured in config.json.") return client = FedExClient( api_key=cfg.fedex_api_key, secret=cfg.fedex_secret, account=cfg.fedex_account, sandbox=cfg.fedex_sandbox, shipper={ "name": cfg.fedex_shipper_name, "company": cfg.fedex_shipper_company, "phone": cfg.fedex_shipper_phone, "address1": cfg.fedex_shipper_address1, "city": cfg.fedex_shipper_city, "state": cfg.fedex_shipper_state, "zip": cfg.fedex_shipper_zip, }, ) self._result.grid_remove() self._set_busy(True) def _run(): try: result = client.create_shipment(d) self.after(0, lambda: self._on_success(result)) except Exception as exc: msg = str(exc) log.exception("FedEx shipment failed") self.after(0, lambda: self._on_error(msg)) threading.Thread(target=_run, daemon=True).start() def _on_success(self, result: ShipmentResult) -> None: self._set_busy(False) self._result._tracking_lbl.configure( text=result.tracking_number or "(not returned)") self._result._label_path = result.label_pdf_path self._result.grid() self._save_to_history(result) try: os.startfile(result.label_pdf_path, "print") except Exception: try: os.startfile(result.label_pdf_path) except Exception: pass def _on_error(self, msg: str) -> None: self._set_busy(False) _show_error(self, "FedEx Shipment", msg) def _save_to_history(self, result: ShipmentResult) -> None: from datetime import datetime, timezone from .label_history import LabelEntry, LabelHistory city = self._city.get().strip() state = self._state.get().strip() zip_ = self._zip.get().strip() addr = "\n".join(filter(None, [ self._address1.get().strip(), self._address2.get().strip(), f"{city}, {state} {zip_}".strip(", "), ])) entry = LabelEntry( label_id = result.tracking_number or str(uuid.uuid4()), tracking_number = result.tracking_number, date_printed = datetime.now(timezone.utc).isoformat(), recipient = self._name.get().strip(), service = f"{self._svc_var.get()} – {self._pkg_var.get()}", package_type = self._pkg_var.get(), amount = None, notes = "", address_block = addr, carrier = "fedex", ) try: LabelHistory().add(entry) except Exception as exc: log.warning("Could not save FedEx label to history: %s", exc) def _open_history(self) -> None: from .ui_history_window import HistoryWindow win = HistoryWindow(self, cfg=self._cfg, carrier="fedex") win.grab_set() def _clear(self) -> None: for e in (self._name, self._company, self._address1, self._address2, self._city, self._state, self._zip, self._phone, self._email): e.delete(0, "end") self._pkg_var.set(_FEDEX_PACKAGES[0]) self._svc_var.set(_FEDEX_SERVICES[0]) self._result.grid_remove() def _set_busy(self, busy: bool) -> None: s = "disabled" if busy else "normal" self._ship_btn.configure( text="Creating…" if busy else "Create Shipment", state=s) self._clear_btn.configure(state=s) # ══════════════════════════════════════════════════════════════════════════════ # Shared helpers # ══════════════════════════════════════════════════════════════════════════════ def _result_card(parent, title: str) -> ctk.CTkFrame: card = _card(parent, row=99, column=0) # row overridden by caller card.columnconfigure(0, weight=1) card._label_path: str | None = None ctk.CTkLabel(card, text=title, font=T.FONT_BODY_BOLD, text_color=T.NET_COLOR).grid( row=0, column=0, columnspan=3, sticky="w", padx=16, pady=(12, 6)) _lbl(card, "Tracking Number", row=1, colspan=3) lbl = ctk.CTkLabel(card, text="", font=T.FONT_FACT_VALUE, text_color=T.TEXT_PRIMARY) lbl.grid(row=2, column=0, sticky="w", padx=16, pady=(0, 10)) card._tracking_lbl = lbl copy_btn = ctk.CTkButton( card, text="Copy", width=70, height=26, font=T.FONT_LABEL, fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER, text_color=T.TEXT_PRIMARY, border_width=1, border_color=T.NEUTRAL_BTN_BORDER, corner_radius=6, ) copy_btn.grid(row=2, column=1, sticky="e", padx=(4, 4), pady=(0, 10)) copy_btn.configure(command=lambda: _copy_tracking(lbl, copy_btn, parent)) reprint_btn = ctk.CTkButton( card, text="Reprint", width=76, height=26, font=T.FONT_LABEL, fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER, text_color=T.TEXT_PRIMARY, border_width=1, border_color=T.NEUTRAL_BTN_BORDER, corner_radius=6, command=lambda: _reprint_label(card), ) reprint_btn.grid(row=2, column=2, sticky="e", padx=(0, 16), pady=(0, 10)) ctk.CTkLabel(card, text="Label opened for printing.", font=T.FONT_BODY, text_color=T.TEXT_MUTED).grid( row=3, column=0, columnspan=3, sticky="w", padx=16, pady=(0, 12)) return card def _reprint_label(card) -> None: path = getattr(card, "_label_path", None) if not path: return try: os.startfile(path) except Exception: pass def _copy_tracking(lbl, btn, widget) -> None: t = lbl.cget("text") if t: widget.clipboard_clear() widget.clipboard_append(t) _flash_button(btn, T.NEUTRAL_BTN_BG, restore_text="Copy", flash_text="Copied!") def _show_error(widget, title: str, msg: str) -> None: from tkinter import messagebox messagebox.showerror(title, msg, parent=widget) # ══════════════════════════════════════════════════════════════════════════════ # USPS API — ShipEngine # ══════════════════════════════════════════════════════════════════════════════ def _se_client(cfg): from .shipengine import ShipEngineClient client = ShipEngineClient(cfg.shipengine_api_key) client.set_shipper(cfg) return client def _fetch_usps_rate( cfg, to_zip: str, weight_oz: int, service_code: str, package_code: str, dim_l: float, dim_w: float, dim_h: float, ) -> float | None: try: return _se_client(cfg).get_rate( from_zip=cfg.usps_shipper_zip, to_zip=to_zip, weight_oz=weight_oz, service_code=service_code, package_code=package_code, dim_l=dim_l, dim_w=dim_w, dim_h=dim_h, ) except Exception as exc: log.debug("Rate fetch failed: %s", exc) return None def _parse_usps_address(cfg, address_block: str) -> dict: """Parse freeform text locally then validate/normalise via ShipEngine.""" from .shipengine import parse_address_text parsed = parse_address_text(address_block) if not parsed.get("address_line1"): raise RuntimeError( "Could not parse the address block.\n" "Make sure it contains a street address and City, State ZIP line." ) try: return _se_client(cfg).validate_address(parsed) except Exception: return parsed def _address_changed(original_block: str, parsed: dict) -> bool: """True if city, state, or ZIP differ from what was typed.""" block = original_block.upper() city = (parsed.get("city") or "").upper().strip() state = (parsed.get("state_province") or "").upper().strip() zip_ = (parsed.get("postal_code") or "").split("-")[0].strip() return any([ city and city not in block, state and state not in block, zip_ and zip_ not in block, ]) def _create_usps_label(cfg, data: dict, parsed_addr: dict | None = None) -> dict: """Create a USPS label via ShipEngine.""" from .shipengine import parse_address_text if parsed_addr is None: parsed_addr = parse_address_text(data["address_block"]) result = _se_client(cfg).create_label(data, parsed_addr) return { "label_id": result.label_id, "tracking_number": result.tracking_number, "label_pdf_path": result.label_pdf_path, }