From f7840ed78afc9021a19231432b14b4125e372a08 Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 20 Mar 2026 11:31:34 +0700 Subject: [PATCH] first commit --- .gitignore | 37 +++++ main.py | 347 +++++++++++++++++++++++++++++++++++++++++++++++ readme.md | 70 ++++++++++ requirements.txt | 3 + setup.py | 25 ++++ 5 files changed, 482 insertions(+) create mode 100644 .gitignore create mode 100644 main.py create mode 100644 readme.md create mode 100644 requirements.txt create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c95110 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.egg +*.egg-info/ +.eggs/ + +# Virtual environments +.venv/ +.venv312/ +venv/ +env/ + +# py2app build output +build/ +dist/ + +# PyInstaller +*.spec + +# macOS +.DS_Store +.AppleDouble +.LSOverride +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Env files +.env +.env.local \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..7d42764 --- /dev/null +++ b/main.py @@ -0,0 +1,347 @@ +""" +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":"zł", "CZK":"Kč", "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("", lambda e: self._live_convert()) + self._entry.bind("", 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() \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..66a5c01 --- /dev/null +++ b/readme.md @@ -0,0 +1,70 @@ +# 💱 Currency Converter — Desktop App + +Cross-platform desktop app (Windows & macOS) viết bằng Python. + +--- + +## ✨ Tính năng + +| Tính năng | Mô tả | +| --------------------- | ---------------------------------------------------------------- | +| 🔄 Chuyển đổi tiền tệ | 40+ loại tiền, tỉ giá thực từ open.er-api.com | +| ⌨️ Global Hotkey | `Ctrl+Shift+C` — bôi đen số bất kỳ → nhấn hotkey → tự chuyển đổi | +| 📋 Copy nhanh | Sao chép kết quả 1 click | +| 🔃 Swap | Đổi chiều nhanh (From ↔ To) | +| 📜 Lịch sử | 4 lần chuyển đổi gần nhất | +| 🌐 Offline fallback | Nếu không có mạng, dùng tỉ giá offline gần đúng | + +--- + +## 📦 Cài đặt + +```bash +# 1. Cài Python 3.10+ (nếu chưa có) + +# 2. Cài dependencies +pip install requests pyperclip keyboard + +# 3. Chạy app +python currency_converter.py +``` + +--- + +## 🖱️ Cách dùng tính năng Hotkey (highlight text) + +1. Bôi đen / highlight một đoạn text có chứa số (VD: "Price: 299.99") +2. Nhấn `Ctrl+C` để copy (như bình thường) +3. Nhấn **`Ctrl+Shift+C`** → App tự bật lên, điền số và chuyển đổi + +--- + +## ⚠️ Lưu ý theo hệ điều hành + +### Windows + +- Chạy bình thường, không cần quyền admin (trừ khi app khác chặn hotkey) + +### macOS + +- Cần cấp quyền **Accessibility**: + - System Settings → Privacy & Security → Accessibility + - Thêm Terminal hoặc Python interpreter của bạn +- Nếu dùng `Cmd` thay `Ctrl`, sửa dòng `HOTKEY = "ctrl+shift+c"` thành `"cmd+shift+c"` + +--- + +## 🔧 Tuỳ chỉnh + +Mở file `currency_converter.py`, tìm các hằng số ở đầu file: + +```python +HOTKEY = "ctrl+shift+c" # Đổi phím tắt +``` + +--- + +## 📡 Nguồn tỉ giá + +- **Live**: [open.er-api.com](https://open.er-api.com) (miễn phí, cập nhật hàng ngày) +- **Offline**: Tỉ giá xấp xỉ được nhúng sẵn trong code diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..22d6a99 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +requests>=2.28.0 +pyperclip>=1.8.2 + \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..d9c7dcd --- /dev/null +++ b/setup.py @@ -0,0 +1,25 @@ +from setuptools import setup + +APP = ['main.py'] +DATA_FILES = [] +OPTIONS = { + 'argv_emulation': False, + 'packages': ['customtkinter', 'requests', 'pyperclip'], + 'iconfile': None, + 'plist': { + 'CFBundleName': 'Currency', + 'CFBundleDisplayName': 'Currency Converter', + 'CFBundleIdentifier': 'com.currency.converter', + 'CFBundleVersion': '1.0.0', + 'CFBundleShortVersionString': '1.0', + 'LSMinimumSystemVersion': '10.13', + 'NSHighResolutionCapable': True, + } +} + +setup( + app=APP, + data_files=DATA_FILES, + options={'py2app': OPTIONS}, + setup_requires=['py2app'], +) \ No newline at end of file