|
| 1 | +import sys |
| 2 | +import os |
| 3 | +import json |
| 4 | +import threading |
| 5 | +import tkinter as tk |
| 6 | +from tkinter import ttk, messagebox |
| 7 | +import sv_ttk |
| 8 | +import requests |
| 9 | + |
| 10 | +# ========================= |
| 11 | +# Helpers |
| 12 | +# ========================= |
| 13 | +def resource_path(file_name): |
| 14 | + base_path = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__))) |
| 15 | + return os.path.join(base_path, file_name) |
| 16 | + |
| 17 | +DATA_FILE = resource_path("rates_live.json") |
| 18 | + |
| 19 | +# ========================= |
| 20 | +# App Setup |
| 21 | +# ========================= |
| 22 | +root = tk.Tk() |
| 23 | +root.title("CurrencyTool Pro") |
| 24 | +root.geometry("980x620") |
| 25 | +root.minsize(900, 550) |
| 26 | + |
| 27 | +sv_ttk.set_theme("light") |
| 28 | + |
| 29 | +# ========================= |
| 30 | +# Globals |
| 31 | +# ========================= |
| 32 | +amount_var = tk.DoubleVar(value=100.0) |
| 33 | +from_var = tk.StringVar(value="USD") |
| 34 | +to_var = tk.StringVar(value="EUR") |
| 35 | +result_var = tk.StringVar(value="—") |
| 36 | +status_var = tk.StringVar(value="Ready") |
| 37 | +mode_var = tk.StringVar(value="Offline") |
| 38 | + |
| 39 | +# Default fallback rates |
| 40 | +RATES = { |
| 41 | + "USD": 1.0, |
| 42 | + "EUR": 0.92, |
| 43 | + "GBP": 0.79, |
| 44 | + "JPY": 157.0, |
| 45 | + "AUD": 1.52, |
| 46 | + "CAD": 1.36, |
| 47 | + "CHF": 0.88, |
| 48 | + "CNY": 7.18 |
| 49 | +} |
| 50 | + |
| 51 | +# ========================= |
| 52 | +# Persistence |
| 53 | +# ========================= |
| 54 | +def load_rates(): |
| 55 | + if os.path.exists(DATA_FILE): |
| 56 | + try: |
| 57 | + with open(DATA_FILE, "r", encoding="utf-8") as f: |
| 58 | + data = json.load(f) |
| 59 | + RATES.clear() |
| 60 | + RATES.update(data) |
| 61 | + RATES["USD"] = 1.0 |
| 62 | + mode_var.set("Online") |
| 63 | + except Exception: |
| 64 | + pass |
| 65 | + |
| 66 | +def save_rates(): |
| 67 | + with open(DATA_FILE, "w", encoding="utf-8") as f: |
| 68 | + json.dump(RATES, f, indent=2) |
| 69 | + |
| 70 | +# ========================= |
| 71 | +# Status |
| 72 | +# ========================= |
| 73 | +def set_status(msg): |
| 74 | + status_var.set(msg) |
| 75 | + root.update_idletasks() |
| 76 | + |
| 77 | +# ========================= |
| 78 | +# Live Rate Fetch |
| 79 | +# ========================= |
| 80 | +def fetch_live_rates(): |
| 81 | + update_btn.config(state="disabled") |
| 82 | + set_status("🌐 Fetching live rates...") |
| 83 | + try: |
| 84 | + r = requests.get("https://open.er-api.com/v6/latest/USD", timeout=6) |
| 85 | + data = r.json() |
| 86 | + |
| 87 | + if data.get("result") != "success": |
| 88 | + raise RuntimeError |
| 89 | + |
| 90 | + RATES.clear() |
| 91 | + RATES.update(data["rates"]) |
| 92 | + RATES["USD"] = 1.0 |
| 93 | + |
| 94 | + save_rates() |
| 95 | + refresh_currency_lists() |
| 96 | + |
| 97 | + mode_var.set("Online") |
| 98 | + set_status("🌐 Live rates updated & saved") |
| 99 | + |
| 100 | + except Exception: |
| 101 | + messagebox.showwarning( |
| 102 | + "Live Update Failed", |
| 103 | + "Unable to fetch live rates.\nUsing offline data." |
| 104 | + ) |
| 105 | + set_status("Offline mode") |
| 106 | + |
| 107 | + finally: |
| 108 | + update_btn.config(state="normal") |
| 109 | + |
| 110 | +# ========================= |
| 111 | +# Conversion |
| 112 | +# ========================= |
| 113 | +def convert_currency(): |
| 114 | + try: |
| 115 | + amount = amount_var.get() |
| 116 | + f, t = from_var.get(), to_var.get() |
| 117 | + |
| 118 | + usd = amount / RATES[f] |
| 119 | + result = usd * RATES[t] |
| 120 | + |
| 121 | + result_var.set(f"{result:,.4f} {t}") |
| 122 | + set_status(f"Converted ({mode_var.get()})") |
| 123 | + |
| 124 | + except Exception: |
| 125 | + messagebox.showerror("Error", "Invalid amount or currency.") |
| 126 | + |
| 127 | +def swap_currencies(): |
| 128 | + f, t = from_var.get(), to_var.get() |
| 129 | + from_var.set(t) |
| 130 | + to_var.set(f) |
| 131 | + |
| 132 | +# ========================= |
| 133 | +# Rate Editor |
| 134 | +# ========================= |
| 135 | +def open_rate_editor(): |
| 136 | + editor = tk.Toplevel(root) |
| 137 | + editor.title("✏️ Manual Rate Override (USD Base)") |
| 138 | + |
| 139 | + # Center the window |
| 140 | + w, h = 420, 520 |
| 141 | + ws = root.winfo_screenwidth() |
| 142 | + hs = root.winfo_screenheight() |
| 143 | + x = (ws // 2) - (w // 2) |
| 144 | + y = (hs // 2) - (h // 2) |
| 145 | + editor.geometry(f"{w}x{h}+{x}+{y}") |
| 146 | + editor.transient(root) |
| 147 | + editor.grab_set() |
| 148 | + |
| 149 | + frame = ttk.Frame(editor, padding=16) |
| 150 | + frame.pack(fill="both", expand=True) |
| 151 | + |
| 152 | + ttk.Label( |
| 153 | + frame, |
| 154 | + text="Manual Rate Override", |
| 155 | + font=("Segoe UI", 14, "bold") |
| 156 | + ).pack(anchor="w") |
| 157 | + |
| 158 | + ttk.Label( |
| 159 | + frame, |
| 160 | + text="Saving switches app to Offline mode", |
| 161 | + foreground="#666" |
| 162 | + ).pack(anchor="w", pady=(2, 10)) |
| 163 | + |
| 164 | + # Scrollable frame |
| 165 | + canvas = tk.Canvas(frame) |
| 166 | + scrollbar = ttk.Scrollbar(frame, orient="vertical", command=canvas.yview) |
| 167 | + scrollable_frame = ttk.Frame(canvas) |
| 168 | + |
| 169 | + scrollable_frame.bind( |
| 170 | + "<Configure>", |
| 171 | + lambda e: canvas.configure(scrollregion=canvas.bbox("all")) |
| 172 | + ) |
| 173 | + canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") |
| 174 | + canvas.configure(yscrollcommand=scrollbar.set) |
| 175 | + |
| 176 | + canvas.pack(side="left", fill="both", expand=True) |
| 177 | + scrollbar.pack(side="right", fill="y") |
| 178 | + |
| 179 | + entries = {} |
| 180 | + |
| 181 | + for c in sorted(RATES.keys()): |
| 182 | + row = ttk.Frame(scrollable_frame) |
| 183 | + row.pack(fill="x", pady=2) |
| 184 | + |
| 185 | + ttk.Label(row, text=c, width=6).pack(side="left") |
| 186 | + |
| 187 | + v = tk.DoubleVar(value=RATES[c]) |
| 188 | + ttk.Entry(row, textvariable=v, width=12).pack(side="left", padx=4) |
| 189 | + entries[c] = v |
| 190 | + |
| 191 | + def make_save(currency): |
| 192 | + return lambda: save_single_rate(currency) |
| 193 | + |
| 194 | + ttk.Button( |
| 195 | + row, |
| 196 | + text="💾", |
| 197 | + width=3, |
| 198 | + command=make_save(c), |
| 199 | + style="Action.TButton" |
| 200 | + ).pack(side="right") |
| 201 | + |
| 202 | + def save_single_rate(currency): |
| 203 | + try: |
| 204 | + RATES[currency] = float(entries[currency].get()) |
| 205 | + save_rates() # Save to JSON |
| 206 | + refresh_currency_lists() |
| 207 | + mode_var.set("Offline") |
| 208 | + set_status(f"💾 {currency} rate saved") |
| 209 | + except Exception: |
| 210 | + messagebox.showerror("Invalid Input", "Rate must be numeric.") |
| 211 | + |
| 212 | + |
| 213 | +# ========================= |
| 214 | +# UI Refresh |
| 215 | +# ========================= |
| 216 | +def refresh_currency_lists(): |
| 217 | + cur = sorted(RATES.keys()) |
| 218 | + from_combo["values"] = cur |
| 219 | + to_combo["values"] = cur |
| 220 | + |
| 221 | +# ========================= |
| 222 | +# Styles |
| 223 | +# ========================= |
| 224 | +style = ttk.Style() |
| 225 | +style.configure("Title.TLabel", font=("Segoe UI", 24, "bold")) |
| 226 | +style.configure("Subtitle.TLabel", font=("Segoe UI", 11)) |
| 227 | +style.configure("Action.TButton", font=("Segoe UI", 11, "bold"), padding=10) |
| 228 | +style.configure("Result.TLabel", font=("Segoe UI", 18, "bold")) |
| 229 | + |
| 230 | +# ========================= |
| 231 | +# Layout |
| 232 | +# ========================= |
| 233 | +root.columnconfigure(0, weight=1) |
| 234 | +root.rowconfigure(1, weight=1) |
| 235 | + |
| 236 | +# ----- Header ----- |
| 237 | +header = ttk.Frame(root, padding=(24, 16)) |
| 238 | +header.grid(row=0, column=0, sticky="ew") |
| 239 | +header.columnconfigure(1, weight=1) |
| 240 | + |
| 241 | +ttk.Label(header, text="CurrencyTool Pro", style="Title.TLabel").grid(row=0, column=0, sticky="w") |
| 242 | +ttk.Label( |
| 243 | + header, |
| 244 | + text="Live + Offline currency converter with persistence", |
| 245 | + style="Subtitle.TLabel" |
| 246 | +).grid(row=1, column=0, sticky="w", pady=(2, 0)) |
| 247 | + |
| 248 | +controls = ttk.Frame(header) |
| 249 | +controls.grid(row=0, column=1, rowspan=2, sticky="e") |
| 250 | + |
| 251 | +# 🌐 Update Live Rates button |
| 252 | +update_btn = ttk.Button( |
| 253 | + controls, |
| 254 | + text="🌐 Update Live Rates", |
| 255 | + command=lambda: threading.Thread(target=fetch_live_rates, daemon=True).start(), |
| 256 | + style="Action.TButton" |
| 257 | +) |
| 258 | +update_btn.pack(side="right", padx=6) |
| 259 | + |
| 260 | +# ✏️ Manual Override button |
| 261 | +manual_btn = ttk.Button( |
| 262 | + controls, |
| 263 | + text="✏️ Manual Override", |
| 264 | + command=open_rate_editor, |
| 265 | + style="Action.TButton" |
| 266 | +) |
| 267 | +manual_btn.pack(side="right", padx=6) |
| 268 | + |
| 269 | +# ----- Main Converter Card ----- |
| 270 | +main = ttk.Frame(root, padding=(24, 0, 24, 16)) |
| 271 | +main.grid(row=1, column=0, sticky="nsew") |
| 272 | +main.columnconfigure(0, weight=1) |
| 273 | + |
| 274 | +card = ttk.LabelFrame(main, text="Converter", padding=20) |
| 275 | +card.grid(row=0, column=0, sticky="ew") |
| 276 | +for i in range(4): |
| 277 | + card.columnconfigure(i, weight=1) |
| 278 | + |
| 279 | +# Amount |
| 280 | +ttk.Label(card, text="Amount").grid(row=0, column=0, sticky="w", pady=(0, 4)) |
| 281 | +ttk.Entry(card, textvariable=amount_var, font=("Segoe UI", 11)).grid(row=1, column=0, sticky="ew", padx=(0, 10)) |
| 282 | + |
| 283 | +# From currency |
| 284 | +ttk.Label(card, text="From").grid(row=0, column=1, sticky="w", pady=(0, 4)) |
| 285 | +from_combo = ttk.Combobox(card, textvariable=from_var, state="readonly", font=("Segoe UI", 11)) |
| 286 | +from_combo.grid(row=1, column=1, sticky="ew", padx=(0, 10)) |
| 287 | + |
| 288 | +# To currency |
| 289 | +ttk.Label(card, text="To").grid(row=0, column=2, sticky="w", pady=(0, 4)) |
| 290 | +to_combo = ttk.Combobox(card, textvariable=to_var, state="readonly", font=("Segoe UI", 11)) |
| 291 | +to_combo.grid(row=1, column=2, sticky="ew", padx=(0, 10)) |
| 292 | + |
| 293 | +# Swap button |
| 294 | +ttk.Button(card, text="⇄", command=swap_currencies, style="Action.TButton").grid(row=1, column=3, sticky="ew") |
| 295 | + |
| 296 | +# Result |
| 297 | +ttk.Label(card, text="Result", style="Subtitle.TLabel").grid( |
| 298 | + row=2, column=0, columnspan=4, pady=(20, 4), sticky="w" |
| 299 | +) |
| 300 | +ttk.Label(card, textvariable=result_var, style="Result.TLabel", font=("Segoe UI", 14, "bold")).grid( |
| 301 | + row=3, column=0, columnspan=4, sticky="w" |
| 302 | +) |
| 303 | + |
| 304 | +# ----- Actions ----- |
| 305 | +actions = ttk.Frame(main, padding=(0, 0, 0, 12)) |
| 306 | +actions.grid(row=1, column=0, pady=16, sticky="ew") |
| 307 | +actions.columnconfigure(0, weight=1) |
| 308 | + |
| 309 | +ttk.Button( |
| 310 | + actions, |
| 311 | + text="Convert", |
| 312 | + command=convert_currency, |
| 313 | + style="Action.TButton" |
| 314 | +).pack(side="left") |
| 315 | + |
| 316 | +ttk.Label(actions, textvariable=mode_var, foreground="#666", font=("Segoe UI", 10)).pack(side="right") |
| 317 | + |
| 318 | +# ----- Status ----- |
| 319 | +status = ttk.Label(root, textvariable=status_var, anchor="w", padding=6) |
| 320 | +status.grid(row=2, column=0, sticky="ew") |
| 321 | + |
| 322 | +# ========================= |
| 323 | +# Init |
| 324 | +# ========================= |
| 325 | +load_rates() |
| 326 | +refresh_currency_lists() |
| 327 | + |
| 328 | +# ========================= |
| 329 | +# Run |
| 330 | +# ========================= |
| 331 | +root.mainloop() |
0 commit comments