diff --git a/README.md b/README.md
index 7a553d1..eb0988d 100644
--- a/README.md
+++ b/README.md
@@ -28,36 +28,37 @@ Displays the hierarchy of Departments and Categories. Features a document table
### Document Editor:
-Allows authorized users to create, edit, and manage document pages. Supports drag-and-drop reordering.
+Allows authorized users to create, edit, and manage document pages. Support for adding and working with electronic document files. Supports drag-and-drop reordering.

+
---
-## π‘ What's New (v1.1.0) - Tags & Filters
+## π‘ What's New (v0.2.0) - Profile & Settings
-This update brings powerful new ways to organize and find your documents.
+This update focuses on personalization and user experience.
-### π·οΈ Tags System
+### π€ Profile Editing
-- **Document Tags:** You can now assign tags to documents for better categorization.
-- **Auto-generation:** Automatically generate relevant tags based on document content.
-- **Search by Tags:** Quickly find documents by clicking on tags or typing `@tagname` in the search bar.
+- **Personal Info:** You can now edit your user information (First Name, Last Name, Department) directly within the application.
+- **Access:** The profile dialog is accessible from the main application menu.
-### π Search Filters
+### βοΈ Settings Persistence
-A new search filter menu has been added to refine your search results:
-
-- **Search Targets:** Option to include or exclude document pages from search results.
-- **Search Fields:** Filter by Name or Code.
-- **Match Mode:** Toggle exact match for precise queries.
+- **Personalization:** The application now remembers your preferences on a per-user basis.
+- **Theme:** Your selected theme (Light/Dark) is saved between sessions.
+- **Filters:** Your most recently used search filters are also saved, speeding up repeated searches.
### β‘ Also in this update
-- **"All Documents" Category:** Option to create a virtual category that aggregates all documents within a department.
-- **Guest Visibility:** Granular control over the visibility of departments and categories for guest users.
-- **Password Hints:** New interactive password strength hint widget during registration and password change.
-- **UI Improvements:** Minor visual enhancements and bug fixes.
+- Added a dedicated "What's New" changelog window that opens after updates and shows version changes.
+- Minor UI improvements and bug fixes for a more stable experience.
+- Improved session stability after idle time: document lists and search results no longer disappear if a reload or search request fails.
+- Main window startup loading was moved off the UI thread to reduce freezes during the initial data fetch.
+- Authentication flows now fail safely if the system keyring or session storage is unavailable.
+- API client now correctly handles empty successful responses such as `204 No Content`.
+- Logging out now explicitly disables auto-login for the current profile.
---
diff --git a/README_RU.md b/README_RU.md
index a0419ca..cb6edee 100644
--- a/README_RU.md
+++ b/README_RU.md
@@ -28,36 +28,37 @@
### Π Π΅Π΄Π°ΠΊΡΠΎΡ Π΄ΠΎΠΊΡΠΌΠ΅Π½ΡΠΎΠ²
-ΠΠΎΠ·Π²ΠΎΠ»ΡΠ΅Ρ Π°Π²ΡΠΎΡΠΈΠ·ΠΎΠ²Π°Π½Π½ΡΠΌ ΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»ΡΠΌ ΡΠΎΠ·Π΄Π°Π²Π°ΡΡ, ΡΠ΅Π΄Π°ΠΊΡΠΈΡΠΎΠ²Π°ΡΡ ΠΈ ΡΠΏΡΠ°Π²Π»ΡΡΡ ΡΡΡΠ°Π½ΠΈΡΠ°ΠΌΠΈ Π΄ΠΎΠΊΡΠΌΠ΅Π½ΡΠ°. ΠΠΎΠ΄Π΄Π΅ΡΠΆΠΊΠ° Drag-and-Drop.
+ΠΠΎΠ·Π²ΠΎΠ»ΡΠ΅Ρ Π°Π²ΡΠΎΡΠΈΠ·ΠΎΠ²Π°Π½Π½ΡΠΌ ΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»ΡΠΌ ΡΠΎΠ·Π΄Π°Π²Π°ΡΡ, ΡΠ΅Π΄Π°ΠΊΡΠΈΡΠΎΠ²Π°ΡΡ ΠΈ ΡΠΏΡΠ°Π²Π»ΡΡΡ ΡΡΡΠ°Π½ΠΈΡΠ°ΠΌΠΈ Π΄ΠΎΠΊΡΠΌΠ΅Π½ΡΠ°. ΠΠΎΠ΄Π΄Π΅ΡΠΆΠΊΠ° Π΄ΠΎΠ±Π°Π²Π»Π΅Π½ΠΈΡ ΠΈ ΡΠ°Π±ΠΎΡΡ Ρ ΡΠ»Π΅ΠΊΡΡΠΎΠ½Π½ΡΠΌΠΈ ΡΠ°ΠΉΠ»Π°ΠΌΠΈ Π΄ΠΎΠΊΡΠΌΠ΅Π½ΡΠ°. ΠΠΎΠ΄Π΄Π΅ΡΠΆΠΊΠ° Drag-and-Drop.

