diff --git a/api/api_client.py b/api/api_client.py index 7872ad0..f133ebc 100644 --- a/api/api_client.py +++ b/api/api_client.py @@ -28,10 +28,12 @@ def verify(self, token: str) -> dict: dict: The JSON response from the API containing user data. """ headers = {"Authorization": f"Bearer {token}"} - return self._request("GET", + res = self._request("GET", url=self.base_url + "/auth/user", headers=headers ) + print(res) + return res def get_user_data(self, token: str) -> dict: @@ -44,6 +46,26 @@ def get_user_data(self, token: str) -> dict: dict: The JSON response containing user data. """ return self.verify(token) + + + def update_user_data(self, token: str, user_id: int, data: dict) -> dict: + """Updates user data. + + Args: + token (str): The access token. + user_id (int): The ID of the user to update. + data (dict): The user data to update. + + Returns: + dict: The JSON response from the API containing user data. + """ + print(data) + headers = {"Authorization": f"Bearer {token}"} + return self._request("PATCH", + url=self.base_url + f"/auth/user/{user_id}", + headers=headers, + json=data + ) # === Login === diff --git a/modules/main/mvc/main_controller.py b/modules/main/mvc/main_controller.py index 836dcc7..40bd78b 100644 --- a/modules/main/mvc/main_controller.py +++ b/modules/main/mvc/main_controller.py @@ -19,6 +19,7 @@ from modules.categories_editings import ( CreateCategory, EditCategory ) +from modules.profile.profile_dialog import ProfileDialog from utils import NotificationService from utils.error_messages import get_friendly_error_message @@ -378,6 +379,59 @@ def _on_logout_clicked(self) -> None: self.logout_requested.emit() + def _show_profile_dialog(self) -> None: + """Handles showing the user profile dialog.""" + if self.mode != "auth": + return + + try: + user_data = self.model.get_full_user_data() + if not user_data: + logger.warning("Could not retrieve user data for profile.") + return + + departments = self.model.departments + + updated_data = ProfileDialog.show_dialog( + parent=self.window, + user_data=user_data, + departments=departments + ) + + if updated_data: + worker = APIWorker( + self.model.update_user_profile, + user_id=user_data['id'], + data=updated_data + ) + self.active_workers.add(worker) + worker.finished.connect(self._on_profile_update_finished) + worker.error.connect(lambda e: self._handle_error(e, "Ошибка обновления профиля")) + worker.finished.connect(self._cleanup_worker) + worker.error.connect(self._cleanup_worker) + worker.start() + + except Exception as e: + self._handle_error(e, "Ошибка профиля") + + + def _on_profile_update_finished(self, new_data: dict): + """Handles successful profile update.""" + logger.info(f"User profile updated successfully for user ID: {new_data.get('id')}") + + # Refresh all data to ensure consistency + self.model.refresh_data() + + # Update the UI with new user data + self._init_ui() + + NotificationService().show_toast( + notification_type="success", + title="Профиль обновлен", + message="Ваши данные были успешно сохранены." + ) + + def _on_department_selected(self, selected, deselected) -> None: """Handles department selection change.""" if self.is_updating_data: @@ -765,9 +819,29 @@ def _init_ui(self) -> None: if user_data: username = user_data.get("username") - department = user_data.get("department") + department_name = user_data.get("department") self.view.set_username(name=username if username else user_data.get("email")) - self.view.set_user_department(dept=department if department else "Отдел не выбран") + self.view.set_user_department(dept=department_name if department_name else "Отдел не выбран") + + # Find the user's department ID from the name + user_dept_id = None + if department_name: + for dept in self.model.departments: + if dept.get("name") == department_name: + user_dept_id = dept.get("id") + break + + # If a department is found, set it as current by triggering selection + if user_dept_id: + # Use a timer to ensure the UI is ready for selection. + # This will trigger the _on_department_selected slot, + # which will then update the model and load the data. + QTimer.singleShot(50, lambda: self.view.select_department(user_dept_id)) + # If user has no department, select the first one in the list + elif self.model.departments: + first_dept_id = self.model.departments[0].get("id") + if first_dept_id: + QTimer.singleShot(50, lambda: self.view.select_department(first_dept_id)) else: self.view.set_username("Гость") @@ -782,6 +856,7 @@ def _setup_connections(self) -> None: """Sets up signal-slot connections.""" # Sidebar self.view.connect_logout(self._on_logout_clicked) + self.view.connect_profile_action(self._show_profile_dialog) self.view.connect_departments_selection(self._on_department_selected) self.view.connect_categories_selection(self._on_category_selected) self.view.connect_department_edit(self._on_edit_department_clicked) diff --git a/modules/main/mvc/main_model.py b/modules/main/mvc/main_model.py index 520a013..d9087c5 100644 --- a/modules/main/mvc/main_model.py +++ b/modules/main/mvc/main_model.py @@ -59,6 +59,26 @@ def get_document_pages( ) -> list[dict]: pages = self.api.get_document_pages(document_id) return pages["pages"] + + + def get_full_user_data(self) -> dict | None: + """Retrieves full user data for the profile dialog.""" + token = self._get_user_token() + if not token: + return None + + # This might be the same as get_user_data, but let's assume it could be different + return self.api.get_user_data(token) + + + def update_user_profile(self, user_id: int, data: dict) -> dict: + """Updates the user's profile data.""" + # The data dict should contain 'username', 'department' (ID) + return self._make_authorized_request( + self.api.update_user_data, + user_id=user_id, + data=data + ) def create_department( diff --git a/modules/main/mvc/main_view.py b/modules/main/mvc/main_view.py index 34b6713..10cbec0 100644 --- a/modules/main/mvc/main_view.py +++ b/modules/main/mvc/main_view.py @@ -820,8 +820,7 @@ def __init__(self, ui: MainWindow_UI) -> None: # Disabled until the next update for ui_element in[ - self.ui.change_view_pushButton, - self.ui.profile_info_label + self.ui.change_view_pushButton ]: ui_element.setVisible(False) @@ -930,7 +929,6 @@ def _setup_profile_menu(self) -> None: ) # Disable unimplemented actions - self.user_profile_action.setVisible(False) self.settings_action.setVisible(False) @@ -1296,6 +1294,16 @@ def connect_logout(self, handler) -> None: self.logout_action.triggered.connect(handler) + def connect_profile_action(self, handler) -> None: + """Connects the user profile action to a handler. + + Args: + handler: The callback function. + """ + if self.user_profile_action: + self.user_profile_action.triggered.connect(handler) + + def connect_departments_selection(self, handler) -> None: """Connects the departments selection signal to a handler. diff --git a/modules/profile/profile_dialog.py b/modules/profile/profile_dialog.py new file mode 100644 index 0000000..eab9f20 --- /dev/null +++ b/modules/profile/profile_dialog.py @@ -0,0 +1,150 @@ +from PyQt5.QtWidgets import QDialog, QVBoxLayout, QGraphicsDropShadowEffect +from PyQt5.QtGui import QColor, QRegExpValidator +from PyQt5.QtCore import QRegExp + +from ui.ui_converted.profile_dialog import Ui_ProfileDialog +from ui.custom_widgets.modal_window import ShadowContainer, BaseModalDialog + +class ProfileDialog(BaseModalDialog): + def __init__(self, parent, user_data: dict, departments: list[dict]): + super().__init__(parent) + self.user_data = user_data + self.departments = departments + + # === Setup UI === + self.ui = Ui_ProfileDialog() + self.ui.setupUi(self) + + # Take layout from UI + original_layout = self.layout() + + # Create a container widget that will hold the UI and have the shadow + container = ShadowContainer(self) + container.setLayout(original_layout) + container.setObjectName("profileDialogContainer") + + # Reparent UI frames into container + self.ui.texts_frame.setParent(container) + self.ui.form_frame.setParent(container) + self.ui.buttons_frame.setParent(container) + + # === Add single word validators === + single_word_validator = QRegExpValidator(QRegExp(r'^\S+$')) + self.ui.firstname_lineEdit.setValidator(single_word_validator) + self.ui.lastname_lineEdit.setValidator(single_word_validator) + + # === Shadow === + shadow = QGraphicsDropShadowEffect() + shadow.setBlurRadius(20) + shadow.setColor(QColor(0, 0, 0, int(255 * 0.10))) + shadow.setOffset(0, 5) + container.setGraphicsEffect(shadow) + + # === Main layout (holds container with shadow margins) === + main_layout = QVBoxLayout() + main_layout.setContentsMargins(20, 20, 20, 20) # For shadow space + main_layout.addWidget(container) + self.setLayout(main_layout) + + # === Populate data === + self._populate_initial_data() + + # === Connect handlers === + self.ui.accept_pushButton.clicked.connect(self.accept) + self.ui.cancel_pushButton.clicked.connect(self.reject) + self.ui.firstname_lineEdit.textChanged.connect(self._validate_changes) + self.ui.lastname_lineEdit.textChanged.connect(self._validate_changes) + self.ui.department_comboBox.currentIndexChanged.connect(self._validate_changes) + + def _get_original_department_id(self) -> int | None: + """Robustly determines the original department ID from user_data.""" + dept_id = self.user_data.get("department_id") + if dept_id is not None: + return dept_id + + dept_name = self.user_data.get("department") + if dept_name is not None: + for dept in self.departments: + if dept.get("name") == dept_name: + return dept.get("id") + return None + + def _populate_initial_data(self): + """Fills the widgets with the current user data.""" + username = self.user_data.get("username", "") + parts = username.split() + first_name = parts[0] if parts else "" + last_name = " ".join(parts[1:]) if len(parts) > 1 else "" + + self.ui.firstname_lineEdit.setText(first_name) + self.ui.lastname_lineEdit.setText(last_name) + + # Populate departments and select the current one + self.ui.department_comboBox.clear() + target_dept_id = self._get_original_department_id() + + # Add a placeholder for no department + self.ui.department_comboBox.addItem("Отдел не выбран", user_data=None) + + selected_index = 0 # Default to placeholder + for i, dept in enumerate(self.departments): + dept_id = dept.get("id") + self.ui.department_comboBox.addItem(dept.get("name"), user_data=dept_id) + + if target_dept_id is not None and str(dept_id) == str(target_dept_id): + # The index in the combobox is i + 1 because of the placeholder + selected_index = i + 1 + + self.ui.department_comboBox.setCurrentIndex(selected_index) + + def _validate_changes(self): + """Enables the save button only if there are actual changes.""" + is_changed = False + + username = self.user_data.get("username", "") + parts = username.split() + original_first_name = parts[0] if parts else "" + original_last_name = " ".join(parts[1:]) if len(parts) > 1 else "" + + # Check names + if self.ui.firstname_lineEdit.text() != original_first_name: + is_changed = True + if self.ui.lastname_lineEdit.text() != original_last_name: + is_changed = True + + # Check department with explicit None checks + selected_dept_id = self.ui.department_comboBox.currentData() + original_dept_id = self._get_original_department_id() + + department_changed = False + if selected_dept_id is None and original_dept_id is not None: + department_changed = True + elif selected_dept_id is not None and original_dept_id is None: + department_changed = True + elif selected_dept_id is not None and original_dept_id is not None: + if str(selected_dept_id) != str(original_dept_id): + department_changed = True + + if department_changed: + is_changed = True + + self.ui.accept_pushButton.setEnabled(is_changed) + + def get_updated_data(self) -> dict: + """Returns the updated user data from the form.""" + return { + "username": " ".join([ + self.ui.firstname_lineEdit.text(), + self.ui.lastname_lineEdit.text() + ]), + "department_id": self.ui.department_comboBox.currentData() + } + + @staticmethod + def show_dialog(parent, user_data: dict, departments: list[dict]): + """Creates, shows the dialog, and returns the updated data if accepted.""" + dialog = ProfileDialog(parent, user_data, departments) + if dialog.exec_() == QDialog.Accepted: + return dialog.get_updated_data() + return None + diff --git a/ui/custom_widgets/__init__.py b/ui/custom_widgets/__init__.py index 9b9cabe..f2c9f12 100644 --- a/ui/custom_widgets/__init__.py +++ b/ui/custom_widgets/__init__.py @@ -16,4 +16,5 @@ from .hints import PasswordHint from .progress_bar import ProgressBar from .file_drop import FileDropWidget -from .files import FileWidget, FileListWidget \ No newline at end of file +from .files import FileWidget, FileListWidget +from .combobox import ComboBox \ No newline at end of file diff --git a/ui/custom_widgets/combobox.py b/ui/custom_widgets/combobox.py new file mode 100644 index 0000000..f4016ec --- /dev/null +++ b/ui/custom_widgets/combobox.py @@ -0,0 +1,495 @@ +from PyQt5.QtCore import ( + Qt, QEvent, QPoint, QPropertyAnimation, + QEasingCurve, QRect, QSize, pyqtSignal, pyqtProperty +) +from PyQt5.QtGui import ( + QPainter, QPainterPath, QTransform, + QPixmap, QIcon, QFontMetrics +) +from PyQt5.QtWidgets import ( + QWidget, QHBoxLayout, QVBoxLayout, + QLabel, QFrame, QScrollArea, + QSizePolicy, QApplication +) + +from utils import ThemeManagerInstance + + +# --------------------------------------------------------------------------- +# Arrow label with rotation animation +# --------------------------------------------------------------------------- + +class _ArrowLabel(QLabel): + """Renders a pixmap rotated by an animated angle.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setFixedSize(16, 16) + self._angle: float = 0.0 + self._source: QPixmap | None = None + + def set_source(self, pixmap: QPixmap) -> None: + """Sets the source pixmap to rotate.""" + self._source = pixmap + self._repaint() + + @pyqtProperty(float) + def angle(self) -> float: + return self._angle + + @angle.setter + def angle(self, value: float) -> None: + self._angle = value + self._repaint() + + def _repaint(self) -> None: + if self._source is None: + return + rotated = self._source.transformed( + QTransform().rotate(self._angle), + Qt.SmoothTransformation + ) + result = QPixmap(self.size()) + result.fill(Qt.transparent) + p = QPainter(result) + x = (self.width() - rotated.width()) // 2 + y = (self.height() - rotated.height()) // 2 + p.drawPixmap(x, y, rotated) + p.end() + self.setPixmap(result) + + +# --------------------------------------------------------------------------- +# Single item in the dropdown list +# --------------------------------------------------------------------------- + +class _ComboBoxItem(QWidget): + """A single selectable row in the dropdown popup.""" + + clicked = pyqtSignal(str, object) + + def __init__( + self, + text: str, + user_data: object = None, + parent: QWidget | None = None + ) -> None: + super().__init__(parent) + self._text = text + self._user_data = user_data + self._hovered = False + + self.setFixedHeight(40) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setCursor(Qt.PointingHandCursor) + self.setAttribute(Qt.WA_StyledBackground, True) + + layout = QHBoxLayout(self) + layout.setContentsMargins(16, 0, 16, 0) + + self._label = QLabel(text, self) + self._label.setObjectName("comboItemLabel") + layout.addWidget(self._label) + + def set_hovered(self, hovered: bool) -> None: + """Updates the hovered state and refreshes styling.""" + self._hovered = hovered + self.setProperty("hovered", "true" if hovered else "false") + self.style().unpolish(self) + self.style().polish(self) + self.update() + + def mousePressEvent(self, event) -> None: + if event.button() == Qt.LeftButton: + self.clicked.emit(self._text, self._user_data) + + def enterEvent(self, event) -> None: + self.set_hovered(True) + + def leaveEvent(self, event) -> None: + self.set_hovered(False) + + +# --------------------------------------------------------------------------- +# Floating dropdown popup +# --------------------------------------------------------------------------- + +class _ComboBoxPopup(QFrame): + """Frameless popup window containing the list of combo box items.""" + + item_selected = pyqtSignal(str, object) + + _MAX_VISIBLE_ITEMS = 6 + _ITEM_HEIGHT = 42 # item height + spacing + _CONTAINER_PADDING = 8 # top + bottom padding inside container + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__( + parent, + Qt.Popup | Qt.FramelessWindowHint | Qt.NoDropShadowWindowHint + ) + self.setAttribute(Qt.WA_TranslucentBackground) + self.setAttribute(Qt.WA_StyledBackground, True) + self.setObjectName("ComboBoxPopup") + + self._items: list[_ComboBoxItem] = [] + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + + self._container = QFrame(self) + self._container.setObjectName("comboPopupContainer") + self._container.setAttribute(Qt.WA_StyledBackground, True) + + container_layout = QVBoxLayout(self._container) + container_layout.setContentsMargins(4, 4, 4, 4) + container_layout.setSpacing(0) + + self._scroll = QScrollArea(self._container) + self._scroll.setWidgetResizable(True) + self._scroll.setFrameShape(QFrame.NoFrame) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self._scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self._scroll.setObjectName("comboPopupScroll") + + self._list_widget = QWidget() + self._list_widget.setObjectName("comboPopupList") + self._list_widget.setAttribute(Qt.WA_StyledBackground, True) + + self._list_layout = QVBoxLayout(self._list_widget) + self._list_layout.setContentsMargins(0, 0, 0, 0) + self._list_layout.setSpacing(2) + + self._scroll.setWidget(self._list_widget) + container_layout.addWidget(self._scroll) + outer.addWidget(self._container) + + def add_item(self, text: str, user_data: object = None) -> None: + """Appends an item to the dropdown list.""" + item = _ComboBoxItem(text, user_data, self._list_widget) + item.clicked.connect(self._on_item_clicked) + self._list_layout.addWidget(item) + self._items.append(item) + + def clear_items(self) -> None: + """Removes all items from the dropdown list.""" + for item in self._items: + item.deleteLater() + self._items.clear() + + def show_below(self, widget: QWidget, min_width: int) -> None: + """Positions and shows the popup directly below the given widget.""" + pos = widget.mapToGlobal(QPoint(0, widget.height() + 4)) + + visible = min(len(self._items), self._MAX_VISIBLE_ITEMS) + popup_height = visible * self._ITEM_HEIGHT + self._CONTAINER_PADDING + + self.setFixedWidth(max(min_width, 120)) + self.setFixedHeight(popup_height) + self._scroll.setFixedHeight(popup_height - self._CONTAINER_PADDING) + + self.move(pos) + self.show() + self.raise_() + + def _on_item_clicked(self, text: str, user_data: object) -> None: + self.item_selected.emit(text, user_data) + self.hide() + + +# --------------------------------------------------------------------------- +# Public ComboBox widget +# --------------------------------------------------------------------------- + +class ComboBox(QWidget): + """ + Custom combo box widget with animated arrow rotation and themed dropdown. + + Provides a drop-in replacement for QComboBox with full QSS theme support + and smooth open/close arrow animation. + + Signals: + currentTextChanged(str): Emitted when the selected text changes. + currentIndexChanged(int): Emitted when the selected index changes. + + Usage: + combo = ComboBox() + combo.add_item("Option 1") + combo.add_item("Option 2", user_data={"id": 2}) + combo.current_text_changed.connect(lambda t: print(t)) + """ + + currentTextChanged = pyqtSignal(str) + currentIndexChanged = pyqtSignal(int) + + _ARROW_ANIMATION_DURATION = 180 + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setAttribute(Qt.WA_StyledBackground, True) + self.setObjectName("ComboBox") + + self._items: list[tuple[str, object]] = [] + self._current_index: int = -1 + self._placeholder: str = "Select..." + self._is_open: bool = False + self._hovered: bool = False + + self.setFixedHeight(40) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setCursor(Qt.PointingHandCursor) + + # Layout + layout = QHBoxLayout(self) + layout.setContentsMargins(12, 0, 8, 0) + layout.setSpacing(4) + + self._text_label = QLabel(self._placeholder, self) + self._text_label.setObjectName("comboTextLabel") + self._text_label.setAttribute(Qt.WA_StyledBackground, True) + layout.addWidget(self._text_label, 1) + + self._arrow = _ArrowLabel(self) + self._arrow.setObjectName("comboArrow") + self._arrow.setAttribute(Qt.WA_StyledBackground, True) + layout.addWidget(self._arrow, 0, Qt.AlignVCenter) + + # Arrow animation + self._anim = QPropertyAnimation(self._arrow, b"angle") + self._anim.setDuration(self._ARROW_ANIMATION_DURATION) + self._anim.setEasingCurve(QEasingCurve.InOutQuad) + + # Popup + self._popup = _ComboBoxPopup() + self._popup.item_selected.connect(self._on_item_selected) + self._popup.installEventFilter(self) + + # Theme support + ThemeManagerInstance.themeChanged.connect(self._on_theme_changed) + self._load_arrow_icon() + self._refresh_states() + + # ------------------------------------------------------------------ + # Theme and icon management + # ------------------------------------------------------------------ + + def _load_arrow_icon(self) -> None: + """Loads the arrow icon for the current theme.""" + theme_id = ThemeManagerInstance.current_theme_id + theme = "light" if theme_id == "0" else "dark" + + pixmap = QPixmap(f":/icons/{theme}/{theme}/arrow_default.svg") + if pixmap.isNull(): + pixmap = self._draw_fallback_arrow() + + scaled = pixmap.scaled(12, 12, Qt.KeepAspectRatio, Qt.SmoothTransformation) + self._arrow.set_source(scaled) + + def _draw_fallback_arrow(self, size: int = 12) -> QPixmap: + """Draws a simple triangle arrow as a fallback when the icon is missing.""" + px = QPixmap(size, size) + px.fill(Qt.transparent) + p = QPainter(px) + p.setRenderHint(QPainter.Antialiasing) + p.setPen(Qt.NoPen) + p.setBrush(Qt.white) + path = QPainterPath() + m = size * 0.15 + path.moveTo(m, size * 0.35) + path.lineTo(size - m, size * 0.35) + path.lineTo(size / 2, size * 0.72) + path.closeSubpath() + p.drawPath(path) + p.end() + return px + + def _on_theme_changed(self, theme_id: str) -> None: + """Reloads the arrow icon when the application theme changes.""" + self._load_arrow_icon() + + # ------------------------------------------------------------------ + # State management and QSS property refresh + # ------------------------------------------------------------------ + + def _refresh_states(self) -> None: + """Updates QSS dynamic properties to reflect the current widget state.""" + self.setProperty("open", "true" if self._is_open else "false") + self.setProperty("hovered", "true" if self._hovered else "false") + self.setProperty( + "empty", "true" if self._current_index == -1 else "false" + ) + self.style().unpolish(self) + self.style().polish(self) + + self._text_label.setProperty("open", "true" if self._is_open else "false") + self._text_label.setProperty("hovered", "true" if self._hovered else "false") + self._text_label.setProperty( + "empty", "true" if self._current_index == -1 else "false" + ) + self.style().unpolish(self._text_label) + self.style().polish(self._text_label) + self.update() + + # ------------------------------------------------------------------ + # Popup open / close + # ------------------------------------------------------------------ + + def _open_popup(self) -> None: + if not self._items or not self.isEnabled(): + return + self._is_open = True + self._refresh_states() + self._animate_arrow(open_=True) + + self._popup.clear_items() + for text, data in self._items: + self._popup.add_item(text, data) + self._popup.show_below(self, self.width()) + + def _close_popup(self) -> None: + self._is_open = False + self._refresh_states() + self._animate_arrow(open_=False) + self._popup.hide() + + def _toggle_popup(self) -> None: + if self._is_open: + self._close_popup() + else: + self._open_popup() + + def _animate_arrow(self, open_: bool) -> None: + """Animates the arrow rotation between 0° (closed) and 180° (open).""" + self._anim.stop() + self._anim.setStartValue(self._arrow.angle) + self._anim.setEndValue(180.0 if open_ else 0.0) + self._anim.start() + + # ------------------------------------------------------------------ + # Callbacks + # ------------------------------------------------------------------ + + def _on_item_selected(self, text: str, user_data: object) -> None: + old_index = self._current_index + for i, (t, _) in enumerate(self._items): + if t == text: + self._current_index = i + break + + self._text_label.setText(text) + self._is_open = False + self._animate_arrow(open_=False) + self._refresh_states() + + if self._current_index != old_index: + self.currentIndexChanged.emit(self._current_index) + self.currentTextChanged.emit(text) + + # ------------------------------------------------------------------ + # Qt event overrides + # ------------------------------------------------------------------ + + def mousePressEvent(self, event) -> None: + if event.button() == Qt.LeftButton: + self._toggle_popup() + + def enterEvent(self, event) -> None: + self._hovered = True + self._refresh_states() + + def leaveEvent(self, event) -> None: + self._hovered = False + self._refresh_states() + + def changeEvent(self, event) -> None: + if event.type() == QEvent.EnabledChange: + self._refresh_states() + self.setCursor( + Qt.ArrowCursor if not self.isEnabled() else Qt.PointingHandCursor + ) + super().changeEvent(event) + + def hideEvent(self, event) -> None: + self._close_popup() + super().hideEvent(event) + + def eventFilter(self, obj, event) -> bool: + if obj is self._popup and event.type() == QEvent.Hide: + if self._is_open: + self._close_popup() + return super().eventFilter(obj, event) + + # ------------------------------------------------------------------ + # Public API (compatible with QComboBox naming conventions) + # ------------------------------------------------------------------ + + def addItem(self, text: str, user_data: object = None) -> None: + """Appends an item with optional associated data.""" + self._items.append((text, user_data)) + + def addItems(self, texts: list[str]) -> None: + """Appends multiple items by text.""" + for t in texts: + self.addItem(t) + + def setPlaceholderText(self, text: str) -> None: + """Sets the placeholder text shown when no item is selected.""" + self._placeholder = text + if self._current_index == -1: + self._text_label.setText(text) + + def setCurrentText(self, text: str) -> None: + """Selects the item matching the given text.""" + for i, (t, _) in enumerate(self._items): + if t == text: + self._current_index = i + self._text_label.setText(text) + self._refresh_states() + return + + def setCurrentIndex(self, index: int) -> None: + """Selects the item at the given index.""" + if 0 <= index < len(self._items): + self._current_index = index + self._text_label.setText(self._items[index][0]) + self._refresh_states() + + def currentText(self) -> str: + """Returns the currently selected text, or an empty string if none.""" + if self._current_index >= 0: + return self._items[self._current_index][0] + return "" + + def currentData(self) -> object: + """Returns the user data associated with the currently selected item.""" + if self._current_index >= 0: + return self._items[self._current_index][1] + return None + + def currentIndex(self) -> int: + """Returns the index of the currently selected item, or -1 if none.""" + return self._current_index + + def count(self) -> int: + """Returns the total number of items.""" + return len(self._items) + + def itemText(self, index: int) -> str: + """Returns the text of the item at the given index.""" + if 0 <= index < len(self._items): + return self._items[index][0] + return "" + + def itemData(self, index: int) -> object: + """Returns the user data of the item at the given index.""" + if 0 <= index < len(self._items): + return self._items[index][1] + return None + + def clear(self) -> None: + """Removes all items and resets the selection.""" + self._items.clear() + self._current_index = -1 + self._text_label.setText(self._placeholder) + self._refresh_states() diff --git a/ui/styles/templates/dark.j2 b/ui/styles/templates/dark.j2 index b0d5907..68e44fb 100644 --- a/ui/styles/templates/dark.j2 +++ b/ui/styles/templates/dark.j2 @@ -539,6 +539,13 @@ QFrame#editCategoryContainer { border: 1px solid {{ neutral.neutral_250 }}; } +/* Profile Dialog Window */ +QFrame#profileDialogContainer { + border-radius: 8px; + background-color: {{ neutral.neutral_100 }}; + border: 1px solid {{ neutral.neutral_250 }}; +} + /* Editor Window */ QFrame#editorContainer { border: 1px solid {{ neutral.neutral_250 }}; @@ -1058,4 +1065,71 @@ FileWidget QLabel#fileInfoLabel { border-radius: 8px; border: 1px solid {{ neutral.neutral_250 }}; background-color: {{ neutral.neutral_100 }}; -} \ No newline at end of file +} + + +/* ComboBox */ +ComboBox { + border-radius: 8px; + border: 1px solid {{ neutral.neutral_250 }}; + background-color: {{ neutral.neutral_100 }}; +} +ComboBox[hovered="true"] { + border: 1px solid {{ neutral.neutral_300 }}; +} +ComboBox[open="true"], ComboBox:focus { + border: 1px solid {{ accent.accent_500 }}; +} +ComboBox[disabled="true"] { + border: 1px solid {{ neutral.neutral_200 }}; + background-color: {{ neutral.neutral_100 }}; +} + +QLabel#comboTextLabel { + color: {{ neutral.neutral_400 }}; + background-color: transparent; + font-size: 12pt; +} +ComboBox[hovered="true"] QLabel#comboTextLabel { + color: {{ neutral.neutral_700 }}; +} +ComboBox[empty="false"] QLabel#comboTextLabel { + color: {{ neutral.neutral_900 }}; +} +ComboBox[disabled="true"] QLabel#comboTextLabel { + color: {{ neutral.neutral_200 }}; +} + +/* Popup styles */ +QFrame#ComboBoxPopup { + border: none; +} +QFrame#comboPopupContainer { + border: 1px solid {{ neutral.neutral_250 }}; + border-radius: 8px; + background-color: {{ neutral.neutral_100 }}; +} +QScrollArea#comboPopupScroll { + border: none; +} +QWidget#comboPopupList { + background-color: transparent; +} + +/* Item styles */ +_ComboBoxItem { + background-color: transparent; +} +_ComboBoxItem[hovered="true"] { + background-color: {{ neutral.neutral_200 }}; + border-radius: 8px; +} +_ComboBoxItem QLabel#comboItemLabel { + color: {{ neutral.neutral_800 }}; + background-color: transparent; + font-size: 12pt; +} +_ComboBoxItem[hovered="true"] QLabel#comboItemLabel { + color: {{ neutral.neutral_900 }}; +} + diff --git a/ui/styles/templates/light.j2 b/ui/styles/templates/light.j2 index 5ea3d57..1cf5c48 100644 --- a/ui/styles/templates/light.j2 +++ b/ui/styles/templates/light.j2 @@ -537,6 +537,13 @@ QFrame#editCategoryContainer { border: 1px solid {{ neutral.neutral_100 }}; } +/* Profile Dialog Window */ +QFrame#profileDialogContainer { + border-radius: 8px; + background-color: {{ neutral.neutral_0 }}; + border: 1px solid {{ neutral.neutral_100 }}; +} + /* Editor Window */ QFrame#editorContainer { border: 1px solid {{ neutral.neutral_100 }}; @@ -1062,4 +1069,71 @@ FileWidget QPushButton#fileDeleteButton:hover { border-radius: 8px; border: 1px solid {{ neutral.neutral_200 }}; background-color: {{ neutral.neutral_0 }}; -} \ No newline at end of file +} + + +/* ComboBox */ +ComboBox { + border-radius: 8px; + border: 1px solid {{ neutral.neutral_100 }}; + background-color: {{ neutral.neutral_0 }}; +} +ComboBox[hovered="true"] { + border: 1px solid {{ neutral.neutral_150 }}; +} +ComboBox[open="true"], ComboBox:focus { + border: 1px solid {{ accent.accent_500 }}; +} +ComboBox[disabled="true"] { + border: 1px solid {{ neutral.neutral_50 }}; + background-color: {{ neutral.neutral_0 }}; +} + +QLabel#comboTextLabel { + color: {{ neutral.neutral_500 }}; + background-color: transparent; + font-size: 12pt; +} +ComboBox[hovered="true"] QLabel#comboTextLabel { + color: {{ neutral.neutral_700 }}; +} +ComboBox[empty="false"] QLabel#comboTextLabel { + color: {{ neutral.neutral_900 }}; +} +ComboBox[disabled="true"] QLabel#comboTextLabel { + color: {{ neutral.neutral_100 }}; +} + +/* Popup styles */ +QFrame#ComboBoxPopup { + border: none; +} +QFrame#comboPopupContainer { + border: 1px solid {{ neutral.neutral_100 }}; + border-radius: 8px; + background-color: {{ neutral.neutral_0 }}; +} +QScrollArea#comboPopupScroll { + border: none; +} +QWidget#comboPopupList { + background-color: transparent; +} + +/* Item styles */ +_ComboBoxItem { + background-color: transparent; +} +_ComboBoxItem[hovered="true"] { + background-color: {{ neutral.neutral_50 }}; + border-radius: 8px; +} +_ComboBoxItem QLabel#comboItemLabel { + color: {{ neutral.neutral_800 }}; + background-color: transparent; + font-size: 12pt; +} +_ComboBoxItem[hovered="true"] QLabel#comboItemLabel { + color: {{ neutral.neutral_900 }}; +} + diff --git a/ui/styles/themes/dark.qss b/ui/styles/themes/dark.qss index 26d9d44..c3c206b 100644 --- a/ui/styles/themes/dark.qss +++ b/ui/styles/themes/dark.qss @@ -539,6 +539,13 @@ QFrame#editCategoryContainer { border: 1px solid #404040; } +/* Profile Dialog Window */ +QFrame#profileDialogContainer { + border-radius: 8px; + background-color: #262626; + border: 1px solid #404040; +} + /* Editor Window */ QFrame#editorContainer { border: 1px solid #404040; @@ -1059,4 +1066,71 @@ FileWidget QLabel#fileInfoLabel { border-radius: 8px; border: 1px solid #404040; background-color: #262626; -} \ No newline at end of file +} + + +/* ComboBox */ +ComboBox { + border-radius: 8px; + border: 1px solid #404040; + background-color: #262626; +} +ComboBox[hovered="true"] { + border: 1px solid #4D4D4D; +} +ComboBox[open="true"], ComboBox:focus { + border: 1px solid #C43A3A; +} +ComboBox[disabled="true"] { + border: 1px solid #333333; + background-color: #262626; +} + +QLabel#comboTextLabel { + color: #666666; + background-color: transparent; + font-size: 12pt; +} +ComboBox[hovered="true"] QLabel#comboTextLabel { + color: #B3B3B3; +} +ComboBox[empty="false"] QLabel#comboTextLabel { + color: #E6E6E6; +} +ComboBox[disabled="true"] QLabel#comboTextLabel { + color: #333333; +} + +/* Popup styles */ +QFrame#ComboBoxPopup { + border: none; +} +QFrame#comboPopupContainer { + border: 1px solid #404040; + border-radius: 8px; + background-color: #262626; +} +QScrollArea#comboPopupScroll { + border: none; +} +QWidget#comboPopupList { + background-color: transparent; +} + +/* Item styles */ +_ComboBoxItem { + background-color: transparent; +} +_ComboBoxItem[hovered="true"] { + background-color: #333333; + border-radius: 8px; +} +_ComboBoxItem QLabel#comboItemLabel { + color: #CCCCCC; + background-color: transparent; + font-size: 12pt; +} +_ComboBoxItem[hovered="true"] QLabel#comboItemLabel { + color: #E6E6E6; +} + diff --git a/ui/styles/themes/light.qss b/ui/styles/themes/light.qss index 294e327..fa0042d 100644 --- a/ui/styles/themes/light.qss +++ b/ui/styles/themes/light.qss @@ -537,6 +537,13 @@ QFrame#editCategoryContainer { border: 1px solid #E6E6E6; } +/* Profile Dialog Window */ +QFrame#profileDialogContainer { + border-radius: 8px; + background-color: #FFFFFF; + border: 1px solid #E6E6E6; +} + /* Editor Window */ QFrame#editorContainer { border: 1px solid #E6E6E6; @@ -1059,4 +1066,71 @@ FileWidget QPushButton#fileDeleteButton:hover { border-radius: 8px; border: 1px solid #CCCCCC; background-color: #FFFFFF; -} \ No newline at end of file +} + + +/* ComboBox */ +ComboBox { + border-radius: 8px; + border: 1px solid #E6E6E6; + background-color: #FFFFFF; +} +ComboBox[hovered="true"] { + border: 1px solid #D9D9D9; +} +ComboBox[open="true"], ComboBox:focus { + border: 1px solid #CC3333; +} +ComboBox[disabled="true"] { + border: 1px solid #F2F2F2; + background-color: #FFFFFF; +} + +QLabel#comboTextLabel { + color: #808080; + background-color: transparent; + font-size: 12pt; +} +ComboBox[hovered="true"] QLabel#comboTextLabel { + color: #4D4D4D; +} +ComboBox[empty="false"] QLabel#comboTextLabel { + color: #1A1A1A; +} +ComboBox[disabled="true"] QLabel#comboTextLabel { + color: #E6E6E6; +} + +/* Popup styles */ +QFrame#ComboBoxPopup { + border: none; +} +QFrame#comboPopupContainer { + border: 1px solid #E6E6E6; + border-radius: 8px; + background-color: #FFFFFF; +} +QScrollArea#comboPopupScroll { + border: none; +} +QWidget#comboPopupList { + background-color: transparent; +} + +/* Item styles */ +_ComboBoxItem { + background-color: transparent; +} +_ComboBoxItem[hovered="true"] { + background-color: #F2F2F2; + border-radius: 8px; +} +_ComboBoxItem QLabel#comboItemLabel { + color: #333333; + background-color: transparent; + font-size: 12pt; +} +_ComboBoxItem[hovered="true"] QLabel#comboItemLabel { + color: #1A1A1A; +} + diff --git a/ui/ui/profile_dialog.ui b/ui/ui/profile_dialog.ui new file mode 100644 index 0000000..2e4fab8 --- /dev/null +++ b/ui/ui/profile_dialog.ui @@ -0,0 +1,269 @@ + + + ProfileDialog + + + Qt::ApplicationModal + + + + 0 + 0 + 450 + 480 + + + + Dialog + + + true + + + + 24 + + + 16 + + + 16 + + + 16 + + + 16 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 14 + + + + Редактирование профиля + + + + + + + + + + + 16 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 16 + + + 16 + + + + + + 12 + + + + Имя + + + + + + + + 0 + 42 + + + + + 12 + + + + + + + + + 12 + + + + Фамилия + + + + + + + + 0 + 42 + + + + + 12 + + + + + + + + + 12 + + + + Отдел + + + + + + + + 0 + 42 + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + 16 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 120 + 42 + + + + + 12 + + + + Отменить + + + + + + + false + + + + 200 + 42 + + + + + 12 + + + + Сохранить изменения + + + + + + + + + + + PrimaryButton + QPushButton +
ui.custom_widgets.buttons
+
+ + TertiaryButton + QPushButton +
ui.custom_widgets.buttons
+
+ + ComboBox + QWidget +
ui.custom_widgets.combobox
+
+
+ + +
diff --git a/ui/ui_converted/profile_dialog.py b/ui/ui_converted/profile_dialog.py new file mode 100644 index 0000000..b286ac3 --- /dev/null +++ b/ui/ui_converted/profile_dialog.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file '.\ui\ui\profile_dialog.ui' +# +# Created by: PyQt5 UI code generator 5.15.11 +# +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt5 import QtCore, QtGui, QtWidgets + + +class Ui_ProfileDialog(object): + def setupUi(self, ProfileDialog): + ProfileDialog.setObjectName("ProfileDialog") + ProfileDialog.setWindowModality(QtCore.Qt.ApplicationModal) + ProfileDialog.resize(450, 480) + ProfileDialog.setModal(True) + self.verticalLayout_3 = QtWidgets.QVBoxLayout(ProfileDialog) + self.verticalLayout_3.setContentsMargins(16, 16, 16, 16) + self.verticalLayout_3.setSpacing(24) + self.verticalLayout_3.setObjectName("verticalLayout_3") + self.texts_frame = QtWidgets.QFrame(ProfileDialog) + self.texts_frame.setObjectName("texts_frame") + self.verticalLayout = QtWidgets.QVBoxLayout(self.texts_frame) + self.verticalLayout.setContentsMargins(0, 0, 0, 0) + self.verticalLayout.setObjectName("verticalLayout") + self.label_label = QtWidgets.QLabel(self.texts_frame) + font = QtGui.QFont() + font.setPointSize(14) + self.label_label.setFont(font) + self.label_label.setObjectName("label_label") + self.verticalLayout.addWidget(self.label_label) + self.verticalLayout_3.addWidget(self.texts_frame) + self.form_frame = QtWidgets.QFrame(ProfileDialog) + self.form_frame.setObjectName("form_frame") + self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.form_frame) + self.verticalLayout_2.setContentsMargins(0, 0, 0, 0) + self.verticalLayout_2.setSpacing(16) + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.formLayout = QtWidgets.QFormLayout() + self.formLayout.setSpacing(16) + self.formLayout.setObjectName("formLayout") + self.label_firstname = QtWidgets.QLabel(self.form_frame) + font = QtGui.QFont() + font.setPointSize(12) + self.label_firstname.setFont(font) + self.label_firstname.setObjectName("label_firstname") + self.formLayout.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.label_firstname) + self.firstname_lineEdit = QtWidgets.QLineEdit(self.form_frame) + self.firstname_lineEdit.setMinimumSize(QtCore.QSize(0, 42)) + font = QtGui.QFont() + font.setPointSize(12) + self.firstname_lineEdit.setFont(font) + self.firstname_lineEdit.setObjectName("firstname_lineEdit") + self.formLayout.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.firstname_lineEdit) + self.label_lastname = QtWidgets.QLabel(self.form_frame) + font = QtGui.QFont() + font.setPointSize(12) + self.label_lastname.setFont(font) + self.label_lastname.setObjectName("label_lastname") + self.formLayout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.label_lastname) + self.lastname_lineEdit = QtWidgets.QLineEdit(self.form_frame) + self.lastname_lineEdit.setMinimumSize(QtCore.QSize(0, 42)) + font = QtGui.QFont() + font.setPointSize(12) + self.lastname_lineEdit.setFont(font) + self.lastname_lineEdit.setObjectName("lastname_lineEdit") + self.formLayout.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.lastname_lineEdit) + self.label_department = QtWidgets.QLabel(self.form_frame) + font = QtGui.QFont() + font.setPointSize(12) + self.label_department.setFont(font) + self.label_department.setObjectName("label_department") + self.formLayout.setWidget(3, QtWidgets.QFormLayout.LabelRole, self.label_department) + self.department_comboBox = ComboBox(self.form_frame) + self.department_comboBox.setMinimumSize(QtCore.QSize(0, 42)) + self.department_comboBox.setObjectName("department_comboBox") + self.formLayout.setWidget(3, QtWidgets.QFormLayout.FieldRole, self.department_comboBox) + self.verticalLayout_2.addLayout(self.formLayout) + self.verticalLayout_3.addWidget(self.form_frame) + spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout_3.addItem(spacerItem) + self.buttons_frame = QtWidgets.QFrame(ProfileDialog) + self.buttons_frame.setObjectName("buttons_frame") + self.horizontalLayout = QtWidgets.QHBoxLayout(self.buttons_frame) + self.horizontalLayout.setContentsMargins(0, 0, 0, 0) + self.horizontalLayout.setSpacing(16) + self.horizontalLayout.setObjectName("horizontalLayout") + self.cancel_pushButton = TertiaryButton(self.buttons_frame) + self.cancel_pushButton.setMinimumSize(QtCore.QSize(120, 42)) + font = QtGui.QFont() + font.setPointSize(12) + self.cancel_pushButton.setFont(font) + self.cancel_pushButton.setObjectName("cancel_pushButton") + self.horizontalLayout.addWidget(self.cancel_pushButton) + self.accept_pushButton = PrimaryButton(self.buttons_frame) + self.accept_pushButton.setEnabled(False) + self.accept_pushButton.setMinimumSize(QtCore.QSize(200, 42)) + font = QtGui.QFont() + font.setPointSize(12) + self.accept_pushButton.setFont(font) + self.accept_pushButton.setObjectName("accept_pushButton") + self.horizontalLayout.addWidget(self.accept_pushButton) + self.verticalLayout_3.addWidget(self.buttons_frame) + + self.retranslateUi(ProfileDialog) + QtCore.QMetaObject.connectSlotsByName(ProfileDialog) + + def retranslateUi(self, ProfileDialog): + _translate = QtCore.QCoreApplication.translate + ProfileDialog.setWindowTitle(_translate("ProfileDialog", "Dialog")) + self.label_label.setText(_translate("ProfileDialog", "Редактирование профиля")) + self.label_firstname.setText(_translate("ProfileDialog", "Имя")) + self.label_lastname.setText(_translate("ProfileDialog", "Фамилия")) + self.label_department.setText(_translate("ProfileDialog", "Отдел")) + self.cancel_pushButton.setText(_translate("ProfileDialog", "Отменить")) + self.accept_pushButton.setText(_translate("ProfileDialog", "Сохранить изменения")) +from ui.custom_widgets.buttons import PrimaryButton, TertiaryButton +from ui.custom_widgets.combobox import ComboBox