import os import re from tkinter import TclError import customtkinter as ctk try: import arabic_reshaper from bidi.algorithm import get_display except ImportError: arabic_reshaper = None get_display = None ctk.set_appearance_mode("light") ctk.set_default_color_theme("blue") PRIMARY = "#175B4B" PRIMARY_DARK = "#0E4236" PRIMARY_SOFT = "#DFF1E8" ACCENT = "#E8A04D" ACCENT_DARK = "#C97E2D" BACKGROUND = "#F5EFE7" CARD = "#FFFDFC" SURFACE = "#FBF7F2" INPUT_BG = "#FFFCF8" TEXT = "#16312A" MUTED = "#6B7A77" DANGER = "#B6465F" WARNING = "#9A6C3C" BORDER = "#E5DCCE" KEYBOARD_BG = "#D4DCE2" KEY_FACE = "#FFFFFF" KEY_MUTED = "#BCC1C9" KEY_TEXT = "#000000" SIDEBAR = "#1B5A4A" SIDEBAR_SOFT = "#245E4E" BUBBLE_OUT = "#E1F2E9" BUBBLE_IN = "#FFFFFF" FONT_BODY = "Tahoma" if os.name == "nt" else "DejaVu Sans" FONT_TITLE = "Tahoma" if os.name == "nt" else "DejaVu Sans" RTL_PATTERN = re.compile(r"[\u0600-\u06FF]") KEYBOARD_LAYOUTS = { "fa": [ ['۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹', '۰'], ['ض', 'ص', 'ث', 'ق', 'ف', 'غ', 'ع', 'ه', 'خ', 'ح', 'ج', 'چ'], ['ش', 'س', 'ی', 'ب', 'ل', 'ا', 'ت', 'ن', 'م', 'ک', 'گ'], ['ظ', 'ط', 'ز', 'ر', 'ذ', 'د', 'پ', 'و', '⌫'], ['123', 'انگلیسی', '،', 'فاصله', '.', 'تایید'], ], "en": [ ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], ['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'], ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'], ['z', 'x', 'c', 'v', 'b', 'n', 'm', '⌫'], ['123', 'فارسی', ',', 'فاصله', '.', 'تایید'], ], "numeric": [ ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], ['+', '-', '/', '@', '_', '.', ':', '(', ')', '?'], ['فارسی', 'انگلیسی', '⌫'], ['بستن', 'فاصله', 'تایید'], ], } KEYBOARD_ACTIONS = [ ('فارسی', "fa"), ('انگلیسی', "en"), ("123", "numeric"), ('پاک', "backspace"), ('بستن', "close"), ] def ui_text(value): if not isinstance(value, str) or not value: return value if arabic_reshaper is None or get_display is None or not RTL_PATTERN.search(value): return value return get_display(arabic_reshaper.reshape(value)) class _RTLTextMixin: @staticmethod def _normalize_kwargs(kwargs): normalized = dict(kwargs) if "text" in normalized: normalized["text"] = ui_text(normalized["text"]) if "placeholder_text" in normalized: normalized["placeholder_text"] = ui_text(normalized["placeholder_text"]) if "label_text" in normalized: normalized["label_text"] = ui_text(normalized["label_text"]) return normalized class RTLLabel(_RTLTextMixin, ctk.CTkLabel): def __init__(self, *args, **kwargs): super().__init__(*args, **self._normalize_kwargs(kwargs)) def configure(self, require_redraw=False, **kwargs): return super().configure(require_redraw=require_redraw, **self._normalize_kwargs(kwargs)) class RTLButton(_RTLTextMixin, ctk.CTkButton): def __init__(self, *args, **kwargs): kwargs.setdefault("corner_radius", 16) super().__init__(*args, **self._normalize_kwargs(kwargs)) def configure(self, require_redraw=False, **kwargs): return super().configure(require_redraw=require_redraw, **self._normalize_kwargs(kwargs)) class RTLEntry(_RTLTextMixin, ctk.CTkEntry): def __init__(self, *args, **kwargs): kwargs.setdefault("justify", "right") kwargs.setdefault("fg_color", INPUT_BG) kwargs.setdefault("border_color", BORDER) kwargs.setdefault("corner_radius", 16) super().__init__(*args, **self._normalize_kwargs(kwargs)) def configure(self, require_redraw=False, **kwargs): return super().configure(require_redraw=require_redraw, **self._normalize_kwargs(kwargs)) class RTLTextbox(ctk.CTkTextbox): def insert(self, index, text, *tags): return super().insert(index, ui_text(text), *tags) class RTLScrollableFrame(_RTLTextMixin, ctk.CTkScrollableFrame): def __init__(self, *args, **kwargs): super().__init__(*args, **self._normalize_kwargs(kwargs)) def configure(self, require_redraw=False, **kwargs): return super().configure(require_redraw=require_redraw, **self._normalize_kwargs(kwargs)) class SecureSmsApp(ctk.CTk): def __init__(self, controller): super().__init__() self.controller = controller self.current_contact_phone = None self.active_input = None self.current_keyboard_layout = "fa" self._keyboard_interaction_guard = False self._text_inputs = {} self._input_aliases = {} self.kiosk_mode = os.environ.get("SECURE_SMS_WINDOWED", "0") != "1" self.screen_width = max(480, self.winfo_screenwidth()) self.screen_height = max(320, self.winfo_screenheight()) self.is_portrait = self.screen_height > self.screen_width self.compact_mode = self.is_portrait or self.screen_width <= 560 if self.kiosk_mode: self.window_width = self.screen_width self.window_height = self.screen_height else: self.window_width, self.window_height = (480, 800) if self.is_portrait else (800, 480) self.title(ui_text("پیام‌رسان امن صبا")) self.geometry(f"{self.window_width}x{self.window_height}") self.minsize(self.window_width, self.window_height) self.resizable(False, False) self.configure(fg_color=BACKGROUND) self.option_add("*Cursor", "none") self._build_shell() self.bind_all("", self._handle_global_tap, add="+") self._show_lock_screen() self.after(50, self._enable_touch_kiosk_mode) def _enable_touch_kiosk_mode(self): self._hide_mouse_cursor() if not self.kiosk_mode: return try: self.attributes("-topmost", True) self.attributes("-fullscreen", True) except TclError: self.geometry(f"{self.window_width}x{self.window_height}+0+0") def _hide_mouse_cursor(self, widget=None): current = widget or self try: current.configure(cursor="none") except Exception: pass for child in current.winfo_children(): self._hide_mouse_cursor(child) def _input_target(self, widget): return getattr(widget, "_entry", getattr(widget, "_textbox", widget)) def _resolve_registered_input(self, widget): if widget in self._text_inputs and widget.winfo_exists(): return widget resolved = self._input_aliases.get(str(widget)) if resolved is not None and resolved.winfo_exists(): return resolved current = getattr(widget, "master", None) while current is not None: if current in self._text_inputs and current.winfo_exists(): return current resolved = self._input_aliases.get(str(current)) if resolved is not None and resolved.winfo_exists(): return resolved current = getattr(current, "master", None) return None def _widget_is_descendant(self, widget, ancestor): current = widget while current is not None: if current == ancestor: return True current = getattr(current, "master", None) return False def _point_inside_widget(self, widget, x_root, y_root): if widget is None or not widget.winfo_exists() or not widget.winfo_ismapped(): return False left = widget.winfo_rootx() top = widget.winfo_rooty() right = left + widget.winfo_width() bottom = top + widget.winfo_height() return left <= x_root <= right and top <= y_root <= bottom def _register_text_input(self, widget, *, title, layout="fa", multiline=False, submit=None): self._text_inputs[widget] = { "title": title, "layout": layout, "multiline": multiline, "submit": submit, } self._input_aliases[str(widget)] = widget bind_target = self._input_target(widget) self._input_aliases[str(bind_target)] = widget bind_target.bind("", lambda _event, target=widget: self._activate_text_input(target), add="+") bind_target.bind("", lambda _event, target=widget: self.after(10, lambda: self._activate_text_input(target)), add="+") def _focus_registered_input(self, widget): if widget not in self._text_inputs or not widget.winfo_exists(): return self._input_target(widget).focus_set() self._activate_text_input(widget) def _activate_text_input(self, widget): if widget not in self._text_inputs or not widget.winfo_exists(): return self.active_input = widget self._show_virtual_keyboard(self._text_inputs[widget]["layout"]) def _handle_global_tap(self, event): widget = event.widget if self._keyboard_interaction_guard: return if self._resolve_registered_input(widget) is not None: return if self._widget_is_descendant(widget, self.keyboard_host): return if self._point_inside_widget(self.keyboard_frame, event.x_root, event.y_root): return self.after(20, self._hide_virtual_keyboard) def _register_keyboard_interaction(self): self._keyboard_interaction_guard = True self.after(160, self._clear_keyboard_interaction_guard) def _clear_keyboard_interaction_guard(self): self._keyboard_interaction_guard = False def _shift_layout_for_keyboard(self, show: bool): if not hasattr(self, 'main_panel') or not self.active_input: return in_main_panel = self._widget_is_descendant(self.active_input, self.main_panel) if show and in_main_panel: if self.is_portrait and hasattr(self, 'sidebar') and self.sidebar.winfo_ismapped(): self.sidebar.grid_remove() elif not self.is_portrait and hasattr(self, 'header_card') and self.header_card.winfo_ismapped(): self.header_card.grid_remove() elif not show: if self.is_portrait and hasattr(self, 'sidebar') and not self.sidebar.winfo_ismapped(): self.sidebar.grid() elif not self.is_portrait and hasattr(self, 'header_card') and not self.header_card.winfo_ismapped(): self.header_card.grid() def _update_keyboard_preview(self): widget = self.active_input if widget is None or not widget.winfo_exists(): return target = self._input_target(widget) try: if isinstance(widget, ctk.CTkTextbox): text = target.get("1.0", "end-1c") else: text = target.get() text = text.replace('\n', ' ') if len(text) > 40: text = "..." + text[-37:] self.keyboard_preview.configure(text=text + " |") except Exception: pass def _show_virtual_keyboard(self, layout=None): if layout: self.current_keyboard_layout = layout self.keyboard_hint.configure(text=self._keyboard_title()) self._refresh_keyboard_action_bar() self._update_keyboard_preview() self._shift_layout_for_keyboard(True) if not self.keyboard_host.winfo_ismapped(): self.keyboard_host.grid() self._render_keyboard(self.current_keyboard_layout) self.after(0, self._hide_mouse_cursor) def _hide_virtual_keyboard(self): self.active_input = None self._shift_layout_for_keyboard(False) self.keyboard_host.grid_remove() def _keyboard_title(self): if self.active_input in self._text_inputs: return self._text_inputs[self.active_input]["title"] return "ورودی" def _keyboard_weight(self, key): return { 'فاصله': 50, "Space": 50, 'تایید': 20, "Enter": 20, 'بستن': 15, 'حذف': 15, "Back": 15, '⌫': 15, 'فارسی': 15, "English": 15, 'انگلیسی': 15, "123": 15, }.get(key, 10) def _keyboard_style(self, key): if key in {'تایید', "Enter"}: return PRIMARY, PRIMARY_DARK, "white" if key in {"ABC", 'فا', '۱۲۳', "123", "English", 'انگلیسی', 'فارسی', 'حذف', 'بستن', "Back", '⌫'}: return KEY_MUTED, "#A8AFB9", KEY_TEXT return KEY_FACE, "#F0F0F0", KEY_TEXT def _keyboard_action_style(self, action): if action in {"fa", "en", "numeric"}: active = self.current_keyboard_layout == action return (PRIMARY, PRIMARY_DARK, "white") if active else (ACCENT, ACCENT_DARK, "#3A2514") if action == "backspace": return KEY_MUTED, "#E4D4C2", TEXT return "#F4EFE9", "#E8D8C7", TEXT def _keyboard_action_command(self, action): if action in {"fa", "en", "numeric"}: return lambda: (self._register_keyboard_interaction(), self._show_virtual_keyboard(action)) if action == "backspace": return lambda: (self._register_keyboard_interaction(), self._backspace_active_input()) if action == "close": return lambda: (self._register_keyboard_interaction(), self._hide_virtual_keyboard()) return lambda: None def _refresh_keyboard_action_bar(self): pass def _render_keyboard(self, layout_name): for child in self.keyboard_keys.winfo_children(): child.destroy() key_height = 44 if self.compact_mode else 40 key_font_size = 17 if self.compact_mode else 15 for row_index, row in enumerate(KEYBOARD_LAYOUTS.get(layout_name, KEYBOARD_LAYOUTS["fa"])): row_frame = ctk.CTkFrame(self.keyboard_keys, fg_color="transparent") row_frame.grid(row=row_index, column=0, sticky="ew", pady=2 if self.compact_mode else 3) for column_index, key in enumerate(row): row_frame.grid_columnconfigure(column_index, weight=self._keyboard_weight(key)) fg_color, hover_color, text_color = self._keyboard_style(key) RTLButton( row_frame, text=key, height=key_height, corner_radius=6, fg_color=fg_color, hover_color=hover_color, text_color=text_color, font=ctk.CTkFont(family=FONT_BODY, size=key_font_size, weight="bold"), command=lambda value=key: (self._register_keyboard_interaction(), self._apply_virtual_key(value)), ).grid(row=0, column=column_index, padx=2, sticky="ew") def _apply_virtual_key(self, key): if key in {"ABC", "English", 'انگلیسی'}: self._show_virtual_keyboard("en") return if key in {'فا', 'فارسی'}: self._show_virtual_keyboard("fa") return if key in {'۱۲۳', "123"}: self._show_virtual_keyboard("numeric") return if key in {'فاصله', "Space"}: self._insert_into_active_input(" ") return if key in {'حذف', "Back", '⌫'}: self._backspace_active_input() return if key == 'بستن': self._hide_virtual_keyboard() return if key in {'تایید', "Enter"}: self._submit_active_input() return self._insert_into_active_input(key) def _insert_into_active_input(self, text): widget = self.active_input if widget is None or not widget.winfo_exists(): return target = self._input_target(widget) try: if isinstance(widget, ctk.CTkTextbox): selection = target.tag_ranges("sel") if len(selection) == 2: target.delete(selection[0], selection[1]) target.insert("insert", text) target.see("insert") else: try: if target.selection_present(): target.delete("sel.first", "sel.last") except Exception: pass target.insert("insert", text) target.focus_set() self._update_keyboard_preview() except TclError: self._hide_virtual_keyboard() def _backspace_active_input(self): widget = self.active_input if widget is None or not widget.winfo_exists(): return target = self._input_target(widget) try: if isinstance(widget, ctk.CTkTextbox): selection = target.tag_ranges("sel") if len(selection) == 2: target.delete(selection[0], selection[1]) elif target.compare("insert", ">", "1.0"): target.delete("insert-1c") target.see("insert") else: try: if target.selection_present(): target.delete("sel.first", "sel.last") target.focus_set() self._update_keyboard_preview() return except Exception: pass index = target.index("insert") if index > 0: target.delete(index - 1) target.focus_set() self._update_keyboard_preview() except TclError: self._hide_virtual_keyboard() def _submit_active_input(self): widget = self.active_input if widget is None or not widget.winfo_exists(): return meta = self._text_inputs.get(widget, {}) submit = meta.get("submit") if submit is not None: submit() return if meta.get("multiline"): self._insert_into_active_input("\n") return self._hide_virtual_keyboard() def _build_shell(self): self.grid_columnconfigure(0, weight=1) self.grid_rowconfigure(0, weight=1) self.grid_rowconfigure(1, weight=0) self.root_frame = ctk.CTkFrame(self, fg_color=BACKGROUND, corner_radius=0) self.root_frame.grid(row=0, column=0, sticky="nsew") self.root_frame.grid_columnconfigure(1, weight=1) self.root_frame.grid_rowconfigure(0, weight=1) self.keyboard_host = ctk.CTkFrame(self, fg_color=BACKGROUND, corner_radius=0) self.keyboard_host.grid(row=1, column=0, sticky="ew") self.keyboard_host.grid_columnconfigure(0, weight=1) self.keyboard_frame = ctk.CTkFrame( self.keyboard_host, fg_color=KEYBOARD_BG, corner_radius=0, border_width=1, border_color="#C0C5CB", ) self.keyboard_frame.grid(row=0, column=0, sticky="ew", padx=0, pady=0) self.keyboard_frame.grid_columnconfigure(0, weight=1) keyboard_header = ctk.CTkFrame(self.keyboard_frame, fg_color="transparent") keyboard_header.grid(row=0, column=0, sticky="ew", padx=10, pady=(8, 4)) keyboard_header.grid_columnconfigure(0, weight=1) self.keyboard_preview = RTLLabel( keyboard_header, text="", fg_color="#FFFFFF", text_color="#000000", corner_radius=8, padx=12, justify="right", font=ctk.CTkFont(family=FONT_BODY, size=16), ) self.keyboard_preview.grid(row=0, column=0, sticky="ew", padx=(6, 12), ipady=8) RTLButton( keyboard_header, text="بستن کیبورد", width=76, height=36, corner_radius=8, fg_color=KEY_MUTED, hover_color="#A8AFB9", text_color=KEY_TEXT, font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), command=self._hide_virtual_keyboard, ).grid(row=0, column=1, padx=(4, 6), sticky="e") self.keyboard_hint = RTLLabel( self.keyboard_frame, text="", text_color="#636A73", font=ctk.CTkFont(family=FONT_BODY, size=13), ) self.keyboard_hint.grid(row=1, column=0, padx=14, pady=(0, 2), sticky="e") self.keyboard_action_bar = ctk.CTkFrame(self.keyboard_frame, fg_color="transparent", height=0) self.keyboard_keys = ctk.CTkFrame(self.keyboard_frame, fg_color="transparent") self.keyboard_keys.grid(row=2, column=0, sticky="ew", padx=6, pady=(2, 12)) self.keyboard_keys.grid_columnconfigure(0, weight=1) self._refresh_keyboard_action_bar() self._render_keyboard(self.current_keyboard_layout) self.keyboard_host.grid_remove() def _reset_text_input_registry(self): self.active_input = None self._text_inputs.clear() self._input_aliases.clear() def _clear_root(self): self._hide_virtual_keyboard() self._reset_text_input_registry() for widget in self.root_frame.winfo_children(): widget.destroy() def _show_lock_screen(self): self._clear_root() frame = ctk.CTkFrame(self.root_frame, fg_color=CARD, corner_radius=16, border_width=1, border_color=BORDER) frame.place( relx=0.5, rely=0.5, anchor="center", relwidth=0.86 if self.is_portrait else 0.54, relheight=0.52 if self.is_portrait else 0.66, ) title = 'راه\u200cاندازی امن برنامه' if not self.controller.is_bootstrapped() else 'ورود به برنامه' subtitle = ( 'یک رمز اصلی تعیین کن تا کلیدها و داده\u200cهای حساس داخل دیتابیس به صورت رمز\u200cشده نگه\u200cداری شوند.' if not self.controller.is_bootstrapped() else 'برای ورود، رمز اصلی برنامه را وارد کن.' ) RTLLabel( frame, text='📨 صبا', font=ctk.CTkFont(family=FONT_TITLE, size=32, weight="bold"), text_color=PRIMARY, ).pack(pady=(24, 4)) RTLLabel( frame, text=title, font=ctk.CTkFont(family=FONT_BODY, size=20, weight="bold"), text_color=TEXT, ).pack(pady=(4, 6)) RTLLabel( frame, text=subtitle, wraplength=min(self.window_width - 110, 420), justify="right", font=ctk.CTkFont(family=FONT_BODY, size=14), text_color=MUTED, ).pack(padx=28) self.password_entry = RTLEntry( frame, placeholder_text='رمز اصلی', show="*", height=44, font=ctk.CTkFont(family=FONT_BODY, size=16), fg_color=INPUT_BG, border_color=BORDER, text_color=TEXT, ) self.password_entry.pack(fill="x", padx=36, pady=(20, 10)) self.confirm_entry = None if not self.controller.is_bootstrapped(): self.confirm_entry = RTLEntry( frame, placeholder_text='تکرار رمز', show="*", height=44, font=ctk.CTkFont(family=FONT_BODY, size=16), fg_color=INPUT_BG, border_color=BORDER, text_color=TEXT, ) self.confirm_entry.pack(fill="x", padx=36, pady=6) self.lock_message = RTLLabel( frame, text="", text_color=DANGER, font=ctk.CTkFont(family=FONT_BODY, size=13), ) self.lock_message.pack(pady=(4, 8)) action_text = 'شروع برنامه' if not self.controller.is_bootstrapped() else 'ورود' RTLButton( frame, text=action_text, height=44, corner_radius=8, fg_color=PRIMARY, hover_color=PRIMARY_DARK, text_color="#FFFFFF", command=self._submit_lock_screen, font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), ).pack(fill="x", padx=36, pady=(4, 16)) self.password_entry.bind("", lambda _event: self._submit_lock_screen()) self._register_text_input(self.password_entry, title='رمز اصلی', layout="en", submit=self._submit_lock_screen) if self.confirm_entry: self.confirm_entry.bind("", lambda _event: self._submit_lock_screen()) self._register_text_input(self.confirm_entry, title='تکرار رمز', layout="en", submit=self._submit_lock_screen) self.after(80, lambda: self._focus_registered_input(self.password_entry)) def _configure_root_layout(self): for column in range(2): self.root_frame.grid_columnconfigure(column, weight=0, minsize=0) for row in range(2): self.root_frame.grid_rowconfigure(row, weight=0, minsize=0) if self.is_portrait: top_height = min(max(270, int(self.window_height * 0.36)), 320) self.root_frame.grid_columnconfigure(0, weight=1) self.root_frame.grid_rowconfigure(0, weight=0, minsize=top_height) self.root_frame.grid_rowconfigure(1, weight=1) else: self.root_frame.grid_columnconfigure(0, weight=0, minsize=248) self.root_frame.grid_columnconfigure(1, weight=1) self.root_frame.grid_rowconfigure(0, weight=1) def _configure_profile_card_layout(self): for widget in ( self.profile_title_label, self.profile_name, self.profile_phone, self.profile_hint, self.secure_button, self.normal_button, ): widget.grid_forget() if self.is_portrait: self.profile_card.grid_columnconfigure(0, weight=1) self.profile_card.grid_columnconfigure(1, weight=1) self.profile_title_label.grid(row=0, column=0, columnspan=2, padx=14, pady=(14, 6), sticky="e") self.profile_name.configure(font=ctk.CTkFont(family=FONT_BODY, size=17, weight="bold")) self.profile_name.grid(row=1, column=0, padx=14, sticky="e") self.profile_phone.configure(font=ctk.CTkFont(family=FONT_BODY, size=13 if self.is_portrait else 14)) self.profile_phone.grid(row=2, column=0, padx=14, pady=(0, 8), sticky="e") self.profile_hint.configure(wraplength=min(self.window_width - 90, 340), font=ctk.CTkFont(family=FONT_BODY, size=14)) self.profile_hint.grid(row=3, column=0, columnspan=2, padx=14, pady=(0, 10), sticky="e") self.secure_button.configure(height=42, font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold")) self.secure_button.grid(row=1, column=1, padx=14, pady=(2, 6), sticky="ew") self.normal_button.configure(height=40, font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold")) self.normal_button.grid(row=2, column=1, padx=14, pady=(0, 8), sticky="ew") else: self.profile_card.grid_columnconfigure(0, weight=1) self.profile_title_label.grid(row=0, column=0, padx=18, pady=(18, 6), sticky="e") self.profile_name.configure(font=ctk.CTkFont(family=FONT_BODY, size=19, weight="bold")) self.profile_name.grid(row=1, column=0, padx=18, sticky="e") self.profile_phone.configure(font=ctk.CTkFont(family=FONT_BODY, size=16)) self.profile_phone.grid(row=2, column=0, padx=18, pady=(0, 18), sticky="e") self.profile_hint.configure(wraplength=220, font=ctk.CTkFont(family=FONT_BODY, size=15)) self.profile_hint.grid(row=3, column=0, padx=18, pady=(0, 18), sticky="e") self.secure_button.configure(height=48, font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold")) self.secure_button.grid(row=4, column=0, padx=18, pady=(0, 10), sticky="ew") self.normal_button.configure(height=44, font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold")) self.normal_button.grid(row=5, column=0, padx=18, pady=(0, 16), sticky="ew") def _submit_lock_screen(self): password = self.password_entry.get().strip() if len(password) < 6: self.lock_message.configure(text="رمز باید حداقل ۶ کاراکتر باشد.") return if not self.controller.is_bootstrapped(): confirm = self.confirm_entry.get().strip() if password != confirm: self.lock_message.configure(text="تکرار رمز با رمز اصلی یکسان نیست.") return self.controller.bootstrap(password) self._build_main_app() return if self.controller.unlock(password): self._build_main_app() return self.lock_message.configure(text="رمز وارد شده درست نیست.") def _build_main_app(self): self._clear_root() self._configure_root_layout() outer_pad = 12 if self.is_portrait else 18 inner_pad = 8 if self.is_portrait else 12 title_size = 24 if self.is_portrait else 28 subtitle_size = 12 if self.is_portrait else 13 action_height = 46 if self.is_portrait else 42 main_row = 1 if self.is_portrait else 0 main_column = 0 if self.is_portrait else 1 self.sidebar = ctk.CTkFrame(self.root_frame, fg_color=SIDEBAR, corner_radius=0, border_width=0) self.sidebar.grid(row=0, column=0, sticky="nsew") self.sidebar.grid_columnconfigure(0, weight=1) self.sidebar.grid_rowconfigure(5, weight=1) sidebar_header = ctk.CTkFrame(self.sidebar, fg_color="transparent") sidebar_header.grid(row=0, column=0, padx=14, pady=(12, 2), sticky="ew") sidebar_header.grid_columnconfigure(0, weight=1) RTLLabel( sidebar_header, text='صبا', text_color="white", font=ctk.CTkFont(family=FONT_TITLE, size=title_size, weight="bold"), ).grid(row=0, column=0, sticky="e") RTLLabel( sidebar_header, text='پیام\u200cرسان امن', text_color=PRIMARY_SOFT, font=ctk.CTkFont(family=FONT_BODY, size=subtitle_size), ).grid(row=1, column=0, sticky="e") self.connection_badge = RTLLabel( self.sidebar, text="", corner_radius=6, fg_color="#2E7D62", text_color="white", font=ctk.CTkFont(family=FONT_BODY, size=13, weight="bold"), padx=10, pady=6, ) self.connection_badge.grid(row=2, column=0, padx=14, pady=(6, 8), sticky="ew") top_actions = ctk.CTkFrame(self.sidebar, fg_color="transparent") top_actions.grid(row=3, column=0, padx=14, pady=(0, 6), sticky="ew") top_actions.grid_columnconfigure((0, 1), weight=1) RTLButton( top_actions, text='گفتگوی جدید', command=self._open_contact_dialog, fg_color=PRIMARY, text_color="#FFFFFF", hover_color=PRIMARY_DARK, corner_radius=8, height=action_height, font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"), ).grid(row=0, column=0, padx=4, sticky="ew") RTLButton( top_actions, text='⚙ تنظیمات', command=self._open_settings_panel, fg_color=INPUT_BG, text_color=TEXT, hover_color=BORDER, corner_radius=8, height=action_height, font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"), ).grid(row=0, column=1, padx=4, sticky="ew") self.contact_form_card = ctk.CTkFrame( self.sidebar, fg_color=SIDEBAR_SOFT, corner_radius=10, border_width=1, border_color=BORDER, ) self.contact_form_card.grid(row=4, column=0, padx=14, pady=(0, 10), sticky="ew") self.contact_form_card.grid_columnconfigure(0, weight=1) RTLLabel( self.contact_form_card, text='مخاطب جدید', text_color="white", font=ctk.CTkFont(family=FONT_BODY, size=17, weight="bold"), ).grid(row=0, column=0, padx=14, pady=(14, 8), sticky="e") self.contact_name_entry = RTLEntry( self.contact_form_card, placeholder_text='نام مخاطب', height=44, font=ctk.CTkFont(family=FONT_BODY, size=14 if self.is_portrait else 15), ) self.contact_name_entry.grid(row=1, column=0, padx=14, pady=6, sticky="ew") self.contact_phone_entry = RTLEntry( self.contact_form_card, placeholder_text='شماره موبایل', height=44, font=ctk.CTkFont(family=FONT_BODY, size=14 if self.is_portrait else 15), ) self.contact_phone_entry.grid(row=2, column=0, padx=14, pady=6, sticky="ew") self.contact_form_message = RTLLabel( self.contact_form_card, text="", text_color="#FDE68A", font=ctk.CTkFont(family=FONT_BODY, size=13), ) self.contact_form_message.grid(row=3, column=0, padx=14, pady=(4, 2), sticky="e") contact_actions = ctk.CTkFrame(self.contact_form_card, fg_color="transparent") contact_actions.grid(row=4, column=0, padx=10, pady=(4, 14), sticky="ew") contact_actions.grid_columnconfigure((0, 1), weight=1) RTLButton( contact_actions, text='ذخیره', fg_color=PRIMARY, text_color="#FFFFFF", hover_color=PRIMARY_DARK, command=self._save_contact_inline, corner_radius=8, font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), height=40, ).grid(row=0, column=0, padx=4, sticky="ew") RTLButton( contact_actions, text='بستن', fg_color=INPUT_BG, text_color=TEXT, hover_color=BORDER, command=self._hide_contact_form, corner_radius=8, font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), height=40, ).grid(row=0, column=1, padx=4, sticky="ew") self.contact_phone_entry.bind("", lambda _event: self._save_contact_inline()) self._register_text_input(self.contact_name_entry, title="نام مخاطب", layout="fa") self._register_text_input( self.contact_phone_entry, title="شماره موبایل", layout="numeric", submit=self._save_contact_inline, ) self.contact_form_card.grid_remove() self.contacts_frame = RTLScrollableFrame( self.sidebar, height=160 if self.is_portrait else 320, label_text='گفتگو\u200cها', label_font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), fg_color="transparent", ) self.contacts_frame.grid(row=5, column=0, padx=8, pady=4, sticky="nsew") self.main_panel = ctk.CTkFrame(self.root_frame, fg_color=BACKGROUND, corner_radius=0) self.main_panel.grid(row=main_row, column=main_column, sticky="nsew") self.main_panel.grid_rowconfigure(1, weight=1) self.main_panel.grid_columnconfigure(0, weight=1) self.header_card = ctk.CTkFrame(self.main_panel, fg_color=CARD, corner_radius=0, border_width=0) self.header_card.grid(row=0, column=0, sticky="ew") self.header_card.grid_columnconfigure(0, weight=1) self.header_card.grid_columnconfigure(1, weight=0) self.chat_title = RTLLabel( self.header_card, text='یک مخاطب را انتخاب کن', text_color=TEXT, font=ctk.CTkFont(family=FONT_TITLE, size=16 if self.is_portrait else 18, weight="bold"), ) self.chat_title.grid(row=0, column=0, padx=16, pady=(10, 2), sticky="e") self.chat_subtitle = RTLLabel( self.header_card, text='در اینجا فقط دو حالت داری: عادی یا امن', text_color=MUTED, font=ctk.CTkFont(family=FONT_BODY, size=12), ) self.chat_subtitle.grid(row=1, column=0, padx=16, pady=(0, 10), sticky="e") self.mode_badge = RTLLabel( self.header_card, text='عادی', fg_color=PRIMARY_SOFT, text_color=PRIMARY, corner_radius=6, padx=14, pady=6, font=ctk.CTkFont(family=FONT_BODY, size=12, weight="bold"), ) self.mode_badge.grid(row=0, column=1, rowspan=2, padx=14, sticky="e") content = ctk.CTkFrame(self.main_panel, fg_color="transparent") content.grid(row=1, column=0, padx=outer_pad, pady=(0, inner_pad), sticky="nsew") if self.is_portrait: content.grid_rowconfigure(0, weight=1) content.grid_rowconfigure(1, weight=0) content.grid_columnconfigure(0, weight=1) else: content.grid_rowconfigure(0, weight=1) content.grid_columnconfigure(0, weight=1) content.grid_columnconfigure(1, weight=0, minsize=220) self.chat_container = RTLScrollableFrame( content, fg_color="transparent" ) if self.is_portrait: self.chat_container.grid(row=0, column=0, sticky="nsew", pady=(0, inner_pad)) else: self.chat_container.grid(row=0, column=0, sticky="nsew", padx=(0, inner_pad)) self.profile_card = ctk.CTkFrame(content, fg_color=CARD, corner_radius=12, border_width=1, border_color=BORDER) self.profile_card.grid(row=1, column=0, sticky="ew") if self.is_portrait else self.profile_card.grid(row=0, column=1, sticky="nsew") self.profile_card.grid_columnconfigure(0, weight=1) self.profile_title_label = RTLLabel( self.profile_card, text='پروفایل مخاطب', text_color=TEXT, font=ctk.CTkFont(family=FONT_TITLE, size=17 if self.is_portrait else 18, weight="bold"), ) self.profile_name = RTLLabel( self.profile_card, text='نام مخاطب', text_color=TEXT, font=ctk.CTkFont(family=FONT_BODY, size=19, weight="bold"), ) self.profile_name.grid(row=1, column=0, padx=18, sticky="e") self.profile_phone = RTLLabel( self.profile_card, text='شماره', text_color=MUTED, font=ctk.CTkFont(family=FONT_BODY, size=16), ) self.profile_phone.grid(row=2, column=0, padx=18, pady=(0, 18), sticky="e") self.profile_hint = RTLLabel( self.profile_card, text='برای این مخاطب هنوز حالت امن فعال نشده است.', wraplength=220, justify="right", text_color=MUTED, font=ctk.CTkFont(family=FONT_BODY, size=15), ) self.profile_hint.grid(row=3, column=0, padx=18, pady=(0, 18), sticky="e") self.secure_button = RTLButton( self.profile_card, text='فعال\u200cسازی ارتباط امن', fg_color=PRIMARY, hover_color=PRIMARY_DARK, text_color="#FFFFFF", corner_radius=8, font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), command=self._toggle_secure_mode, height=42, ) self.secure_button.grid(row=4, column=0, padx=16, pady=(0, 8), sticky="ew") self.normal_button = RTLButton( self.profile_card, text='بازگشت به حالت عادی', fg_color=INPUT_BG, text_color=TEXT, hover_color=BORDER, corner_radius=8, font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), command=self._switch_to_normal, height=40, ) self.normal_button.grid(row=5, column=0, padx=16, pady=(0, 14), sticky="ew") self._configure_profile_card_layout() composer = ctk.CTkFrame(self.main_panel, fg_color=CARD, corner_radius=0, border_width=0) composer.grid(row=2, column=0, sticky="ew") composer.grid_columnconfigure(0, weight=1) self.message_entry = RTLTextbox( composer, height=52 if self.is_portrait else 62, fg_color=INPUT_BG, text_color=TEXT, border_width=0, corner_radius=10, font=ctk.CTkFont(family=FONT_BODY, size=15), wrap="word", ) self.message_entry.grid(row=0, column=0, padx=(10, 6), pady=8, sticky="ew") actions = ctk.CTkFrame(composer, fg_color="transparent") actions.grid(row=0, column=1, padx=(0, 10), pady=8, sticky="ns") self.send_state_label = RTLLabel( actions, text="", text_color=MUTED, font=ctk.CTkFont(family=FONT_BODY, size=11), ) self.send_state_label.pack(pady=(2, 4)) RTLButton( actions, text='➤', fg_color=PRIMARY, text_color="#FFFFFF", hover_color=PRIMARY_DARK, font=ctk.CTkFont(family=FONT_BODY, size=20, weight="bold"), width=48, height=48, corner_radius=24, command=self._send_message, ).pack() self.message_entry.bind("", lambda _event: self._send_message()) self._register_text_input(self.message_entry, title="متن پیام", layout="fa", multiline=True) self.overlay_frame = ctk.CTkFrame( self.main_panel, fg_color=CARD, corner_radius=12, border_width=1, border_color=BORDER, ) self.overlay_frame.grid(row=0, column=0, rowspan=3, padx=outer_pad, pady=outer_pad, sticky="nsew") self.overlay_frame.grid_columnconfigure(0, weight=1) self.overlay_frame.grid_remove() self.refresh_all() def refresh_all(self): self._refresh_connection_badge() self._refresh_contacts() self._refresh_current_chat() self.after(0, self._hide_mouse_cursor) def handle_background_refresh(self, phone=None): self.refresh_all() def _refresh_connection_badge(self): modem = self.controller.modem_status() if modem["connected"]: self.connection_badge.configure(text=f"مودم متصل | {modem['port']}", fg_color="#2E7D62", text_color="white") else: self.connection_badge.configure(text=f"مودم آفلاین | {modem['port']}", fg_color="#9A6C3C", text_color="white") def _refresh_contacts(self): for widget in self.contacts_frame.winfo_children(): widget.destroy() contacts = self.controller.list_contacts() if not contacts: RTLLabel( self.contacts_frame, text='هنوز مخاطبی اضافه نشده است.', text_color="#D5E8E1", font=ctk.CTkFont(family=FONT_BODY, size=16), ).grid(row=0, column=0, padx=12, pady=12, sticky="e") return for index, contact in enumerate(contacts): selected = self.current_contact_phone == contact.phone card = RTLButton( self.contacts_frame, text=f"{contact.name}\n{contact.phone}\n{contact.last_message_preview or 'آماده گفتگو'}", anchor="e", height=72 if self.is_portrait else 80, corner_radius=8, command=lambda phone=contact.phone: self._select_contact(phone), fg_color=SURFACE if selected else "transparent", hover_color=BORDER, text_color=TEXT if selected else "white", font=ctk.CTkFont(family=FONT_BODY, size=14), ) card.grid(row=index, column=0, padx=4, pady=2, sticky="ew") def _select_contact(self, phone: str): self.current_contact_phone = phone self._refresh_contacts() self._refresh_current_chat() def _refresh_current_chat(self): if not self.current_contact_phone: self.chat_title.configure(text='یک مخاطب را انتخاب کن') self.chat_subtitle.configure(text='در اینجا فقط دو حالت داری: عادی یا امن') self.mode_badge.configure(text='عادی', fg_color=PRIMARY_SOFT, text_color=PRIMARY) self.profile_name.configure(text='نام مخاطب') self.profile_phone.configure(text='شماره') self.profile_hint.configure(text='برای شروع، یک مخاطب از ستون سمت راست انتخاب کن.') self._render_messages([]) return contact = self.controller.get_contact(self.current_contact_phone) messages = self.controller.get_messages(self.current_contact_phone) self.chat_title.configure(text=contact.name) self.chat_subtitle.configure(text=contact.phone) self.profile_name.configure(text=contact.name) self.profile_phone.configure(text=contact.phone) if contact.secure_state == "pending": self.mode_badge.configure(text='در انتظار', fg_color="#FCEBD7", text_color="#9A6C3C") self.profile_hint.configure(text='درخواست ارتباط امن ارسال شده و برنامه منتظر پاسخ طرف مقابل است.') elif contact.mode == "secure": self.mode_badge.configure(text='امن', fg_color="#D9F5E8", text_color="#0F8A5F") self.profile_hint.configure(text='ارتباط امن فعال است. هر زمان بخواهی می\u200cتوانی به حالت عادی برگردی.') else: self.mode_badge.configure(text='عادی', fg_color=PRIMARY_SOFT, text_color=PRIMARY) if contact.has_peer_key: self.profile_hint.configure(text='کلید این مخاطب آماده است. اگر بخواهی می\u200cتوانی دوباره ارتباط امن را فعال کنی.') else: self.profile_hint.configure(text='برای امن شدن گفتگو، فقط روی دکمه فعال\u200cسازی ارتباط امن بزن.') self.secure_button.configure(state="normal") self.normal_button.configure(state="normal" if contact.mode == "secure" or contact.secure_state == "pending" else "disabled") self._render_messages(messages) def _render_messages(self, messages): for widget in self.chat_container.winfo_children(): widget.destroy() if not messages: RTLLabel( self.chat_container, text='هنوز پیامی ثبت نشده است.\nاز نوار پایین برای نوشتن پیام استفاده کن.', text_color=MUTED, font=ctk.CTkFont(family=FONT_BODY, size=14) ).pack(pady=40) return for message in messages: if message.direction == "system": sys_frame = ctk.CTkFrame(self.chat_container, fg_color=SURFACE, corner_radius=8) sys_frame.pack(pady=6, anchor="center") RTLLabel( sys_frame, text=message.body, text_color=MUTED, font=ctk.CTkFont(family=FONT_BODY, size=12), wraplength=int(self.window_width * 0.6) ).pack(padx=12, pady=6) continue is_out = message.direction == "out" bubble_color = BUBBLE_OUT if is_out else BUBBLE_IN anchor = "e" if is_out else "w" bubble = ctk.CTkFrame( self.chat_container, fg_color=bubble_color, corner_radius=12, border_width=0, ) bubble.pack(anchor=anchor, padx=8, pady=3, fill="none") RTLLabel( bubble, text=message.body, text_color=TEXT, font=ctk.CTkFont(family=FONT_BODY, size=15), wraplength=max(180, int(self.window_width * 0.5)), justify="right" ).pack(padx=12, pady=(8, 2), anchor="e") badge_text = f"🛡️ {message.created_at}" if message.mode == "secure" else message.created_at RTLLabel( bubble, text=badge_text, text_color=MUTED, font=ctk.CTkFont(family=FONT_BODY, size=10), justify="right" ).pack(padx=12, pady=(0, 6), anchor="w" if is_out else "e") try: self.after(50, lambda: self.chat_container._parent_canvas.yview_moveto(1.0)) except Exception: pass def _send_message(self): if not self.current_contact_phone: self.send_state_label.configure(text='اول یک مخاطب را انتخاب کن.', text_color=DANGER) return text = self.message_entry.get("1.0", "end-1c").strip() if not text: self.send_state_label.configure(text='متن پیام خالی است.', text_color=DANGER) return ok, state = self.controller.send_message(self.current_contact_phone, text) if ok: self.message_entry.delete("1.0", "end") label = 'ارسال شد.' if state == "sent" else 'در حالت آفلاین، پیام به صورت شبیه\u200cسازی ثبت شد.' self.send_state_label.configure(text=label, text_color=PRIMARY) else: self.send_state_label.configure(text=state, text_color=DANGER) self.refresh_all() def _toggle_secure_mode(self): if not self.current_contact_phone: return ok, state = self.controller.request_secure(self.current_contact_phone) if ok: self.send_state_label.configure( text='درخواست ارتباط امن ارسال شد.' if state == "sent" else 'در حالت آفلاین، درخواست امن به صورت محلی ثبت شد.', text_color=PRIMARY, ) else: self.send_state_label.configure(text='ارسال درخواست امن ناموفق بود.', text_color=DANGER) self.refresh_all() def _switch_to_normal(self): if not self.current_contact_phone: return ok, state = self.controller.switch_to_normal(self.current_contact_phone) if ok: self.send_state_label.configure( text='گفتگو به حالت عادی برگشت.' if state == "sent" else 'در حالت آفلاین، بازگشت به عادی محلی ثبت شد.', text_color=PRIMARY, ) else: self.send_state_label.configure(text='بازگشت به حالت عادی انجام نشد.', text_color=DANGER) self.refresh_all() def _open_contact_dialog(self): if self.contact_form_card.winfo_ismapped(): self._hide_contact_form() return self.contact_form_message.configure(text="") self.contact_name_entry.delete(0, "end") self.contact_phone_entry.delete(0, "end") self.contact_form_card.grid() self.after(80, lambda: self._focus_registered_input(self.contact_name_entry)) def _hide_contact_form(self): self._hide_virtual_keyboard() self.contact_form_card.grid_remove() self.contact_form_message.configure(text="") def _save_contact_inline(self): name = self.contact_name_entry.get().strip() phone = self.contact_phone_entry.get().strip() if not name or not phone: self.contact_form_message.configure(text='نام و شماره هر دو لازم هستند.') return self.controller.save_contact(name, phone) self.current_contact_phone = phone self.contact_form_message.configure(text='مخاطب ذخیره شد.') self.contact_name_entry.delete(0, "end") self.contact_phone_entry.delete(0, "end") self._hide_virtual_keyboard() self.refresh_all() self.after(900, self._hide_contact_form) def _open_settings_panel(self): self._show_overlay() header = self._build_overlay_header( 'تنظیمات', 'همه بخش\u200cها داخل همین پنجره باز می\u200cشوند تا برای نمایشگر ۷ اینچی ساده و قابل لمس بمانند.', ) header.pack(fill="x", padx=16, pady=(16, 10)) body = ctk.CTkFrame(self.overlay_frame, fg_color=SURFACE, corner_radius=22) body.pack(fill="both", expand=True, padx=16, pady=(0, 16)) modem = self.controller.modem_status() RTLLabel( body, text='وضعیت فعلی دستگاه', text_color=TEXT, font=ctk.CTkFont(family=FONT_TITLE, size=18, weight="bold"), ).pack(anchor="e", padx=18, pady=(18, 6)) RTLLabel( body, text=f"مودم: {'متصل' if modem['connected'] else 'آفلاین'} | پورت: {modem['port']} | Baudrate: {modem['baudrate']}", text_color=MUTED, wraplength=min(self.window_width - 96, 480), justify="right", font=ctk.CTkFont(family=FONT_BODY, size=14), ).pack(anchor="e", padx=18, pady=(0, 14)) RTLButton( body, text='ورود به پنل ادمین', fg_color=PRIMARY, hover_color=PRIMARY_DARK, font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), height=42, command=self._open_admin_login, ).pack(fill="x", padx=18, pady=(8, 8)) RTLButton( body, text='بازگشت به گفتگو', fg_color=INPUT_BG, text_color=TEXT, hover_color=BORDER, font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), height=40, command=self._hide_overlay, ).pack(fill="x", padx=18, pady=(0, 18)) def _show_overlay(self): self._hide_virtual_keyboard() for widget in self.overlay_frame.winfo_children(): widget.destroy() self.overlay_frame.grid() self.overlay_frame.lift() self.after(0, self._hide_mouse_cursor) def _hide_overlay(self): self._hide_virtual_keyboard() for widget in self.overlay_frame.winfo_children(): widget.destroy() self.overlay_frame.grid_remove() self.after(0, self._hide_mouse_cursor) def _build_overlay_header(self, title: str, subtitle: str): header = ctk.CTkFrame(self.overlay_frame, fg_color="transparent") header.grid_columnconfigure(0, weight=1) RTLButton( header, text='بستن', width=86, height=36, fg_color=INPUT_BG, text_color=TEXT, hover_color=BORDER, font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"), command=self._hide_overlay, ).grid(row=0, column=1, padx=(8, 0), sticky="e") RTLLabel( header, text=title, text_color=TEXT, font=ctk.CTkFont(family=FONT_TITLE, size=22, weight="bold"), ).grid(row=0, column=0, sticky="e") RTLLabel( header, text=subtitle, text_color=MUTED, wraplength=min(self.window_width - 96, 500), justify="right", font=ctk.CTkFont(family=FONT_BODY, size=13), ).grid(row=1, column=0, columnspan=2, pady=(4, 0), sticky="e") return header def _open_admin_login(self): self._show_overlay() header = self._build_overlay_header( 'ورود ادمین', 'جزئیات فنی، لاگ\u200cها و تنظیمات امنیتی فقط بعد از ورود ادمین نمایش داده می\u200cشوند.', ) header.pack(fill="x", padx=16, pady=(16, 10)) body = ctk.CTkFrame(self.overlay_frame, fg_color=SURFACE, corner_radius=22) body.pack(fill="both", expand=True, padx=16, pady=(0, 16)) RTLLabel( body, text='رمز اصلی را وارد کن', text_color=TEXT, font=ctk.CTkFont(family=FONT_BODY, size=18, weight="bold"), ).pack(anchor="e", padx=18, pady=(22, 10)) password = RTLEntry( body, placeholder_text='رمز اصلی', show="*", height=46, font=ctk.CTkFont(family=FONT_BODY, size=15), ) password.pack(fill="x", padx=18, pady=6) message = RTLLabel(body, text="", text_color=DANGER, font=ctk.CTkFont(family=FONT_BODY, size=14)) message.pack(anchor="e", padx=18, pady=(4, 8)) def submit(): if not self.controller.verify_password(password.get().strip()): message.configure(text='رمز ادمین صحیح نیست.') return self._open_admin_panel() RTLButton( body, text='ورود به پنل ادمین', fg_color=PRIMARY, hover_color=PRIMARY_DARK, font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), height=42, command=submit, ).pack(fill="x", padx=18, pady=(8, 8)) RTLButton( body, text='بازگشت به تنظیمات', fg_color=INPUT_BG, text_color=TEXT, hover_color=BORDER, font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), height=40, command=self._open_settings_panel, ).pack(fill="x", padx=18, pady=(0, 18)) self._register_text_input(password, title="رمز اصلی ادمین", layout="en", submit=submit) self.after(80, lambda: self._focus_registered_input(password)) def _open_admin_panel(self): self._show_overlay() header = self._build_overlay_header( 'پنل ادمین', 'این بخش برای نمایشگر کوچک خلاصه شده تا اطلاعات اصلی، لاگ\u200cها و تنظیمات مودم/رمز داخل همان پنجره در دسترس باشند.', ) header.pack(fill="x", padx=16, pady=(16, 10)) snapshot = self.controller.get_admin_snapshot() stats = snapshot["stats"] system_info = snapshot["system_info"] body = RTLScrollableFrame( self.overlay_frame, fg_color=SURFACE, corner_radius=22, label_text="", ) body.pack(fill="both", expand=True, padx=16, pady=(0, 16)) body.grid_columnconfigure((0, 1), weight=1) stat_labels = [ ('کل مخاطب\u200cها', stats["contacts"]), ('مخاطب امن', stats["secure_contacts"]), ('در انتظار', stats["pending_contacts"]), ('پیام امن', stats["secure_messages"]), ] for index, (title, value) in enumerate(stat_labels): card = ctk.CTkFrame(body, fg_color=CARD, corner_radius=18) card.grid(row=0, column=index % 2, padx=8, pady=8, sticky="ew") RTLLabel( card, text=title, text_color=MUTED, font=ctk.CTkFont(family=FONT_BODY, size=13), ).pack(anchor="e", padx=12, pady=(10, 2)) RTLLabel( card, text=str(value), text_color=TEXT, font=ctk.CTkFont(family=FONT_BODY, size=18, weight="bold"), ).pack(anchor="e", padx=12, pady=(0, 10)) system_card = ctk.CTkFrame(body, fg_color=CARD, corner_radius=18) system_card.grid(row=1, column=0, columnspan=2, padx=8, pady=8, sticky="ew") RTLLabel( system_card, text='اطلاعات سیستمی', text_color=TEXT, font=ctk.CTkFont(family=FONT_TITLE, size=18, weight="bold"), ).pack(anchor="e", padx=12, pady=(12, 4)) RTLLabel( system_card, text=f"پلتفرم: {system_info['platform']}\nپورت: {system_info['modem_port']} | Baudrate: {system_info['baudrate']}\nاثر انگشت کلید: {system_info['fingerprint']}", text_color=MUTED, justify="right", wraplength=min(self.window_width - 96, 500), font=ctk.CTkFont(family=FONT_BODY, size=13), ).pack(anchor="e", padx=12, pady=(0, 12)) logs_card = ctk.CTkFrame(body, fg_color=CARD, corner_radius=18) logs_card.grid(row=2, column=0, columnspan=2, padx=8, pady=8, sticky="ew") RTLLabel( logs_card, text='آخرین لاگ\u200cها', text_color=TEXT, font=ctk.CTkFont(family=FONT_TITLE, size=18, weight="bold"), ).pack(anchor="e", padx=12, pady=(12, 4)) log_text = RTLTextbox(logs_card, height=140, fg_color=SURFACE, font=ctk.CTkFont(family=FONT_BODY, size=13), wrap="word") log_text.pack(fill="x", padx=12, pady=(0, 12)) if snapshot["events"]: for event in snapshot["events"][:8]: log_text.insert("end", f"{event.created_at} | {event.phone}\n{event.details}\n\n") else: log_text.insert("end", 'هنوز لاگی ثبت نشده است.') log_text.configure(state="disabled") pending_card = ctk.CTkFrame(body, fg_color=CARD, corner_radius=18) pending_card.grid(row=3, column=0, columnspan=2, padx=8, pady=8, sticky="ew") RTLLabel( pending_card, text='بسته\u200cهای ناقص', text_color=TEXT, font=ctk.CTkFont(family=FONT_TITLE, size=18, weight="bold"), ).pack(anchor="e", padx=12, pady=(12, 4)) pending_text = RTLTextbox(pending_card, height=90, fg_color=SURFACE, font=ctk.CTkFont(family=FONT_BODY, size=13), wrap="word") pending_text.pack(fill="x", padx=12, pady=(0, 12)) if snapshot["pending_packets"]: for packet in snapshot["pending_packets"]: pending_text.insert("end", f"{packet.phone} | {packet.received_parts}/{packet.total_parts}\n") else: pending_text.insert("end", 'پیام ناقصی در صف وجود ندارد.') pending_text.configure(state="disabled") settings_card = ctk.CTkFrame(body, fg_color=CARD, corner_radius=18) settings_card.grid(row=4, column=0, columnspan=2, padx=8, pady=8, sticky="ew") RTLLabel( settings_card, text='تنظیمات مودم و رمز', text_color=TEXT, font=ctk.CTkFont(family=FONT_TITLE, size=18, weight="bold"), ).pack(anchor="e", padx=12, pady=(12, 4)) port_entry = RTLEntry(settings_card, placeholder_text='پورت مودم مثل COM3', height=38, font=ctk.CTkFont(family=FONT_BODY, size=14)) port_entry.pack(fill="x", padx=12, pady=6) port_entry.insert(0, system_info["modem_port"]) baud_entry = RTLEntry(settings_card, placeholder_text="Baudrate", height=38, font=ctk.CTkFont(family=FONT_BODY, size=14)) baud_entry.pack(fill="x", padx=12, pady=6) baud_entry.insert(0, str(system_info["baudrate"])) current_password = RTLEntry(settings_card, placeholder_text='رمز فعلی', show="*", height=38, font=ctk.CTkFont(family=FONT_BODY, size=14)) current_password.pack(fill="x", padx=12, pady=6) new_password = RTLEntry(settings_card, placeholder_text='رمز جدید', show="*", height=38, font=ctk.CTkFont(family=FONT_BODY, size=14)) new_password.pack(fill="x", padx=12, pady=6) admin_message = RTLLabel(settings_card, text="", text_color=DANGER, font=ctk.CTkFont(family=FONT_BODY, size=13)) admin_message.pack(anchor="e", padx=12, pady=(2, 6)) self._register_text_input(port_entry, title="پورت مودم", layout="en") self._register_text_input(baud_entry, title="Baudrate", layout="numeric") self._register_text_input(current_password, title="رمز فعلی", layout="en") self._register_text_input(new_password, title="رمز جدید", layout="en") def save_admin_changes(): try: connected = self.controller.reconnect_modem(port_entry.get().strip(), int(baud_entry.get().strip())) if current_password.get().strip() and new_password.get().strip(): self.controller.change_master_password(current_password.get().strip(), new_password.get().strip()) if connected: admin_message.configure(text='تغییرات ذخیره شد و مودم متصل است.', text_color=PRIMARY) else: admin_message.configure(text='تنظیمات ذخیره شد، اما مودم هنوز آفلاین است.', text_color="#9A6C3C") except Exception as exc: admin_message.configure(text=str(exc), text_color=DANGER) RTLButton( settings_card, text='ذخیره تغییرات', fg_color=PRIMARY, hover_color=PRIMARY_DARK, font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), height=40, command=save_admin_changes, ).pack(fill="x", padx=12, pady=(2, 12))