+
---
-## π‘ Π§ΡΠΎ Π½ΠΎΠ²ΠΎΠ³ΠΎ (v1.1.0) - Π’Π΅Π³ΠΈ ΠΈ Π€ΠΈΠ»ΡΡΡΡ
+## π‘ Π§ΡΠΎ Π½ΠΎΠ²ΠΎΠ³ΠΎ (v0.2.0) - ΠΡΠΎΡΠΈΠ»Ρ ΠΈ ΠΠ°ΡΡΡΠΎΠΉΠΊΠΈ
-ΠΡΠΎ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠ΅ Π΄ΠΎΠ±Π°Π²Π»ΡΠ΅Ρ ΠΌΠΎΡΠ½ΡΠ΅ ΠΈΠ½ΡΡΡΡΠΌΠ΅Π½ΡΡ Π΄Π»Ρ ΠΎΡΠ³Π°Π½ΠΈΠ·Π°ΡΠΈΠΈ ΠΈ ΠΏΠΎΠΈΡΠΊΠ° Π΄ΠΎΠΊΡΠΌΠ΅Π½ΡΠΎΠ².
+ΠΡΠΎ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠ΅ Π΄ΠΎΠ±Π°Π²Π»ΡΠ΅Ρ ΠΏΠ΅ΡΡΠΎΠ½Π°Π»ΠΈΠ·Π°ΡΠΈΡ ΠΈ ΡΠ΄ΠΎΠ±ΡΡΠ²ΠΎ ΠΈΡΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°Π½ΠΈΡ.
-### π·οΈ Π‘ΠΈΡΡΠ΅ΠΌΠ° ΡΠ΅Π³ΠΎΠ²
+### π€ Π Π΅Π΄Π°ΠΊΡΠΈΡΠΎΠ²Π°Π½ΠΈΠ΅ ΠΏΡΠΎΡΠΈΠ»Ρ
-- **Π’Π΅Π³ΠΈ Π΄ΠΎΠΊΡΠΌΠ΅Π½ΡΠΎΠ²:** Π’Π΅ΠΏΠ΅ΡΡ ΠΌΠΎΠΆΠ½ΠΎ ΠΏΡΠΈΡΠ²Π°ΠΈΠ²Π°ΡΡ ΡΠ΅Π³ΠΈ Π΄ΠΎΠΊΡΠΌΠ΅Π½ΡΠ°ΠΌ Π΄Π»Ρ Π»ΡΡΡΠ΅ΠΉ ΠΊΠ°ΡΠ΅Π³ΠΎΡΠΈΠ·Π°ΡΠΈΠΈ.
-- **ΠΠ²ΡΠΎΠ³Π΅Π½Π΅ΡΠ°ΡΠΈΡ:** ΠΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎΡΡΡ Π°Π²ΡΠΎΠΌΠ°ΡΠΈΡΠ΅ΡΠΊΠΎΠΉ Π³Π΅Π½Π΅ΡΠ°ΡΠΈΠΈ ΡΠ΅Π³ΠΎΠ² Π½Π° ΠΎΡΠ½ΠΎΠ²Π΅ ΡΠΎΠ΄Π΅ΡΠΆΠΈΠΌΠΎΠ³ΠΎ Π΄ΠΎΠΊΡΠΌΠ΅Π½ΡΠ°.
-- **ΠΠΎΠΈΡΠΊ ΠΏΠΎ ΡΠ΅Π³Π°ΠΌ:** ΠΡΡΡΡΡΠΉ ΠΏΠΎΠΈΡΠΊ Π΄ΠΎΠΊΡΠΌΠ΅Π½ΡΠΎΠ² ΠΏΠΎ ΠΊΠ»ΠΈΠΊΡ Π½Π° ΡΠ΅Π³ ΠΈΠ»ΠΈ ΠΏΡΠΈ Π²Π²ΠΎΠ΄Π΅ `@ΡΠ΅Π³` Π² ΡΡΡΠΎΠΊΡ ΠΏΠΎΠΈΡΠΊΠ°.
+- **ΠΠ΅ΡΡΠΎΠ½Π°Π»ΡΠ½ΡΠ΅ Π΄Π°Π½Π½ΡΠ΅:** Π’Π΅ΠΏΠ΅ΡΡ Π²Ρ ΠΌΠΎΠΆΠ΅ΡΠ΅ ΠΈΠ·ΠΌΠ΅Π½ΡΡΡ ΡΠ²ΠΎΠΈ Π΄Π°Π½Π½ΡΠ΅ (ΠΠΌΡ, Π€Π°ΠΌΠΈΠ»ΠΈΡ, ΠΡΠ΄Π΅Π») ΠΏΡΡΠΌΠΎ Π² ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠΈ.
+- **ΠΠΎΡΡΡΠΏ:** ΠΠΈΠ°Π»ΠΎΠ³ ΠΏΡΠΎΡΠΈΠ»Ρ Π΄ΠΎΡΡΡΠΏΠ΅Π½ ΠΈΠ· Π³Π»Π°Π²Π½ΠΎΠ³ΠΎ ΠΌΠ΅Π½Ρ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡ.
-### π Π€ΠΈΠ»ΡΡΡΡ ΠΏΠΎΠΈΡΠΊΠ°
+### βοΈ Π‘ΠΎΡ
ΡΠ°Π½Π΅Π½ΠΈΠ΅ Π½Π°ΡΡΡΠΎΠ΅ΠΊ
-ΠΠΎΠ±Π°Π²Π»Π΅Π½ΠΎ ΠΌΠ΅Π½Ρ ΡΠΈΠ»ΡΡΡΠΎΠ² Π΄Π»Ρ ΡΡΠΎΡΠ½Π΅Π½ΠΈΡ ΡΠ΅Π·ΡΠ»ΡΡΠ°ΡΠΎΠ² ΠΏΠΎΠΈΡΠΊΠ°:
-
-- **ΠΠ±ΡΠ΅ΠΊΡΡ ΠΏΠΎΠΈΡΠΊΠ°:** ΠΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎΡΡΡ Π²ΠΊΠ»ΡΡΠΈΡΡ ΠΈΠ»ΠΈ ΠΈΡΠΊΠ»ΡΡΠΈΡΡ ΡΡΡΠ°Π½ΠΈΡΡ Π΄ΠΎΠΊΡΠΌΠ΅Π½ΡΠΎΠ² ΠΈΠ· ΠΏΠΎΠΈΡΠΊΠ°.
-- **ΠΠΎΠ»Ρ ΠΏΠΎΠΈΡΠΊΠ°:** Π€ΠΈΠ»ΡΡΡΠ°ΡΠΈΡ ΠΏΠΎ ΠΠ°ΠΈΠΌΠ΅Π½ΠΎΠ²Π°Π½ΠΈΡ ΠΈΠ»ΠΈ ΠΠΎΠ΄Ρ.
-- **Π Π΅ΠΆΠΈΠΌ ΡΠΎΠ²ΠΏΠ°Π΄Π΅Π½ΠΈΡ:** ΠΠΏΡΠΈΡ ΡΠΎΡΠ½ΠΎΠ³ΠΎ ΡΠΎΠ²ΠΏΠ°Π΄Π΅Π½ΠΈΡ Π΄Π»Ρ ΠΊΠΎΠ½ΠΊΡΠ΅ΡΠ½ΡΡ
Π·Π°ΠΏΡΠΎΡΠΎΠ².
+- **ΠΠ΅ΡΡΠΎΠ½Π°Π»ΠΈΠ·Π°ΡΠΈΡ:** ΠΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΡΠ΅ΠΏΠ΅ΡΡ Π·Π°ΠΏΠΎΠΌΠΈΠ½Π°Π΅Ρ Π²Π°ΡΠΈ ΠΏΡΠ΅Π΄ΠΏΠΎΡΡΠ΅Π½ΠΈΡ Π΄Π»Ρ ΠΊΠ°ΠΆΠ΄ΠΎΠ³ΠΎ ΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»Ρ.
+- **Π’Π΅ΠΌΠ°:** ΠΡΠ±ΡΠ°Π½Π½Π°Ρ ΡΠ΅ΠΌΠ° (ΡΠ²Π΅ΡΠ»Π°Ρ/ΡΡΠΌΠ½Π°Ρ) ΡΠΎΡ
ΡΠ°Π½ΡΠ΅ΡΡΡ ΠΌΠ΅ΠΆΠ΄Ρ ΡΠ΅ΡΡΠΈΡΠΌΠΈ.
+- **Π€ΠΈΠ»ΡΡΡΡ:** ΠΠΎΡΠ»Π΅Π΄Π½ΠΈΠ΅ ΠΈΡΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°Π½Π½ΡΠ΅ ΡΠΈΠ»ΡΡΡΡ ΠΏΠΎΠΈΡΠΊΠ° ΡΠ°ΠΊΠΆΠ΅ ΡΠΎΡ
ΡΠ°Π½ΡΡΡΡΡ, ΡΡΠΊΠΎΡΡΡ ΠΏΠΎΠ²ΡΠΎΡΠ½ΡΠΉ ΠΏΠΎΠΈΡΠΊ.
### β‘ Π’Π°ΠΊΠΆΠ΅ Π² ΡΡΠΎΠΌ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠΈ
-- **ΠΠ°ΡΠ΅Π³ΠΎΡΠΈΡ "ΠΡΠ΅ Π΄ΠΎΠΊΡΠΌΠ΅Π½ΡΡ":** ΠΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎΡΡΡ ΡΠΎΠ·Π΄Π°Π½ΠΈΡ Π²ΠΈΡΡΡΠ°Π»ΡΠ½ΠΎΠΉ ΠΊΠ°ΡΠ΅Π³ΠΎΡΠΈΠΈ, ΠΎΠ±ΡΠ΅Π΄ΠΈΠ½ΡΡΡΠ΅ΠΉ Π²ΡΠ΅ Π΄ΠΎΠΊΡΠΌΠ΅Π½ΡΡ ΠΎΡΠ΄Π΅Π»Π°.
-- **ΠΠΈΠ΄ΠΈΠΌΠΎΡΡΡ Π΄Π»Ρ Π³ΠΎΡΡΠ΅ΠΉ:** ΠΠ°ΡΡΡΠΎΠΉΠΊΠ° Π²ΠΈΠ΄ΠΈΠΌΠΎΡΡΠΈ ΠΎΡΠ΄Π΅Π»ΠΎΠ² ΠΈ ΠΊΠ°ΡΠ΅Π³ΠΎΡΠΈΠΉ Π΄Π»Ρ ΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»Π΅ΠΉ Π² Π³ΠΎΡΡΠ΅Π²ΠΎΠΌ ΡΠ΅ΠΆΠΈΠΌΠ΅.
-- **ΠΠΎΠ΄ΡΠΊΠ°Π·ΠΊΠΈ ΠΏΠ°ΡΠΎΠ»Ρ:** ΠΠΎΠ²ΡΠΉ ΠΈΠ½ΡΠ΅ΡΠ°ΠΊΡΠΈΠ²Π½ΡΠΉ Π²ΠΈΠ΄ΠΆΠ΅Ρ Ρ ΡΡΠ΅Π±ΠΎΠ²Π°Π½ΠΈΡΠΌΠΈ ΠΊ ΠΏΠ°ΡΠΎΠ»Ρ ΠΏΡΠΈ ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΠΈ ΠΈ ΡΠΌΠ΅Π½Π΅ ΠΏΠ°ΡΠΎΠ»Ρ.
-- **Π£Π»ΡΡΡΠ΅Π½ΠΈΡ UI:** ΠΠ΅Π±ΠΎΠ»ΡΡΠΈΠ΅ Π²ΠΈΠ·ΡΠ°Π»ΡΠ½ΡΠ΅ ΡΠ»ΡΡΡΠ΅Π½ΠΈΡ ΠΈ ΠΈΡΠΏΡΠ°Π²Π»Π΅Π½ΠΈΡ ΠΎΡΠΈΠ±ΠΎΠΊ.
+- ΠΠΎΠ±Π°Π²Π»Π΅Π½ΠΎ ΠΎΡΠ΄Π΅Π»ΡΠ½ΠΎΠ΅ ΠΎΠΊΠ½ΠΎ "Π§ΡΠΎ Π½ΠΎΠ²ΠΎΠ³ΠΎ", ΠΊΠΎΡΠΎΡΠΎΠ΅ ΠΎΡΠΊΡΡΠ²Π°Π΅ΡΡΡ ΠΏΠΎΡΠ»Π΅ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΡ ΠΈ ΠΏΠΎΠΊΠ°Π·ΡΠ²Π°Π΅Ρ ΡΠΏΠΈΡΠΎΠΊ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ Π²Π΅ΡΡΠΈΠΈ.
+- Π£Π»ΡΡΡΠ΅Π½ΠΈΡ ΠΈΠ½ΡΠ΅ΡΡΠ΅ΠΉΡΠ° ΠΈ ΠΈΡΠΏΡΠ°Π²Π»Π΅Π½ΠΈΡ ΠΎΡΠΈΠ±ΠΎΠΊ Π΄Π»Ρ Π±ΠΎΠ»Π΅Π΅ ΡΡΠ°Π±ΠΈΠ»ΡΠ½ΠΎΠΉ ΡΠ°Π±ΠΎΡΡ.
+- Π£Π»ΡΡΡΠ΅Π½Π° ΡΡΠ°Π±ΠΈΠ»ΡΠ½ΠΎΡΡΡ ΡΠ΅ΡΡΠΈΠΈ ΠΏΠΎΡΠ»Π΅ ΠΏΡΠΎΡΡΠΎΡ: ΡΠΏΠΈΡΠΎΠΊ Π΄ΠΎΠΊΡΠΌΠ΅Π½ΡΠΎΠ² ΠΈ ΡΠ΅Π·ΡΠ»ΡΡΠ°ΡΡ ΠΏΠΎΠΈΡΠΊΠ° Π±ΠΎΠ»ΡΡΠ΅ Π½Π΅ ΠΈΡΡΠ΅Π·Π°ΡΡ, Π΅ΡΠ»ΠΈ Π·Π°ΠΏΡΠΎΡ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΡ ΠΈΠ»ΠΈ ΠΏΠΎΠΈΡΠΊΠ° Π·Π°Π²Π΅ΡΡΠΈΠ»ΡΡ ΠΎΡΠΈΠ±ΠΊΠΎΠΉ.
+- ΠΠ°ΡΠ°Π»ΡΠ½Π°Ρ Π·Π°Π³ΡΡΠ·ΠΊΠ° Π³Π»Π°Π²Π½ΠΎΠ³ΠΎ ΠΎΠΊΠ½Π° ΠΏΠ΅ΡΠ΅Π½Π΅ΡΠ΅Π½Π° ΠΈΠ· UI-ΠΏΠΎΡΠΎΠΊΠ° Π² ΡΠΎΠ½ΠΎΠ²ΡΠΉ, ΡΡΠΎΠ±Ρ ΡΠΌΠ΅Π½ΡΡΠΈΡΡ ΠΏΠΎΠ΄Π²ΠΈΡΠ°Π½ΠΈΡ ΠΏΡΠΈ ΠΏΠ΅ΡΠ²ΠΎΠΌ ΠΏΠΎΠ»ΡΡΠ΅Π½ΠΈΠΈ Π΄Π°Π½Π½ΡΡ
.
+- Π‘ΡΠ΅Π½Π°ΡΠΈΠΈ Π°Π²ΡΠΎΡΠΈΠ·Π°ΡΠΈΠΈ ΡΠ΅ΠΏΠ΅ΡΡ ΠΊΠΎΡΡΠ΅ΠΊΡΠ½ΠΎ Π·Π°Π²Π΅ΡΡΠ°ΡΡΡΡ ΠΎΡΠΈΠ±ΠΊΠΎΠΉ, Π΅ΡΠ»ΠΈ ΡΠΈΡΡΠ΅ΠΌΠ½ΠΎΠ΅ Ρ
ΡΠ°Π½ΠΈΠ»ΠΈΡΠ΅ ΠΊΠ»ΡΡΠ΅ΠΉ ΠΈΠ»ΠΈ ΡΠ΅ΡΡΠΈΠΈ Π½Π΅Π΄ΠΎΡΡΡΠΏΠ½ΠΎ.
+- API-ΠΊΠ»ΠΈΠ΅Π½Ρ ΡΠ΅ΠΏΠ΅ΡΡ ΠΊΠΎΡΡΠ΅ΠΊΡΠ½ΠΎ ΠΎΠ±ΡΠ°Π±Π°ΡΡΠ²Π°Π΅Ρ ΡΡΠΏΠ΅ΡΠ½ΡΠ΅ ΠΏΡΡΡΡΠ΅ ΠΎΡΠ²Π΅ΡΡ, Π½Π°ΠΏΡΠΈΠΌΠ΅Ρ `204 No Content`.
+- ΠΡΠΈ Π²ΡΡ
ΠΎΠ΄Π΅ ΠΈΠ· Π°ΠΊΠΊΠ°ΡΠ½ΡΠ° ΡΠ΅ΠΏΠ΅ΡΡ ΡΠ²Π½ΠΎ ΠΎΡΠΊΠ»ΡΡΠ°Π΅ΡΡΡ auto-login Π΄Π»Ρ ΡΠ΅ΠΊΡΡΠ΅Π³ΠΎ ΠΏΡΠΎΡΠΈΠ»Ρ.
---
diff --git a/api/api_client.py b/api/api_client.py
index 7872ad0..fb446ab 100644
--- a/api/api_client.py
+++ b/api/api_client.py
@@ -28,10 +28,11 @@ 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
)
+ return res
def get_user_data(self, token: str) -> dict:
@@ -44,6 +45,25 @@ 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.
+ """
+ headers = {"Authorization": f"Bearer {token}"}
+ return self._request("PATCH",
+ url=self.base_url + f"/auth/user/{user_id}",
+ headers=headers,
+ json=data
+ )
# === Login ===
@@ -517,4 +537,6 @@ def _request(self, method: str, url: str, timeout: int = 10, **kwargs) -> dict:
"""Generic method for making API requests."""
request = self.session.request(method, url, timeout=timeout, **kwargs)
request.raise_for_status()
- return request.json()
\ No newline at end of file
+ if request.status_code == 204 or not request.content:
+ return {}
+ return request.json()
diff --git a/app.py b/app.py
index c65b090..f89933d 100644
--- a/app.py
+++ b/app.py
@@ -18,6 +18,8 @@
from utils.app_paths import get_local_data_dir
from core.updater import UpdateManager
from utils.file_utils import load_config
+from utils.settings_manager import SettingsManager
+from utils.whats_new_modal import WhatsNewDialog
os.environ.setdefault("QT_ENABLE_HIGHDPI_SCALING", "1")
@@ -25,7 +27,7 @@
os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough")
-APP_VERSION = "0.1.1"
+APP_VERSION = "0.2.0"
class Application:
"""
@@ -49,6 +51,8 @@ def __init__(self):
self.main_window = None
self.auth_window = None
+ self.settings_manager = None
+ self.current_user_id = None
# Setup logging
log_dir = get_local_data_dir()
@@ -70,7 +74,7 @@ def __init__(self):
self.logger = logging.getLogger("App")
# Set theme
- self.theme_manager = ThemeManagerInstance()
+ self.theme_manager = ThemeManagerInstance
self.theme_manager.switch_theme(theme=1)
# Check for updates
@@ -84,6 +88,22 @@ def __init__(self):
# NotificationService is initialized in AuthWindow
self.show_auth_window()
+ def init_user_services(self, user_id: int):
+ """Initializes user-specific services like SettingsManager."""
+ if not user_id or user_id == -1:
+ self.logger.warning("Cannot initialize user services: invalid user_id.")
+ # For guests or invalid IDs, we proceed without personalization.
+ self.theme_manager.switch_theme(theme=1) # Default to dark theme
+ return
+
+ self.current_user_id = user_id
+ self.settings_manager = SettingsManager(user_id=self.current_user_id)
+ self.theme_manager.set_settings_manager(self.settings_manager)
+
+ # Apply theme from settings
+ theme_id = self.settings_manager.get_setting("theme", 1) # Default to dark
+ self.theme_manager.switch_theme(theme=theme_id)
+
def check_for_updates(self):
"""Checks for updates using the GitHub repository specified in config."""
config = load_config()
@@ -94,6 +114,12 @@ def check_for_updates(self):
def attempt_auto_login(self):
"""Attempts to automatically log in the user using stored tokens."""
+ user_id = self.auth_model.get_last_logged_user_id()
+ if not user_id:
+ self.show_auth_window()
+ return
+
+ self.current_user_id = user_id
self.worker = APIWorker(self.auth_model.verify_token)
self.worker.finished.connect(self.on_token_verified)
self.worker.error.connect(self.on_token_verification_failed)
@@ -107,6 +133,8 @@ def on_token_verified(self, data):
Args:
data (dict): The data returned from the verification API.
"""
+ # Initialize user-specific services (settings, etc.)
+ self.init_user_services(self.current_user_id)
# Token is valid, show the main window
self.show_main_window(mode="auth")
@@ -167,7 +195,10 @@ def show_main_window(self, mode: str = "auth"):
mode (str): The mode to open the window in ('auth' or 'guest').
"""
try:
- self.main_window = MainWindow(mode=mode)
+ self.main_window = MainWindow(
+ mode=mode,
+ settings_manager=self.settings_manager
+ )
self.main_window.logout_requested.connect(self.on_logout_requested)
self.main_window.showMaximized()
@@ -175,6 +206,8 @@ def show_main_window(self, mode: str = "auth"):
self.auth_window.close()
self.auth_window = None
+ self.maybe_show_whats_new(mode=mode)
+
except Exception as e:
self.logger.critical(f"Error initializing MainWindow: {e}", exc_info=True)
if not self.auth_window:
@@ -187,16 +220,29 @@ def show_main_window(self, mode: str = "auth"):
message=msg
)
+ def maybe_show_whats_new(self, mode: str) -> None:
+ """Shows the release notes once per app version for authorized users."""
+ if mode != "auth" or not self.settings_manager or not self.main_window:
+ return
+
+ last_seen_version = self.settings_manager.get_setting("last_seen_whats_new_version", "")
+ if last_seen_version == APP_VERSION:
+ return
- def on_login_successful(self, mode: str):
+ dialog = WhatsNewDialog(parent=self.main_window, version=APP_VERSION)
+ dialog.exec_()
+ self.settings_manager.set_setting("last_seen_whats_new_version", APP_VERSION)
+
+
+ def on_login_successful(self, mode: str, user_id: int):
"""
Handles successful login event from AuthWindow.
Args:
mode (str): The mode of login ('auth' or 'guest').
+ user_id (int): The ID of the logged-in user (-1 for guest).
"""
- # Guest mode doesn't need verification.
- # Auth mode just came from a successful login, so it's already verified.
+ self.init_user_services(user_id=user_id)
self.show_main_window(mode=mode)
@@ -205,8 +251,11 @@ def on_logout_requested(self):
if self.main_window:
self.main_window.close()
self.main_window = None
- # On logout, clear the stored user data
+
+ # On logout, clear the stored user data and settings
self.auth_model.logout()
+ self.current_user_id = None
+ self.settings_manager = None
self.show_auth_window()
diff --git a/modules/auth/mvc/auth_controller.py b/modules/auth/mvc/auth_controller.py
index cfab9df..5a19bdd 100644
--- a/modules/auth/mvc/auth_controller.py
+++ b/modules/auth/mvc/auth_controller.py
@@ -37,7 +37,7 @@ class AuthController(QObject):
"""
# Successful login signal
- login_successful = pyqtSignal(str)
+ login_successful = pyqtSignal(str, int)
def __init__(
self,
@@ -93,15 +93,15 @@ def login_user(self, data: dict) -> None:
Args:
data (dict): The user data received from the API upon login.
"""
- # Save user data
- auto_login = self.view.login_page.get_auto_login_state()
- self.model.save_user(user_data=data, auto_login=auto_login)
+ try:
+ auto_login = self.view.login_page.get_auto_login_state()
+ user_id = self.model.save_user(user_data=data, auto_login=auto_login)
- # Clear lineedits
- self.view.login_page.clear_lineedits()
-
- # Switch to main window
- self.login_successful.emit("auth")
+ self.view.login_page.clear_lineedits()
+ self.login_successful.emit("auth", user_id)
+ except Exception as e:
+ msg = get_friendly_error_message(e)
+ NotificationService().show_toast("error", "ΠΡΠΈΠ±ΠΊΠ° Π²Ρ
ΠΎΠ΄Π°", msg)
@@ -117,20 +117,21 @@ def signup_user(self, user_data: dict) -> None:
Args:
user_data (dict): The user data received from the API upon signup.
"""
- # Save user data
- auto_login = self.view.signup_page.get_auto_login_state()
- self.model.save_user(user_data=user_data, auto_login=auto_login)
-
- # Clear lineedits
- self.view.signup_page.clear_lineedits()
-
- NotificationService().show_toast(
- notification_type="success",
- title="Π Π΅Π³ΠΈΡΡΡΠ°ΡΠΈΡ ΡΡΠΏΠ΅ΡΠ½Π°",
- message="ΠΠΊΠΊΠ°ΡΠ½Ρ ΡΠΎΠ·Π΄Π°Π½. ΠΠΎΠ±ΡΠΎ ΠΏΠΎΠΆΠ°Π»ΠΎΠ²Π°ΡΡ!"
- )
- # Switch to main window
- self.login_successful.emit("auth")
+ try:
+ auto_login = self.view.signup_page.get_auto_login_state()
+ user_id = self.model.save_user(user_data=user_data, auto_login=auto_login)
+
+ self.view.signup_page.clear_lineedits()
+
+ NotificationService().show_toast(
+ notification_type="success",
+ title="Π Π΅Π³ΠΈΡΡΡΠ°ΡΠΈΡ ΡΡΠΏΠ΅ΡΠ½Π°",
+ message="ΠΠΊΠΊΠ°ΡΠ½Ρ ΡΠΎΠ·Π΄Π°Π½. ΠΠΎΠ±ΡΠΎ ΠΏΠΎΠΆΠ°Π»ΠΎΠ²Π°ΡΡ!"
+ )
+ self.login_successful.emit("auth", user_id)
+ except Exception as e:
+ msg = get_friendly_error_message(e)
+ NotificationService().show_toast("error", "ΠΡΠΈΠ±ΠΊΠ° ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΠΈ", msg)
def signup(self, data: dict, email: str, password: str) -> None:
@@ -203,13 +204,15 @@ def switch_to_reset_password_page(self, data: dict) -> None:
Args:
data (dict): Data containing the reset token from the API.
"""
- # Save token
- self.model.save_token(token_name="reset_token", token="reset_token", data=data)
+ try:
+ self.model.save_token(token_name="reset_token", token="reset_token", data=data)
- # Switch to change password page
- page = self.view.pages.get("change_password_change_page", None)
- if page:
- self.view.switch_page(page=page)
+ page = self.view.pages.get("change_password_change_page", None)
+ if page:
+ self.view.switch_page(page=page)
+ except Exception as e:
+ msg = get_friendly_error_message(e)
+ NotificationService().show_toast("error", "ΠΡΠΈΠ±ΠΊΠ° Π²ΠΎΡΡΡΠ°Π½ΠΎΠ²Π»Π΅Π½ΠΈΡ", msg)
def open_email_confirm_modal_window(self, data, email: str) -> None:
@@ -285,7 +288,7 @@ def on_login_page_guest_button_clicked(self) -> None:
Emits the `login_successful` signal with 'guest' as the login type,
allowing guest access to the application.
"""
- self.login_successful.emit("guest")
+ self.login_successful.emit("guest", -1)
def on_login_page_create_button_clicked(self) -> None:
@@ -653,4 +656,4 @@ def _worker_error(self, exception: Exception) -> None:
notification_type="error",
title="ΠΡΠΈΠ±ΠΊΠ°",
message=message
- )
\ No newline at end of file
+ )
diff --git a/modules/auth/mvc/auth_model.py b/modules/auth/mvc/auth_model.py
index e77453b..ecf4bd5 100644
--- a/modules/auth/mvc/auth_model.py
+++ b/modules/auth/mvc/auth_model.py
@@ -125,7 +125,7 @@ def reset_password(self, password: str) -> dict:
"""=== User ==="""
- def save_user(self, user_data: dict, auto_login: bool) -> None:
+ def save_user(self, user_data: dict, auto_login: bool) -> int | None:
"""Saves user data and tokens to local files and keyring.
Args:
@@ -135,7 +135,7 @@ def save_user(self, user_data: dict, auto_login: bool) -> None:
# Get user data
user = user_data.get("user", None)
if not user:
- return
+ return None
user_id = user.get("id", None)
@@ -177,6 +177,8 @@ def save_user(self, user_data: dict, auto_login: bool) -> None:
with open(self.LOCAL_DIR_LAST_LOGGED, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
+
+ return user_id
"""=== Login ==="""
@@ -230,6 +232,22 @@ def get_flag(path: Path) -> bool:
return get_flag(path=user_data_file_path)
+ def get_last_logged_user_id(self) -> int | None:
+ """
+ Retrieves the user ID of the last successfully logged-in user.
+
+ Returns:
+ The user ID as an integer, or None if not found.
+ """
+ last_logged_data = read_json(self.LOCAL_DIR_LAST_LOGGED)
+ if not last_logged_data:
+ return None
+
+ user_id = last_logged_data.get("user_id")
+
+ return user_id if isinstance(user_id, int) else None
+
+
def logout(self) -> None:
"""Logs out the current user.
@@ -241,6 +259,16 @@ def delete_file(file_path: Path) -> None:
if file_path.exists():
file_path.unlink()
+ def disable_auto_login(user_id: int) -> None:
+ profile_path = self.APP_DIR / "Profiles" / f"user_data_{user_id}.json"
+ profile_data = read_json(profile_path)
+ if not isinstance(profile_data, dict):
+ return
+
+ profile_data["auto_login"] = False
+ with open(profile_path, "w", encoding="utf-8") as f:
+ json.dump(profile_data, f, indent=4, ensure_ascii=False)
+
# Get last user id
last_logged_data = read_json(self.LOCAL_DIR_LAST_LOGGED)
if not last_logged_data:
@@ -258,6 +286,8 @@ def delete_file(file_path: Path) -> None:
except keyring_errors.PasswordDeleteError:
logging.info(f"Tokens for user_id {user_id} not found in keyring, skipping deletion.")
+ disable_auto_login(user_id)
+
# Delete the last logged user file to disable auto-login on next start
delete_file(self.LOCAL_DIR_LAST_LOGGED)
logging.info("Last logged user file deleted to disable auto-login. User is fully logged out.")
@@ -358,7 +388,9 @@ def save_token(self, token_name: str, token: str, data: dict) -> None:
password=data.get(token, None)
)
- except keyring_errors.PasswordSetError:
+ except keyring_errors.PasswordSetError as e:
logging.error(msg="PasswordSetError", exc_info=True)
+ raise RuntimeError("ΠΠ΅ ΡΠ΄Π°Π»ΠΎΡΡ ΡΠΎΡ
ΡΠ°Π½ΠΈΡΡ Π΄Π°Π½Π½ΡΠ΅ ΡΠ΅ΡΡΠΈΠΈ Π² ΡΠΈΡΡΠ΅ΠΌΠ½ΠΎΠ΅ Ρ
ΡΠ°Π½ΠΈΠ»ΠΈΡΠ΅.") from e
except Exception as e:
- logging.error(msg=e, exc_info=True)
\ No newline at end of file
+ logging.error(msg=e, exc_info=True)
+ raise RuntimeError("ΠΠ΅ ΡΠ΄Π°Π»ΠΎΡΡ ΡΠΎΡ
ΡΠ°Π½ΠΈΡΡ Π΄Π°Π½Π½ΡΠ΅ ΡΠ΅ΡΡΠΈΠΈ Π² ΡΠΈΡΡΠ΅ΠΌΠ½ΠΎΠ΅ Ρ
ΡΠ°Π½ΠΈΠ»ΠΈΡΠ΅.") from e
diff --git a/modules/auth/mvc/auth_view.py b/modules/auth/mvc/auth_view.py
index 784052a..8b2c6e3 100644
--- a/modules/auth/mvc/auth_view.py
+++ b/modules/auth/mvc/auth_view.py
@@ -355,7 +355,7 @@ def __init__(self, ui: AuthWindow_UI):
ui (AuthWindow_UI): The UI object generated from Qt Designer.
"""
self.ui = ui
- self.theme_manager = ThemeManagerInstance()
+ self.theme_manager = ThemeManagerInstance
# Setting icon for logo label
self.ui.logo_label.set_icon_paths(
diff --git a/modules/document_editor/mvc/document_editor_view.py b/modules/document_editor/mvc/document_editor_view.py
index 7e2218a..813b8fa 100644
--- a/modules/document_editor/mvc/document_editor_view.py
+++ b/modules/document_editor/mvc/document_editor_view.py
@@ -473,7 +473,7 @@ def __init__(self, container):
self.ui.setupUi(container)
container.setObjectName("editorContainer")
- self.theme_manager = ThemeManagerInstance()
+ self.theme_manager = ThemeManagerInstance
self.ui_config = self._load_ui_config()
icons_config = self.ui_config.get("icons", {})
diff --git a/modules/main/main_module.py b/modules/main/main_module.py
index 2d6d366..db01c68 100644
--- a/modules/main/main_module.py
+++ b/modules/main/main_module.py
@@ -7,22 +7,25 @@
from PyQt5.QtWidgets import QMainWindow
+from utils.settings_manager import SettingsManager
+
class MainWindow(QMainWindow):
logout_requested = pyqtSignal()
- def __init__(self, mode: str = "guest") -> None:
+ def __init__(self, mode: str = "guest", settings_manager: SettingsManager | None = None) -> None:
super().__init__()
self.setAttribute(Qt.WA_DeleteOnClose)
# Application work mode ('guest' - Guest mode, 'auth' - Authorized mode)
self.mode = mode
+ self.settings_manager = settings_manager
# UI Initialization
self.ui = MainWindow_UI()
self.ui.setupUi(self)
# Set theme
- self.theme_manager = ThemeManagerInstance()
+ self.theme_manager = ThemeManagerInstance
# MVC Initialization
self.model = MainModel(mode=self.mode)
@@ -31,7 +34,8 @@ def __init__(self, mode: str = "guest") -> None:
model=self.model,
view=self.view,
window=self,
- mode=self.mode
+ mode=self.mode,
+ settings_manager=self.settings_manager
)
# Logout signal
self.controller.logout_requested.connect(self.logout_requested.emit)
\ No newline at end of file
diff --git a/modules/main/mvc/main_controller.py b/modules/main/mvc/main_controller.py
index 2b9ee9f..c00ccde 100644
--- a/modules/main/mvc/main_controller.py
+++ b/modules/main/mvc/main_controller.py
@@ -19,11 +19,14 @@
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
+from utils.settings_manager import SettingsManager
+
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
@@ -42,7 +45,8 @@ def __init__(
model: MainModel,
view: MainView,
window: QMainWindow,
- mode: str = "guest"
+ mode: str = "guest",
+ settings_manager: SettingsManager | None = None
) -> None:
"""Initializes the MainController.
@@ -51,16 +55,19 @@ def __init__(
view: The main UI view.
window: The main QMainWindow instance.
mode: The application mode ('guest' or 'auth').
+ settings_manager: The manager for user-specific settings.
"""
super().__init__()
self.model = model
self.view = view
self.window = window
self.mode = mode
+ self.settings_manager = settings_manager
self.editor_window = None
self.current_documents = []
self.current_search_worker = None
self.current_load_worker = None
+ self.initial_load_worker = None
self.active_workers = set()
self.is_updating_data = False
@@ -111,7 +118,11 @@ def _on_search_lineedit_text_changed(self) -> None:
def _on_search_filters_changed(self, checked: bool) -> None:
- """Handles search filter changes."""
+ """Handles search filter changes and saves them."""
+ filters = self.view.get_search_filters()
+ if self.settings_manager:
+ self.settings_manager.set_setting("search_filters", filters)
+
if self.view.get_search_text():
self._on_search_lineedit_text_changed()
@@ -369,6 +380,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:
@@ -725,6 +789,7 @@ def _on_search_error(self, error: Exception) -> None:
"""Handles search errors."""
if self.sender() != self.current_search_worker:
return
+ self.current_search_worker = None
self._handle_error(error, "ΠΡΠΈΠ±ΠΊΠ° ΠΏΠΎΠΈΡΠΊΠ°")
def _cleanup_worker(self) -> None:
@@ -741,32 +806,92 @@ def _cleanup_worker(self) -> None:
def _init_ui(self) -> None:
"""Initializes the UI with default data."""
- self._load_sidebar_data()
self.view.set_profile_mode(self.mode)
- # Set user data
- if self.mode == "auth":
- user_data = self.model.get_user_data()
+ # Load and apply settings
+ if self.settings_manager:
+ search_filters = self.settings_manager.get_setting("search_filters")
+ if search_filters:
+ self.view.set_search_filters(search_filters)
+ self._load_initial_data()
+
+ def _load_initial_data(self) -> None:
+ """Loads the initial sidebar and profile data without blocking the UI thread."""
+ worker = APIWorker(self.model.load_initial_data)
+ self.initial_load_worker = worker
+ self.active_workers.add(worker)
+ worker.finished.connect(self._on_initial_data_loaded)
+ worker.error.connect(self._on_initial_data_error)
+ worker.finished.connect(self._cleanup_worker)
+ worker.error.connect(self._cleanup_worker)
+ worker.start()
- if user_data:
- username = user_data.get("username")
- department = 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 "ΠΡΠ΄Π΅Π» Π½Π΅ Π²ΡΠ±ΡΠ°Π½")
+ def _on_initial_data_loaded(self, data: dict) -> None:
+ if self.sender() != self.initial_load_worker:
+ return
+ self.initial_load_worker = None
+ self.model.departments = data.get("departments", [])
+ self.model.categories = data.get("categories", [])
+ self.model.current_department_id = self.model.departments[0]["id"] if self.model.departments else None
+ self._load_sidebar_data()
+
+ if self.mode == "auth":
+ self._apply_user_data(data.get("user_data"))
else:
self.view.set_username("ΠΠΎΡΡΡ")
self.view.set_user_department("ΠΠΎΠΉΠ΄ΠΈΡΠ΅ Π² Π°ΠΊΠΊΠ°ΡΠ½Ρ")
+ if 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))
- # Set documents data
self._update_create_category_state()
self._update_create_document_state()
+ def _on_initial_data_error(self, error: Exception) -> None:
+ if self.sender() != self.initial_load_worker:
+ return
+
+ self.initial_load_worker = None
+ if self.mode == "auth":
+ self.view.set_username("ΠΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»Ρ")
+ self.view.set_user_department("ΠΠ΅ ΡΠ΄Π°Π»ΠΎΡΡ Π·Π°Π³ΡΡΠ·ΠΈΡΡ Π΄Π°Π½Π½ΡΠ΅")
+ else:
+ self.view.set_username("ΠΠΎΡΡΡ")
+ self.view.set_user_department("ΠΠΎΠΉΠ΄ΠΈΡΠ΅ Π² Π°ΠΊΠΊΠ°ΡΠ½Ρ")
+ self._handle_error(error, "ΠΡΠΈΠ±ΠΊΠ° Π·Π°Π³ΡΡΠ·ΠΊΠΈ Π΄Π°Π½Π½ΡΡ
")
+
+ def _apply_user_data(self, user_data: dict | None) -> None:
+ """Applies the loaded user data to the UI and restores sidebar selection."""
+ if not user_data:
+ return
+
+ username = user_data.get("username")
+ 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_name if department_name else "ΠΡΠ΄Π΅Π» Π½Π΅ Π²ΡΠ±ΡΠ°Π½")
+
+ 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 user_dept_id:
+ QTimer.singleShot(50, lambda: self.view.select_department(user_dept_id))
+ 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))
+
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)
@@ -864,20 +989,26 @@ def _update_categories_list(self) -> None:
def _update_documents_list(self) -> None:
"""Updates the documents list based on the current category."""
self.model.current_document_id = None
-
- # Reset pagination
+
+ if self.model.current_category_id is None:
+ self.offset = 0
+ self.has_more = False
+ self.current_documents = []
+ self.search_results_all = []
+ self.pending_documents_to_add = []
+ self.view.clear_documents_table()
+ self.view.set_finded_counter(0)
+ self.view.set_active_search_tags([])
+ self.view.set_export_print_enabled(False)
+ return
+
self.offset = 0
self.has_more = True
- self.current_documents = []
- self.view.clear_documents_table()
- self.view.set_finded_counter(0)
- self.view.set_active_search_tags([])
- self.view.set_export_print_enabled(False)
-
- # Reset loading state to ensure we can load new data
+
self.is_loading = False
self.current_load_worker = None
-
+ self.pending_documents_to_add = []
+
self._load_more_documents()
@@ -923,6 +1054,7 @@ def _on_load_more_finished(self, docs: list) -> None:
self.is_loading = False
self.current_load_worker = None
+ self.search_results_all = []
# Prevent updating UI if search is active (stale request)
if self.view.get_search_text():
@@ -939,6 +1071,7 @@ def _on_load_more_finished(self, docs: list) -> None:
# Instead of one large, blocking update, we add the data in chunks
# to keep the UI responsive.
self.view.clear_documents_table()
+ self.view.set_active_search_tags([])
self.pending_documents_to_add = list(self.current_documents)
# Start the chunked update. Use a single shot timer to yield to the event loop.
QTimer.singleShot(0, self._add_document_chunk)
@@ -969,6 +1102,7 @@ def _on_load_more_error(self, error: Exception) -> None:
return
self.is_loading = False
+ self.current_load_worker = None
self._handle_error(error, "ΠΡΠΈΠ±ΠΊΠ° Π·Π°Π³ΡΡΠ·ΠΊΠΈ Π΄ΠΎΠΊΡΠΌΠ΅Π½ΡΠΎΠ²")
@@ -1104,4 +1238,4 @@ def _handle_error(self, e: Exception, title: str = "ΠΡΠΈΠ±ΠΊΠ°") -> None:
notification_type="error",
title=title,
message=msg
- )
\ No newline at end of file
+ )
diff --git a/modules/main/mvc/main_model.py b/modules/main/mvc/main_model.py
index 520a013..0729f99 100644
--- a/modules/main/mvc/main_model.py
+++ b/modules/main/mvc/main_model.py
@@ -24,10 +24,10 @@ def __init__(self, mode: str = "guest") -> None:
self.LOCAL_DIR_LAST_LOGGED = self.LOCAL_DIR / "last_logged.json"
# Sidebar data
- self.departments = self._get_departments()
- self.current_department_id = self.departments[0]["id"] if self.departments else None
- self.categories = self._get_categories()
-
+ self.departments = []
+ self.current_department_id = None
+ self.categories = []
+
self.current_category_id = None
# Table data
@@ -46,6 +46,17 @@ def get_user_data(self) -> dict | None:
data = self.api.get_user_data(token)
return data
+
+ def load_initial_data(self) -> dict:
+ """Loads the sidebar data and current user profile for the startup flow."""
+ departments = self._get_departments()
+ categories = self._get_categories()
+ user_data = self.get_user_data() if self.mode == "auth" else None
+ return {
+ "departments": departments,
+ "categories": categories,
+ "user_data": user_data,
+ }
def get_document(self, document_id: int) -> dict:
@@ -59,6 +70,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(
@@ -141,6 +172,7 @@ def delete_category(self, category_id: int):
def refresh_data(self) -> None:
"""Refreshes the data from the API."""
self.departments = self._get_departments()
+ self.current_department_id = self.departments[0]["id"] if self.departments else None
self.categories = self._get_categories()
@@ -308,4 +340,4 @@ def _get_departments(self) -> list[dict]:
def _get_categories(self) -> list[dict]:
categories = self._make_authorized_request(self.api.get_categories)
return categories["categories"]
-
\ No newline at end of file
+
diff --git a/modules/main/mvc/main_view.py b/modules/main/mvc/main_view.py
index 13eedff..cf228a5 100644
--- a/modules/main/mvc/main_view.py
+++ b/modules/main/mvc/main_view.py
@@ -1,7 +1,7 @@
import json
from pathlib import Path
-from PyQt5.QtWidgets import QWidget, QLabel, QLineEdit, QFrame, QHBoxLayout, QAction
+from PyQt5.QtWidgets import QWidget, QLabel, QLineEdit, QFrame, QHBoxLayout, QAction, QPushButton
from PyQt5.QtGui import QIcon
from PyQt5.QtCore import QEvent, Qt, QObject, QPoint, QItemSelectionModel
@@ -349,6 +349,13 @@ def get_search_filters(self) -> dict:
"exact_match": self.action_exact_match.isChecked()
}
+ def set_search_filters(self, filters: dict) -> None:
+ """Sets the state of search filters from a dictionary."""
+ self.action_search_pages.setChecked(filters.get("include_pages", True))
+ self.action_search_name.setChecked(filters.get("search_by_name", True))
+ self.action_search_code.setChecked(filters.get("search_by_code", True))
+ self.action_exact_match.setChecked(filters.get("exact_match", False))
+
def _setup_filter_menu(self) -> None:
"""Configures the search filter menu."""
@@ -806,15 +813,14 @@ def __init__(self, ui: MainWindow_UI) -> None:
"""
super().__init__()
self.ui = ui
- self.theme_manager = ThemeManagerInstance()
+ self.theme_manager = ThemeManagerInstance
self.ui_config = self._load_ui_config()
self.current_mode = "guest"
# 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)
@@ -923,7 +929,6 @@ def _setup_profile_menu(self) -> None:
)
# Disable unimplemented actions
- self.user_profile_action.setVisible(False)
self.settings_action.setVisible(False)
@@ -1010,29 +1015,31 @@ def _replace_theme_button(self) -> None:
This method removes the placeholder button from the layout and inserts
the custom ThemeSwitch widget in its place, preserving the layout index.
"""
- # Getting the parent container and its layout
parent = self.ui.navbar_actions_frame
layout = parent.layout()
-
- # We find the old button and its position
+ if not layout:
+ return
+
old_button = self.ui.theme_pushButton
- if old_button:
- index = layout.indexOf(old_button)
- layout.removeWidget(old_button)
- old_button.deleteLater()
-
- # Creating and inserting a new switcher in the same place
- self.ui.theme_pushButton = ThemeSwitch(parent)
+ index = layout.indexOf(old_button) if old_button else -1
+ if index < 0:
+ index = 1 # Between search block and create block in navbar_actions_frame.
- # Copying properties from the old button if needed, or setting defaults
- self.ui.theme_pushButton.setMinimumSize(125, 42)
- self.ui.theme_pushButton.setObjectName("themeSwitch")
-
- # Setting the initial state
- is_dark = self.theme_manager.current_theme_id != "0"
- self.ui.theme_pushButton.setChecked(is_dark)
-
- layout.insertWidget(index, self.ui.theme_pushButton)
+ # Remove any leftover placeholder buttons from .ui to avoid "theme" rectangle artifacts.
+ for placeholder in parent.findChildren(QPushButton, "theme_pushButton"):
+ layout.removeWidget(placeholder)
+ placeholder.hide()
+ placeholder.setParent(None)
+ placeholder.deleteLater()
+
+ self.ui.theme_pushButton = ThemeSwitch(parent)
+ self.ui.theme_pushButton.setMinimumSize(125, 42)
+ self.ui.theme_pushButton.setObjectName("themeSwitch")
+
+ is_dark = self.theme_manager.current_theme_id != "0"
+ self.ui.theme_pushButton.setChecked(is_dark)
+
+ layout.insertWidget(index, self.ui.theme_pushButton)
def _replace_profile_icon_label(self) -> None:
@@ -1083,6 +1090,10 @@ def get_search_filters(self) -> dict:
"""Returns the current search filters."""
return self.navbar.get_search_filters()
+ def set_search_filters(self, filters: dict) -> None:
+ """Sets the search filters in the navbar."""
+ self.navbar.set_search_filters(filters)
+
def get_search_text(self) -> str:
"""Returns the search line edit text."""
@@ -1285,6 +1296,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.
@@ -1390,4 +1411,4 @@ def connect_documents_scroll(self, handler) -> None:
Args:
handler: The callback function.
"""
- self.documents_list.connect_scroll_changed(handler)
\ No newline at end of file
+ self.documents_list.connect_scroll_changed(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/screenshots/document_editor_files.png b/screenshots/document_editor_files.png
new file mode 100644
index 0000000..99804ca
Binary files /dev/null and b/screenshots/document_editor_files.png differ
diff --git a/tests/test_apiclient.py b/tests/test_apiclient.py
index 01edb08..5c078a7 100644
--- a/tests/test_apiclient.py
+++ b/tests/test_apiclient.py
@@ -146,6 +146,30 @@ def test_delete_document(self, client):
headers={"Authorization": f"Bearer {token}"}
)
+ def test_request_returns_empty_dict_for_204(self, client):
+ with patch.object(client.session, "request") as mock_request:
+ mock_response = Mock()
+ mock_response.status_code = 204
+ mock_response.content = b""
+ mock_response.raise_for_status = Mock()
+ mock_request.return_value = mock_response
+
+ result = client.delete_document(123, "auth_token")
+
+ assert result == {}
+
+ def test_request_returns_empty_dict_for_empty_body(self, client):
+ with patch.object(client.session, "request") as mock_request:
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.content = b""
+ mock_response.raise_for_status = Mock()
+ mock_request.return_value = mock_response
+
+ result = client._request("DELETE", f"{self.BASE_URL}/app/files/1")
+
+ assert result == {}
+
def test_search_data(self, client):
token = "auth_token"
query = "test query"
@@ -434,4 +458,4 @@ def test_update_document(self, client):
assert result == expected
mock_request.assert_called_once_with(
"PATCH", f"{self.BASE_URL}/app/documents/{doc_id}", timeout=10, headers={"Authorization": f"Bearer {token}"}, json=data
- )
\ No newline at end of file
+ )
diff --git a/tests/test_app.py b/tests/test_app.py
index de43c3f..4ae1b10 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -11,6 +11,7 @@ def mock_dependencies(self):
patch("app.AuthWindow") as MockAuthWindow, \
patch("app.MainWindow") as MockMainWindow, \
patch("app.AuthModel") as MockAuthModel, \
+ patch("app.WhatsNewDialog") as MockWhatsNewDialog, \
patch("app.ThemeManagerInstance"), \
patch("app.NotificationService"), \
patch("app.UpdateManager") as MockUpdateManager, \
@@ -35,7 +36,8 @@ def mock_dependencies(self):
"MainWindow": MockMainWindow,
"AuthModel": auth_model,
"APIWorker": MockAPIWorker,
- "UpdateManager": MockUpdateManager
+ "UpdateManager": MockUpdateManager,
+ "WhatsNewDialog": MockWhatsNewDialog,
}
def test_init_no_auto_login(self, mock_dependencies):
@@ -75,7 +77,10 @@ def test_token_verified(self, mock_dependencies):
app.on_token_verified({})
# Should create and show main window
- mock_dependencies["MainWindow"].assert_called_with(mode="auth")
+ mock_dependencies["MainWindow"].assert_called_with(
+ mode="auth",
+ settings_manager=app.settings_manager
+ )
mock_dependencies["MainWindow"].return_value.showMaximized.assert_called_once()
# Should close auth window
auth_window_mock.close.assert_called_once()
@@ -135,4 +140,36 @@ def test_logout_requested(self, mock_dependencies):
main_window_mock.close.assert_called_once()
assert app.main_window is None
app.auth_model.logout.assert_called_once()
- mock_dependencies["AuthWindow"].return_value.show.assert_called()
\ No newline at end of file
+ mock_dependencies["AuthWindow"].return_value.show.assert_called()
+
+ def test_show_main_window_displays_whats_new_once_for_authorized_user(self, mock_dependencies):
+ app = Application()
+ app.settings_manager = Mock()
+ app.settings_manager.get_setting.return_value = ""
+
+ app.show_main_window(mode="auth")
+
+ mock_dependencies["WhatsNewDialog"].assert_called_once_with(
+ parent=app.main_window,
+ version=APP_VERSION
+ )
+ mock_dependencies["WhatsNewDialog"].return_value.exec_.assert_called_once()
+ app.settings_manager.set_setting.assert_called_once_with("last_seen_whats_new_version", APP_VERSION)
+
+ def test_show_main_window_skips_whats_new_if_already_seen(self, mock_dependencies):
+ app = Application()
+ app.settings_manager = Mock()
+ app.settings_manager.get_setting.return_value = APP_VERSION
+
+ app.show_main_window(mode="auth")
+
+ mock_dependencies["WhatsNewDialog"].assert_not_called()
+ app.settings_manager.set_setting.assert_not_called()
+
+ def test_show_main_window_skips_whats_new_for_guest(self, mock_dependencies):
+ app = Application()
+ app.settings_manager = Mock()
+
+ app.show_main_window(mode="guest")
+
+ mock_dependencies["WhatsNewDialog"].assert_not_called()
diff --git a/tests/test_auth.py b/tests/test_auth.py
index 8cacb54..c77ca76 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -59,18 +59,27 @@ def test_save_user(self, model):
# Check file writing (user data and last logged)
assert mock_open.call_count >= 2
+ def test_save_token_raises_when_keyring_write_fails(self, model):
+ with patch("modules.auth.mvc.auth_model.keyring.set_password", side_effect=Exception("keyring fail")):
+ with pytest.raises(RuntimeError):
+ model.save_token("access_token_1", "access_token", {"access_token": "acc"})
+
def test_logout(self, model):
"""Test logout clears keyring and deletes last logged file."""
# Configure the mock path object to simulate file existence
model.LOCAL_DIR_LAST_LOGGED.exists.return_value = True
- with patch("modules.auth.mvc.auth_model.read_json", return_value={"user_id": 1}), \
+ with patch("modules.auth.mvc.auth_model.read_json", side_effect=[{"user_id": 1}, {"auto_login": True}]), \
patch("modules.auth.mvc.auth_model.keyring.delete_password") as mock_keyring_del:
- model.logout()
+ with patch("builtins.open", new_callable=MagicMock) as mock_open, \
+ patch("json.dump") as mock_json_dump:
+ model.logout()
assert mock_keyring_del.call_count == 2
+ assert mock_open.call_count >= 1
+ mock_json_dump.assert_called()
model.LOCAL_DIR_LAST_LOGGED.unlink.assert_called_once()
@@ -133,6 +142,7 @@ def test_login_success_callback(self, controller):
"""Test login success callback saves user and emits signal."""
data = {"user": {"id": 1}}
controller.view.login_page.get_auto_login_state.return_value = True
+ controller.model.save_user.return_value = 1
# Mock signal
mock_signal = Mock()
@@ -142,7 +152,32 @@ def test_login_success_callback(self, controller):
controller.model.save_user.assert_called_once_with(user_data=data, auto_login=True)
controller.view.login_page.clear_lineedits.assert_called_once()
- mock_signal.assert_called_once_with("auth")
+ mock_signal.assert_called_once_with("auth", 1)
+
+ def test_login_success_callback_handles_save_error(self, controller):
+ data = {"user": {"id": 1}}
+ controller.view.login_page.get_auto_login_state.return_value = True
+ controller.model.save_user.side_effect = RuntimeError("keyring fail")
+
+ mock_signal = Mock()
+ controller.login_successful.connect(mock_signal)
+
+ with patch("modules.auth.mvc.auth_controller.NotificationService") as MockNotify:
+ controller.login_user(data)
+
+ mock_signal.assert_not_called()
+ controller.view.login_page.clear_lineedits.assert_not_called()
+ MockNotify.return_value.show_toast.assert_called_once()
+
+ def test_reset_password_page_switch_handles_save_error(self, controller):
+ controller.view.pages = {"change_password_change_page": Mock()}
+ controller.model.save_token.side_effect = RuntimeError("keyring fail")
+
+ with patch("modules.auth.mvc.auth_controller.NotificationService") as MockNotify:
+ controller.switch_to_reset_password_page({"reset_token": "token"})
+
+ controller.view.switch_page.assert_not_called()
+ MockNotify.return_value.show_toast.assert_called_once()
def test_signup_flow(self, controller):
@@ -180,4 +215,4 @@ def test_validation_login_invalid(self, controller):
controller.view.login_page.get_email.return_value = "invalid"
controller.on_login_page_lineedits_changed()
- controller.view.login_page.update_submit_button_state.assert_not_called()
\ No newline at end of file
+ controller.view.login_page.update_submit_button_state.assert_not_called()
diff --git a/tests/test_main.py b/tests/test_main.py
index 570b7a8..76bf886 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -20,21 +20,45 @@ def controller(self, qapp):
# Mock NotificationService and QTimer to avoid side effects
with patch("modules.main.mvc.main_controller.NotificationService"), \
- patch("modules.main.mvc.main_controller.QTimer"):
+ patch("modules.main.mvc.main_controller.QTimer"), \
+ patch("modules.main.mvc.main_controller.APIWorker"):
ctrl = MainController(model, view, window, mode="auth")
yield ctrl
def test_init_ui(self, controller):
- """Test UI initialization loads sidebar and user data."""
- controller.model.get_user_data.return_value = {"username": "User", "department": "IT"}
-
- controller._init_ui()
-
+ """Test UI initialization starts background loading instead of blocking."""
+ with patch("modules.main.mvc.main_controller.APIWorker") as MockWorker:
+ controller._init_ui()
+
+ MockWorker.assert_called_once_with(controller.model.load_initial_data)
+ MockWorker.return_value.start.assert_called_once()
+
+ def test_initial_data_loaded(self, controller):
+ """Test applying startup data after the background worker finishes."""
+ controller.initial_load_worker = Mock()
+ with patch.object(controller, "sender", return_value=controller.initial_load_worker):
+ controller._on_initial_data_loaded({
+ "departments": [{"id": 1, "name": "Dept 1", "documents_count": 5}],
+ "categories": [{"id": 10, "name": "Cat 1", "group_id": 1, "documents_count": 2}],
+ "user_data": {"username": "User", "department": "Dept 1"},
+ })
+
controller.view.update_departments.assert_called()
controller.view.update_categories.assert_called()
controller.view.set_username.assert_called_with(name="User")
+ def test_initial_data_error(self, controller):
+ """Test startup error handling leaves the window in a safe state."""
+ controller.initial_load_worker = Mock()
+ with patch.object(controller, "sender", return_value=controller.initial_load_worker), \
+ patch.object(controller, "_handle_error") as mock_handle_error:
+ controller._on_initial_data_error(Exception("boom"))
+
+ controller.view.set_username.assert_called_with("ΠΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»Ρ")
+ controller.view.set_user_department.assert_called_with("ΠΠ΅ ΡΠ΄Π°Π»ΠΎΡΡ Π·Π°Π³ΡΡΠ·ΠΈΡΡ Π΄Π°Π½Π½ΡΠ΅")
+ mock_handle_error.assert_called_once()
+
def test_search_trigger(self, controller):
"""Test that typing in search triggers the search worker."""
@@ -62,7 +86,6 @@ def test_department_selection(self, controller):
assert controller.model.current_department_id == 2
assert controller.model.current_category_id is None
- controller.view.clear_documents_table.assert_called()
controller.view.update_categories.assert_called()
@@ -106,6 +129,48 @@ def test_load_more_documents(self, controller):
assert kwargs['offset'] == 0
assert kwargs['limit'] == controller.limit
+ def test_update_documents_list_keeps_previous_table_until_success(self, controller):
+ """Reloading the table should not clear the old data before the new response arrives."""
+ controller.model.current_category_id = 10
+ controller.current_documents = [{"id": 1, "name": "Doc"}]
+ controller.view.get_search_text.return_value = ""
+ controller.view.clear_documents_table.reset_mock()
+
+ with patch.object(controller, "_load_more_documents") as mock_load_more:
+ controller._update_documents_list()
+
+ assert controller.current_documents == [{"id": 1, "name": "Doc"}]
+ controller.view.clear_documents_table.assert_not_called()
+ mock_load_more.assert_called_once()
+
+ def test_load_more_error_keeps_existing_documents_visible(self, controller):
+ """A failed reload should not wipe the currently visible documents."""
+ controller.current_documents = [{"id": 1, "name": "Existing"}]
+ controller.current_load_worker = Mock()
+ controller.view.clear_documents_table.reset_mock()
+
+ with patch.object(controller, "sender", return_value=controller.current_load_worker), \
+ patch.object(controller, "_handle_error") as mock_handle_error:
+ controller._on_load_more_error(Exception("boom"))
+
+ assert controller.current_documents == [{"id": 1, "name": "Existing"}]
+ controller.view.clear_documents_table.assert_not_called()
+ mock_handle_error.assert_called_once()
+
+ def test_search_error_keeps_existing_documents_visible(self, controller):
+ """A failed search should leave the previously rendered table intact."""
+ controller.current_documents = [{"id": 1, "name": "Existing"}]
+ controller.current_search_worker = Mock()
+ controller.view.clear_documents_table.reset_mock()
+
+ with patch.object(controller, "sender", return_value=controller.current_search_worker), \
+ patch.object(controller, "_handle_error") as mock_handle_error:
+ controller._on_search_error(Exception("boom"))
+
+ assert controller.current_documents == [{"id": 1, "name": "Existing"}]
+ controller.view.clear_documents_table.assert_not_called()
+ mock_handle_error.assert_called_once()
+
def test_create_department_flow(self, controller):
"""Test create department dialog and model update."""
@@ -178,4 +243,4 @@ def test_logout(self, controller):
mock_slot = Mock()
controller.logout_requested.connect(mock_slot)
controller._on_logout_clicked()
- mock_slot.assert_called_once()
\ No newline at end of file
+ mock_slot.assert_called_once()
diff --git a/tests/test_notifications.py b/tests/test_notifications.py
index c8808ee..2128bf3 100644
--- a/tests/test_notifications.py
+++ b/tests/test_notifications.py
@@ -15,7 +15,7 @@ def reset_singleton(self):
def test_initialization(self, mock_tm):
"""Test service initialization and config loading."""
# Setup config mock
- mock_tm.return_value.notification_config = {"spacing": 15, "toast_duration": 3000}
+ mock_tm.notification_config = {"spacing": 15, "toast_duration": 3000}
service = NotificationService()
diff --git a/tests/test_settings_manager.py b/tests/test_settings_manager.py
new file mode 100644
index 0000000..9eeedab
--- /dev/null
+++ b/tests/test_settings_manager.py
@@ -0,0 +1,13 @@
+from unittest.mock import MagicMock, patch
+
+from utils.settings_manager import SettingsManager
+
+
+class TestSettingsManager:
+ def test_default_settings_include_whats_new_version(self):
+ with patch("utils.settings_manager.get_app_data_dir") as mock_app_dir:
+ mock_app_dir.return_value = MagicMock()
+
+ manager = SettingsManager(user_id=1)
+
+ assert manager.get_default_settings()["last_seen_whats_new_version"] == ""
diff --git a/tests/test_theme_manager.py b/tests/test_theme_manager.py
index df2e109..824688a 100644
--- a/tests/test_theme_manager.py
+++ b/tests/test_theme_manager.py
@@ -152,4 +152,21 @@ def test_auto_compile_on_missing_qss(self, mock_configs):
light_qss = themes_dir / "light.qss"
assert light_qss.exists()
content = light_qss.read_text(encoding="utf-8")
- assert "background: #FFFFFF;" in content
\ No newline at end of file
+ assert "background: #FFFFFF;" in content
+
+ def test_compile_whats_new_styles(self, mock_configs):
+ templates_dir = mock_configs / "ui/styles/templates"
+ templates_dir.mkdir(parents=True)
+ (templates_dir / "light.j2").write_text("#whatsNewContainer { background: {{ color }}; }", encoding="utf-8")
+
+ themes_dir = mock_configs / "ui/styles/themes"
+ themes_dir.mkdir(parents=True, exist_ok=True)
+
+ with patch("utils.theme_manager.get_app_root", return_value=mock_configs):
+ tm = ThemeManager()
+ success = tm._compile_all_themes()
+
+ assert success is True
+ light_qss = themes_dir / "light.qss"
+ assert light_qss.exists()
+ assert "#whatsNewContainer" in light_qss.read_text(encoding="utf-8")
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/buttons.py b/ui/custom_widgets/buttons.py
index 854f036..fb81c94 100644
--- a/ui/custom_widgets/buttons.py
+++ b/ui/custom_widgets/buttons.py
@@ -22,7 +22,7 @@ def __init__(self, parent: QWidget | None = None) -> None:
"""Initializes the button with icon support."""
super().__init__(parent=parent)
self.setAttribute(Qt.WA_StyledBackground, True)
- ThemeManagerInstance().themeChanged.connect(self._on_theme_changed)
+ ThemeManagerInstance.themeChanged.connect(self._on_theme_changed)
self.icons = {
"light": {"default": None, "hover": None,
@@ -77,7 +77,7 @@ def set_icon_paths(self,
def _update_icon(self) -> None:
"""Updates the icon based on the current state and theme."""
- theme_id = ThemeManagerInstance().current_theme_id
+ theme_id = ThemeManagerInstance.current_theme_id
theme = "light" if theme_id == "0" else "dark"
state = "default"
@@ -157,7 +157,7 @@ def paintEvent(self, event: QPaintEvent) -> None:
# Determine mode/state for icon
mode = QIcon.Normal
if not self.isEnabled():
- theme_id = ThemeManagerInstance().current_theme_id
+ theme_id = ThemeManagerInstance.current_theme_id
theme = "light" if theme_id == "0" else "dark"
disabled_icon = self.icons[theme].get("disabled")
diff --git a/ui/custom_widgets/checkboxes.py b/ui/custom_widgets/checkboxes.py
index b512640..acb6bc6 100644
--- a/ui/custom_widgets/checkboxes.py
+++ b/ui/custom_widgets/checkboxes.py
@@ -22,7 +22,7 @@ def __init__(self, parent: QWidget | None = None) -> None:
"QCheckBox::indicator { width: 0px; height: 0px; border: none; background: transparent; }"
)
- ThemeManagerInstance().themeChanged.connect(self._on_theme_changed)
+ ThemeManagerInstance.themeChanged.connect(self._on_theme_changed)
self.stateChanged.connect(self._on_state_changed)
# Structure: theme -> status (checked/unchecked) -> mode (default/hover/pressed)
@@ -111,7 +111,7 @@ def set_icon_paths(
def _update_icon(self) -> None:
"""Updates the icon based on the current state and theme."""
- theme_id = ThemeManagerInstance().current_theme_id
+ theme_id = ThemeManagerInstance.current_theme_id
theme = "light" if theme_id == "0" else "dark"
state = "checked" if self.isChecked() else "unchecked"
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/custom_widgets/file_drop.py b/ui/custom_widgets/file_drop.py
index c317a5b..f6ac9b5 100644
--- a/ui/custom_widgets/file_drop.py
+++ b/ui/custom_widgets/file_drop.py
@@ -19,7 +19,7 @@ def __init__(self, parent=None):
self.setAttribute(Qt.WA_StyledBackground, True)
self.setAttribute(Qt.WA_Hover, True)
- ThemeManagerInstance().themeChanged.connect(self._on_theme_changed)
+ ThemeManagerInstance.themeChanged.connect(self._on_theme_changed)
# Layout
layout = QVBoxLayout(self)
diff --git a/ui/custom_widgets/files.py b/ui/custom_widgets/files.py
index 324e58b..fff52e4 100644
--- a/ui/custom_widgets/files.py
+++ b/ui/custom_widgets/files.py
@@ -190,7 +190,7 @@ def __init__(self, file_data: object, width: int = 300, parent=None):
actions_layout.addWidget(self.open_btn)
actions_layout.addWidget(self.download_btn)
- ThemeManagerInstance().themeChanged.connect(self._on_theme_changed)
+ ThemeManagerInstance.themeChanged.connect(self._on_theme_changed)
self._update_icon()
def _setup_menu(self):
@@ -307,7 +307,7 @@ def _get_icon_name(self) -> str:
return "attachment_gray"
def _update_icon(self):
- theme_id = ThemeManagerInstance().current_theme_id
+ theme_id = ThemeManagerInstance.current_theme_id
theme = "light" if theme_id == "0" else "dark"
icon_name = self._get_icon_name()
diff --git a/ui/custom_widgets/labels.py b/ui/custom_widgets/labels.py
index e89bf9c..ce24ba6 100644
--- a/ui/custom_widgets/labels.py
+++ b/ui/custom_widgets/labels.py
@@ -21,7 +21,7 @@ class LogoLabel(QLabel):
def __init__(self, parent: QWidget | None = None) -> None:
"""Initializes the logo label."""
super().__init__(parent=parent)
- ThemeManagerInstance().themeChanged.connect(self._on_theme_changed)
+ ThemeManagerInstance.themeChanged.connect(self._on_theme_changed)
self.icons = {"light": None, "dark": None}
@@ -42,7 +42,7 @@ def set_icon_paths(self, light: str | None = None, dark: str | None = None) -> N
def paintEvent(self, event: QPaintEvent) -> None:
"""Paints the logo icon."""
super().paintEvent(event)
- theme_id = ThemeManagerInstance().current_theme_id
+ theme_id = ThemeManagerInstance.current_theme_id
theme = "light" if theme_id == "0" else "dark"
icon = self.icons.get(theme)
@@ -187,7 +187,7 @@ class ProfileIconLabel(IconLabel):
def __init__(self, parent: QWidget | None = None) -> None:
"""Initializes the profile icon label."""
super().__init__(parent)
- ThemeManagerInstance().themeChanged.connect(self._on_theme_changed)
+ ThemeManagerInstance.themeChanged.connect(self._on_theme_changed)
self.icons = {
"guest": {"light": None, "dark": None},
@@ -223,7 +223,7 @@ def set_custom_avatar(self, icon: QIcon | None) -> None:
self._update_icon()
def _update_icon(self) -> None:
- theme_id = ThemeManagerInstance().current_theme_id
+ theme_id = ThemeManagerInstance.current_theme_id
theme = "light" if theme_id == "0" else "dark"
if self._mode == "auth" and self._custom_avatar and not self._custom_avatar.isNull():
@@ -243,7 +243,7 @@ def __init__(self, parent: QWidget | None = None) -> None:
"""Initializes the slide label."""
super().__init__(parent=parent)
self._renderer = QSvgRenderer()
- ThemeManagerInstance().themeChanged.connect(self._on_theme_changed)
+ ThemeManagerInstance.themeChanged.connect(self._on_theme_changed)
self.svg_paths = {"light": None, "dark": None}
def set_svg_paths(self, light: str | None = None, dark: str | None = None) -> None:
@@ -253,7 +253,7 @@ def set_svg_paths(self, light: str | None = None, dark: str | None = None) -> No
self._update_image()
def _update_image(self) -> None:
- theme_id = ThemeManagerInstance().current_theme_id
+ theme_id = ThemeManagerInstance.current_theme_id
theme = "light" if theme_id == "0" else "dark"
path = self.svg_paths.get(theme)
diff --git a/ui/custom_widgets/lineedits.py b/ui/custom_widgets/lineedits.py
index 502fc2b..b6300f5 100644
--- a/ui/custom_widgets/lineedits.py
+++ b/ui/custom_widgets/lineedits.py
@@ -20,7 +20,7 @@ def __init__(self, parent: QWidget | None = None) -> None:
"""Initializes the icon line edit."""
super().__init__(parent)
- ThemeManagerInstance().themeChanged.connect(self._on_theme_changed)
+ ThemeManagerInstance.themeChanged.connect(self._on_theme_changed)
# icon settings
self.icon_size = 20
@@ -146,7 +146,7 @@ def setDisabled(self, disabled: bool) -> None:
# State priority + theme-based icon selection
def _update_icon(self) -> None:
"""Updates the icon based on the current state and theme."""
- theme_id = ThemeManagerInstance().current_theme_id
+ theme_id = ThemeManagerInstance.current_theme_id
theme = "light" if theme_id == "0" else "dark"
if self._disabled:
diff --git a/ui/custom_widgets/menu.py b/ui/custom_widgets/menu.py
index a5dd6ae..c978966 100644
--- a/ui/custom_widgets/menu.py
+++ b/ui/custom_widgets/menu.py
@@ -25,7 +25,7 @@ def __init__(
parent: QWidget | None = None
) -> None:
super().__init__(parent)
- ThemeManagerInstance().themeChanged.connect(self._on_theme_changed)
+ ThemeManagerInstance.themeChanged.connect(self._on_theme_changed)
self.action = action
self.checkable = checkable
self.setMouseTracking(True)
@@ -102,7 +102,7 @@ def _update_icon(self) -> None:
if self.checkable:
return
- theme_id = ThemeManagerInstance().current_theme_id
+ theme_id = ThemeManagerInstance.current_theme_id
theme = "light" if theme_id == "0" else "dark"
state = "default"
diff --git a/ui/custom_widgets/modal_window.py b/ui/custom_widgets/modal_window.py
index 126015a..7d4a1da 100644
--- a/ui/custom_widgets/modal_window.py
+++ b/ui/custom_widgets/modal_window.py
@@ -1,5 +1,5 @@
from PyQt5.QtWidgets import QFrame, QWidget, QDialog, QApplication
-from PyQt5.QtCore import Qt, QTimer, QPoint
+from PyQt5.QtCore import Qt, QTimer, QPoint, QEvent
from PyQt5.QtGui import QColor, QPainter, QShowEvent, QCloseEvent
@@ -17,6 +17,7 @@ class ModalOverlay(QWidget):
def __init__(self, parent):
super().__init__(parent)
self.setAttribute(Qt.WA_TransparentForMouseEvents, False)
+ self.setAttribute(Qt.WA_AlwaysStackOnTop, True)
self.setWindowFlags(Qt.FramelessWindowHint)
self.setAttribute(Qt.WA_NoSystemBackground, True)
self.setAttribute(Qt.WA_TranslucentBackground, True)
@@ -32,6 +33,7 @@ class BaseModalDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.overlay = None
+ self._overlay_parent = None
self.setWindowFlags(Qt.FramelessWindowHint | Qt.Dialog)
self.setWindowModality(Qt.ApplicationModal)
self.setAttribute(Qt.WA_TranslucentBackground)
@@ -44,26 +46,53 @@ def showEvent(self, event: QShowEvent):
def closeEvent(self, event: QCloseEvent):
if self.overlay:
self.overlay.close()
+ if self._overlay_parent:
+ self._overlay_parent.removeEventFilter(self)
+ self._overlay_parent = None
super().closeEvent(event)
def accept(self):
if self.overlay:
self.overlay.close()
+ if self._overlay_parent:
+ self._overlay_parent.removeEventFilter(self)
+ self._overlay_parent = None
super().accept()
def reject(self):
if self.overlay:
self.overlay.close()
+ if self._overlay_parent:
+ self._overlay_parent.removeEventFilter(self)
+ self._overlay_parent = None
super().reject()
def create_overlay(self):
if self.parent():
parent_window = self.parent().window()
+ self._overlay_parent = parent_window
+ parent_window.installEventFilter(self)
self.overlay = ModalOverlay(parent_window)
- self.overlay.resize(parent_window.size())
+ self._sync_overlay_geometry()
self.overlay.show()
self.overlay.raise_()
+ def _sync_overlay_geometry(self):
+ if self.overlay and self._overlay_parent:
+ self.overlay.setGeometry(self._overlay_parent.rect())
+ self.overlay.raise_()
+
+ def eventFilter(self, watched, event):
+ if watched == self._overlay_parent and event.type() in (
+ QEvent.Resize,
+ QEvent.Move,
+ QEvent.Show,
+ QEvent.ZOrderChange,
+ QEvent.ChildAdded,
+ ):
+ self._sync_overlay_geometry()
+ return super().eventFilter(watched, event)
+
def center_on_screen(self):
self.adjustSize()
if self.parent():
@@ -75,4 +104,4 @@ def center_on_screen(self):
screen = QApplication.primaryScreen().availableGeometry()
x = screen.center().x() - self.width() // 2
y = screen.center().y() - self.height() // 2
- self.move(x, y)
\ No newline at end of file
+ self.move(x, y)
diff --git a/ui/custom_widgets/progress_bar.py b/ui/custom_widgets/progress_bar.py
index d524d38..e74c3a3 100644
--- a/ui/custom_widgets/progress_bar.py
+++ b/ui/custom_widgets/progress_bar.py
@@ -12,7 +12,7 @@ def __init__(self, parent: QWidget | None = None) -> None:
self.setTextVisible(False)
self.setFixedHeight(12)
- ThemeManagerInstance().themeChanged.connect(self._on_theme_changed)
+ ThemeManagerInstance.themeChanged.connect(self._on_theme_changed)
def setProgressValue(self, value: int) -> None:
"""
diff --git a/ui/custom_widgets/switchers.py b/ui/custom_widgets/switchers.py
index 5ac53e6..be69db2 100644
--- a/ui/custom_widgets/switchers.py
+++ b/ui/custom_widgets/switchers.py
@@ -19,7 +19,7 @@ class ThemeSwitch(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
- ThemeManagerInstance().themeChanged.connect(self._on_theme_changed)
+ ThemeManagerInstance.themeChanged.connect(self._on_theme_changed)
self.setCursor(Qt.PointingHandCursor)
self.setObjectName("themeSwitch")
diff --git a/ui/custom_widgets/treeview.py b/ui/custom_widgets/treeview.py
index 7bd1a37..07a5bb9 100644
--- a/ui/custom_widgets/treeview.py
+++ b/ui/custom_widgets/treeview.py
@@ -357,7 +357,7 @@ def __init__(self, parent: QWidget | None = None) -> None:
self._edit_pressed_idx = QPersistentModelIndex()
self.clicked.connect(self._on_clicked)
- ThemeManagerInstance().themeChanged.connect(self._on_theme_changed)
+ ThemeManagerInstance.themeChanged.connect(self._on_theme_changed)
# Initialization of private attributes for properties
self._badge_background_color = QColor()
self._badge_text_color = QColor()
@@ -538,7 +538,7 @@ def set_edit_icon_paths(
def get_edit_icon(self, index: QModelIndex) -> QIcon:
"""Returns the edit icon for the given index based on state."""
- theme_id = ThemeManagerInstance().current_theme_id
+ theme_id = ThemeManagerInstance.current_theme_id
theme = "light" if theme_id == "0" else "dark"
state = "default"
diff --git a/ui/styles/templates/dark.j2 b/ui/styles/templates/dark.j2
index b0d5907..b349a5a 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 }};
@@ -613,6 +620,110 @@ QFrame#deleteInfoContainer {
border: 1px solid {{ neutral.neutral_250 }};
}
+/* What's New Window */
+#whatsNewContainer {
+ background-color: {{ neutral.neutral_100 }};
+ border-radius: 12px;
+ border: 1px solid {{ neutral.neutral_250 }};
+}
+
+#whatsNewHeader {
+ background-color: {{ accent.accent_700 }};
+ border-radius: 10px;
+}
+
+#whatsNewBadge {
+ background-color: {{ neutral.neutral_100 }};
+ color: {{ accent.accent_100 }};
+ border: 1px solid {{ accent.accent_500 }};
+ border-radius: 12px;
+ padding: 4px 10px;
+ font-size: 10pt;
+ font-weight: bold;
+}
+
+#whatsNewTitle {
+ background-color: transparent;
+ color: {{ accent.accent_100 }};
+ font-size: 18pt;
+ font-weight: bold;
+}
+
+#whatsNewSubtitle {
+ background-color: transparent;
+ color: {{ accent.accent_300 }};
+ font-size: 11pt;
+}
+
+/* ΠΠ½Π΅ΡΠ½ΠΈΠΉ ΡΡΠ΅ΠΉΠΌ β ΠΎΠ½ Π΄Π°ΡΡ ΡΠΊΡΡΠ³Π»Π΅Π½ΠΈΡ ΠΈ ΠΊΠ»ΠΈΠΏΠΏΠΈΠ½Π³ */
+QFrame#whatsNewContentFrame {
+ background-color: {{ neutral.neutral_0 }};
+ border-radius: 10px;
+}
+
+/* QScrollArea ΠΏΡΠΎΠ·ΡΠ°ΡΠ½Π°Ρ, Π±Π΅Π· ΡΠ°ΠΌΠΎΠΊ, Π±Π΅Π· Π½Π°ΡΠΈΠ²Π½ΠΎΠ³ΠΎ ΡΠΊΡΠΎΠ»Π»Π±Π°ΡΠ° */
+QScrollArea#whatsNewContentScroll {
+ background-color: transparent;
+ border: none;
+}
+
+QWidget#whatsNewContentViewport {
+ background-color: {{ neutral.neutral_0 }};
+}
+
+QWidget#whatsNewContentWidget {
+ background-color: transparent;
+}
+
+/* Overlay-ΡΠΊΡΠΎΠ»Π»Π±Π°Ρ ΠΏΠΎΠ²Π΅ΡΡ
ΠΊΠΎΠ½ΡΠ΅Π½ΡΠ° */
+QScrollBar#whatsNewScrollBar {
+ background: transparent;
+ width: 12px;
+ border-radius: 6px;
+}
+
+QScrollBar#whatsNewScrollBar::handle {
+ background-color: {{ accent.accent_500 }};
+ min-height: 36px;
+ border-radius: 3px;
+}
+
+QScrollBar#whatsNewScrollBar::add-line,
+QScrollBar#whatsNewScrollBar::sub-line {
+ border: none;
+ background: none;
+ height: 0px;
+}
+
+QScrollBar#whatsNewScrollBar::add-page,
+QScrollBar#whatsNewScrollBar::sub-page {
+ background: transparent;
+}
+
+#whatsNewItem,
+#whatsNewItemText,
+#whatsNewBullet {
+ background-color: transparent;
+}
+
+#whatsNewSectionTitle {
+ background-color: transparent;
+ color: {{ neutral.neutral_900 }};
+ font-size: 13pt;
+ font-weight: bold;
+}
+
+#whatsNewBullet {
+ color: {{ accent.accent_400 }};
+ font-size: 16pt;
+ font-weight: bold;
+}
+
+#whatsNewItemText {
+ color: {{ neutral.neutral_800 }};
+ font-size: 11pt;
+}
+
/* MAIN APPLICATION */
@@ -1058,4 +1169,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..fd6bd7a 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 }};
@@ -611,6 +618,110 @@ QFrame#deleteInfoContainer {
border: 1px solid {{ neutral.neutral_100 }};
}
+/* What's New Window */
+#whatsNewContainer {
+ background-color: {{ neutral.neutral_0 }};
+ border-radius: 12px;
+ border: 1px solid {{ neutral.neutral_100 }};
+}
+
+#whatsNewHeader {
+ background-color: {{ accent.accent_100 }};
+ border-radius: 10px;
+}
+
+#whatsNewBadge {
+ background-color: {{ neutral.neutral_0 }};
+ color: {{ accent.accent_500 }};
+ border: 1px solid {{ accent.accent_300 }};
+ border-radius: 12px;
+ padding: 4px 10px;
+ font-size: 10pt;
+ font-weight: bold;
+}
+
+#whatsNewTitle {
+ background-color: transparent;
+ color: {{ accent.accent_500 }};
+ font-size: 18pt;
+ font-weight: bold;
+}
+
+#whatsNewSubtitle {
+ background-color: transparent;
+ color: {{ neutral.neutral_700 }};
+ font-size: 11pt;
+}
+
+/* ΠΠ½Π΅ΡΠ½ΠΈΠΉ ΡΡΠ΅ΠΉΠΌ β ΠΎΠ½ Π΄Π°ΡΡ ΡΠΊΡΡΠ³Π»Π΅Π½ΠΈΡ ΠΈ ΠΊΠ»ΠΈΠΏΠΏΠΈΠ½Π³ */
+QFrame#whatsNewContentFrame {
+ background-color: {{ neutral.neutral_50 }};
+ border-radius: 10px;
+}
+
+/* QScrollArea ΠΏΡΠΎΠ·ΡΠ°ΡΠ½Π°Ρ, Π±Π΅Π· ΡΠ°ΠΌΠΎΠΊ, Π±Π΅Π· Π½Π°ΡΠΈΠ²Π½ΠΎΠ³ΠΎ ΡΠΊΡΠΎΠ»Π»Π±Π°ΡΠ° */
+QScrollArea#whatsNewContentScroll {
+ background-color: transparent;
+ border: none;
+}
+
+QWidget#whatsNewContentViewport {
+ background-color: {{ neutral.neutral_50 }};
+}
+
+QWidget#whatsNewContentWidget {
+ background-color: transparent;
+}
+
+/* Overlay-ΡΠΊΡΠΎΠ»Π»Π±Π°Ρ ΠΏΠΎΠ²Π΅ΡΡ
ΠΊΠΎΠ½ΡΠ΅Π½ΡΠ° */
+QScrollBar#whatsNewScrollBar {
+ background: transparent;
+ width: 12px;
+ border-radius: 6px;
+}
+
+QScrollBar#whatsNewScrollBar::handle {
+ background-color: {{ accent.accent_500 }};
+ min-height: 36px;
+ border-radius: 3px;
+}
+
+QScrollBar#whatsNewScrollBar::add-line,
+QScrollBar#whatsNewScrollBar::sub-line {
+ border: none;
+ background: none;
+ height: 0px;
+}
+
+QScrollBar#whatsNewScrollBar::add-page,
+QScrollBar#whatsNewScrollBar::sub-page {
+ background: transparent;
+}
+
+#whatsNewItem,
+#whatsNewItemText,
+#whatsNewBullet {
+ background-color: transparent;
+}
+
+#whatsNewSectionTitle {
+ background-color: transparent;
+ color: {{ neutral.neutral_900 }};
+ font-size: 13pt;
+ font-weight: bold;
+}
+
+#whatsNewBullet {
+ color: {{ accent.accent_500 }};
+ font-size: 16pt;
+ font-weight: bold;
+}
+
+#whatsNewItemText {
+ color: {{ neutral.neutral_800 }};
+ font-size: 11pt;
+}
+
@@ -902,7 +1013,7 @@ QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {
/* Password Hint */
QWidget#passwordHintContainer {
- border-radius: 8px;
+ border-radius: 8px;
border: 1px solid {{ neutral.neutral_100 }};
background-color: {{ neutral.neutral_0 }};
}
@@ -1062,4 +1173,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..c611327 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;
@@ -613,6 +620,109 @@ QFrame#deleteInfoContainer {
border: 1px solid #404040;
}
+/* What's New Window */
+#whatsNewContainer {
+ background-color: #262626;
+ border-radius: 12px;
+ border: 1px solid #404040;
+}
+
+#whatsNewHeader {
+ background-color: #5D1717;
+ border-radius: 10px;
+}
+
+#whatsNewBadge {
+ background-color: #262626;
+ color: #F3C7C7;
+ border: 1px solid #C43A3A;
+ border-radius: 12px;
+ padding: 4px 10px;
+ font-size: 10pt;
+ font-weight: bold;
+}
+
+#whatsNewTitle {
+ background-color: transparent;
+ color: #F3C7C7;
+ font-size: 18pt;
+ font-weight: bold;
+}
+
+#whatsNewSubtitle {
+ background-color: transparent;
+ color: #E38282;
+ font-size: 11pt;
+}
+
+/* The outer frame provides rounding and clipping */
+QFrame#whatsNewContentFrame {
+ background-color: #1A1A1A;
+ border-radius: 10px;
+}
+
+/* QScrollArea is transparent, without borders, and without a native scrollbar */
+QScrollArea#whatsNewContentScroll {
+ background-color: transparent;
+ border: none;
+}
+
+QWidget#whatsNewContentViewport {
+ background-color: #1A1A1A;
+}
+
+QWidget#whatsNewContentWidget {
+ background-color: transparent;
+}
+
+/* Overlay-scrollbar on top of the content */
+QScrollBar#whatsNewScrollBar {
+ background: transparent;
+ width: 12px;
+ border-radius: 6px;
+}
+
+QScrollBar#whatsNewScrollBar::handle {
+ background-color: #C43A3A;
+ min-height: 36px;
+ border-radius: 3px;
+}
+
+QScrollBar#whatsNewScrollBar::add-line,
+QScrollBar#whatsNewScrollBar::sub-line {
+ border: none;
+ background: none;
+ height: 0px;
+}
+
+QScrollBar#whatsNewScrollBar::add-page,
+QScrollBar#whatsNewScrollBar::sub-page {
+ background: transparent;
+}
+
+#whatsNewItem,
+#whatsNewItemText,
+#whatsNewBullet {
+ background-color: transparent;
+}
+
+#whatsNewSectionTitle {
+ background-color: transparent;
+ color: #E6E6E6;
+ font-size: 13pt;
+ font-weight: bold;
+}
+
+#whatsNewBullet {
+ color: #D65A5A;
+ font-size: 16pt;
+ font-weight: bold;
+}
+
+#whatsNewItemText {
+ color: #CCCCCC;
+ font-size: 11pt;
+}
/* MAIN APPLICATION */
@@ -1059,4 +1169,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..a40e53b 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;
@@ -611,6 +618,110 @@ QFrame#deleteInfoContainer {
border: 1px solid #E6E6E6;
}
+/* What's New Window */
+#whatsNewContainer {
+ background-color: #FFFFFF;
+ border-radius: 12px;
+ border: 1px solid #E6E6E6;
+}
+
+#whatsNewHeader {
+ background-color: #F5D6D6;
+ border-radius: 10px;
+}
+
+#whatsNewBadge {
+ background-color: #FFFFFF;
+ color: #CC3333;
+ border: 1px solid #E08585;
+ border-radius: 12px;
+ padding: 4px 10px;
+ font-size: 10pt;
+ font-weight: bold;
+}
+
+#whatsNewTitle {
+ background-color: transparent;
+ color: #CC3333;
+ font-size: 18pt;
+ font-weight: bold;
+}
+
+#whatsNewSubtitle {
+ background-color: transparent;
+ color: #4D4D4D;
+ font-size: 11pt;
+}
+
+/* The outer frame provides rounding and clipping */
+QFrame#whatsNewContentFrame {
+ background-color: #F2F2F2;
+ border-radius: 10px;
+}
+
+/* QScrollArea is transparent, without borders, and without a native scrollbar */
+QScrollArea#whatsNewContentScroll {
+ background-color: transparent;
+ border: none;
+}
+
+QWidget#whatsNewContentViewport {
+ background-color: #F2F2F2;
+}
+
+QWidget#whatsNewContentWidget {
+ background-color: transparent;
+}
+
+/* Overlay-scrollbar on top of the content */
+QScrollBar#whatsNewScrollBar {
+ background: transparent;
+ width: 12px;
+ border-radius: 6px;
+}
+
+QScrollBar#whatsNewScrollBar::handle {
+ background-color: #CC3333;
+ min-height: 36px;
+ border-radius: 3px;
+}
+
+QScrollBar#whatsNewScrollBar::add-line,
+QScrollBar#whatsNewScrollBar::sub-line {
+ border: none;
+ background: none;
+ height: 0px;
+}
+
+QScrollBar#whatsNewScrollBar::add-page,
+QScrollBar#whatsNewScrollBar::sub-page {
+ background: transparent;
+}
+
+#whatsNewItem,
+#whatsNewItemText,
+#whatsNewBullet {
+ background-color: transparent;
+}
+
+#whatsNewSectionTitle {
+ background-color: transparent;
+ color: #1A1A1A;
+ font-size: 13pt;
+ font-weight: bold;
+}
+
+#whatsNewBullet {
+ color: #CC3333;
+ font-size: 16pt;
+ font-weight: bold;
+}
+
+#whatsNewItemText {
+ color: #333333;
+ font-size: 11pt;
+}
+
/* MAIN APPLICATION */
@@ -1059,4 +1170,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/main.ui b/ui/ui/main.ui
index 7eb077d..66cf493 100644
--- a/ui/ui/main.ui
+++ b/ui/ui/main.ui
@@ -493,7 +493,7 @@
- theme
+
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/main.py b/ui/ui_converted/main.py
index a4d4fe1..abdadfc 100644
--- a/ui/ui_converted/main.py
+++ b/ui/ui_converted/main.py
@@ -333,7 +333,7 @@ def retranslateUi(self, MainWindow):
self.profile_name_label.setText(_translate("MainWindow", "ΠΠΎΡΡΡ"))
self.profile_info_label.setText(_translate("MainWindow", "ΠΠΎΠΉΠ΄ΠΈΡΠ΅ Π² Π°ΠΊΠΊΠ°ΡΠ½Ρ"))
self.search_lineEdit.setPlaceholderText(_translate("MainWindow", "ΠΠΎΠΈΡΠΊ..."))
- self.theme_pushButton.setText(_translate("MainWindow", "theme"))
+ self.theme_pushButton.setText(_translate("MainWindow", ""))
self.create_pushButton.setText(_translate("MainWindow", "Π‘ΠΎΠ·Π΄Π°ΡΡ"))
self.finded_label.setText(_translate("MainWindow", "ΠΠ°ΠΉΠ΄Π΅Π½ΠΎ:"))
self.tags_label.setText(_translate("MainWindow", "ΠΠΎΠΏΡΠ»ΡΡΠ½ΡΠ΅ ΡΠ΅Π³ΠΈ:"))
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
diff --git a/utils/delete_info_modal.py b/utils/delete_info_modal.py
index e8911d0..142a721 100644
--- a/utils/delete_info_modal.py
+++ b/utils/delete_info_modal.py
@@ -77,10 +77,10 @@ def __init__(self, parent=None, info_type: str = None):
# === Icon setup ===
self._update_icon()
- ThemeManagerInstance().themeChanged.connect(self._on_theme_changed)
+ ThemeManagerInstance.themeChanged.connect(self._on_theme_changed)
def _update_icon(self):
- theme_id = ThemeManagerInstance().current_theme_id
+ theme_id = ThemeManagerInstance.current_theme_id
if theme_id == "0":
icon_path = ":/icons/light/light/notification_warning_red.svg"
else:
diff --git a/utils/notifications/notification_service.py b/utils/notifications/notification_service.py
index ff6257d..7c1a66b 100644
--- a/utils/notifications/notification_service.py
+++ b/utils/notifications/notification_service.py
@@ -42,7 +42,7 @@ def __init__(self):
Loads configuration from the ThemeManager and prepares the service.
"""
- config = ThemeManagerInstance().notification_config
+ config = ThemeManagerInstance.notification_config
self.SPACING = config.get("spacing", 10)
self.TOAST_DURATION = config.get("toast_duration", 5000)
diff --git a/utils/notifications/toast_notification.py b/utils/notifications/toast_notification.py
index 43cee5a..ccdea3b 100644
--- a/utils/notifications/toast_notification.py
+++ b/utils/notifications/toast_notification.py
@@ -3,7 +3,7 @@
from PyQt5.QtCore import QPropertyAnimation, QEasingCurve, QRect, Qt, QSize, QEvent
from ui.ui_converted.toast_notification import Ui_ToastNotification
-from utils.theme_manager import theme_manager_singleton
+from utils.theme_manager import ThemeManagerInstance
class ToastNotification(QFrame, Ui_ToastNotification):
@@ -49,7 +49,7 @@ def __init__(
self.description.setText(message)
# Programmatically set the icon to avoid SVG scaling issues with stylesheets
- theme_name = theme_manager_singleton.themes.get(theme_manager_singleton.current_theme_id, 'light')
+ theme_name = ThemeManagerInstance.themes.get(ThemeManagerInstance.current_theme_id, 'light')
icon_path = f":/icons/{theme_name}/{theme_name}/notification_{notification_type}.svg"
icon = QIcon(icon_path)
diff --git a/utils/settings_manager.py b/utils/settings_manager.py
new file mode 100644
index 0000000..fdb3786
--- /dev/null
+++ b/utils/settings_manager.py
@@ -0,0 +1,94 @@
+import json
+import logging
+from pathlib import Path
+from typing import Any, Dict
+
+from utils.app_paths import get_app_data_dir
+
+class SettingsManager:
+ """
+ Manages user-specific application settings.
+
+ This class handles loading, saving, and managing settings for a specific user.
+ Settings are stored in a JSON file in the user's profile directory.
+ """
+ def __init__(self, user_id: int):
+ if not isinstance(user_id, int):
+ raise TypeError("user_id must be an integer.")
+
+ self.user_id = user_id
+ self.settings_dir = get_app_data_dir() / "Profiles"
+ self.settings_dir.mkdir(parents=True, exist_ok=True)
+ self.settings_file = self.settings_dir / f"user_settings_{self.user_id}.json"
+
+ self.settings = self.load_settings()
+ self.logger = logging.getLogger(f"SettingsManager(user_{self.user_id})")
+
+ def get_default_settings(self) -> Dict[str, Any]:
+ """
+ Returns the default settings for a user.
+ """
+ return {
+ "theme": 1, # 0 for light, 1 for dark
+ "last_seen_whats_new_version": "",
+ "search_filters": {
+ "search_in_pages": True,
+ "search_field": "name",
+ "match_mode": "contains"
+ }
+ }
+
+ def load_settings(self) -> Dict[str, Any]:
+ """
+ Loads settings from the user's settings file.
+
+ If the file doesn't exist or is invalid, it returns the default settings.
+ """
+ default_settings = self.get_default_settings()
+ if not self.settings_file.exists():
+ return default_settings
+
+ try:
+ with open(self.settings_file, "r", encoding="utf-8") as f:
+ loaded_settings = json.load(f)
+
+ # Merge loaded settings with defaults to ensure all keys are present
+ settings = default_settings.copy()
+ settings.update(loaded_settings)
+ return settings
+
+ except (json.JSONDecodeError, IOError) as e:
+ logging.error(f"Failed to load settings file {self.settings_file}: {e}. Using default settings.")
+ return default_settings
+
+ def save_settings(self) -> None:
+ """Saves the current settings to the user's settings file."""
+ try:
+ with open(self.settings_file, "w", encoding="utf-8") as f:
+ json.dump(self.settings, f, indent=4, ensure_ascii=False)
+ except IOError as e:
+ self.logger.error(f"Failed to save settings to {self.settings_file}: {e}")
+
+ def get_setting(self, key: str, default: Any = None) -> Any:
+ """
+ Retrieves a specific setting by key.
+
+ Args:
+ key: The key of the setting to retrieve.
+ default: The value to return if the key is not found.
+
+ Returns:
+ The value of the setting.
+ """
+ return self.settings.get(key, default)
+
+ def set_setting(self, key: str, value: Any) -> None:
+ """
+ Sets a specific setting and saves it to the file.
+
+ Args:
+ key: The key of the setting to set.
+ value: The new value for the setting.
+ """
+ self.settings[key] = value
+ self.save_settings()
diff --git a/utils/theme_manager.py b/utils/theme_manager.py
index 190a462..7ef135f 100644
--- a/utils/theme_manager.py
+++ b/utils/theme_manager.py
@@ -37,6 +37,7 @@ def __init__(self) -> None:
"""
super().__init__()
+ self.settings_manager = None
self.ui_config = self._load_ui_config()
# If the config is empty, we don't do anything.
@@ -53,6 +54,10 @@ def __init__(self) -> None:
self.current_theme_id = "0" # The topic ID as a string
+ def set_settings_manager(self, manager) -> None:
+ """Sets the settings manager instance for saving theme settings."""
+ self.settings_manager = manager
+
@property
def notification_config(self) -> dict:
"""Returns the notification-specific configuration."""
@@ -84,6 +89,10 @@ def switch_theme(self, theme: int | str | None = None) -> None:
return
self._apply_theme()
+
+ # Save the theme setting if a settings manager is available
+ if self.settings_manager:
+ self.settings_manager.set_setting("theme", int(self.current_theme_id))
self.themeChanged.emit(self.current_theme_id)
@@ -260,8 +269,4 @@ def _compile_all_themes(self) -> bool:
return False
-theme_manager_singleton = ThemeManager()
-
-
-def ThemeManagerInstance():
- return theme_manager_singleton
\ No newline at end of file
+ThemeManagerInstance = ThemeManager()
\ No newline at end of file
diff --git a/utils/whats_new_modal.py b/utils/whats_new_modal.py
new file mode 100644
index 0000000..b926899
--- /dev/null
+++ b/utils/whats_new_modal.py
@@ -0,0 +1,202 @@
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QColor, QPainterPath, QRegion
+from PyQt5.QtWidgets import (
+ QFrame,
+ QGraphicsDropShadowEffect,
+ QHBoxLayout,
+ QLabel,
+ QScrollArea,
+ QScrollBar,
+ QSizePolicy,
+ QVBoxLayout,
+ QWidget,
+)
+
+from ui.custom_widgets import PrimaryButton
+from ui.custom_widgets.modal_window import BaseModalDialog, ShadowContainer
+
+
+RELEASE_NOTES = {
+ "0.2.0": [
+ {
+ "title": "Π Π΅Π΄Π°ΠΊΡΠΈΡΠΎΠ²Π°Π½ΠΈΠ΅ ΠΏΡΠΎΡΠΈΠ»Ρ",
+ "items": [
+ "Π’Π΅ΠΏΠ΅ΡΡ ΠΌΠΎΠΆΠ½ΠΎ ΡΠ΅Π΄Π°ΠΊΡΠΈΡΠΎΠ²Π°ΡΡ Π΄Π°Π½Π½ΡΠ΅ ΠΏΡΠΎΡΠΈΠ»Ρ ΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»Ρ ΠΏΡΡΠΌΠΎ Π² ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠΈ: ΠΈΠΌΡ, ΡΠ°ΠΌΠΈΠ»ΠΈΡ ΠΈ ΠΎΡΠ΄Π΅Π».",
+ "ΠΠΈΠ°Π»ΠΎΠ³ ΠΏΡΠΎΡΠΈΠ»Ρ Π΄ΠΎΡΡΡΠΏΠ΅Π½ ΠΈΠ· Π³Π»Π°Π²Π½ΠΎΠ³ΠΎ ΠΌΠ΅Π½Ρ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΡ.",
+ ],
+ },
+ {
+ "title": "Π‘ΠΎΡ
ΡΠ°Π½Π΅Π½ΠΈΠ΅ Π½Π°ΡΡΡΠΎΠ΅ΠΊ",
+ "items": [
+ "ΠΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΡΠ΅ΠΏΠ΅ΡΡ Π·Π°ΠΏΠΎΠΌΠΈΠ½Π°Π΅Ρ ΠΏΠ΅ΡΡΠΎΠ½Π°Π»ΡΠ½ΡΠ΅ Π½Π°ΡΡΡΠΎΠΉΠΊΠΈ ΠΎΡΠ΄Π΅Π»ΡΠ½ΠΎ Π΄Π»Ρ ΠΊΠ°ΠΆΠ΄ΠΎΠ³ΠΎ ΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»Ρ.",
+ "ΠΡΠ±ΡΠ°Π½Π½Π°Ρ ΡΠ΅ΠΌΠ° ΠΈΠ½ΡΠ΅ΡΡΠ΅ΠΉΡΠ° ΡΠΎΡ
ΡΠ°Π½ΡΠ΅ΡΡΡ ΠΌΠ΅ΠΆΠ΄Ρ ΡΠ΅ΡΡΠΈΡΠΌΠΈ.",
+ "ΠΠΎΡΠ»Π΅Π΄Π½ΠΈΠ΅ ΠΈΡΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°Π½Π½ΡΠ΅ ΡΠΈΠ»ΡΡΡΡ ΠΏΠΎΠΈΡΠΊΠ° ΡΠΎΡ
ΡΠ°Π½ΡΡΡΡΡ ΠΈ ΡΡΠΊΠΎΡΡΡΡ ΠΏΠΎΠ²ΡΠΎΡΠ½ΡΡ ΡΠ°Π±ΠΎΡΡ.",
+ ],
+ },
+ {
+ "title": "Π’Π°ΠΊΠΆΠ΅ Π² ΡΡΠΎΠΌ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠΈ",
+ "items": [
+ "ΠΠΎΠ±Π°Π²Π»Π΅Π½ΠΎ ΠΎΡΠ΄Π΅Π»ΡΠ½ΠΎΠ΅ ΠΎΠΊΠ½ΠΎ Β«Π§ΡΠΎ Π½ΠΎΠ²ΠΎΠ³ΠΎΒ», ΠΊΠΎΡΠΎΡΠΎΠ΅ ΠΏΠΎΠΊΠ°Π·ΡΠ²Π°Π΅ΡΡΡ ΠΏΠΎΡΠ»Π΅ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΡ ΠΈ ΠΎΡΠΎΠ±ΡΠ°ΠΆΠ°Π΅Ρ ΡΠΏΠΈΡΠΎΠΊ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ Π²Π΅ΡΡΠΈΠΈ.",
+ "ΠΠ΅Π±ΠΎΠ»ΡΡΠΈΠ΅ ΡΠ»ΡΡΡΠ΅Π½ΠΈΡ ΠΈΠ½ΡΠ΅ΡΡΠ΅ΠΉΡΠ° ΠΈ ΠΎΠ±ΡΠΈΠ΅ ΠΈΡΠΏΡΠ°Π²Π»Π΅Π½ΠΈΡ ΠΎΡΠΈΠ±ΠΎΠΊ Π΄Π»Ρ Π±ΠΎΠ»Π΅Π΅ ΡΡΠ°Π±ΠΈΠ»ΡΠ½ΠΎΠΉ ΡΠ°Π±ΠΎΡΡ.",
+ "Π£Π»ΡΡΡΠ΅Π½Π° ΡΡΠ°Π±ΠΈΠ»ΡΠ½ΠΎΡΡΡ ΠΏΠΎΡΠ»Π΅ ΠΏΡΠΎΡΡΠΎΡ: ΡΠΏΠΈΡΠΎΠΊ Π΄ΠΎΠΊΡΠΌΠ΅Π½ΡΠΎΠ² ΠΈ ΡΠ΅Π·ΡΠ»ΡΡΠ°ΡΡ ΠΏΠΎΠΈΡΠΊΠ° Π±ΠΎΠ»ΡΡΠ΅ Π½Π΅ ΠΈΡΡΠ΅Π·Π°ΡΡ ΠΏΡΠΈ ΠΎΡΠΈΠ±ΠΊΠ°Ρ
ΠΏΠΎΠ²ΡΠΎΡΠ½ΠΎΠΉ Π·Π°Π³ΡΡΠ·ΠΊΠΈ.",
+ "Π‘ΡΠ°ΡΡΠΎΠ²Π°Ρ Π·Π°Π³ΡΡΠ·ΠΊΠ° Π³Π»Π°Π²Π½ΠΎΠ³ΠΎ ΠΎΠΊΠ½Π° Π²ΡΠ½Π΅ΡΠ΅Π½Π° ΠΈΠ· UI-ΠΏΠΎΡΠΎΠΊΠ°, ΠΏΠΎΡΡΠΎΠΌΡ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΠΌΠ΅Π½ΡΡΠ΅ ΠΏΠΎΠ΄Π²ΠΈΡΠ°Π΅Ρ ΠΏΡΠΈ ΠΎΡΠΊΡΡΡΠΈΠΈ.",
+ "Π‘ΡΠ΅Π½Π°ΡΠΈΠΈ Π°Π²ΡΠΎΡΠΈΠ·Π°ΡΠΈΠΈ ΡΠ΅ΠΏΠ΅ΡΡ ΠΊΠΎΡΡΠ΅ΠΊΡΠ½ΠΎ Π·Π°Π²Π΅ΡΡΠ°ΡΡΡΡ ΠΎΡΠΈΠ±ΠΊΠΎΠΉ, Π΅ΡΠ»ΠΈ ΡΠΈΡΡΠ΅ΠΌΠ½ΠΎΠ΅ Ρ
ΡΠ°Π½ΠΈΠ»ΠΈΡΠ΅ ΡΠ΅ΡΡΠΈΠΈ Π½Π΅Π΄ΠΎΡΡΡΠΏΠ½ΠΎ.",
+ "API-ΠΊΠ»ΠΈΠ΅Π½Ρ ΡΠ΅ΠΏΠ΅ΡΡ ΠΊΠΎΡΡΠ΅ΠΊΡΠ½ΠΎ ΠΎΠ±ΡΠ°Π±Π°ΡΡΠ²Π°Π΅Ρ ΠΏΡΡΡΡΠ΅ ΡΡΠΏΠ΅ΡΠ½ΡΠ΅ ΠΎΡΠ²Π΅ΡΡ, Π½Π°ΠΏΡΠΈΠΌΠ΅Ρ 204 No Content.",
+ "ΠΡΠΈ Π²ΡΡ
ΠΎΠ΄Π΅ ΠΈΠ· Π°ΠΊΠΊΠ°ΡΠ½ΡΠ° ΡΠ΅ΠΏΠ΅ΡΡ ΡΠ²Π½ΠΎ ΠΎΡΠΊΠ»ΡΡΠ°Π΅ΡΡΡ auto-login Π΄Π»Ρ ΡΠ΅ΠΊΡΡΠ΅Π³ΠΎ ΠΏΡΠΎΡΠΈΠ»Ρ.",
+ ],
+ },
+ ]
+}
+
+
+class RoundedScrollArea(QFrame):
+ """QScrollArea Ρ overlay-ΡΠΊΡΠΎΠ»Π»Π±Π°ΡΠΎΠΌ ΠΈ ΡΠΊΡΡΠ³Π»ΡΠ½Π½ΡΠΌΠΈ ΡΠ³Π»Π°ΠΌΠΈ."""
+
+ RADIUS = 10
+ BAR_WIDTH = 10
+ BAR_MARGIN_X = 4 # ΠΎΡΡΡΡΠΏ ΠΎΡ ΠΏΡΠ°Π²ΠΎΠ³ΠΎ ΠΊΡΠ°Ρ
+ BAR_MARGIN_Y = 8 # ΠΎΡΡΡΡΠΏ ΡΠ²Π΅ΡΡ
Ρ/ΡΠ½ΠΈΠ·Ρ
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setObjectName("whatsNewContentFrame")
+
+ # ΠΠ½ΡΡΡΠ΅Π½Π½ΡΡ QScrollArea Π±Π΅Π· Π½Π°ΡΠΈΠ²Π½ΠΎΠ³ΠΎ Π²Π΅ΡΡΠΈΠΊΠ°Π»ΡΠ½ΠΎΠ³ΠΎ ΡΠΊΡΠΎΠ»Π»Π±Π°ΡΠ°
+ self._scroll = QScrollArea(self)
+ self._scroll.setObjectName("whatsNewContentScroll")
+ self._scroll.setWidgetResizable(True)
+ self._scroll.setFrameShape(QFrame.NoFrame)
+ self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ self._scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ self._scroll.viewport().setObjectName("whatsNewContentViewport")
+
+ # Overlay-ΡΠΊΡΠΎΠ»Π»Π±Π°Ρ ΠΏΠΎΠ²Π΅ΡΡ
ΠΊΠΎΠ½ΡΠ΅Π½ΡΠ°
+ self._bar = QScrollBar(Qt.Vertical, self)
+ self._bar.setObjectName("whatsNewScrollBar")
+
+ native = self._scroll.verticalScrollBar()
+ native.valueChanged.connect(self._bar.setValue)
+ native.rangeChanged.connect(self._sync_range)
+ self._bar.valueChanged.connect(native.setValue)
+
+ self._update_mask()
+
+ def setWidget(self, widget):
+ self._scroll.setWidget(widget)
+
+ def _sync_range(self, min_val, max_val):
+ self._bar.setRange(min_val, max_val)
+ self._bar.setPageStep(self._scroll.verticalScrollBar().pageStep())
+ self._bar.setVisible(max_val > min_val)
+
+ def _update_mask(self):
+ """ΠΠΈΠΊΡΠ΅Π»ΡΠ½Π°Ρ ΠΌΠ°ΡΠΊΠ° Π΄Π»Ρ ΡΠ΅Π°Π»ΡΠ½ΠΎΠ³ΠΎ ΠΊΠ»ΠΈΠΏΠΏΠΈΠ½Π³Π° Π΄ΠΎΡΠ΅ΡΠ½ΠΈΡ
Π²ΠΈΠ΄ΠΆΠ΅ΡΠΎΠ²."""
+ path = QPainterPath()
+ path.addRoundedRect(0.0, 0.0, float(self.width()), float(self.height()),
+ self.RADIUS, self.RADIUS)
+ self.setMask(QRegion(path.toFillPolygon().toPolygon()))
+
+ def resizeEvent(self, event):
+ super().resizeEvent(event)
+ self._scroll.setGeometry(self.rect())
+ self._bar.setGeometry(
+ self.width() - self.BAR_WIDTH - self.BAR_MARGIN_X,
+ self.BAR_MARGIN_Y,
+ self.BAR_WIDTH,
+ self.height() - self.BAR_MARGIN_Y * 2,
+ )
+ self._update_mask()
+
+
+class WhatsNewDialog(BaseModalDialog):
+ def __init__(self, parent=None, version: str = "", notes: list[str] | None = None):
+ super().__init__(parent)
+ self.notes = notes or RELEASE_NOTES.get(version, [])
+
+ self.container = ShadowContainer(self)
+ self.container.setObjectName("whatsNewContainer")
+ self.container.setMinimumWidth(640)
+ self.container.setMaximumWidth(720)
+
+ container_layout = QVBoxLayout(self.container)
+ container_layout.setContentsMargins(28, 28, 28, 28)
+ container_layout.setSpacing(18)
+
+ header_frame = QFrame()
+ header_frame.setObjectName("whatsNewHeader")
+ header_layout = QVBoxLayout(header_frame)
+ header_layout.setContentsMargins(20, 20, 20, 20)
+ header_layout.setSpacing(8)
+
+ badge_label = QLabel(f"ΠΠ΅ΡΡΠΈΡ {version}")
+ badge_label.setObjectName("whatsNewBadge")
+ badge_label.setAlignment(Qt.AlignCenter)
+ header_layout.addWidget(badge_label, 0, Qt.AlignLeft)
+
+ title_label = QLabel("Π§ΡΠΎ Π½ΠΎΠ²ΠΎΠ³ΠΎ")
+ title_label.setObjectName("whatsNewTitle")
+ header_layout.addWidget(title_label)
+
+ container_layout.addWidget(header_frame)
+
+ content_frame = RoundedScrollArea()
+
+ content_widget = QWidget()
+ content_widget.setObjectName("whatsNewContentWidget")
+ content_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
+ content_layout = QVBoxLayout(content_widget)
+ content_layout.setContentsMargins(20, 20, 28, 20)
+ content_layout.setSpacing(18)
+
+ for section in self.notes:
+ section_title = QLabel(section["title"])
+ section_title.setObjectName("whatsNewSectionTitle")
+ content_layout.addWidget(section_title)
+
+ for note in section["items"]:
+ item_row = QWidget()
+ item_row.setObjectName("whatsNewItem")
+ item_layout = QHBoxLayout(item_row)
+ item_layout.setContentsMargins(0, 0, 0, 0)
+ item_layout.setSpacing(12)
+
+ bullet = QLabel("β’")
+ bullet.setObjectName("whatsNewBullet")
+ bullet.setAlignment(Qt.AlignTop)
+
+ note_label = QLabel(note)
+ note_label.setObjectName("whatsNewItemText")
+ note_label.setWordWrap(True)
+ note_label.setTextFormat(Qt.PlainText)
+
+ item_layout.addWidget(bullet, 0, Qt.AlignTop)
+ item_layout.addWidget(note_label, 1)
+ content_layout.addWidget(item_row)
+
+ content_frame.setWidget(content_widget)
+ content_frame.setMinimumHeight(320)
+ content_frame.setMaximumHeight(420)
+ container_layout.addWidget(content_frame)
+
+ action_row = QHBoxLayout()
+ action_row.setContentsMargins(0, 0, 0, 0)
+ action_row.addStretch(1)
+
+ continue_button = PrimaryButton()
+ continue_button.setText("ΠΡΠΎΠ΄ΠΎΠ»ΠΆΠΈΡΡ")
+ continue_button.setMinimumHeight(42)
+ continue_button.clicked.connect(self.accept)
+ action_row.addWidget(continue_button)
+
+ container_layout.addLayout(action_row)
+
+ shadow = QGraphicsDropShadowEffect()
+ shadow.setBlurRadius(20)
+ shadow.setColor(QColor(0, 0, 0, int(255 * 0.10)))
+ shadow.setOffset(0, 5)
+ self.container.setGraphicsEffect(shadow)
+
+ main_layout = QVBoxLayout()
+ main_layout.setContentsMargins(20, 20, 20, 20)
+ main_layout.addWidget(self.container)
+ self.setLayout(main_layout)