1916 lines
82 KiB
Python
1916 lines
82 KiB
Python
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("<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):
|
||
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")
|
||
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_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("<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_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.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("<Button-1>", open_contact_page, add="+")
|
||
self.chat_subtitle.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=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("<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 _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.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("<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
|
||
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))
|
||
|
||
|
||
|
||
|
||
|