Files
2026-07-06 00:24:30 -05:00

1685 lines
69 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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(
"<ButtonPress>", 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("<ButtonPress>", 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("<Return>", 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("<Return>", 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("<KeyRelease>", lambda _e: self._schedule_rate_fetch())
self._address_block.bind("<KeyRelease>", 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,
}