Saba-python/secure_sms/ui/main_window.py

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