Saba-python/secure_sms/ui/main_window.py
2026-03-27 19:20:38 +03:30

2189 lines
91 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import re
from tkinter import TclError
from typing import Optional
import customtkinter as ctk
from secure_sms.core.utils import normalize_phone
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("<KeyRelease>", 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):
self.max_length = kwargs.pop("max_length", None)
self.on_change = kwargs.pop("on_change", None)
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("<KeyRelease>", 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")
# Enforce max_length if defined
if self.max_length is not None and len(raw) > self.max_length:
truncated = raw[:self.max_length]
# Temporarily unbind or use a flag to avoid recursion if needed,
# but delete/insert will trigger sync_display again.
# A simple way is to check if it's already truncated.
self.delete("1.0", "end")
super().insert("1.0", truncated) # Use super().insert to avoid immediate recursion
raw = truncated
if self.on_change:
self.on_change(raw)
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("<Button-1>", self._handle_global_tap, add="+")
self.bind_all("<ButtonPress-1>", self._handle_global_drag_start, add="+")
self.bind_all("<B1-Motion>", self._handle_global_drag, add="+")
self.bind_all("<ButtonRelease-1>", self._handle_global_drag_stop, add="+")
self._touch_start_y = None
self._active_scroll_frame = None
self._show_mobile_launcher()
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("<FocusIn>", lambda _event, target=widget: self._activate_text_input(target), add="+")
bind_target.bind("<Button-1>", 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("<Button-1>", lambda _event, target=widget: self.after(10, lambda: self._activate_text_input(target)), add="+")
widget.bind("<Button-1>", 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_mobile_launcher(self):
"""Simulates a mobile home screen with a Saba icon."""
self._clear_root()
# We can use a slightly different background for the 'wallpaper' feel
# Using a dark gradient-like feel or just a premium dark surface
wallpaper_color = "#1A1C1E"
wallpaper = ctk.CTkFrame(self.root_frame, fg_color=wallpaper_color, corner_radius=0)
wallpaper.place(relx=0, rely=0, relwidth=1, relheight=1)
# Icon container
container = ctk.CTkFrame(wallpaper, fg_color="transparent")
container.place(relx=0.5, rely=0.4, anchor="center")
# Large premium icon
# We use a rounded button to represent the app icon
icon_size = 92
RTLButton(
container,
text='📨',
width=icon_size,
height=icon_size,
corner_radius=22,
fg_color=PRIMARY,
hover_color=PRIMARY_DARK,
font=ctk.CTkFont(family=FONT_TITLE, size=46),
command=self._show_lock_screen
).pack(pady=(0, 12))
RTLLabel(
container,
text='صبا',
text_color="white",
font=ctk.CTkFont(family=FONT_TITLE, size=20, weight="bold")
).pack()
# Add a subtle 'mobile' hint at the bottom
RTLLabel(
wallpaper,
text='برای ورود روی آیکون بزنید',
text_color="#636A73",
font=ctk.CTkFont(family=FONT_BODY, size=13)
).place(relx=0.5, rely=0.9, anchor="center")
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("<Return>", 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("<Return>", 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("<Return>", 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.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"),
)
def open_contact_page(e=None):
if self.current_contact_phone:
self._show_contact_details_page()
self.chat_title.grid(row=0, column=0, padx=16, pady=20, sticky="e")
self.chat_title.bind("<Button-1>", 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=1, 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_key_btn = RTLButton(
self.profile_card,
text='تنظیم کلید متقارن',
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._handle_profile_key_setup,
height=42,
)
self.profile_key_btn.grid(row=3, column=0, padx=16, pady=(0, 14), sticky="ew")
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",
max_length=1000,
on_change=self._update_message_counter
)
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.char_counter_label = RTLLabel(
actions,
text="0/1000",
text_color=MUTED,
font=ctk.CTkFont(family=FONT_BODY, size=10),
)
self.char_counter_label.pack(pady=(0, 2))
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("<Control-Return>", 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 _update_message_counter(self, text):
count = len(text)
limit = 1000
if hasattr(self, 'char_counter_label'):
self.char_counter_label.configure(
text=f"{count}/{limit}",
text_color=DANGER if count >= limit else MUTED
)
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("<Button-1>", make_onclick())
info_frame.bind("<Button-1>", make_onclick())
avatar.bind("<Button-1>", make_onclick())
for child in info_frame.winfo_children():
child.bind("<Button-1>", make_onclick())
for child in avatar.winfo_children():
child.bind("<Button-1>", 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.profile_name.configure(text='نام مخاطب')
self.profile_phone.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.profile_name.configure(text=contact.name)
self.profile_phone.configure(text=contact.phone)
# Update key button text
has_key = contact.symmetric_key is not None and contact.symmetric_key != ""
btn_text = 'تغییر کلید متقارن' if has_key else 'تنظیم کلید متقارن'
self.profile_key_btn.configure(text=btn_text)
self._render_messages(messages)
def handle_background_refresh(self, phone: Optional[str] = None):
"""Called by controller when background events (like incoming SMS) occur."""
self._refresh_contacts()
if phone and self.current_contact_phone == phone:
self._refresh_current_chat()
if phone:
contact = self.controller.get_contact(phone)
name = contact.name if contact else phone
self._show_toast(f"پیام جدید از {name}")
def _show_toast(self, message: str):
"""Show a temporary notification at the top of the screen."""
toast = ctk.CTkFrame(self, fg_color=PRIMARY, corner_radius=20)
toast.place(relx=0.5, rely=0.08, anchor="center")
RTLLabel(toast, text=message, text_color="white", font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold")).pack(padx=20, pady=10)
self.after(3000, toast.destroy)
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()
is_failed = state_val == "decrypt_failed"
if is_failed:
state_text = "🔒 وضعیت: رمزنگاری شده (نیاز به کلید)"
elif 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=DANGER if is_failed else ("#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")
if is_failed:
lock_btn = RTLButton(
bubble,
text="🔓 بازگشایی دستی پیام",
width=120,
height=28,
corner_radius=8,
fg_color=DANGER,
hover_color="#C0392B",
font=ctk.CTkFont(family=FONT_BODY, size=11, weight="bold"),
command=lambda m=message: self._show_manual_decrypt_overlay(m)
)
lock_btn.pack(padx=12, pady=(2, 6), 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("<Button-1>", toggle_fn, add="+")
status_label.bind("<Button-1>", toggle_fn, add="+")
text_label.bind("<Button-1>", toggle_fn, add="+")
badge_label.bind("<Button-1>", 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
# Show Send Mode Choice Overlay
self._show_overlay()
self._build_overlay_header("ارسال پیام", "نحوه ارسال را انتخاب کنید")
container = ctk.CTkFrame(self.overlay_frame, fg_color="transparent")
container.pack(expand=True, fill="both", padx=20, pady=20)
def on_send_normal():
self._hide_overlay()
self._execute_send(self.current_contact_phone, text)
def on_send_secure():
self._hide_overlay()
self._prompt_symmetric_key_and_send(self.current_contact_phone, text)
RTLButton(
container,
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=on_send_normal,
).pack(pady=10, fill="x")
RTLButton(
container,
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=on_send_secure,
).pack(pady=10, fill="x")
RTLButton(
container,
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._hide_overlay,
).pack(pady=10, fill="x")
def _handle_profile_key_setup(self):
if not self.current_contact_phone: return
contact = self.controller.get_contact(self.current_contact_phone)
self._show_symmetric_key_dialog(contact.phone, contact.symmetric_key or "")
def _prompt_symmetric_key_and_send(self, phone: str, text: str):
contact = self.controller.get_contact(phone)
saved_key = contact.symmetric_key or ""
self._show_overlay()
self._build_overlay_header("رمزنگاری متقارن", "کلید متقارن shared key را وارد کنید")
container = ctk.CTkFrame(self.overlay_frame, fg_color="transparent")
container.pack(expand=True, fill="both", padx=20, pady=20)
entry = RTLEntry(container, placeholder_text='کلید متقارن', height=48)
entry.pack(pady=10, fill="x")
entry.insert(0, saved_key)
self._register_text_input(entry, title="کلید متقارن", layout="fa")
def submit():
key = entry.get().strip()
if not key:
return
self._hide_overlay()
self._execute_send(phone, text, symmetric_key=key)
RTLButton(
container,
text='تایید و ارسال',
corner_radius=27,
fg_color=PRIMARY,
text_color="white",
font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"),
height=54,
command=submit,
).pack(pady=10, fill="x")
RTLButton(
container,
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._hide_overlay,
).pack(pady=10, fill="x")
def _execute_send(self, phone, text, symmetric_key=None):
ok, state = self.controller.send_message(phone, text, symmetric_key=symmetric_key)
if ok:
self.message_entry.delete("1.0", "end")
label = 'در صف ارسال...' if state == "queued" else 'ارسال شد.'
self.send_state_label.configure(text=label, text_color=PRIMARY)
self._refresh_contacts()
self._refresh_current_chat()
else:
self.send_state_label.configure(text=state, text_color=DANGER)
self.refresh_all()
def _show_manual_decrypt_overlay(self, message):
"""Show an integrated overlay to manually enter a symmetric key for a specific message."""
self._show_overlay()
header = self._build_overlay_header(
'بازگشایی دستی پیام',
'کلید متقارن برای بازگشایی این پیام را وارد کنید. در صورت صحت کلید، پیام رمزگشایی و ذخیره می\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))
key_entry = RTLEntry(
body,
placeholder_text='مثلاً: my_secret_key_123',
height=46,
font=ctk.CTkFont(family=FONT_BODY, size=15),
)
key_entry.pack(fill="x", padx=18, pady=6)
self._register_text_input(key_entry, title="کلید متقارن", layout="en")
def handle_submit():
key = key_entry.get().strip()
if not key: return
success = self.controller.decrypt_message_manually(message.id, key)
if success:
self._hide_overlay()
self._show_toast("پیام با موفقیت بازگشایی شد.")
self.refresh_all()
else:
self._show_toast("خطا: کلید نامعتبر است.")
RTLButton(
body,
text='تایید و بازگشایی',
height=48,
corner_radius=24,
fg_color=PRIMARY,
text_color="white",
font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"),
command=handle_submit
).pack(fill="x", padx=18, pady=20)
def _open_contact_dialog(self, phone: str = "", name: str = ""):
self.contact_form_message.configure(text="")
self.contact_name_entry.delete(0, "end")
self.contact_phone_entry.delete(0, "end")
if name and name != "مخاطب ناشناس":
self.contact_name_entry.insert(0, name)
if phone:
self.contact_phone_entry.insert(0, phone)
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
# Translate Persian digits to English
persian_to_english = str.maketrans('۰۱۲۳۴۵۶۷۸۹', '0123456789')
phone = phone.translate(persian_to_english)
# Normalize to canonical format
phone = normalize_phone(phone)
if len(phone) != 11:
self.contact_form_message.configure(text='شماره نامعتبر است. باید ۱۱ رقم باشد (مثل 0912).')
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, 6))
# Mode indicator
if contact.symmetric_key:
mode_text = "ارتباط امن (کلید متقارن)"
mode_color = "#2E7D32" # Dark Green
elif contact.mode == "secure":
mode_text = "ارتباط امن (ECC)"
mode_color = PRIMARY
else:
mode_text = "حالت عادی"
mode_color = MUTED
RTLLabel(body, text=mode_text, text_color=mode_color,
font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold")).pack(pady=(0, 20))
# Add to Contacts button for unknown senders
if contact.name == "مخاطب ناشناس":
RTLButton(
body, text='افزودن به لیست مخاطبین',
corner_radius=24,
fg_color=PRIMARY, hover_color=PRIMARY_DARK, text_color="white",
font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), height=52,
command=lambda: (self._hide_contact_details_page(), self._open_contact_dialog(phone=contact.phone))
).pack(fill="x", padx=18, pady=(0, 15))
# Symmetric Key button
RTLButton(
body, text='تنظیم کلید متقارن (Shared Key)',
corner_radius=24,
fg_color=BACKGROUND, hover_color=ACCENT, text_color=ACCENT_DARK, border_color=ACCENT, border_width=2,
font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), height=48,
command=lambda: self._show_symmetric_key_dialog(contact.phone, contact.symmetric_key or "")
).pack(fill="x", padx=18, pady=(0, 8))
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=(0, 8))
def _hide_contact_details_page(self):
self.contact_details_panel.grid_remove()
self._show_chat_screen()
def _show_symmetric_key_dialog(self, phone, current_key):
self._show_overlay()
header = self._build_overlay_header('تنظیم کلید متقارن', 'کلیدی که در هر دو دستگاه وارد می‌شود را اینجا وارد کنید.')
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))
entry = RTLEntry(body, placeholder_text='مثلاً: saba123', height=48, font=ctk.CTkFont(family=FONT_BODY, size=16))
entry.pack(fill="x", padx=36, pady=(30, 10))
entry.insert(0, current_key)
def save():
key = entry.get().strip()
self.controller.set_symmetric_key(phone, key)
self._hide_overlay()
self._show_contact_details_page()
RTLButton(
body, text='ذخیره',
corner_radius=24,
fg_color=PRIMARY, hover_color=PRIMARY_DARK, text_color="white",
font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), height=48,
command=save
).pack(fill="x", padx=36, pady=(10, 20))
self._register_text_input(entry, title="کلید متقارن", layout="en", submit=save)
self.after(80, lambda: self._focus_registered_input(entry))
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))