exchange_money_mac/main.py

347 lines
13 KiB
Python

"""
Currency Converter — customtkinter edition
Rounded corners, dark/light mode toggle.
Dependencies: pip install requests pyperclip customtkinter
"""
import customtkinter as ctk
import threading
import time
import re
import platform
try:
import requests
HAS_REQUESTS = True
except ImportError:
HAS_REQUESTS = False
try:
import pyperclip
HAS_PYPERCLIP = True
except ImportError:
HAS_PYPERCLIP = False
IS_MAC = platform.system() == "Darwin"
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")
FONT = "SF Pro Text" if IS_MAC else "Helvetica"
FONT_D = "SF Pro Display" if IS_MAC else "Helvetica"
ACCENT = "#0A84FF"
GREEN = "#30D158"
# Dynamic colors per mode — (dark, light)
def T(dark, light):
"""Return a tuple customtkinter uses for dark/light."""
return (dark, light)
# Color pairs
C_BG = T("#1C1C1E", "#F2F2F7")
C_PANEL = T("#2C2C2E", "#FFFFFF")
C_PILL = T("#3A3A3C", "#E5E5EA")
C_PILLH = T("#555558", "#D1D1D6")
C_WHITE = T("#FFFFFF", "#000000")
C_GREY = T("#8E8E93", "#6C6C70")
C_GREY2 = T("#636366", "#8E8E93")
C_SEP = T("#38383A", "#C6C6C8")
FALLBACK_RATES = {
"USD": 1.0, "VND": 25450.0, "EUR": 0.92, "GBP": 0.79,
"JPY": 149.5, "KRW": 1340.0, "CNY": 7.24, "SGD": 1.35,
"THB": 35.5, "MYR": 4.72, "AUD": 1.54, "CAD": 1.37,
"CHF": 0.90, "HKD": 7.82, "INR": 83.2, "NZD": 1.65,
"SEK": 10.5, "NOK": 10.8, "DKK": 6.89, "BRL": 4.97,
"MXN": 17.2, "ZAR": 18.6, "TRY": 32.1, "RUB": 90.5,
"AED": 3.67, "SAR": 3.75, "ILS": 3.72, "PLN": 3.98,
"CZK": 23.1, "HUF": 358.0, "RON": 4.59, "IDR": 15750.0,
"PHP": 56.8, "PKR": 278.0, "BDT": 110.0, "NGN": 1550.0,
}
CURRENCIES = sorted(FALLBACK_RATES.keys())
SYMBOLS = {
"USD":"$", "VND":"", "EUR":"", "GBP":"£", "JPY":"¥",
"KRW":"", "CNY":"¥", "SGD":"S$", "THB":"฿", "MYR":"RM",
"AUD":"A$", "CAD":"C$", "CHF":"Fr", "HKD":"HK$","INR":"",
"NZD":"NZ$","SEK":"kr", "NOK":"kr", "DKK":"kr", "BRL":"R$",
"MXN":"$", "ZAR":"R", "TRY":"", "RUB":"", "AED":"د.إ",
"SAR":"", "ILS":"", "PLN":"", "CZK":"", "HUF":"Ft",
"RON":"lei","IDR":"Rp", "PHP":"", "PKR":"", "BDT":"",
"NGN":"",
}
FALLBACK_RATES
class RateCache:
def __init__(self):
self.rates = dict(FALLBACK_RATES)
self._lock = threading.Lock()
def fetch(self, cb=None):
if not HAS_REQUESTS:
if cb: cb(False); return
try:
r = requests.get("https://open.er-api.com/v6/latest/USD", timeout=8)
d = r.json()
if d.get("result") == "success":
with self._lock: self.rates = d["rates"]
if cb: cb(True)
else:
if cb: cb(False)
except Exception:
if cb: cb(False)
def convert(self, amt, frm, to):
with self._lock: r = self.rates
return amt / r[frm] * r[to]
def rate_str(self, frm, to):
with self._lock: r = self.rates
v = r[to] / r[frm]
return f"1 {frm} = {v:,.4f} {to}" if v >= 1 else f"1 {frm} = {v:.6f} {to}"
# ── App ───────────────────────────────────────────────────────────────────────
class App(ctk.CTk):
def __init__(self):
super().__init__()
self.cache = RateCache()
self._result_str = ""
self._build_window()
self._build_ui()
threading.Thread(target=self.cache.fetch,
kwargs={"cb": self._on_rates}, daemon=True).start()
self._live_convert()
def _build_window(self):
self.title("Currency")
self.configure(fg_color=C_BG)
self.resizable(False, False)
W, H = 320, 420
sw, sh = self.winfo_screenwidth(), self.winfo_screenheight()
self.geometry(f"{W}x{H}+{(sw-W)//2}+{(sh-H)//2}")
def _build_ui(self):
self.from_var = ctk.StringVar(value="USD")
self.to_var = ctk.StringVar(value="VND")
# ── Currency selector row ─────────────────────────────────────────
top = ctk.CTkFrame(self, fg_color=C_BG)
top.pack(fill="x", padx=16, pady=(16, 0))
top.columnconfigure(0, weight=1)
top.columnconfigure(2, weight=1)
# FROM dropdown
self._from_dd = ctk.CTkOptionMenu(
top, variable=self.from_var, values=CURRENCIES,
width=110, height=32, corner_radius=8,
fg_color=C_PILL, button_color=C_PILL,
button_hover_color=C_PILLH,
text_color=C_WHITE, dropdown_fg_color=C_PANEL,
dropdown_hover_color=C_PILL,
font=(FONT, 13, "bold"),
command=lambda _: self._on_from_change())
self._from_dd.grid(row=0, column=0, sticky="ew")
# Swap button
swap_btn = ctk.CTkButton(
top, text="", width=36, height=32,
corner_radius=8,
fg_color=C_PILL, hover_color=C_PILLH,
text_color=C_GREY,
font=(FONT, 14, "bold"),
command=self._swap)
swap_btn.grid(row=0, column=1, padx=8)
# TO dropdown
self._to_dd = ctk.CTkOptionMenu(
top, variable=self.to_var, values=CURRENCIES,
width=110, height=32, corner_radius=8,
fg_color=C_PILL, button_color=C_PILL,
button_hover_color=C_PILLH,
text_color=C_WHITE, dropdown_fg_color=C_PANEL,
dropdown_hover_color=C_PILL,
font=(FONT, 13, "bold"),
command=lambda _: self._live_convert())
self._to_dd.grid(row=0, column=2, sticky="ew")
# ── Rate label ────────────────────────────────────────────────────
self._rate_lbl = ctk.CTkLabel(self, text="",
font=(FONT, 11),
text_color=C_GREY)
self._rate_lbl.pack(pady=(10, 0))
# Separator
ctk.CTkFrame(self, height=1, fg_color=C_SEP, corner_radius=0)\
.pack(fill="x", padx=16, pady=(10, 0))
# ── INPUT card ────────────────────────────────────────────────────
in_card = ctk.CTkFrame(self, fg_color=C_PANEL, corner_radius=12)
in_card.pack(fill="x", padx=16, pady=(12, 0))
in_hdr = ctk.CTkFrame(in_card, fg_color=C_PANEL)
in_hdr.pack(fill="x", padx=14, pady=(10, 0))
self._in_tag = ctk.CTkLabel(in_hdr, text="USD $",
font=(FONT, 10, "bold"),
text_color=ACCENT)
self._in_tag.pack(side="left")
self._copy_in_btn = ctk.CTkButton(
in_hdr, text="", width=28, height=22,
corner_radius=6,
fg_color="transparent", hover_color=C_PILL,
text_color=C_GREY2, font=(FONT, 11),
command=self._copy_input)
self._copy_in_btn.pack(side="right")
self.amount_var = ctk.StringVar(value="1")
self._entry = ctk.CTkEntry(
in_card, textvariable=self.amount_var,
font=(FONT_D, 38, "bold"),
fg_color=C_PANEL, border_width=0,
text_color=C_WHITE,
justify="center", width=280, height=56)
self._entry.pack(pady=(2, 12), padx=14)
self._entry.bind("<KeyRelease>", lambda e: self._live_convert())
self._entry.bind("<Return>", lambda e: self._live_convert())
# ── Swap vertical ─────────────────────────────────────────────────
mid = ctk.CTkFrame(self, fg_color=C_BG)
mid.pack(pady=8)
ctk.CTkButton(mid, text="", width=32, height=32,
corner_radius=16,
fg_color=C_PILL, hover_color=C_PILLH,
text_color=C_GREY, font=(FONT, 13),
command=self._swap).pack()
# ── OUTPUT card ───────────────────────────────────────────────────
out_card = ctk.CTkFrame(self, fg_color=C_PANEL, corner_radius=12)
out_card.pack(fill="x", padx=16, pady=(0, 12))
out_hdr = ctk.CTkFrame(out_card, fg_color=C_PANEL)
out_hdr.pack(fill="x", padx=14, pady=(10, 0))
self._out_tag = ctk.CTkLabel(out_hdr, text="VND ₫",
font=(FONT, 10, "bold"),
text_color=ACCENT)
self._out_tag.pack(side="left")
self._copy_out_btn = ctk.CTkButton(
out_hdr, text="", width=28, height=22,
corner_radius=6,
fg_color="transparent", hover_color=C_PILL,
text_color=C_GREY2, font=(FONT, 11),
command=self._copy_output)
self._copy_out_btn.pack(side="right")
self._result_lbl = ctk.CTkLabel(
out_card, text="",
font=(FONT_D, 38, "bold"),
text_color=C_WHITE)
self._result_lbl.pack(pady=(2, 12))
# ── Status bar ────────────────────────────────────────────────────
bot = ctk.CTkFrame(self, fg_color=C_BG)
bot.pack(fill="x", padx=16, pady=(0, 12))
self._status_lbl = ctk.CTkLabel(bot, text="● connecting…",
font=(FONT, 9), text_color=C_GREY2)
self._status_lbl.pack(side="left")
# Dark / Light toggle
self._mode = "dark"
self._mode_btn = ctk.CTkButton(
bot, text="", width=28, height=22,
corner_radius=6,
fg_color="transparent", hover_color=C_PILL,
text_color=C_GREY2, font=(FONT, 13),
command=self._toggle_mode)
self._mode_btn.pack(side="right", padx=(6, 0))
ctk.CTkLabel(bot, text="Currency Converter",
font=(FONT, 9), text_color=C_GREY2).pack(side="right")
# ── Logic ─────────────────────────────────────────────────────────────────
def _toggle_mode(self):
if self._mode == "dark":
self._mode = "light"
ctk.set_appearance_mode("light")
self._mode_btn.configure(text="🌙")
else:
self._mode = "dark"
ctk.set_appearance_mode("dark")
self._mode_btn.configure(text="")
def _on_from_change(self):
self._refresh_tags()
self._live_convert()
def _refresh_tags(self):
f, t = self.from_var.get(), self.to_var.get()
self._in_tag.configure(text=f"{f} {SYMBOLS.get(f,'')}")
self._out_tag.configure(text=f"{t} {SYMBOLS.get(t,'')}")
def _get_amount(self):
raw = self.amount_var.get().strip().replace(",", "")
m = re.findall(r"[\d.]+", raw)
if not m: return None
try: return float(m[0])
except ValueError: return None
def _fmt(self, v):
if v >= 1000: return f"{v:,.2f}"
if v >= 1: return f"{v:.4f}"
return f"{v:.6f}"
def _live_convert(self):
self._refresh_tags()
amt = self._get_amount()
if amt is None: return
frm, to = self.from_var.get(), self.to_var.get()
try:
result = self.cache.convert(amt, frm, to)
self._result_str = self._fmt(result)
self._result_lbl.configure(text=self._result_str, text_color=C_WHITE)
self._rate_lbl.configure(text=self.cache.rate_str(frm, to))
except Exception:
pass
def _swap(self):
a, b = self.from_var.get(), self.to_var.get()
res = self._result_str.replace(",", "") if self._result_str else None
self.from_var.set(b)
self.to_var.set(a)
if res:
try: self.amount_var.set(res)
except Exception: pass
self._refresh_tags()
self._live_convert()
def _do_copy(self, btn, value):
if not value: return
try:
if HAS_PYPERCLIP: pyperclip.copy(value)
else: self.clipboard_clear(); self.clipboard_append(value)
except Exception: pass
btn.configure(text="", text_color=GREEN)
self.after(1200, lambda: btn.configure(text="", text_color=C_GREY2))
def _copy_input(self):
self._do_copy(self._copy_in_btn, self.amount_var.get())
def _copy_output(self):
self._do_copy(self._copy_out_btn, self._result_str)
def _on_rates(self, live):
def _u():
self._status_lbl.configure(
text="● live rates" if live else "● offline",
text_color=GREEN if live else C_GREY2)
self._live_convert()
self.after(0, _u)
def main():
print("💱 Currency | ⌘+Shift+T")
App().mainloop()
if __name__ == "__main__":
main()