first commit
This commit is contained in:
commit
f7840ed78a
|
|
@ -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
|
||||
|
|
@ -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("<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()
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
requests>=2.28.0
|
||||
pyperclip>=1.8.2
|
||||
|
||||
|
|
@ -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'],
|
||||
)
|
||||
Loading…
Reference in New Issue