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 = "#00B4D8" PRIMARY_DARK = "#0096C7" PRIMARY_SOFT = "#E6F7FA" ACCENT = "#48CAE4" ACCENT_DARK = "#0077B6" 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', 'انگلیسی', '،', 'فاصله (Space)', '.', 'تایید ⏎'], ], "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', '⌫ Delete'], ['123', 'فارسی', ',', ' Space ', '.', 'Enter ⏎'], ], "numeric": [ ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], ['+', '-', '/', '@', '_', '.', ':', '(', ')', '?'], ['فارسی', 'انگلیسی', '⌫ Delete'], ['بستن', 'فاصله (Space)', 'تایید ⏎'], ], } 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)) bg_color = kwargs.get("fg_color", INPUT_BG) try: self._entry.configure(fg=bg_color, insertbackground=bg_color, selectbackground=bg_color) except Exception: pass self._display_label = ctk.CTkLabel( self, text="", text_color=TEXT, justify="right", font=kwargs.get("font", ctk.CTkFont(family=FONT_BODY, size=15)) ) self._display_label.place(relwidth=0.9, relheight=0.9, relx=0.95, rely=0.5, anchor="e") self.bind("", lambda e: self._sync_display(), add="+") self._sync_display() def _sync_display(self, text=None): raw = text if text is not None else self.get() show_char = self.cget("show") if raw: display_text = show_char * len(raw) if show_char else ui_text(raw) self._display_label.configure(text=display_text + " |") else: placeholder = self.cget("placeholder_text") or "" self._display_label.configure(text=ui_text(placeholder)) def configure(self, require_redraw=False, **kwargs): res = super().configure(require_redraw=require_redraw, **self._normalize_kwargs(kwargs)) if "placeholder_text" in kwargs: self._sync_display() return res class RTLTextbox(ctk.CTkTextbox): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) bg_color = kwargs.get("fg_color", INPUT_BG) try: self._textbox.configure(fg=bg_color, insertbackground=bg_color, selectbackground=bg_color) self._textbox.tag_configure("right_align", justify="right") except Exception: pass self._display_label = ctk.CTkLabel( self, text="", text_color=TEXT, justify="right", font=kwargs.get("font", ctk.CTkFont(family=FONT_BODY, size=15)), anchor="ne" ) self._display_label.place(relwidth=0.9, relheight=0.9, relx=0.95, rely=0.08, anchor="ne") self.bind("", lambda e: self._sync_display(), add="+") self._sync_display() def _sync_display(self, text=None): raw = text if text is not None else self.get("1.0", "end-1c") if raw: self._display_label.configure(text=ui_text(raw) + " |") else: self._display_label.configure(text=" |") def insert(self, index, text, *tags): res = super().insert(index, text, *tags) self._textbox.tag_add("right_align", "1.0", "end") self._sync_display() return res def delete(self, index1, index2=None): res = super().delete(index1, index2) self._sync_display() return res 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.bind_all("", self._handle_global_drag_start, add="+") self.bind_all("", self._handle_global_drag, add="+") self.bind_all("", self._handle_global_drag_stop, add="+") self._touch_start_y = None self._active_scroll_frame = None 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="+") if hasattr(widget, "_display_label"): self._input_aliases[str(widget._display_label)] = widget widget._display_label.bind("", lambda _event, target=widget: self.after(10, lambda: self._activate_text_input(target)), add="+") widget.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 _find_scrollable_ancestor(self, widget): current = widget while current is not None: if isinstance(current, ctk.CTkScrollableFrame): return current current = getattr(current, "master", None) return None def _handle_global_drag_start(self, event): self._touch_start_y = event.y_root self._active_scroll_frame = self._find_scrollable_ancestor(event.widget) def _handle_global_drag(self, event): if self._touch_start_y is None or not self._active_scroll_frame: return try: current_y = event.y_root delta_y = current_y - self._touch_start_y # 1 unit is roughly 15 pixels of drag units = int(delta_y / 12) if units != 0: self._active_scroll_frame._parent_canvas.yview_scroll(-units, "units") self._touch_start_y = current_y except Exception: pass def _handle_global_drag_stop(self, event): self._touch_start_y = None self._active_scroll_frame = None 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() if hasattr(widget, "_sync_display"): widget._sync_display(text) 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 hasattr(self, 'keyboard_host') and 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) if hasattr(self, 'keyboard_host'): 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 { 'فاصله (Space)': 60, " Space ": 60, 'تایید ⏎': 20, "Enter ⏎": 20, 'بستن': 15, '⌫ پاک': 25, "⌫ Delete": 25, 'فارسی': 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", 'انگلیسی', 'فارسی', 'بستن', "⌫ Delete", '⌫ پاک'}: 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=KEYBOARD_BG, corner_radius=0) 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, width=20, 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)', " Space ", "Space"}: self._insert_into_active_input(" ") return if key in {'⌫ پاک', "⌫ Delete"}: 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.tag_add("right_align", "1.0", "end") target.tag_configure("right_align", justify="right") target.see("insert") else: try: if target.selection_present(): target.delete("sel.first", "sel.last") except Exception: pass target.insert("insert", text) try: target.configure(justify="right") except Exception: pass 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(0, 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=KEYBOARD_BG, corner_radius=0) 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=KEYBOARD_BG, height=0, corner_radius=0) self.keyboard_keys = ctk.CTkFrame(self.keyboard_frame, fg_color=KEYBOARD_BG, corner_radius=0) 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) if hasattr(self, 'keyboard_host'): 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=22, fg_color=BACKGROUND, hover_color=PRIMARY_SOFT, text_color=PRIMARY_DARK, border_color=PRIMARY, border_width=2, 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): self.root_frame.grid_columnconfigure(0, weight=1) self.root_frame.grid_columnconfigure(1, weight=0) 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 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=SIDEBAR, corner_radius=0) sidebar_header.grid(row=0, column=0, padx=14, pady=(12, 10), sticky="ew") sidebar_header.grid_columnconfigure(0, weight=1) sidebar_header.grid_columnconfigure(1, weight=0) RTLButton( sidebar_header, text='منو', width=50, height=40, corner_radius=20, fg_color=SIDEBAR, hover_color=BORDER, text_color="white", font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), command=self._show_drawer_menu ).grid(row=0, column=1, padx=(6, 0), sticky="e") 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") self.fab = RTLButton( self.sidebar, text='+', width=56, height=56, corner_radius=28, fg_color=PRIMARY, hover_color=PRIMARY_DARK, text_color="white", font=ctk.CTkFont(family=FONT_TITLE, size=32, weight="bold"), command=self._open_contact_dialog ) self.add_contact_panel = ctk.CTkFrame(self.root_frame, fg_color=BACKGROUND, corner_radius=0) self.add_contact_panel.grid_rowconfigure(1, weight=1) self.add_contact_panel.grid_columnconfigure(0, weight=1) add_contact_header = ctk.CTkFrame(self.add_contact_panel, fg_color=CARD, corner_radius=0, border_width=0) add_contact_header.grid(row=0, column=0, sticky="ew") add_contact_header.grid_columnconfigure(0, weight=1) add_contact_header.grid_columnconfigure(1, weight=0) RTLLabel( add_contact_header, text='مخاطب جدید', text_color=TEXT, font=ctk.CTkFont(family=FONT_TITLE, size=18, weight="bold"), ).grid(row=0, column=0, padx=16, pady=14, sticky="e") RTLButton( add_contact_header, text='بازگشت', width=64, height=36, corner_radius=18, fg_color=INPUT_BG, hover_color=BORDER, text_color=TEXT, font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"), command=self._hide_contact_form ).grid(row=0, column=1, padx=(4, 14), sticky="e") self.contact_form_card = ctk.CTkFrame(self.add_contact_panel, fg_color="transparent") self.contact_form_card.grid(row=1, column=0, padx=20, pady=20, sticky="nwe") self.contact_form_card.grid_columnconfigure(0, weight=1) self.contact_name_entry = RTLEntry(self.contact_form_card, placeholder_text='نام مخاطب', height=48, font=ctk.CTkFont(family=FONT_BODY, size=16)) self.contact_name_entry.grid(row=0, column=0, pady=8, sticky="ew") self.contact_phone_entry = RTLEntry(self.contact_form_card, placeholder_text='شماره موبایل (مثبت 09)', height=48, font=ctk.CTkFont(family=FONT_BODY, size=16)) self.contact_phone_entry.grid(row=1, column=0, pady=8, sticky="ew") self.contact_form_message = RTLLabel(self.contact_form_card, text="", text_color=DANGER, font=ctk.CTkFont(family=FONT_BODY, size=13)) self.contact_form_message.grid(row=2, column=0, pady=(2, 6), sticky="e") RTLButton( self.contact_form_card, text='ذخیره', corner_radius=24, fg_color=BACKGROUND, hover_color=PRIMARY_SOFT, text_color=PRIMARY_DARK, border_color=PRIMARY, border_width=2, command=self._save_contact_inline, font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), height=48, ).grid(row=3, column=0, pady=10, 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) # Drawer Menu Panel (Separate Page) self.drawer_panel = ctk.CTkFrame(self.root_frame, fg_color=BACKGROUND, corner_radius=0) self.drawer_panel.grid_rowconfigure(1, weight=1) self.drawer_panel.grid_columnconfigure(0, weight=1) drawer_header = ctk.CTkFrame(self.drawer_panel, fg_color=CARD, corner_radius=0, border_width=0) drawer_header.grid(row=0, column=0, sticky="ew") drawer_header.grid_columnconfigure(0, weight=1) drawer_header.grid_columnconfigure(1, weight=0) RTLLabel( drawer_header, text='منوی اصلی', text_color=TEXT, font=ctk.CTkFont(family=FONT_TITLE, size=18, weight="bold"), ).grid(row=0, column=0, padx=16, pady=14, sticky="e") RTLButton( drawer_header, text='بازگشت', width=64, height=36, corner_radius=18, fg_color=INPUT_BG, hover_color=BORDER, text_color=TEXT, font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"), command=self._hide_drawer_menu ).grid(row=0, column=1, padx=(4, 14), sticky="e") drawer_body = ctk.CTkFrame(self.drawer_panel, fg_color=BACKGROUND, corner_radius=0) drawer_body.grid(row=1, column=0, sticky="nsew", padx=20, pady=20) drawer_body.grid_columnconfigure(0, weight=1) self.drawer_modem_label = RTLLabel( drawer_body, text="", font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), justify="right" ) self.drawer_modem_label.grid(row=0, column=0, pady=(10, 30), sticky="e") RTLButton( drawer_body, text='تنظیمات', corner_radius=27, fg_color=BACKGROUND, hover_color=PRIMARY_SOFT, text_color=PRIMARY_DARK, border_color=PRIMARY, border_width=2, font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), height=54, command=self._open_settings_panel, ).grid(row=1, column=0, pady=8, sticky="ew") RTLButton( drawer_body, text='بخش ادمین', corner_radius=27, fg_color=BACKGROUND, hover_color="#F4F4F4", text_color=TEXT, border_color=BORDER, border_width=2, font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), height=54, command=self._open_admin_login, ).grid(row=2, column=0, pady=8, sticky="ew") self.contacts_frame = RTLScrollableFrame( self.sidebar, height=300 if self.is_portrait else 320, label_text='گفتگو\u200cها', label_font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), fg_color=SIDEBAR, ) self.contacts_frame.grid(row=1, column=0, padx=8, pady=4, sticky="nsew") self.fab.place(relx=0.06, rely=0.94, anchor="sw") self.fab.lift() self.main_panel = ctk.CTkFrame(self.root_frame, fg_color=BACKGROUND, corner_radius=0) self.main_panel.grid(row=0, column=0, 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.header_card.grid_columnconfigure(2, weight=0) 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=0, rowspan=2, padx=14, sticky="w") 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=1, padx=8, 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=1, padx=8, pady=(0, 10), sticky="e") def open_contact_page(e=None): if self.current_contact_phone: self._show_contact_details_page() self.chat_title.bind("", open_contact_page, add="+") self.chat_subtitle.bind("", open_contact_page, add="+") RTLButton( self.header_card, text='بازگشت', width=64, height=36, corner_radius=18, fg_color=INPUT_BG, hover_color=BORDER, text_color=TEXT, font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"), command=self._show_home_screen ).grid(row=0, column=2, rowspan=2, padx=(4, 14), sticky="e") content = ctk.CTkFrame(self.main_panel, fg_color=BACKGROUND, corner_radius=0) 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=BACKGROUND ) 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سازی ارتباط امن', corner_radius=21, fg_color=CARD, hover_color=PRIMARY_SOFT, text_color=PRIMARY_DARK, border_color=PRIMARY, border_width=2, 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='بازگشت به حالت عادی', corner_radius=20, fg_color=CARD, hover_color="#F4F4F4", text_color=TEXT, border_color=BORDER, border_width=2, 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.root_frame, fg_color=CARD, corner_radius=12, border_width=1, border_color=BORDER, ) self.overlay_frame.grid(row=0, column=0, sticky="nsew") self.overlay_frame.grid_columnconfigure(0, weight=1) self.overlay_frame.grid_remove() self.contact_details_panel = ctk.CTkFrame(self.root_frame, fg_color=BACKGROUND, corner_radius=0) self.contact_details_panel.grid_rowconfigure(1, weight=1) self.contact_details_panel.grid_columnconfigure(0, weight=1) self.contact_details_panel.grid_remove() self.refresh_all() self._show_home_screen() def _show_home_screen(self): self.current_contact_phone = None self._hide_virtual_keyboard() self.main_panel.grid_remove() if hasattr(self, 'contact_details_panel'): self.contact_details_panel.grid_remove() self.sidebar.grid(row=0, column=0, sticky="nsew") self._refresh_contacts() def _show_chat_screen(self): self._hide_virtual_keyboard() self.sidebar.grid_remove() self.main_panel.grid(row=0, column=0, sticky="nsew") self._refresh_current_chat() 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): if hasattr(self, '_refresh_timer') and self._refresh_timer: self.after_cancel(self._refresh_timer) self._refresh_timer = self.after(300, self.refresh_all) def _refresh_connection_badge(self): if hasattr(self, 'drawer_modem_label') and self.drawer_modem_label.winfo_exists(): modem = self.controller.modem_status() text = f"وضعیت مودم: {'متصل' if modem['connected'] else 'آفلاین'} | پورت: {modem['port']}" color = "#2E7D62" if modem['connected'] else "#B6465F" self.drawer_modem_label.configure(text=text, text_color=color) 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=MUTED, font=ctk.CTkFont(family=FONT_BODY, size=16), ).grid(row=0, column=0, padx=12, pady=12, sticky="e") return colors = ["#F44336", "#E91E63", "#9C27B0", "#673AB7", "#3F51B5", "#2196F3", "#00BCD4", "#009688", "#4CAF50", "#FF9800", "#FF5722"] for index, contact in enumerate(contacts): card = ctk.CTkFrame(self.contacts_frame, fg_color=SIDEBAR, corner_radius=12) card.grid(row=index, column=0, padx=4, pady=4, sticky="ew") self.contacts_frame.grid_columnconfigure(0, weight=1) card.grid_columnconfigure(0, weight=1) card.grid_columnconfigure(1, weight=0) def make_onclick(p=contact.phone): def click_handler(e): self.after(10, lambda: self._select_contact(p)) return click_handler info_frame = ctk.CTkFrame(card, fg_color=SIDEBAR, corner_radius=0) info_frame.grid(row=0, column=0, sticky="ew", padx=(10, 14), pady=10) RTLLabel(info_frame, text=contact.name, text_color=TEXT, font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold")).pack(anchor="e") preview = contact.last_message_preview or 'آماده گفتگو' preview_str = preview if len(preview) < 32 else preview[:29] + "..." RTLLabel(info_frame, text=preview_str, text_color=MUTED, font=ctk.CTkFont(family=FONT_BODY, size=14)).pack(anchor="e", pady=(4, 0)) avatar_color = colors[hash(contact.name or contact.phone) % len(colors)] avatar_size = 52 avatar = ctk.CTkFrame(card, fg_color=avatar_color, corner_radius=avatar_size // 2, width=avatar_size, height=avatar_size) avatar.grid(row=0, column=1, padx=(0, 10), pady=10) avatar.grid_propagate(False) first_letter = (contact.name or contact.phone)[0].upper() RTLLabel(avatar, text=first_letter, text_color="white", font=ctk.CTkFont(family=FONT_TITLE, size=22, weight="bold")).place(relx=0.5, rely=0.5, anchor="center") card.bind("", make_onclick()) info_frame.bind("", make_onclick()) avatar.bind("", make_onclick()) for child in info_frame.winfo_children(): child.bind("", make_onclick()) for child in avatar.winfo_children(): child.bind("", make_onclick()) # Bind hover effects manually if needed, or rely on transparent bg def _select_contact(self, phone: str): self.current_contact_phone = phone self._show_chat_screen() 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") state_val = getattr(message, 'transport_state', 'unknown').lower() if state_val in ["sent"]: state_text = "وضعیت: ارسال شده ✓" elif state_val in ["delivered", "read"]: state_text = "وضعیت: تحویل داده شده ✓✓" elif state_val in ["failed", "error"]: state_text = "وضعیت: ارسال ناموفق ✗" elif state_val in ["queued", "pending"]: state_text = "وضعیت: در صف ارسال ⏳" elif state_val in ["offline", "local"]: state_text = "وضعیت: ذخیره محلی 📴" else: state_text = f"وضعیت: {state_val}" status_label = RTLLabel( bubble, text=state_text, text_color="#6B8E85" if is_out else MUTED, font=ctk.CTkFont(family=FONT_BODY, size=11, weight="bold"), justify="right" ) text_label = 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" ) text_label.pack(padx=12, pady=(8, 2), anchor="e") badge_text = f"🛡️ {message.created_at}" if message.mode == "secure" else message.created_at badge_label = RTLLabel( bubble, text=badge_text, text_color=MUTED, font=ctk.CTkFont(family=FONT_BODY, size=10), justify="right" ) badge_label.pack(padx=12, pady=(0, 6), anchor="w" if is_out else "e") def make_toggle(lbl=status_label, is_out_dir=is_out): def toggle(e): if lbl.winfo_ismapped(): lbl.pack_forget() else: lbl.pack(padx=12, pady=(0, 8), anchor="w" if is_out_dir else "e") return toggle toggle_fn = make_toggle() bubble.bind("", toggle_fn, add="+") status_label.bind("", toggle_fn, add="+") text_label.bind("", toggle_fn, add="+") badge_label.bind("", toggle_fn, add="+") 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): self.contact_form_message.configure(text="") self.contact_name_entry.delete(0, "end") self.contact_phone_entry.delete(0, "end") self.sidebar.grid_remove() self.add_contact_panel.grid(row=0, column=0, sticky="nsew") self.add_contact_panel.lift() self.after(80, lambda: self._focus_registered_input(self.contact_name_entry)) def _hide_contact_form(self): self._hide_virtual_keyboard() self.add_contact_panel.grid_remove() self.sidebar.grid(row=0, column=0, sticky="nsew") 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 persian_to_english = str.maketrans('۰۱۲۳۴۵۶۷۸۹', '0123456789') phone = phone.translate(persian_to_english) if len(phone) != 11 or not phone.isdigit() or not phone.startswith("09"): self.contact_form_message.configure(text='شماره باید ۱۱ عدد باشد و با 09 شروع شود.') 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(200, self._hide_contact_form) def _trigger_long_press(self, phone, name): self._long_press_triggered = True self._long_press_timer = None self._show_delete_contact_dialog(phone, name) def _show_delete_contact_dialog(self, phone, name): self._show_overlay() header = self._build_overlay_header('حذف مخاطب', f"آیا از حذف گفتگو با {name} مطمئن هستید؟ تمام پیام‌ها پاک خواهد شد.") 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)) def confirm_delete(): self.controller.delete_contact(phone) self._hide_overlay() self._show_home_screen() RTLButton( body, text='بله، حذف کن', corner_radius=24, fg_color=SURFACE, hover_color="#FDE8E8", text_color=DANGER, border_color=DANGER, border_width=2, font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), height=48, command=confirm_delete, ).pack(fill="x", padx=18, pady=(24, 8)) RTLButton( body, text='انصراف', fg_color=INPUT_BG, text_color=TEXT, hover_color=BORDER, font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), height=48, command=self._hide_overlay, ).pack(fill="x", padx=18, pady=(8, 24)) def _show_drawer_menu(self): self._hide_virtual_keyboard() self.sidebar.grid_remove() self.drawer_panel.grid(row=0, column=0, sticky="nsew") self.drawer_panel.lift() self._refresh_connection_badge() def _hide_drawer_menu(self): self.drawer_panel.grid_remove() self.sidebar.grid(row=0, column=0, sticky="nsew") def _show_contact_details_page(self): self._hide_virtual_keyboard() self.main_panel.grid_remove() self.contact_details_panel.grid(row=0, column=0, sticky="nsew") self.contact_details_panel.lift() for child in self.contact_details_panel.winfo_children(): child.destroy() header = ctk.CTkFrame(self.contact_details_panel, fg_color=CARD, corner_radius=0, border_width=0) header.grid(row=0, column=0, sticky="ew") header.grid_columnconfigure(0, weight=1) RTLButton( header, text='بازگشت', width=64, height=36, corner_radius=18, fg_color=INPUT_BG, hover_color=BORDER, text_color=TEXT, font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"), command=self._hide_contact_details_page ).grid(row=0, column=1, padx=(4, 14), pady=(10, 10), sticky="e") RTLLabel( header, text='پروفایل مخاطب', text_color=TEXT, font=ctk.CTkFont(family=FONT_TITLE, size=18, weight="bold"), ).grid(row=0, column=0, padx=8, sticky="e") body = ctk.CTkFrame(self.contact_details_panel, fg_color=BACKGROUND, corner_radius=0) body.grid(row=1, column=0, sticky="nsew", padx=20, pady=20) body.grid_columnconfigure(0, weight=1) contact = self.controller.get_contact(self.current_contact_phone) if not contact: return colors = ["#F44336", "#E91E63", "#9C27B0", "#673AB7", "#3F51B5", "#2196F3", "#00BCD4", "#009688", "#4CAF50", "#FF9800", "#FF5722"] avatar_color = colors[hash(contact.name or contact.phone) % len(colors)] avatar = ctk.CTkFrame(body, width=90, height=90, corner_radius=45, fg_color=avatar_color) avatar.pack(pady=(40, 10)) avatar.pack_propagate(False) initial = contact.name[0] if contact.name else "?" RTLLabel(avatar, text=initial, text_color="white", font=ctk.CTkFont(family=FONT_TITLE, size=36, weight="bold")).place(relx=0.5, rely=0.5, anchor="center") RTLLabel(body, text=contact.name, text_color=TEXT, font=ctk.CTkFont(family=FONT_TITLE, size=24, weight="bold")).pack(pady=(12, 2)) RTLLabel(body, text=contact.phone, text_color=MUTED, font=ctk.CTkFont(family=FONT_BODY, size=16)).pack(pady=(0, 30)) RTLButton( body, text='پاک کردن پروفایل', corner_radius=24, fg_color=BACKGROUND, hover_color="#FDE8E8", text_color=DANGER, border_color=DANGER, border_width=2, font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), height=48, command=lambda: self._show_delete_contact_dialog(contact.phone, contact.name) ).pack(fill="x", padx=18, pady=(24, 8)) def _hide_contact_details_page(self): self.contact_details_panel.grid_remove() self._show_chat_screen() 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='ورود به پنل ادمین', corner_radius=21, fg_color=SURFACE, hover_color=PRIMARY_SOFT, text_color=PRIMARY_DARK, border_color=PRIMARY, border_width=2, 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))