From 8351f328119928e71a13eb3827747cb26d1a805a Mon Sep 17 00:00:00 2001 From: LouisMazin Date: Sun, 18 Jan 2026 16:40:28 +0100 Subject: [PATCH] Initial commit --- .gitignore | 89 ++++ BUILD.spec | 108 ++++ app/core/alert_manager.py | 79 +++ app/core/language_manager.py | 40 ++ app/core/license_manager.py | 220 ++++++++ app/core/main_manager.py | 51 ++ app/core/observer_manager.py | 39 ++ app/core/settings_manager.py | 181 +++++++ app/core/theme_manager.py | 218 ++++++++ app/core/update_manager.py | 205 ++++++++ app/ui/main_window.py | 277 ++++++++++ app/ui/widgets/loading_bar.py | 20 + app/ui/widgets/loading_spinner.py | 66 +++ app/ui/widgets/tabs_widget.py | 760 ++++++++++++++++++++++++++++ app/ui/windows/activation_window.py | 241 +++++++++ app/ui/windows/settings_window.py | 105 ++++ app/ui/windows/splash_screen.py | 159 ++++++ app/ui/windows/suggestion_window.py | 143 ++++++ app/utils/licence.py | 66 +++ app/utils/paths.py | 57 +++ config.json | 23 + data/assets/icon.icns | Bin 0 -> 55740 bytes data/assets/icon.ico | Bin 0 -> 8591 bytes data/assets/icon.png | Bin 0 -> 16156 bytes data/assets/license.svg | 4 + data/assets/settings.svg | 3 + data/assets/splash.png | Bin 0 -> 54826 bytes data/assets/suggestion.svg | 9 + data/lang/en.json | 67 +++ data/lang/fr.json | 68 +++ data/others/defaults_settings.json | 6 + data/themes/dark.json | 16 + data/themes/light.json | 16 + main.py | 100 ++++ requirements.txt | 4 + tools/build.bat | 91 ++++ tools/open.bat | 53 ++ 37 files changed, 3584 insertions(+) create mode 100644 .gitignore create mode 100644 BUILD.spec create mode 100644 app/core/alert_manager.py create mode 100644 app/core/language_manager.py create mode 100644 app/core/license_manager.py create mode 100644 app/core/main_manager.py create mode 100644 app/core/observer_manager.py create mode 100644 app/core/settings_manager.py create mode 100644 app/core/theme_manager.py create mode 100644 app/core/update_manager.py create mode 100644 app/ui/main_window.py create mode 100644 app/ui/widgets/loading_bar.py create mode 100644 app/ui/widgets/loading_spinner.py create mode 100644 app/ui/widgets/tabs_widget.py create mode 100644 app/ui/windows/activation_window.py create mode 100644 app/ui/windows/settings_window.py create mode 100644 app/ui/windows/splash_screen.py create mode 100644 app/ui/windows/suggestion_window.py create mode 100644 app/utils/licence.py create mode 100644 app/utils/paths.py create mode 100644 config.json create mode 100644 data/assets/icon.icns create mode 100644 data/assets/icon.ico create mode 100644 data/assets/icon.png create mode 100644 data/assets/license.svg create mode 100644 data/assets/settings.svg create mode 100644 data/assets/splash.png create mode 100644 data/assets/suggestion.svg create mode 100644 data/lang/en.json create mode 100644 data/lang/fr.json create mode 100644 data/others/defaults_settings.json create mode 100644 data/themes/dark.json create mode 100644 data/themes/light.json create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 tools/build.bat create mode 100644 tools/open.bat diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..347c5af --- /dev/null +++ b/.gitignore @@ -0,0 +1,89 @@ +# Fichiers système +.DS_Store +Thumbs.db +desktop.ini + +# Dossiers de build et distributions +/build/ +/dist/ +/out/ +/release/ +*.exe +*.msi +*.dmg +*.pkg +*.app + +# Fichiers d'environnement et de configuration +.env +.env.local +.env.development +.env.test +.env.production +LINenv*/ +WINenv*/ +MACenv*/ + +# Fichiers de dépendances +/node_modules/ +/.pnp/ +.pnp.js +/vendor/ +/packages/ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Logs et bases de données +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +*.sqlite +*.db + +# Fichiers d'IDE et d'éditeurs +.idea/ +.vscode/ +*.swp +*.swo +*.sublime-workspace +*.sublime-project +.vs/ +*.user +*.userosscache +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Fichiers temporaires +/tmp/ +/temp/ +*.tmp +*.bak +*~ + +# Fichiers spécifiques à l'application +# Ne pas ignorer le fichier config.json +!config.json + +# Dossier de cache +.cache/ +.parcel-cache/ + +# Fichiers de couverture de test +coverage/ +.nyc_output/ +.coverage +htmlcov/ + +# Autres +.vercel +.next +.nuxt +.serverless/ + diff --git a/BUILD.spec b/BUILD.spec new file mode 100644 index 0000000..bf1dc03 --- /dev/null +++ b/BUILD.spec @@ -0,0 +1,108 @@ +# -*- mode: python ; coding: utf-8 -*- +import json +import sys +import os +from pathlib import Path +from os import getenv + +# --- Load config.json --- +config_path = Path("../config.json") +with config_path.open("r", encoding="utf-8") as f: + config = json.load(f) + +# --- Extract values --- +python_version = config.get("python_version", "3.x") +app_name = config.get("app_name", "Application") + +# --- Construct dynamic name --- +name = f"{app_name}" + +# --- Optional icon path --- +icon = getenv("ICON_PATH", "") + +# --- Data files to bundle --- +datas = [ + ("data/assets/*", "data/assets/"), + ("data/", "data/"), + ("config.json", "."), + (".env", "."), +] +binaries = [] + +# --- Analysis --- +a = Analysis( + ["main.py"], + pathex=[], + binaries=binaries, + datas=datas, + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) + +pyz = PYZ(a.pure) + +# --- EXE common to all platforms --- +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name=name, + icon=icon if icon else None, + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, # pas de terminal par défaut + disable_windowed_traceback=False, + argv_emulation=(sys.platform == "darwin"), # utile sur macOS GUI + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) + +# --- Platform-specific targets --- +if sys.platform == "darwin": + # macOS: wrap EXE in a .app bundle + app = BUNDLE( + exe, + name=f"{name}.app", + icon=icon if icon else None, + bundle_identifier=f"com.example.{app_name.lower()}" + ) + # La dernière variable = objet final PyInstaller doit construire + coll = app + +elif sys.platform.startswith("linux"): + # Linux: keep binary + generate .desktop file + coll = exe + + dist_dir = Path("build") + dist_bin = dist_dir / name + desktop_file = dist_dir / f"{app_name}.desktop" + + desktop_content = f"""[Desktop Entry] +Type=Application +Name={app_name} +Exec={dist_bin} +Icon={icon if icon else "application-default-icon"} +Terminal=false +Categories=Utility; +""" + # Création post-build + os.makedirs(dist_dir, exist_ok=True) + with open(desktop_file, "w", encoding="utf-8") as f: + f.write(desktop_content) + os.chmod(desktop_file, 0o755) + +else: + # Windows: just the exe + coll = exe diff --git a/app/core/alert_manager.py b/app/core/alert_manager.py new file mode 100644 index 0000000..edfb1c7 --- /dev/null +++ b/app/core/alert_manager.py @@ -0,0 +1,79 @@ +from PyQt6.QtWidgets import QMessageBox + +class AlertManager: + + def __init__(self, language_manager, theme_manager) -> None: + self.language_manager = language_manager + self.theme_manager = theme_manager + + def show_info(self, info_text: str, parent=None) -> None: + info_title = self.language_manager.get_text("information") + + QMessageBox.information(parent, info_title, info_text) + + def show_success(self, success_key: str, parent=None) -> None: + success_title = self.language_manager.get_text("success") + success_text = self.language_manager.get_text(success_key) + + QMessageBox.information(parent, success_title, success_text) + + def show_error(self, error_key: str, parent=None) -> None: + + error_title = self.language_manager.get_text("error") + error_text = self.language_manager.get_text(error_key) + + QMessageBox.critical(parent, error_title, error_text) + + def show_choice(self, message: str, parent=None) -> bool: + """ + Affiche une boîte de dialogue Oui/Non. + Si detailed_text est fourni, l'ajoute comme texte détaillé (affichable par l'utilisateur). + """ + box = QMessageBox(parent) + box.setWindowTitle(self.language_manager.get_text("confirmation")) + box.setText(message) + box.setIcon(QMessageBox.Icon.Question) + yes = box.addButton(QMessageBox.StandardButton.Yes) + no = box.addButton(QMessageBox.StandardButton.No) + yes.setText(self.language_manager.get_text("yes")) + no.setText(self.language_manager.get_text("no")) + box.setDefaultButton(yes) + box.exec() + return box.clickedButton() == yes + + def show_choice_with_details(self, message: str, parent=None, details_callback=None) -> bool: + """ + Affiche une boîte de dialogue Oui/Non avec un bouton Détails. + Si detailed_text est fourni, l'ajoute comme texte détaillé (affichable par l'utilisateur). + Le callback details_callback est appelé lorsque l'utilisateur clique sur le bouton Détails. + """ + box = QMessageBox(parent) + box.setWindowTitle(self.language_manager.get_text("confirmation")) + box.setText(message) + box.setIcon(QMessageBox.Icon.Question) + yes = box.addButton(QMessageBox.StandardButton.Yes) + no = box.addButton(QMessageBox.StandardButton.No) + details = box.addButton(self.language_manager.get_text("details"), QMessageBox.ButtonRole.ActionRole) + yes.setText(self.language_manager.get_text("yes")) + no.setText(self.language_manager.get_text("no")) + box.setDefaultButton(yes) + + def on_button_clicked(button): + if button == details and details_callback: + box.setResult(QMessageBox.StandardButton.NoButton) + details_callback() + if not box.isVisible(): + box.show() + + box.buttonClicked.connect(on_button_clicked) + + while True: + box.exec() + clicked_button = box.clickedButton() + + # Si c'est le bouton détails, on continue la boucle sans fermer + if clicked_button == details: + continue + # Sinon, on sort de la boucle et retourne le résultat + else: + return clicked_button == yes \ No newline at end of file diff --git a/app/core/language_manager.py b/app/core/language_manager.py new file mode 100644 index 0000000..fe689ce --- /dev/null +++ b/app/core/language_manager.py @@ -0,0 +1,40 @@ +from json import JSONDecodeError, load +from os import listdir, path +from typing import Dict +import app.utils.paths as paths +from app.core.settings_manager import SettingsManager + +class LanguageManager: + """ + Gère les traductions pour l'application. + Charge les fichiers JSON de langue dans data/lang/, permet + de changer la langue courante, et récupérer des textes traduits. + """ + def __init__(self, settings_manager: SettingsManager) -> None: + self.translations: Dict[str, Dict[str, str]] = {} + self.settings_manager: SettingsManager = settings_manager + + self.load_all_translations() + + def load_all_translations(self) -> None: + """ + Charge tous les fichiers JSON dans data/lang/ comme dictionnaires. + """ + lang_dir: str = paths.get_lang_path() + + for filename in listdir(lang_dir): + if filename.endswith(".json"): + lang_code: str = filename[:-5] # strip .json + file_path: str = path.join(lang_dir, filename) + try: + with open(file_path, "r", encoding="utf-8") as f: + self.translations[lang_code] = load(f) + except JSONDecodeError as e: + pass + + def get_text(self, key: str) -> str: + """ + Retourne le texte traduit correspondant à la clé dans la langue courante. + Si la clé n'existe pas, retourne la clé elle-même. + """ + return self.translations.get(self.settings_manager.get_language(), {}).get(key, key) diff --git a/app/core/license_manager.py b/app/core/license_manager.py new file mode 100644 index 0000000..5effd3d --- /dev/null +++ b/app/core/license_manager.py @@ -0,0 +1,220 @@ +import hashlib +import platform +import uuid +import requests +from datetime import datetime +from typing import Dict +from PyQt6.QtCore import QObject, pyqtSignal +import os +from dotenv import load_dotenv +import app.utils.paths as paths + +# Load environment variables +load_dotenv(paths.resource_path(".env")) + +class LicenseManager(QObject): + """Gestionnaire de licences avec liaison matérielle""" + + license_activated = pyqtSignal(str) # Signal émis lors de l'activation + license_expired = pyqtSignal() + + def __init__(self, settings_manager): + super().__init__() + self.settings_manager = settings_manager + self.licensing_enabled = settings_manager.get_config("enable_licensing") + + # Si le système de licence est désactivé, initialiser en mode gratuit + if not self.licensing_enabled: + self.hardware_id = None + self.license_key = "" + self.license_data = None + return + + # Charger l'URL de l'API depuis .env + self.api_url = os.getenv("LICENSE_API_URL") + if not self.api_url: + raise ValueError("LICENSE_API_URL non définie dans le fichier .env") + + self.hardware_id = self._get_hardware_id() + + # Charger la licence sauvegardée + self.license_key = settings_manager.settings.value("license_key", "") + self.license_data = None + + if self.license_key: + self._load_license_data() + + def _get_hardware_id(self) -> str: + """Génère un identifiant unique basé sur le matériel""" + # Combine plusieurs éléments matériels pour un ID unique + mac = ':'.join(['{:02x}'.format((uuid.getnode() >> elements) & 0xff) + for elements in range(0, 2*6, 2)][::-1]) + system = platform.system() + machine = platform.machine() + processor = platform.processor() + + # Créer un hash unique + unique_string = f"{mac}{system}{machine}{processor}" + return hashlib.sha256(unique_string.encode()).hexdigest() + + def get_hardware_id(self) -> str: + """Retourne le hardware ID pour l'afficher à l'utilisateur""" + return self.hardware_id + + def activate_license(self, license_key: str) -> Dict: + """ + Active une licence avec le serveur + + Returns: + dict: {"success": bool, "message": str, "data": dict} + """ + try: + response = requests.post( + f"{self.api_url}/activate", + json={ + "license_key": license_key, + "hardware_id": self.hardware_id, + "app_version": self.settings_manager.get_config("app_version"), + "platform": platform.system() + }, + timeout=10 + ) + + data = response.json() + + if response.status_code == 200 and data.get("success"): + # Sauvegarder la licence + self.license_key = license_key + self.license_data = data.get("license_data", {}) + self.settings_manager.settings.setValue("license_key", license_key) + self.settings_manager.settings.setValue("license_data", self.license_data) + + self.license_activated.emit(license_key) + + return { + "success": True, + "message": "Licence activée avec succès", + "data": self.license_data + } + else: + return { + "success": False, + "message": data.get("message", "Erreur d'activation"), + "data": None + } + + except requests.exceptions.RequestException as e: + return { + "success": False, + "message": f"Erreur de connexion au serveur: {str(e)}", + "data": None + } + + def verify_license(self) -> bool: + """Vérifie la validité de la licence avec le serveur""" + if not self.license_key: + return False + + try: + response = requests.post( + f"{self.api_url}/verify", + json={ + "license_key": self.license_key, + "hardware_id": self.hardware_id + }, + timeout=10 + ) + + data = response.json() + + if response.status_code == 200 and data.get("valid"): + self.license_data = data.get("license_data", {}) + self.settings_manager.settings.setValue("license_data", self.license_data) + return True + else: + return False + + except requests.exceptions.RequestException: + # En cas d'erreur réseau, utiliser les données en cache + return self._verify_offline() + + def _verify_offline(self) -> bool: + """Vérification hors ligne basique""" + if not self.license_key or not self.license_data: + return False + + # Vérifier que le hardware_id correspond + if self.license_data.get("hardware_id") != self.hardware_id: + return False + + # Vérifier la date d'expiration si applicable + expires_at = self.license_data.get("expires_at") + if expires_at: + expiry_date = datetime.fromisoformat(expires_at) + if datetime.now() > expiry_date: + self.license_expired.emit() + return False + + return True + + def _load_license_data(self): + """Charge les données de licence depuis les settings""" + self.license_data = self.settings_manager.settings.value("license_data", {}) + + def is_activated(self) -> bool: + """Vérifie si l'application est activée""" + # Si le système de licence est désactivé, toujours retourner False + if not self.licensing_enabled: + return False + + return bool(self.license_key) and self.verify_license() + + def get_license_type(self) -> str: + """Retourne le type de licence (free, premium, etc.)""" + # Si le système de licence est désactivé, retourner "free" + if not self.licensing_enabled: + return None + + if not self.license_data: + return None + return self.license_data.get("type", None) + + def is_feature_available(self, feature_id: str) -> bool: + """ + Vérifie si une fonctionnalité est disponible + + Args: + feature_id: Identifiant de la fonctionnalité (ex: "advanced_export") + """ + # Si le système de licence est désactivé, toutes les fonctionnalités sont disponibles + if not self.licensing_enabled: + return True + + # Si pas de licence, vérifier dans la config des features gratuites + if not self.is_activated(): + free_features = self.settings_manager.get_config("features_by_license").get(None, []) + return feature_id in free_features + + # Vérifier les features autorisées par la licence + license_type = self.get_license_type() + features_by_type = self.settings_manager.get_config("features_by_license") + allowed_features = features_by_type.get(license_type, []) + + return feature_id in allowed_features + + def get_license_info(self) -> Dict: + """Retourne les informations de la licence""" + if not self.license_data: + return { + "type": None, + "status": "inactive", + "expires_at": None + } + + return { + "type": self.get_license_type(), + "status": "active" if self.is_activated() else "inactive", + "expires_at": self.license_data.get("expires_at"), + "email": self.license_data.get("email"), + "activated_at": self.license_data.get("activated_at") + } \ No newline at end of file diff --git a/app/core/main_manager.py b/app/core/main_manager.py new file mode 100644 index 0000000..b6f9077 --- /dev/null +++ b/app/core/main_manager.py @@ -0,0 +1,51 @@ +from app.core.observer_manager import ObserverManager, NotificationType +from app.core.language_manager import LanguageManager +from app.core.theme_manager import ThemeManager +from app.core.settings_manager import SettingsManager +from app.core.alert_manager import AlertManager +from app.core.update_manager import UpdateManager +from app.core.license_manager import LicenseManager + +from typing import Optional + +class MainManager: + _instance: Optional['MainManager'] = None + + def __init__(self) -> None: + if MainManager._instance is not None: + raise Exception("This class is a singleton!") + else: + MainManager._instance = self + self.observer_manager: ObserverManager = ObserverManager() + self.theme_manager: ThemeManager = ThemeManager() + self.settings_manager: SettingsManager = SettingsManager(self.observer_manager, self.theme_manager) + self.language_manager: LanguageManager = LanguageManager(self.settings_manager) + self.alert_manager: AlertManager = AlertManager(self.language_manager, self.theme_manager) + self.update_manager: UpdateManager = UpdateManager(self.settings_manager, self.language_manager, self.alert_manager) + self.license_manager: LicenseManager = LicenseManager(self.settings_manager) + @classmethod + def get_instance(cls) -> 'MainManager': + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def get_observer_manager(self) -> ObserverManager: + return self.observer_manager + + def get_theme_manager(self) -> ThemeManager: + return self.theme_manager + + def get_settings_manager(self) -> SettingsManager: + return self.settings_manager + + def get_language_manager(self) -> LanguageManager: + return self.language_manager + + def get_alert_manager(self) -> AlertManager: + return self.alert_manager + + def get_update_manager(self) -> UpdateManager: + return self.update_manager + + def get_license_manager(self) -> LicenseManager: + return self.license_manager \ No newline at end of file diff --git a/app/core/observer_manager.py b/app/core/observer_manager.py new file mode 100644 index 0000000..e1f8364 --- /dev/null +++ b/app/core/observer_manager.py @@ -0,0 +1,39 @@ +from typing import Callable, Dict, List, Any + +class NotificationType: + THEME = 0 + LANGUAGE = 1 + +class ObserverManager: + """ + Gestionnaire d'observateurs pour gérer les événements et notifier les abonnés. + Chaque événement est identifié par une clé (string), et les abonnés sont des callbacks. + """ + def __init__(self): + self.observers: Dict[str, List[Callable[[Any], None]]] = {} + + def subscribe(self, event: str, callback: Callable[[Any], None]) -> None: + """ + Ajoute un callback à la liste des abonnés pour l'événement donné. + """ + if event not in self.observers: + self.observers[event] = [] + if callback not in self.observers[event]: + self.observers[event].append(callback) + + def unsubscribe(self, event: str, callback: Callable[[Any], None]) -> None: + """ + Retire un callback de la liste des abonnés pour l'événement donné. + """ + if event in self.observers and callback in self.observers[event]: + self.observers[event].remove(callback) + if not self.observers[event]: + del self.observers[event] + + def notify(self, event: NotificationType) -> None: + """ + Notifie tous les abonnés de l'événement donné, en leur passant la donnée. + """ + if event in self.observers: + for callback in self.observers[event]: + callback() diff --git a/app/core/settings_manager.py b/app/core/settings_manager.py new file mode 100644 index 0000000..c9e138b --- /dev/null +++ b/app/core/settings_manager.py @@ -0,0 +1,181 @@ +from PyQt6.QtCore import QSettings +from app.core.observer_manager import ObserverManager, NotificationType +from app.core.theme_manager import ThemeManager +from os import path +import app.utils.paths as paths +import json +import logging +from typing import Dict, Any + +# Configure logging +logger: logging.Logger = logging.getLogger(__name__) + +class SettingsManager: + """ + Gestion des paramètres utilisateurs avec sauvegarde persistante via QSettings. + Notifie les changements via ObserverManager. + """ + def __init__(self, observer_manager: ObserverManager, theme_manager: ThemeManager) -> None: + self.observer_manager: ObserverManager = observer_manager + self.theme_manager: ThemeManager = theme_manager + + # Load default settings + self.default_settings: Dict[str, Any] = self._load_default_settings() + # Load config + self.config: Dict[str, Any] = self._load_config() + + # Initialize QSettings with error handling + self.settings: QSettings = self._initialize_qsettings() + + # Set initial theme + try: + self.theme_manager.set_theme(self.get_theme()) + except Exception as e: + logger.error(f"Error setting initial theme: {e}") + self.theme_manager.set_theme(self.fallback_settings["theme"]) + + def _load_default_settings(self) -> Dict[str, Any]: + """Load default settings with error handling""" + try: + defaults_path = path.join(paths.get_data_dir(), "others", "defaults_settings.json") + if path.exists(defaults_path): + with open(defaults_path, 'r', encoding='utf-8') as f: + settings = json.load(f) + return settings + else: + logger.warning(f"defaults_settings.json not found") + return {} + except (json.JSONDecodeError, FileNotFoundError, KeyError, UnicodeDecodeError) as e: + logger.error(f"Error loading default settings: {e}") + return {} + except Exception as e: + logger.error(f"Unexpected error loading default settings: {e}") + return {} + + def _load_config(self) -> Dict[str, Any]: + """Load config.json with error handling""" + try: + config_path = paths.resource_path("config.json") + if path.exists(config_path): + with open(config_path, 'r', encoding='utf-8') as f: + config = json.load(f) + return config + else: + logger.warning("config.json not found") + return {} + except (json.JSONDecodeError, FileNotFoundError, KeyError, UnicodeDecodeError) as e: + logger.error(f"Error loading config: {e}") + return {} + except Exception as e: + logger.error(f"Unexpected error loading config: {e}") + return {} + + def _initialize_qsettings(self) -> QSettings: + """Initialize QSettings with error handling""" + try: + app_name = self.get_config("app_name") + user_data_dir = paths.get_user_data_dir(app_name) + settings_path = path.join(user_data_dir, f"{app_name}.ini") + return QSettings(settings_path, QSettings.Format.IniFormat) + except Exception as e: + logger.error(f"Error initializing QSettings: {e}") + # Fallback to memory-only settings + return QSettings() + + # Config + def get_config(self, key: str) -> Any: + """Get configuration value by key""" + try: + return self.config.get(key) + except Exception as e: + logger.error(f"Error getting config key '{key}': {e}") + return None + + # Theme + def get_theme(self) -> str: + """Get the current theme""" + try: + return self.settings.value("theme", self.default_settings.get("theme")) + except Exception as e: + logger.error(f"Error getting theme: {e}") + return "" + + def set_theme(self, mode: str) -> None: + """Set the application theme""" + try: + if mode != self.get_theme(): + self.settings.setValue("theme", mode) + self.theme_manager.set_theme(mode) + self.observer_manager.notify(NotificationType.THEME) + except Exception as e: + logger.error(f"Error setting theme: {e}") + + # Language + def get_language(self) -> str: + """Get the current language""" + try: + return self.settings.value("lang", self.default_settings.get("lang")) + except Exception as e: + logger.error(f"Error getting language: {e}") + return "" + + def set_language(self, lang_code: str) -> None: + """Set the application language""" + try: + if lang_code != self.get_language(): + self.settings.setValue("lang", lang_code) + self.observer_manager.notify(NotificationType.LANGUAGE) + except Exception as e: + logger.error(f"Error setting language: {e}") + + # Window size and maximized + def get_window_size(self) -> Dict[str, int]: + """Get the window size""" + try: + size = self.settings.value("window_size", self.default_settings.get("window_size")) + # Validate window size values + if isinstance(size, dict) and "width" in size and "height" in size: + width = int(size["width"]) if size["width"] > 0 else 1000 + height = int(size["height"]) if size["height"] > 0 else 600 + return {"width": width, "height": height} + else: + return {"width": 1000, "height": 600} + except Exception as e: + logger.error(f"Error getting window size: {e}") + return {"width": 1000, "height": 600} + + def set_window_size(self, width: int, height: int) -> None: + """Set the window size""" + try: + # Validate input values + if width <= 0 or height <= 0: + logger.warning(f"Invalid window size: {width}x{height}") + return + + current_size = self.get_window_size() + if current_size["width"] != width or current_size["height"] != height: + new_size = {"width": width, "height": height} + self.settings.setValue("window_size", new_size) + except Exception as e: + logger.error(f"Error setting window size: {e}") + + def get_maximized(self) -> bool: + """Check if the window is maximized""" + try: + value = self.settings.value("maximized", self.default_settings.get("maximized")) + if isinstance(value, str): + return value.lower() == "true" + elif isinstance(value, bool): + return value + else: + return False + except Exception as e: + logger.error(f"Error getting maximized state: {e}") + return False + + def set_maximized(self, maximized: bool) -> None: + """Set the window maximized state""" + try: + self.settings.setValue("maximized", maximized) + except Exception as e: + logger.error(f"Error setting maximized state: {e}") \ No newline at end of file diff --git a/app/core/theme_manager.py b/app/core/theme_manager.py new file mode 100644 index 0000000..d42f4b9 --- /dev/null +++ b/app/core/theme_manager.py @@ -0,0 +1,218 @@ +import app.utils.paths as paths +import os, json +from typing import List, Dict, Any, Optional + +class Theme: + def __init__(self, name: str, colors: Dict[str, str]) -> None: + self.name: str = name + self.colors: Dict[str, str] = colors + + def get_color(self, element: str) -> str: + return self.colors.get(element, "#FFFFFF") + +class ThemeManager: + + def __init__(self) -> None: + theme_path: str = os.path.join(paths.get_data_dir(), "themes") + self.themes: List[Theme] = [] + for theme_file in os.listdir(theme_path): + if theme_file.endswith(".json"): + with open(os.path.join(theme_path, theme_file), 'r', encoding='utf-8') as f: + theme_data: Dict[str, Any] = json.load(f) + theme: Theme = Theme(theme_data["theme_name"], theme_data["colors"]) + self.themes.append(theme) + self.current_theme: Theme = self.themes[0] + + def set_theme(self, theme: str) -> None: + if theme != self.current_theme.name: + found_theme: Optional[Theme] = next((t for t in self.themes if t.name == theme), None) + if found_theme: + self.current_theme = found_theme + + def get_theme(self) -> Theme: + return self.current_theme + def get_themes(self) -> List[Theme]: + return self.themes + def get_sheet(self) -> str: + return f""" + QWidget {{ + background-color: {self.current_theme.get_color("background_secondary_color")}; + color: {self.current_theme.get_color("text_color")}; + }} + QLabel {{ + background-color: transparent; + color: {self.current_theme.get_color("text_color")}; + font-size: 20px; + }} + QPushButton {{ + background-color: {self.current_theme.get_color("primary_color")}; + color: {self.current_theme.get_color("text_color")}; + border-radius: 8px; + font-size: 16px; + padding: 10px 20px; + border: none; + }} + QPushButton:hover {{ + background-color: {self.current_theme.get_color("primary_hover_color")}; + }} + QProgressBar {{ + border: 1px solid {self.current_theme.get_color("border_color")}; + border-radius: 5px; + background-color: {self.current_theme.get_color("background_secondary_color")}; + text-align: center; + color: {self.current_theme.get_color("text_color")}; + }} + QProgressBar::chunk {{ + background-color: {self.current_theme.get_color("primary_color")}; + border-radius: 3px; + }} + QTextEdit {{ + border: 2px solid {self.current_theme.get_color("border_color")}; + border-radius: 8px; + padding: 5px; + font-size: 14px; + background-color: {self.current_theme.get_color("background_tertiary_color")}; + color: {self.current_theme.get_color("text_color")}; + }} + QLineEdit {{ + border: 2px solid {self.current_theme.get_color("border_color")}; + border-radius: 8px; + padding: 5px; + font-size: 14px; + background-color: {self.current_theme.get_color("background_tertiary_color")}; + color: {self.current_theme.get_color("text_color")}; + }} + + QDateEdit {{ + border: 2px solid {self.current_theme.get_color("border_color")}; + padding: 5px; + border-radius: 8px; + font-size: 14px; + min-height: 30px; + }} + QDateEdit::drop-down {{ + border: none; + background: transparent; + }} + QDateEdit:hover {{ + border-color: {self.current_theme.get_color("primary_hover_color")}; + }} + QComboBox {{ + border: 2px solid {self.current_theme.get_color("border_color")}; + padding: 5px; + border-radius: 8px; + font-size: 14px; + min-height: 30px; + }} + QComboBox QAbstractItemView {{ + border-radius: 8px; + padding: 0px; + outline: none; + }} + QComboBox QAbstractItemView::item {{ + padding: 12px 15px; + margin: 0px; + min-height: 20px; + border: 1px solid {self.current_theme.get_color("border_color")}; + border-radius: 8px; + }} + QComboBox QAbstractItemView::item:hover {{ + background-color: {self.current_theme.get_color("background_tertiary_color")}; + color: {self.current_theme.get_color("text_color")}; + }} + QComboBox QAbstractItemView::item:selected {{ + color: {self.current_theme.get_color("text_color")}; + }} + QComboBox::drop-down {{ + border: none; + background: transparent; + }} + QComboBox::down-arrow {{ + image: none; + }} + QComboBox:hover {{ + border-color: {self.current_theme.get_color("primary_hover_color")}; + }} + + #table_combobox {{ + border: 1px solid {self.current_theme.get_color("border_color")}; + padding: 2px 5px; + border-radius: 4px; + font-size: 12px; + min-height: 20px; + max-height: 28px; + }} + #table_combobox::drop-down {{ + border: none; + background: transparent; + }} + #table_combobox::down-arrow {{ + image: none; + }} + #table_combobox QAbstractItemView {{ + border-radius: 4px; + padding: 0px; + outline: none; + }} + #table_combobox QAbstractItemView::item {{ + padding: 8px 10px; + margin: 0px; + min-height: 16px; + border: 1px solid {self.current_theme.get_color("border_color")}; + border-radius: 4px; + }} + #table_combobox QAbstractItemView::item:hover {{ + background-color: {self.current_theme.get_color("background_tertiary_color")}; + color: {self.current_theme.get_color("text_color")}; + }} + #table_combobox QAbstractItemView::item:selected {{ + color: {self.current_theme.get_color("text_color")}; + }} + + QSlider::groove:horizontal {{ + border: 1px solid {self.current_theme.get_color("primary_color")}; + height: 10px; + background: transparent; + border-radius: 5px; + }} + QSlider::sub-page:horizontal {{ + background: {self.current_theme.get_color("primary_color")}; + border-radius: 5px; + }} + QSlider::add-page:horizontal {{ + background: {self.current_theme.get_color("background_tertiary_color")}; + border-radius: 5px; + }} + QSlider::handle:horizontal {{ + background: white; + border: 2px solid {self.current_theme.get_color("primary_color")}; + width: 14px; + margin: -4px 0; + border-radius: 7px; + }} + QScrollBar:vertical {{ + border: none; + background: {self.current_theme.get_color("background_tertiary_color")}; + width: 8px; + margin: 0px; + }} + QScrollBar::handle:vertical {{ + background: {self.current_theme.get_color("primary_color")}; + border-radius: 4px; + min-height: 20px; + }} + QScrollBar::handle:vertical:hover {{ + background: {self.current_theme.get_color("primary_hover_color")}; + }} + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + border: none; + background: none; + height: 0px; + }} + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ + background: none; + }} + #tab_bar {{ + background-color: {self.current_theme.get_color("background_color")}; + }} + """ \ No newline at end of file diff --git a/app/core/update_manager.py b/app/core/update_manager.py new file mode 100644 index 0000000..730e364 --- /dev/null +++ b/app/core/update_manager.py @@ -0,0 +1,205 @@ +import requests +from packaging import version +from PyQt6.QtWidgets import QApplication +from PyQt6.QtWidgets import QFileDialog, QDialog, QVBoxLayout, QTextEdit, QPushButton +from app.core.alert_manager import AlertManager +from app.core.settings_manager import SettingsManager +from app.core.language_manager import LanguageManager +from app.ui.widgets.loading_bar import LoadingBar +import os +import sys +import subprocess +from typing import List, Dict + +class UpdateManager: + def __init__(self, settings_manager: SettingsManager, language_manager: LanguageManager, alert_manager: AlertManager) -> None: + self.settings_manager = settings_manager + self.language_manager = language_manager + self.alert_manager = alert_manager + self.repo_url = self.settings_manager.get_config("git_repo") + self.app_name = self.settings_manager.get_config("app_name").replace(" ","_") + self.app_os = self.settings_manager.get_config("app_os") + self.arch = self.settings_manager.get_config("architecture") + + def get_releases_with_asset(self) -> List[Dict]: + """ + Retourne la liste des releases (dict) qui contiennent un asset correspondant + à l'OS/architecture attendus. Chaque dict contient: tag_name, download_url, body. + """ + releases_list: List[Dict] = [] + try: + if "gitea" in self.repo_url: + # Gitea: construire URL API (essai basique) + owner_repo = self.repo_url.rstrip("/").split("/")[-2:] + api_base = self.repo_url.replace("/" + owner_repo[0] + "/" + owner_repo[1], "/api/v1/repos/" + owner_repo[0] + "/" + owner_repo[1]) + api_url = api_base + "/releases" + resp = requests.get(api_url) + resp.raise_for_status() + releases = resp.json() + else: + owner_repo = self.repo_url.rstrip("/").split("/")[-2:] + api_url = f"https://api.github.com/repos/{owner_repo[0]}/{owner_repo[1]}/releases" + resp = requests.get(api_url) + resp.raise_for_status() + releases = resp.json() + + expected_filename_frag = f"{self.app_name}-{self.app_os}-{self.arch}" + for release in releases: + tag = release.get("tag_name") or release.get("name") + body = release.get("body", "") or "" + for asset in release.get("assets", []): + name = asset.get("name", "") + if expected_filename_frag in name: + downloads = asset.get("browser_download_url") or asset.get("url") + releases_list.append({ + "tag_name": tag, + "download_url": downloads, + "body": body + }) + break + except Exception: + # En cas d'erreur, retourner liste vide (on ne lève pas pour ne pas bloquer l'app) + return [] + return releases_list + + def check_for_update(self, parent=None, splash_screen=None) -> bool: + current_version = self.settings_manager.get_config("app_version") + releases = self.get_releases_with_asset() + release = releases[0] if releases else None + if release and version.parse(release["tag_name"]) > version.parse(current_version): + # Fermer le splash avant d'afficher la boîte de dialogue + if splash_screen: + splash_screen.hide() + + choice = self.show_update_dialog(releases, current_version, parent) + + if choice: + folder = QFileDialog.getExistingDirectory(parent, self.language_manager.get_text("choose_update_folder")) + if folder: + return self.download(release["download_url"], release["tag_name"], folder, parent) + + # Rouvrir le splash si l'utilisateur a refusé la mise à jour + if splash_screen: + splash_screen.show() + + return False + + def download(self, download_url, version, folder, parent=None): + try: + filename = os.path.basename(download_url).replace(".", "-" +version + ".") + local_path = os.path.join(folder, filename) + resp = requests.get(download_url, stream=True) + total = int(resp.headers.get('content-length', 0)) + + # Crée une boîte de dialogue avec la barre de chargement + dialog = QDialog(parent) + dialog.setWindowTitle(self.language_manager.get_text("update")) + layout = QVBoxLayout(dialog) + loading_bar = LoadingBar(self.language_manager.get_text("downloading_update"), dialog) + layout.addWidget(loading_bar) + dialog.setModal(True) + + # Variable pour tracker si le téléchargement a été annulé + download_cancelled = False + + def on_dialog_rejected(): + nonlocal download_cancelled + download_cancelled = True + + dialog.rejected.connect(on_dialog_rejected) + dialog.show() + + downloaded = 0 + with open(local_path, "wb") as f: + for chunk in resp.iter_content(chunk_size=8192): + QApplication.processEvents() + if download_cancelled: + f.truncate(0) + f.close() + break + + if chunk: + f.write(chunk) + downloaded += len(chunk) + percent = int(downloaded * 100 / total) if total else 0 + loading_bar.set_progress(percent) + + dialog.close() + + if download_cancelled: + os.remove(local_path, dir_fd=None) + self.alert_manager.show_info(self.language_manager.get_text("update_aborted"), parent=parent) + return False + + msg = self.language_manager.get_text("update_downloaded").replace("{local_path}", local_path) + self.alert_manager.show_success(msg, parent=parent) + + if sys.platform.startswith("win"): + os.startfile(local_path) + else: + subprocess.Popen(["chmod", "+x", local_path]) + subprocess.Popen([local_path]) + return True + except Exception as e: + self.alert_manager.show_error("update_download_error", parent=parent) + return False + + def show_update_dialog(self, releases: List[Dict], current_version: str, parent=None) -> bool: + """ + Affiche une boîte de dialogue avec options Mettre à jour et Détails via l'alert_manager + """ + latest_release = releases[0] + message = self.language_manager.get_text("update_found").replace("{latest_tag}", latest_release["tag_name"]) + + choice = self.alert_manager.show_choice_with_details( + message, + parent=parent, + details_callback=lambda: self.show_details_dialog(releases, current_version, parent) + ) + + return choice + + def show_details_dialog(self, releases: List[Dict], current_version: str, parent=None) -> None: + """ + Affiche tous les changelogs des versions supérieures à la version actuelle + """ + dialog = QDialog(parent) + dialog.setWindowTitle(self.language_manager.get_text("update_details")) + dialog.setModal(True) + dialog.resize(600, 500) + + layout = QVBoxLayout(dialog) + + # Zone de texte pour afficher les changelogs + text_edit = QTextEdit() + text_edit.setReadOnly(True) + + # Filtrer et trier les releases supérieures à la version actuelle + newer_releases = [] + for release in releases: + if version.parse(release["tag_name"]) > version.parse(current_version): + newer_releases.append(release) + + # Trier par version décroissante (plus récente en premier) + newer_releases.sort(key=lambda x: version.parse(x["tag_name"]), reverse=True) + + # Construire le texte des changelogs + changelog_text = "" + for release in newer_releases: + changelog_text += f"## {self.language_manager.get_text('version')} {release['tag_name']} :\n\n" + body = release['body'].replace('\n','\n### ') + changelog_text += f"### {body}" + changelog_text += "\n\n" + if release != newer_releases[-1]: + changelog_text += "---\n\n" + + text_edit.setAcceptRichText(True) + text_edit.setMarkdown(changelog_text) + layout.addWidget(text_edit) + + # Bouton Fermer + close_button = QPushButton(self.language_manager.get_text("close")) + close_button.clicked.connect(dialog.close) + layout.addWidget(close_button) + + dialog.exec() diff --git a/app/ui/main_window.py b/app/ui/main_window.py new file mode 100644 index 0000000..cef1341 --- /dev/null +++ b/app/ui/main_window.py @@ -0,0 +1,277 @@ +from PyQt6.QtWidgets import QApplication, QMainWindow, QLabel +from PyQt6.QtGui import QResizeEvent, QCloseEvent +from PyQt6.QtCore import QSize, QEvent +from app.core.main_manager import MainManager, NotificationType +from app.ui.widgets.tabs_widget import TabsWidget, MenuDirection, ButtonPosition, BorderSide, TabSide, TextPosition +from app.ui.windows.settings_window import SettingsWindow +from app.ui.windows.suggestion_window import SuggestionWindow +from app.ui.windows.activation_window import ActivationWindow +import app.utils.paths as paths, shutil +from typing import Optional + +class MainWindow(QMainWindow): + def __init__(self) -> None: + super().__init__() + + self.main_manager: MainManager = MainManager.get_instance() + + self.language_manager = self.main_manager.get_language_manager() + self.theme_manager = self.main_manager.get_theme_manager() + self.settings_manager = self.main_manager.get_settings_manager() + self.observer_manager = self.main_manager.get_observer_manager() + self.observer_manager.subscribe(NotificationType.THEME, self.update_theme) + self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language) + self.is_maximizing: bool = False + + # Initialiser les attributs de taille AVANT setup_ui + app: Optional[QApplication] = QApplication.instance() + size: QSize = app.primaryScreen().size() + self.settings_manager.minScreenSize = min(size.height(), size.width()) + + # Initialiser les tailles par défaut + window_size: dict = self.settings_manager.get_window_size() + self.current_size: QSize = QSize(window_size["width"], window_size["height"]) + self.previous_size: QSize = QSize(window_size["width"], window_size["height"]) + + # Configuration des tailles de police de référence + self.base_width = 1200 # Largeur de référence (taille par défaut) + self.base_height = 700 # Hauteur de référence (taille par défaut) + self.base_tab_height = 70 # Hauteur de base du tab menu + + # Cache pour stocker les font-sizes de base de chaque widget + self._base_font_sizes = {} + self._font_sizes_extracted = False # Flag pour savoir si on a déjà extrait les tailles + + # UI elements + self.side_menu: TabsWidget + self.settings_window: SettingsWindow + self.suggestion_window: SuggestionWindow + + self.setMinimumSize(600, 450) + + # Initialiser l'UI immédiatement (sera fait pendant le splash) + self.setup_ui() + + # Différer l'application des paramètres de fenêtre jusqu'à l'affichage réel + self._window_state_applied = False + + def showEvent(self, event): + """Applique les paramètres de fenêtre lors du premier affichage""" + super().showEvent(event) + if not self._window_state_applied: + self.apply_saved_window_state() + self._window_state_applied = True + + def apply_saved_window_state(self) -> None: + """Apply saved window size and maximized state""" + window_size: dict = self.settings_manager.get_window_size() + self.current_size = QSize(window_size["width"], window_size["height"]) + self.previous_size = QSize(window_size["width"], window_size["height"]) + self.resize(self.current_size) + if self.settings_manager.get_maximized(): + self.is_maximizing = True + self.showMaximized() + + def changeEvent(self, event: QEvent) -> None: + """Handle window state changes""" + super().changeEvent(event) + if event.type() == event.Type.WindowStateChange: + if self.isMaximized(): + # On vient de maximiser + self.is_maximizing = False + else: + # On vient de dé-maximiser, restaurer la taille précédente + if hasattr(self, 'previous_size'): + self.current_size = self.previous_size + self.settings_manager.set_maximized(self.isMaximized()) + + def resizeEvent(self, a0: QResizeEvent) -> None: + # Ne pas sauvegarder la taille si on est en train de maximiser + if not self.isMaximized() and not self.is_maximizing: + self.previous_size = self.current_size + self.current_size = self.size() + + # Ajuster dynamiquement les font-sizes avec un ratio + self.adjust_all_font_sizes() + + def adjust_all_font_sizes(self): + """Ajuste dynamiquement les font-sizes de tous les éléments avec un ratio proportionnel""" + # Calculer le ratio basé sur la largeur ET la hauteur actuelle + current_width = self.width() + current_height = self.height() + + # Calculer les ratios séparément + width_ratio = current_width / self.base_width + height_ratio = current_height / self.base_height + + # Utiliser la moyenne des deux ratios pour un scaling plus naturel + # Ou utiliser le minimum pour éviter le débordement + ratio = min(width_ratio,height_ratio) * 1.5 + + # Limiter le ratio pour éviter des tailles extrêmes + ratio = max(0.5, min(ratio, 2.0)) # Entre 50% et 200% + + # Récupérer tous les widgets des tabs + all_widgets = [] + if hasattr(self, 'side_menu'): + all_widgets = self.side_menu.widgets + + # Extraire les tailles de base une seule fois + if not self._font_sizes_extracted: + self._extract_base_font_sizes(all_widgets) + self._font_sizes_extracted = True + + # Parcourir tous les widgets et ajuster leurs tailles + for widget in all_widgets: + if widget: + self._adjust_widget_font_sizes(widget, ratio) + + def _extract_base_font_sizes(self, widgets): + """Extrait les tailles de police de base de tous les widgets une seule fois""" + from PyQt6.QtWidgets import QPushButton, QLineEdit, QTextEdit, QComboBox + + widget_types = [QLabel, QPushButton, QLineEdit, QTextEdit, QComboBox] + # Extraire les tailles des boutons d'onglets du side menu + if hasattr(self, 'side_menu') and hasattr(self.side_menu, 'buttons'): + for button in self.side_menu.buttons: + if button: + widget_id = id(button) + current_style = button.styleSheet() + base_size = self._extract_font_size_from_style(current_style) + if base_size is None: + base_size = 14 # Taille par défaut + self._base_font_sizes[widget_id] = base_size + + for widget in widgets: + if not widget: + continue + + for widget_type in widget_types: + for child in widget.findChildren(widget_type): + widget_id = id(child) + + # Ignorer les widgets avec un objectName (généralement stylisés spécifiquement) + if child.objectName() != "": + continue + + # Extraire la taille de police depuis le stylesheet + current_style = child.styleSheet() + base_size = self._extract_font_size_from_style(current_style) + + # Si pas trouvé dans le style, utiliser la taille par défaut + if base_size is None: + base_size = 14 # Taille par défaut + + # Stocker la taille de base + self._base_font_sizes[widget_id] = base_size + + def _extract_font_size_from_style(self, style: str) -> Optional[int]: + """Extrait la taille de police depuis un stylesheet""" + import re + + # Chercher "font-size: XXpx" + match = re.search(r'font-size:\s*(\d+)px', style) + if match: + return int(match.group(1)) + + return None + + def _adjust_widget_font_sizes(self, widget, ratio): + """Ajuste les font-sizes de tous les éléments d'un widget avec un ratio proportionnel""" + from PyQt6.QtWidgets import QPushButton, QLineEdit, QTextEdit, QComboBox + import re + + # Ajuster les boutons d'onglets du side menu + if hasattr(self, 'side_menu') and hasattr(self.side_menu, 'buttons'): + for button in self.side_menu.buttons: + if button: + widget_id = id(button) + if widget_id in self._base_font_sizes: + base_size = self._base_font_sizes[widget_id] + new_size = max(8, int(base_size * ratio)) + current_style = button.styleSheet() + style_without_font = re.sub(r'font-size:\s*\d+px;?', '', current_style) + style_without_font = re.sub(r';+', ';', style_without_font) + style_without_font = style_without_font.strip() + if style_without_font and not style_without_font.endswith(';'): + style_without_font += ';' + new_style = f"{style_without_font} font-size: {new_size}px;" + button.setStyleSheet(new_style) + + widget_types = [QLabel, QPushButton, QLineEdit, QTextEdit, QComboBox] + + for widget_type in widget_types: + for child in widget.findChildren(widget_type): + widget_id = id(child) + + # Récupérer la taille de base + if widget_id not in self._base_font_sizes: + continue # Pas de taille de base, ignorer + + base_size = self._base_font_sizes[widget_id] + + # Calculer la nouvelle taille avec le ratio + new_size = max(8, int(base_size * ratio)) # Minimum 8px + + # Appliquer le style en préservant les autres propriétés + current_style = child.styleSheet() + + # Retirer l'ancienne font-size + style_without_font = re.sub(r'font-size:\s*\d+px;?', '', current_style) + + # Nettoyer les points-virgules multiples + style_without_font = re.sub(r';+', ';', style_without_font) + style_without_font = style_without_font.strip() + + # Ajouter la nouvelle font-size + if style_without_font and not style_without_font.endswith(';'): + style_without_font += ';' + + new_style = f"{style_without_font} font-size: {new_size}px;" + child.setStyleSheet(new_style) + + def closeEvent(self, event: QCloseEvent) -> None: + """Handle application close event""" + super().closeEvent(event) + # si la difference de taille est plus grande que 10 pixels, enregistrer previoussize + if abs(self.current_size.width() - self.previous_size.width()) > 10 or abs(self.current_size.height() - self.previous_size.height()) > 10: + self.current_size = self.previous_size + self.settings_manager.set_window_size( + self.current_size.width(), + self.current_size.height() + ) + try: + shutil.rmtree(paths.get_user_temp(self.settings_manager.get_config("app_name"))) + except Exception: + pass + + def setup_ui(self) -> None: + + self.side_menu = TabsWidget(self, MenuDirection.HORIZONTAL, 70, None, 10, BorderSide.BOTTOM, TabSide.TOP) + + self.suggestion_window = SuggestionWindow(self) + self.side_menu.add_widget(self.suggestion_window, self.language_manager.get_text("tab_suggestions"), paths.get_asset_svg_path("suggestion"), position=ButtonPosition.CENTER, text_position=TextPosition.BOTTOM) + + self.settings_window = SettingsWindow(self) + self.side_menu.add_widget(self.settings_window, self.language_manager.get_text("tab_settings"), paths.get_asset_svg_path("settings"), position=ButtonPosition.CENTER, text_position=TextPosition.BOTTOM) + + # Ajouter la tab d'activation uniquement si le système de licence est activé + if self.settings_manager.get_config("enable_licensing"): + self.activation_window = ActivationWindow(self) + self.side_menu.add_widget(self.activation_window, self.language_manager.get_text("tab_licensing"), paths.get_asset_svg_path("license"), position=ButtonPosition.END, text_position=TextPosition.BOTTOM) + + self.setCentralWidget(self.side_menu) + + def get_tab_widget(self): + """Retourne le widget TabsWidget pour permettre le changement d'onglet""" + return self.side_menu + + def update_theme(self) -> None: + self.setStyleSheet(self.theme_manager.get_sheet()) + + def update_language(self) -> None: + # Mettre à jour les textes des onglets + self.side_menu.update_button_text(0, self.language_manager.get_text("tab_suggestions")) + self.side_menu.update_button_text(1, self.language_manager.get_text("tab_settings")) + if self.settings_manager.get_config("enable_licensing"): + self.side_menu.update_button_text(2, self.language_manager.get_text("tab_licensing")) \ No newline at end of file diff --git a/app/ui/widgets/loading_bar.py b/app/ui/widgets/loading_bar.py new file mode 100644 index 0000000..bfc61b3 --- /dev/null +++ b/app/ui/widgets/loading_bar.py @@ -0,0 +1,20 @@ +from PyQt6.QtWidgets import QProgressBar, QWidget, QVBoxLayout, QLabel +from PyQt6.QtCore import Qt + +class LoadingBar(QWidget): + def __init__(self, label_text: str = "", parent=None) -> None: + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.label = QLabel(label_text, self) + self.progress = QProgressBar(self) + self.progress.setMinimum(0) + self.progress.setMaximum(100) + layout.addWidget(self.label) + layout.addWidget(self.progress) + + def set_label(self, text: str) -> None: + self.label.setText(text) + + def set_progress(self, value: int) -> None: + self.progress.setValue(value) diff --git a/app/ui/widgets/loading_spinner.py b/app/ui/widgets/loading_spinner.py new file mode 100644 index 0000000..b224347 --- /dev/null +++ b/app/ui/widgets/loading_spinner.py @@ -0,0 +1,66 @@ +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtWidgets import QLabel +from PyQt6.QtGui import QPainter, QPen, QColor +from app.core.main_manager import MainManager +import math + +class LoadingSpinner(QLabel): + def __init__(self, size=40, parent=None): + super().__init__(parent) + self.size = size + self.angle = 0 + self.setFixedSize(size, size) + + # Timer pour l'animation + self.timer = QTimer() + self.timer.timeout.connect(self.rotate) + self.timer.start(50) # 50ms = rotation fluide + + def rotate(self): + self.angle = (self.angle + 10) % 360 + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Obtenir la couleur du thème + main_manager = MainManager.get_instance() + theme = main_manager.get_theme_manager().get_theme() + color = theme.get_color("background_secondary_color") + + # Dessiner le cercle de chargement + rect = self.rect() + center_x, center_y = rect.width() // 2, rect.height() // 2 + radius = min(center_x, center_y) - 5 + + painter.translate(center_x, center_y) + painter.rotate(self.angle) + + # Dessiner les segments du spinner + pen = QPen() + pen.setWidth(3) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + + for i in range(8): + alpha = 255 - (i * 25) # Dégradé d'opacité + pen.setColor(self.hex_to_qcolor(color, alpha)) + painter.setPen(pen) + + angle = i * 45 + start_x = radius * 0.7 * math.cos(math.radians(angle)) + start_y = radius * 0.7 * math.sin(math.radians(angle)) + end_x = radius * math.cos(math.radians(angle)) + end_y = radius * math.sin(math.radians(angle)) + + painter.drawLine(int(start_x), int(start_y), int(end_x), int(end_y)) + + def hex_to_qcolor(self, hex_color, alpha=255): + hex_color = hex_color.lstrip('#') + r = int(hex_color[0:2], 16) + g = int(hex_color[2:4], 16) + b = int(hex_color[4:6], 16) + return QColor(r, g, b, alpha) + + def stop(self): + self.timer.stop() \ No newline at end of file diff --git a/app/ui/widgets/tabs_widget.py b/app/ui/widgets/tabs_widget.py new file mode 100644 index 0000000..6e2ce7a --- /dev/null +++ b/app/ui/widgets/tabs_widget.py @@ -0,0 +1,760 @@ +from PyQt6.QtWidgets import QLayout, QWidget, QHBoxLayout, QVBoxLayout, QPushButton, QStackedWidget, QSizePolicy, QSpacerItem, QLabel +from PyQt6.QtGui import QIcon +from PyQt6.QtCore import QSize, Qt +from enum import Enum +from pathlib import Path +import hashlib +import app.utils.paths as paths +from app.core.main_manager import MainManager, NotificationType +import xml.etree.ElementTree as ET + +class MenuDirection(Enum): + HORIZONTAL = 0 # Barre en haut ou en bas + VERTICAL = 1 # Barre à gauche ou à droite + +class ButtonPosition(Enum): + START = 0 # Au début (aligné à gauche/haut) + END = 1 # À la fin (aligné à droite/bas) + CENTER = 2 # Au centre + AFTER = 3 # Après un bouton spécifique + +class BorderSide(Enum): + LEFT = "left" # Bord gauche + RIGHT = "right" # Bord droit + TOP = "top" # Bord supérieur + BOTTOM = "bottom" # Bord inférieur + NONE = None + +class TextPosition(Enum): + LEFT = 0 # Texte à gauche de l'icône + RIGHT = 1 # Texte à droite de l'icône + TOP = 2 # Texte au-dessus de l'icône + BOTTOM = 3 # Texte en dessous de l'icône + +class TabSide(Enum): + LEFT = 0 # Barre à gauche (pour VERTICAL) + RIGHT = 1 # Barre à droite (pour VERTICAL) + TOP = 0 # Barre en haut (pour HORIZONTAL) + BOTTOM = 1 # Barre en bas (pour HORIZONTAL) + +class TabsWidget(QWidget): + def __init__(self, parent=None, direction=MenuDirection.VERTICAL, menu_width=80, onTabChange=None, spacing=10, border_side=BorderSide.LEFT, tab_side=None, text_position=TextPosition.BOTTOM): + super().__init__(parent) + self.main_manager = MainManager.get_instance() + self.theme_manager = self.main_manager.get_theme_manager() + self.observer_manager = self.main_manager.get_observer_manager() + self.observer_manager.subscribe(NotificationType.THEME, self.set_theme) + self.direction = direction + self.menu_width = menu_width + self.onTabChange = onTabChange + self.text_position = text_position # Position du texte par rapport à l'icône + + # Gérer border_side comme une liste ou un seul élément + if isinstance(border_side, list): + self.border_sides = border_side + elif border_side is not None: + self.border_sides = [border_side] + else: + self.border_sides = [] + + # Déterminer le côté de la barre d'onglets + if tab_side is None: + self.tab_side = TabSide.LEFT if direction == MenuDirection.VERTICAL else TabSide.TOP + else: + self.tab_side = tab_side + + self.buttons = [] + self.widgets = [] + self.button_positions = [] + self.button_text_positions = [] # Individual text positions for each button + self._icon_cache = {} + self._original_icon_paths = [] + self._square_buttons = [] + # Track alignment zones + self.start_buttons = [] + self.center_buttons = [] + self.end_buttons = [] + # Icon Colors + self.selected_icon_color = self.theme_manager.current_theme.get_color("icon_selected_color") + self.unselected_icon_color = self.theme_manager.current_theme.get_color("icon_unselected_color") + self.selected_border_icon_color = self.theme_manager.current_theme.get_color("icon_selected_border_color") + self.hover_icon_color = self.theme_manager.current_theme.get_color("icon_hover_color") + + # Spacer items for alignment + self.left_spacer = None + self.center_spacer = None + self.right_spacer = None + self.spacing = spacing + self._setup_ui() + + def _setup_ui(self): + """Setup the main layout based on direction and tab_side""" + if self.direction == MenuDirection.HORIZONTAL: + self.main_layout = QVBoxLayout(self) + self.button_layout = QHBoxLayout() + else: # VERTICAL + self.main_layout = QHBoxLayout(self) + self.button_layout = QVBoxLayout() + + # Remove all spacing and margins + self.main_layout.setSpacing(0) + self.main_layout.setContentsMargins(0, 0, 0, 0) + self.button_layout.setSpacing(self.spacing) + self.button_layout.setContentsMargins(0, 0, 0, 0) + + self.stacked_widget = QStackedWidget() + # Create button container widget + self.button_container = QWidget() + self.button_container.setObjectName("tab_bar") + self.button_container.setLayout(self.button_layout) + + # Remove all margins from button container + self.button_container.setContentsMargins(0, 0, 0, 0) + + # Set minimum size for button container to prevent shrinking + if self.direction == MenuDirection.VERTICAL: + self.button_container.setMaximumWidth(self.menu_width) + self.button_container.setMinimumWidth(self.menu_width) + self.button_container.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) + else: + self.button_container.setMinimumHeight(self.menu_width) + self.button_container.setMaximumHeight(self.menu_width) + self.button_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + # Initialize spacers for alignment zones + self._setup_alignment_zones() + + # Add widgets to main layout based on direction and tab_side + if self.tab_side == TabSide.LEFT: + self.main_layout.addWidget(self.button_container) + self.main_layout.addWidget(self.stacked_widget) + else: # TabSide.RIGHT + self.main_layout.addWidget(self.stacked_widget) + self.main_layout.addWidget(self.button_container) + + self.setLayout(self.main_layout) + + def _setup_alignment_zones(self): + """Setup spacers to create alignment zones""" + # Create spacers + if self.direction == MenuDirection.HORIZONTAL: + self.left_spacer = QSpacerItem(0, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + self.center_spacer = QSpacerItem(0, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + else: + self.left_spacer = QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) + self.center_spacer = QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) + + # Add spacers to layout (no right spacer so END buttons stick to edge) + self.button_layout.addSpacerItem(self.left_spacer) # Before center + self.button_layout.addSpacerItem(self.center_spacer) # After center + # Removed right_spacer so END buttons are at the edge + + # Add a widget on the menu, just the widget, with position + def add_random_widget(self, widget, position=ButtonPosition.END): + """Add a widget directly to the menu at the specified position""" + if isinstance(widget, QLayout): + # If it's a layout, wrap it in a container widget + container_widget = QWidget() + container_widget.setLayout(widget) + container_widget.setContentsMargins(0, 0, 0, 0) + widget = container_widget + self._insert_widget_with_alignment(widget, position) + return widget + + def add_random_layout(self, layout, position=ButtonPosition.END): + """Add a layout at the specified position in the button layout""" + # Create a container widget for the layout + container_widget = QWidget() + container_widget.setLayout(layout) + container_widget.setContentsMargins(0, 0, 0, 0) + + # Insert the container widget at the specified position + self._insert_widget_with_alignment(container_widget, position) + return container_widget + + def _insert_widget_with_alignment(self, widget, position): + """Insert any widget at the specified position with proper visual alignment""" + if position == ButtonPosition.START: + # Insert at the beginning (before left spacer) + self.button_layout.insertWidget(0, widget) + + elif position == ButtonPosition.CENTER: + # Insert in center zone (after left spacer, before center spacer) + center_start_index = self._get_spacer_index(self.left_spacer) + 1 + insert_index = center_start_index + len(self.center_buttons) + self.button_layout.insertWidget(insert_index, widget) + + elif position == ButtonPosition.END: + # Insert in end zone (after center spacer, at the end) + end_start_index = self._get_spacer_index(self.center_spacer) + 1 + insert_index = end_start_index + len(self.end_buttons) + self.button_layout.insertWidget(insert_index, widget) + + def add_widget(self, widget, button_text, icon_path=None, position=ButtonPosition.END, after_button_index=None, text_position=None): + """Add a widget with its corresponding button at specified position""" + # Use provided text_position or default to widget's text_position + btn_text_position = text_position if text_position is not None else self.text_position + + # Create button container with custom layout + button_container = self._create_button_with_layout(button_text, icon_path, btn_text_position) + + self._original_icon_paths.append(icon_path) + + button_container.setCheckable(True) + + self.button_text_positions.append(btn_text_position) + + # Make button square with specified ratio + self._style_square_button(button_container) + + # Configurer le widget pour qu'il soit responsive + widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + # Add to collections first + widget_index = len(self.widgets) + self.buttons.append(button_container) + self.widgets.append(widget) + self.button_positions.append(position) + + # Connect button to switch function + button_container.clicked.connect(lambda checked, idx=widget_index: self.switch_to_tab(idx)) + + # Add widget to stacked widget + self.stacked_widget.addWidget(widget) + + # Insert button at specified position with proper alignment + self._insert_button_with_alignment(button_container, position, after_button_index) + + # Select first tab by default + if len(self.buttons) == 1: + self.switch_to_tab(0) + + return widget_index + + def _create_button_with_layout(self, text: str, icon_path: str, text_position: TextPosition) -> QPushButton: + """Create a button with custom layout for icon and text positioning""" + button = QPushButton() + + has_icon = icon_path is not None and icon_path != "" + has_text = text is not None and text.strip() != "" + + # Create icon label only if there's an icon + icon_label = None + if has_icon: + icon_label = QLabel() + icon_label.setObjectName("icon_label") + icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter) + colored_icon = self.apply_color_to_svg_icon(icon_path, self.unselected_icon_color) + pixmap = colored_icon.pixmap(QSize(32, 32)) # Taille par défaut, sera ajustée + icon_label.setPixmap(pixmap) + + # Create text label only if there's text + text_label = None + if has_text: + text_label = QLabel(text) + text_label.setObjectName("text_label") + text_label.setAlignment(Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter) + text_label.setWordWrap(True) + + # Create layout based on what we have and text position + if has_icon and has_text: + # Both icon and text + if text_position == TextPosition.LEFT: + layout = QHBoxLayout() + layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter) + layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter) + elif text_position == TextPosition.RIGHT: + layout = QHBoxLayout() + layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter) + layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter) + elif text_position == TextPosition.TOP: + layout = QVBoxLayout() + layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter) + layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter) + else: # BOTTOM (default) + layout = QVBoxLayout() + layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter) + layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter) + elif has_icon: + # Only icon + layout = QVBoxLayout() + layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter) + elif has_text: + # Only text + layout = QVBoxLayout() + layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter) + else: + # Neither icon nor text - empty button + layout = QVBoxLayout() + + layout.setContentsMargins(2, 2, 2, 2) + layout.setSpacing(2) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + button.setLayout(layout) + + # Store references to labels for later updates (can be None) + button.icon_label = icon_label + button.text_label = text_label + + return button + + def _apply_text_position(self, button: QPushButton, text_position: TextPosition): + """Apply text position to button by recreating its layout""" + # Get existing labels + if not hasattr(button, 'icon_label') or not hasattr(button, 'text_label'): + return + + icon_label = button.icon_label + text_label = button.text_label + + has_icon = icon_label is not None + has_text = text_label is not None + + # Remove old layout + old_layout = button.layout() + if old_layout: + # Remove widgets from layout + while old_layout.count(): + item = old_layout.takeAt(0) + if item.widget(): + item.widget().setParent(None) + QWidget().setLayout(old_layout) # Delete old layout + + # Create new layout based on what we have and text position + if has_icon and has_text: + if text_position == TextPosition.LEFT: + layout = QHBoxLayout() + layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter) + layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter) + elif text_position == TextPosition.RIGHT: + layout = QHBoxLayout() + layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter) + layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter) + elif text_position == TextPosition.TOP: + layout = QVBoxLayout() + layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter) + layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter) + else: # BOTTOM (default) + layout = QVBoxLayout() + layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter) + layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter) + elif has_icon: + layout = QVBoxLayout() + layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter) + elif has_text: + layout = QVBoxLayout() + layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter) + else: + layout = QVBoxLayout() + + layout.setContentsMargins(2, 2, 2, 2) + layout.setSpacing(2) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + button.setLayout(layout) + + def _style_square_button(self, button): + # Set size policy + button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + + # Create border style based on border_sides setting + border_style = self._get_border_style() + button.setStyleSheet(border_style) + + # Install event filter for hover detection + button.installEventFilter(self) + + # Calculate initial size (will be updated in resizeEvent) + self._update_button_size(button) + + # Store reference for resize updates + self._square_buttons.append(button) + + def eventFilter(self, obj : QPushButton, event): + """Handle hover events for buttons""" + if obj in self.buttons: + if event.type() == event.Type.Enter: + # Mouse entered button + button_index = self.buttons.index(obj) + if hasattr(obj, 'icon_label') and button_index < len(self._original_icon_paths): + icon_path = self._original_icon_paths[button_index] + if icon_path: + colored_icon = self.apply_color_to_svg_icon(icon_path, self.hover_icon_color) + current_size = obj.icon_label.size() + pixmap = colored_icon.pixmap(current_size) + obj.icon_label.setPixmap(pixmap) + elif event.type() == event.Type.Leave: + # Mouse left button + button_index = self.buttons.index(obj) + if hasattr(obj, 'icon_label') and button_index < len(self._original_icon_paths): + icon_path = self._original_icon_paths[button_index] + if icon_path: + color = self.unselected_icon_color if not obj.isChecked() else self.selected_icon_color + colored_icon = self.apply_color_to_svg_icon(icon_path, color) + current_size = obj.icon_label.size() + pixmap = colored_icon.pixmap(current_size) + obj.icon_label.setPixmap(pixmap) + + return super().eventFilter(obj, event) + + def _get_border_style(self): + """Generate CSS border style based on border_sides setting""" + if not self.border_sides or BorderSide.NONE in self.border_sides: + return f""" + QPushButton {{ + border-radius: 0px; + background-color: transparent; + border: none; + }} + QPushButton[selected="true"] {{ + border-radius: 0px; + background-color: transparent; + border: none; + }} + """ + + # Construire le style CSS pour chaque côté de bordure + border_declarations = [] + selected_border_declarations = [] + + for border_side in self.border_sides: + if border_side != BorderSide.NONE and border_side is not None: + border_declarations.append(f"border-{border_side.value}: 3px solid transparent") + selected_border_declarations.append(f"border-{border_side.value}: 3px solid {self.selected_border_icon_color}") + + border_style = "; ".join(border_declarations) + selected_border_style = "; ".join(selected_border_declarations) + + return f""" + QPushButton {{ + border-radius: 0px; + background-color: transparent; + {border_style}; + }} + QPushButton[selected="true"] {{ + border-radius: 0px; + background-color: transparent; + {selected_border_style}; + }} + """ + + def _update_button_size(self, button): + """Update button size - will be recalculated globally""" + # This method now triggers a global recalculation + self._recalculate_all_buttons_size() + + def _recalculate_all_buttons_size(self): + """Recalculate size for all buttons to ensure uniform sizing""" + if not self.buttons: + return + + max_secondary_size = 0 + + # First pass: calculate the maximum secondary dimension needed + for i, button in enumerate(self.buttons): + has_icon = button.icon_label is not None + has_text = button.text_label is not None + + if has_icon and has_text: + text_pos = self.button_text_positions[i] if i < len(self.button_text_positions) else self.text_position + if self.direction == MenuDirection.VERTICAL: + # Vertical menu: calculate needed height + if text_pos in [TextPosition.TOP, TextPosition.BOTTOM]: + # Vertical layout: need square + max_secondary_size = max(max_secondary_size, self.menu_width) + else: + # Horizontal layout: need less height but ensure text fits + if has_text and button.text_label: + text_height = button.text_label.sizeHint().height() + 20 + icon_height = int(self.menu_width * 0.4) + 10 + needed_height = max(text_height, icon_height, int(self.menu_width * 0.6)) + max_secondary_size = max(max_secondary_size, needed_height) + else: + # Horizontal menu: calculate needed width + if text_pos in [TextPosition.LEFT, TextPosition.RIGHT]: + # Horizontal layout: need square + max_secondary_size = max(max_secondary_size, self.menu_width) + else: + # Vertical layout: need less width but ensure text fits + if has_text and button.text_label: + text_width = button.text_label.sizeHint().width() + 20 + icon_width = int(self.menu_width * 0.4) + 10 + needed_width = max(text_width, icon_width, int(self.menu_width * 0.6)) + max_secondary_size = max(max_secondary_size, needed_width) + elif has_icon or has_text: + # Only icon or only text + if has_text and button.text_label: + if self.direction == MenuDirection.VERTICAL: + text_height = button.text_label.sizeHint().height() + 20 + max_secondary_size = max(max_secondary_size, text_height, int(self.menu_width * 0.6)) + else: + text_width = button.text_label.sizeHint().width() + 20 + max_secondary_size = max(max_secondary_size, text_width, int(self.menu_width * 0.6)) + else: + max_secondary_size = max(max_secondary_size, int(self.menu_width * 0.6)) + else: + # Empty button + max_secondary_size = max(max_secondary_size, int(self.menu_width * 0.4)) + + # Ensure minimum size + max_secondary_size = max(max_secondary_size, int(self.menu_width * 0.6)) + + # Second pass: apply uniform size to all buttons + for i, button in enumerate(self.buttons): + has_icon = button.icon_label is not None + + if self.direction == MenuDirection.VERTICAL: + # Vertical: width = menu_width, height = max calculated + button_width = self.menu_width + button_height = max_secondary_size + else: + # Horizontal: height = menu_width, width = max calculated + button_height = self.menu_width + button_width = max_secondary_size + + button.setFixedSize(QSize(button_width, button_height)) + + # Update icon size if it exists + if has_icon and i < len(self._original_icon_paths) and self._original_icon_paths[i]: + icon_path = self._original_icon_paths[i] + # Icon size is 40% of the smaller dimension + icon_size = int(min(button_width, button_height) * 0.4) + is_selected = button.isChecked() + color = self.selected_icon_color if is_selected else self.unselected_icon_color + colored_icon = self.apply_color_to_svg_icon(icon_path, color) + pixmap = colored_icon.pixmap(QSize(icon_size, icon_size)) + button.icon_label.setPixmap(pixmap) + button.icon_label.setFixedSize(QSize(icon_size, icon_size)) + + def _update_all_button_sizes(self): + """Update all button sizes when container is resized""" + if hasattr(self, '_square_buttons') and self._square_buttons: + self._recalculate_all_buttons_size() + + def showEvent(self, event): + """Handle show event to set initial button sizes""" + super().showEvent(event) + # Update button sizes when widget is first shown + self._update_all_button_sizes() + + def _insert_button_with_alignment(self, button, position, after_button_index=None): + """Insert button at the specified position with proper visual alignment""" + if position == ButtonPosition.START: + # Insert at the beginning (before left spacer) + self.button_layout.insertWidget(0, button) + self.start_buttons.append(button) + + elif position == ButtonPosition.CENTER: + # Insert in center zone (after left spacer, before center spacer) + center_start_index = self._get_spacer_index(self.left_spacer) + 1 + insert_index = center_start_index + len(self.center_buttons) + self.button_layout.insertWidget(insert_index, button) + self.center_buttons.append(button) + + elif position == ButtonPosition.END: + # Insert in end zone (after center spacer, at the end) + end_start_index = self._get_spacer_index(self.center_spacer) + 1 + insert_index = end_start_index + len(self.end_buttons) + self.button_layout.insertWidget(insert_index, button) + self.end_buttons.append(button) + + elif position == ButtonPosition.AFTER and after_button_index is not None: + if 0 <= after_button_index < len(self.buttons) - 1: + target_button = self.buttons[after_button_index] + target_position = self.button_positions[after_button_index] + + # Find the target button's layout index + target_layout_index = -1 + for i in range(self.button_layout.count()): + item = self.button_layout.itemAt(i) + if item.widget() == target_button: + target_layout_index = i + break + + if target_layout_index != -1: + self.button_layout.insertWidget(target_layout_index + 1, button) + + # Add to the same alignment zone as target + if target_position == ButtonPosition.START: + self.start_buttons.append(button) + elif target_position == ButtonPosition.CENTER: + self.center_buttons.append(button) + elif target_position == ButtonPosition.END: + self.end_buttons.append(button) + else: + # Fallback to END position + self._insert_button_with_alignment(button, ButtonPosition.END) + + def _get_spacer_index(self, spacer_item): + """Get the index of a spacer item in the layout""" + for i in range(self.button_layout.count()): + if self.button_layout.itemAt(i).spacerItem() == spacer_item: + return i + return -1 + + def set_button_size_ratio(self, ratio, button_index=None): + """Deprecated: Button sizes now adapt automatically to content""" + pass + + def switch_to_tab(self, index): + """Switch to the specified tab (only for widgets, not simple buttons)""" + if 0 <= index < len(self.buttons) and self.widgets[index] is not None: + old_index = self.stacked_widget.currentIndex() + + # Update button states and apply theme colors + for i, button in enumerate(self.buttons): + # Only handle tab-like behavior for buttons with widgets + if self.widgets[i] is not None: + is_selected = (i == index) + button.setChecked(is_selected) + + # Update icon color based on selection + if hasattr(button, 'icon_label') and i < len(self._original_icon_paths): + icon_path = self._original_icon_paths[i] + if icon_path: + color = (self.selected_icon_color if is_selected + else self.unselected_icon_color) + colored_icon = self.apply_color_to_svg_icon(icon_path, color) + + # Get current icon size + current_size = button.icon_label.size() + pixmap = colored_icon.pixmap(current_size) + button.icon_label.setPixmap(pixmap) + + # Update button property for styling + button.setProperty("selected", is_selected) + button.style().unpolish(button) + button.style().polish(button) + + # Switch stacked widget + self.stacked_widget.setCurrentIndex(index) + + # Call the callback if provided and index actually changed + if self.onTabChange and old_index != index: + try: + self.onTabChange(index) + except Exception: + pass + + def set_theme(self): + self.selected_icon_color = self.theme_manager.current_theme.get_color("icon_selected_color") + self.unselected_icon_color = self.theme_manager.current_theme.get_color("icon_unselected_color") + self.selected_border_icon_color = self.theme_manager.current_theme.get_color("icon_selected_border_color") + self.hover_icon_color = self.theme_manager.current_theme.get_color("icon_hover_color") + # Apply theme to all buttons + for i, button in enumerate(self.buttons): + # Check if button is currently selected + is_selected = button.isChecked() + + # Update button stylesheet with current theme colors + border_style = self._get_border_style() + button.setStyleSheet(border_style) + + # Update icon color if icon_label exists + if hasattr(button, 'icon_label') and i < len(self._original_icon_paths): + icon_path = self._original_icon_paths[i] + if icon_path: + # Choose color based on selection state + color = self.selected_icon_color if is_selected else self.unselected_icon_color + + # Apply color to SVG and create new QIcon + colored_icon = self.apply_color_to_svg_icon(icon_path, color) + current_size = button.icon_label.size() + pixmap = colored_icon.pixmap(current_size) + button.icon_label.setPixmap(pixmap) + + # Apply button styling based on selection state + button.setProperty("selected", is_selected) + + # Force style update + button.style().unpolish(button) + button.style().polish(button) + + def update_button_text(self, index, new_text): + """Update the text of a button at the specified index""" + if 0 <= index < len(self.buttons): + button = self.buttons[index] + if hasattr(button, 'text_label') and button.text_label is not None: + button.text_label.setText(new_text) + # Optionally, update button size after text change + self._update_button_size(button) + + def apply_color_to_svg_icon(self, icon_path, color) -> QIcon: + """ + Create or reuse a colored copy of the SVG in the user's data folder (temp_icons) + and return a QIcon that points to it. Original SVG is preserved. + + Caching: deterministic filename based on sha256(icon_path + color) so repeated calls reuse files. + """ + try: + if not icon_path: + return QIcon() + + # deterministic filename based on hash to avoid duplicates + hasher = hashlib.sha256() + hasher.update(str(Path(icon_path).resolve()).encode('utf-8')) + hasher.update(str(color).encode('utf-8')) + digest = hasher.hexdigest() + new_name = f"{Path(icon_path).stem}_{digest}.svg" + + app_name = self.main_manager.get_settings_manager().get_config("app_name") + temp_dir = Path(paths.get_user_temp_icons(app_name)) + temp_dir.mkdir(parents=True, exist_ok=True) + + tmp_path = temp_dir / new_name + + # If exists in cache and file exists on disk, return immediately + cache_key = (str(Path(icon_path).resolve()), color) + if cache_key in self._icon_cache: + cached_path = Path(self._icon_cache[cache_key]) + if cached_path.exists(): + return QIcon(str(cached_path)) + else: + # stale cache entry -> remove + del self._icon_cache[cache_key] + + # If file already exists (from previous run), reuse it + if tmp_path.exists(): + self._icon_cache[cache_key] = str(tmp_path) + return QIcon(str(tmp_path)) + + # Parse original SVG + tree = ET.parse(icon_path) + root = tree.getroot() + + # Apply fill/stroke to root and to all relevant elements. + for el in root.iter(): + tag = el.tag + if not isinstance(tag, str): + continue + lname = tag.split('}')[-1] # local name + if lname in ("svg", "g", "path", "rect", "circle", "ellipse", "polygon", "line", "polyline"): + if 'fill' in el.attrib: + el.attrib['fill'] = color + if 'stroke' in el.attrib: + el.attrib['stroke'] = color + + style = el.attrib.get('style') + if style: + parts = [p.strip() for p in style.split(';') if p.strip()] + parts = [p for p in parts if not p.startswith('fill:') and not p.startswith('stroke:')] + parts.append(f'fill:{color}') + parts.append(f'stroke:{color}') + el.attrib['style'] = ';'.join(parts) + + # Write to deterministic file + tree.write(tmp_path, encoding='utf-8', xml_declaration=True) + + # Update cache + self._icon_cache[cache_key] = str(tmp_path) + + return QIcon(str(tmp_path)) + except Exception: + # On any error, fallback to original icon (do not crash) + try: + return QIcon(icon_path) + except Exception: + return QIcon() + + def update_buttons_size(self, size): + """Update all buttons size""" + self._update_all_button_sizes() \ No newline at end of file diff --git a/app/ui/windows/activation_window.py b/app/ui/windows/activation_window.py new file mode 100644 index 0000000..26ba045 --- /dev/null +++ b/app/ui/windows/activation_window.py @@ -0,0 +1,241 @@ +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QFrame, QSizePolicy) +from PyQt6.QtCore import Qt, QThread, pyqtSignal +from app.core.main_manager import MainManager, NotificationType +from app.ui.widgets.loading_bar import LoadingBar +import webbrowser +import os +from dotenv import load_dotenv +import app.utils.paths as paths + +# Load environment variables +load_dotenv(paths.resource_path(".env")) + +class ActivationThread(QThread): + """Thread pour l'activation afin de ne pas bloquer l'UI""" + finished = pyqtSignal(dict) + progress = pyqtSignal(int) + + def __init__(self, license_manager, license_key): + super().__init__() + self.license_manager = license_manager + self.license_key = license_key + + def run(self): + self.progress.emit(30) + result = self.license_manager.activate_license(self.license_key) + self.progress.emit(100) + self.finished.emit(result) + +class ActivationWindow(QWidget): + """Fenêtre d'activation de licence modernisée""" + + def __init__(self, parent=None): + super().__init__(parent) + self.main_manager = MainManager.get_instance() + self.license_manager = self.main_manager.get_license_manager() + self.language_manager = self.main_manager.get_language_manager() + self.theme_manager = self.main_manager.get_theme_manager() + self.alert_manager = self.main_manager.get_alert_manager() + self.observer_manager = self.main_manager.get_observer_manager() + self.settings_manager = self.main_manager.get_settings_manager() + + self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language) + self.observer_manager.subscribe(NotificationType.THEME, self.update_theme) + + self.activation_thread = None + + self.setup_ui() + self.update_license_status() + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(20) + layout.setContentsMargins(20, 20, 20, 20) + layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # === Section titre === + self.title_label = QLabel(self.language_manager.get_text("activate_license")) + self.title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + self.title_label.setWordWrap(True) + layout.addWidget(self.title_label) + + # === Spacer flexible === + layout.addStretch(1) + + # === Section clé de licence === + key_layout = QHBoxLayout() + + self.key_title = QLabel(self.language_manager.get_text("license_key_section")) + self.key_title.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) + key_layout.addWidget(self.key_title) + + self.key_input = QLineEdit() + self.key_input.setPlaceholderText("XXXX-XXXX-XXXX-XXXX-XXXX") + self.key_input.textChanged.connect(self.format_license_key) + self.key_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + key_layout.addWidget(self.key_input) + + layout.addLayout(key_layout) + # === Barre de progression === + self.progress_bar = LoadingBar(self.language_manager.get_text("loading")) + self.progress_bar.setVisible(False) + self.progress_bar.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + layout.addWidget(self.progress_bar) + + # === Spacer flexible === + layout.addStretch(1) + + # === Section statut === + self.status_label = QLabel() + self.status_label.setWordWrap(True) + self.status_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + layout.addWidget(self.status_label) + + # === Spacer flexible === + layout.addStretch(1) + + # === Boutons d'action === + button_layout = QHBoxLayout() + button_layout.setSpacing(10) + + self.activate_btn = QPushButton(self.language_manager.get_text("activate")) + self.activate_btn.clicked.connect(self.activate_license) + self.activate_btn.setDefault(True) + self.activate_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + button_layout.addWidget(self.activate_btn) + + self.buy_btn = QPushButton(self.language_manager.get_text("buy_license")) + self.buy_btn.clicked.connect(self.open_purchase_page) + self.buy_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + button_layout.addWidget(self.buy_btn) + + self.compare_btn = QPushButton(self.language_manager.get_text("compare_versions")) + self.compare_btn.clicked.connect(self.show_features_comparison) + self.compare_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + button_layout.addWidget(self.compare_btn) + + layout.addLayout(button_layout) + + def show_features_comparison(self): + """Affiche la comparaison des versions dans une alerte""" + comparison_text = f"{self.language_manager.get_text('comparaisons')}\n\n" + + for version in self.settings_manager.get_config("features_by_license").keys(): + comparison_text += f"{self.language_manager.get_text(version+'_version')}:\n" + features = self.settings_manager.get_config("features_by_license").get(version, []) + for feature in features: + feature_desc = self.language_manager.get_text(feature) + comparison_text += f" • {feature_desc}\n" + comparison_text += "\n" + + + self.alert_manager.show_info(comparison_text, parent=self) + + def update_license_status(self): + """Met à jour l'affichage du statut de la licence""" + if self.license_manager.is_activated(): + license_info = self.license_manager.get_license_info() + license_type = license_info.get("type", "free").upper() + + status_text = f"✓ {self.language_manager.get_text('license_active')}\n" + status_text += f"{self.language_manager.get_text('license_type')}: {license_type}\n" + if license_info.get("email"): + status_text += f"{self.language_manager.get_text('license_email')}: {license_info['email']}\n" + if license_info.get("expires_at"): + status_text += f"{self.language_manager.get_text('license_expires')}: {license_info['expires_at']}" + + self.status_label.setText(status_text) + + self.key_input.setEnabled(False) + self.activate_btn.setEnabled(False) + else: + status_text = f"{self.language_manager.get_text('no_license')}" + self.status_label.setText(status_text) + + def format_license_key(self, text): + """Formate automatiquement la clé de licence (XXXX-XXXX-...)""" + text = text.replace("-", "").upper() + formatted = "-".join([text[i:i+4] for i in range(0, len(text), 4)]) + if len(formatted) > 24: + formatted = formatted[:24] + self.key_input.blockSignals(True) + self.key_input.setText(formatted) + self.key_input.blockSignals(False) + self.key_input.setCursorPosition(len(formatted)) + + def activate_license(self): + """Lance l'activation de la licence""" + license_key = self.key_input.text().replace("-", "") + + if len(license_key) < 16: + self.alert_manager.show_error("invalid_license_key") + return + + # Disable inputs during activation + self.activate_btn.setEnabled(False) + self.key_input.setEnabled(False) + self.buy_btn.setEnabled(False) + + # Show progress bar with initial message + self.progress_bar.set_label(self.language_manager.get_text("loading")) + self.progress_bar.set_progress(0) + self.progress_bar.setVisible(True) + + # Start activation thread + self.activation_thread = ActivationThread(self.license_manager, license_key) + self.activation_thread.finished.connect(self.on_activation_finished) + self.activation_thread.progress.connect(self.on_activation_progress) + self.activation_thread.start() + + def on_activation_progress(self, value): + """Update progress bar during activation""" + self.progress_bar.set_progress(value) + + def on_activation_finished(self, result): + """Callback quand l'activation est terminée""" + # Hide progress bar + self.progress_bar.setVisible(False) + + # Re-enable inputs + self.activate_btn.setEnabled(True) + self.key_input.setEnabled(True) + self.buy_btn.setEnabled(True) + + if result["success"]: + # Show success message using AlertManager + success_msg = result['message'] + if result.get("data"): + success_msg += f"\n\n{self.language_manager.get_text('license_type')}: {result['data'].get('type', 'N/A')}" + success_msg += f"\n{self.language_manager.get_text('license_email')}: {result['data'].get('email', 'N/A')}" + + self.alert_manager.show_info(success_msg, parent=self) + self.update_license_status() + + # Clear the input field + self.key_input.clear() + else: + # Show error message using AlertManager + self.alert_manager.show_info(f"✗ {result['message']}", parent=self) + + def open_purchase_page(self): + """Ouvre la page d'achat dans le navigateur""" + purchase_url = os.getenv("PURCHASE_URL") + if purchase_url: + webbrowser.open(purchase_url) + else: + self.alert_manager.show_error("PURCHASE_URL non définie dans .env", parent=self) + + def update_language(self): + """Met à jour tous les textes selon la nouvelle langue""" + self.title_label.setText(self.language_manager.get_text("activate_license")) + self.activate_btn.setText(self.language_manager.get_text("activate")) + self.buy_btn.setText(self.language_manager.get_text("buy_license")) + self.compare_btn.setText(self.language_manager.get_text("compare_versions")) + self.progress_bar.set_label(self.language_manager.get_text("loading")) + self.key_title.setText(self.language_manager.get_text("license_key_section")) + self.update_license_status() + + def update_theme(self): + """Met à jour le style selon le nouveau thème""" + self.update_license_status() \ No newline at end of file diff --git a/app/ui/windows/settings_window.py b/app/ui/windows/settings_window.py new file mode 100644 index 0000000..34d4302 --- /dev/null +++ b/app/ui/windows/settings_window.py @@ -0,0 +1,105 @@ +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QComboBox, QLabel, QHBoxLayout, QSizePolicy +from PyQt6.QtCore import Qt +from app.core.main_manager import MainManager, NotificationType +from typing import Optional + +class SettingsWindow(QWidget): + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self.main_manager: MainManager = MainManager.get_instance() + self.language_manager = self.main_manager.get_language_manager() + self.settings_manager = self.main_manager.get_settings_manager() + self.theme_manager = self.main_manager.get_theme_manager() + + self.observer_manager = self.main_manager.get_observer_manager() + self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language) + + # Type hints for UI elements + self.language_layout: QHBoxLayout + self.languageLabel: QLabel + self.languageCombo: QComboBox + self.theme_layout: QHBoxLayout + self.themeLabel: QLabel + self.themeCombo: QComboBox + + self.setup_ui() + + def setup_ui(self) -> None: + layout: QVBoxLayout = QVBoxLayout(self) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.setSpacing(20) + layout.setContentsMargins(20, 20, 20, 20) + + layout.addStretch(1) + + self.language_layout = QHBoxLayout() + # Paramètres de langue + self.languageLabel = QLabel(self.language_manager.get_text("language"),self) + self.languageLabel.setMinimumWidth(100) + self.languageLabel.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) + self.language_layout.addWidget(self.languageLabel) + + self.languageCombo = self.createLanguageSelector() + self.languageCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.language_layout.addWidget(self.languageCombo) + + layout.addLayout(self.language_layout) + + layout.addStretch(1) + + # Paramètres de thème + self.theme_layout = QHBoxLayout() + + self.themeLabel = QLabel(self.language_manager.get_text("theme"), self) + self.themeLabel.setMinimumWidth(100) + self.themeLabel.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) + self.theme_layout.addWidget(self.themeLabel) + + self.themeCombo = self.createThemeSelector() + self.themeCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.theme_layout.addWidget(self.themeCombo) + + layout.addLayout(self.theme_layout) + + layout.addStretch(1) + + def createLanguageSelector(self) -> QComboBox: + combo: QComboBox = QComboBox() + # Ajouter toutes les langues disponibles + for langCode, langData in self.language_manager.translations.items(): + combo.addItem(langData["lang_name"], langCode) + + # Sélectionner la langue actuelle + currentIndex = combo.findData(self.settings_manager.get_language()) + combo.setCurrentIndex(currentIndex) + combo.currentIndexChanged.connect(self.change_language) + + return combo + + def createThemeSelector(self) -> QComboBox: + combo: QComboBox = QComboBox() + # Ajouter toutes les options de thème disponibles + for theme in self.theme_manager.get_themes(): + combo.addItem(self.language_manager.get_text(theme.name+"_theme"), theme.name) + + # Sélectionner le thème actuel + currentIndex = combo.findData(self.settings_manager.get_theme()) + combo.setCurrentIndex(currentIndex) + combo.currentIndexChanged.connect(self.change_theme) + + return combo + + def change_language(self, index: int) -> None: + self.settings_manager.set_language(self.languageCombo.itemData(index)) + + def change_theme(self, index: int) -> None: + theme: str = self.themeCombo.itemData(index) + self.settings_manager.set_theme(theme) + + def update_language(self) -> None: + self.languageLabel.setText(self.language_manager.get_text("language")) + self.themeLabel.setText(self.language_manager.get_text("theme")) + + # Mettre à jour les textes dans la combo de thème + for i in range(self.themeCombo.count()): + self.themeCombo.setItemText(i, self.language_manager.get_text(self.themeCombo.itemData(i)+ "_theme")) \ No newline at end of file diff --git a/app/ui/windows/splash_screen.py b/app/ui/windows/splash_screen.py new file mode 100644 index 0000000..a5118fc --- /dev/null +++ b/app/ui/windows/splash_screen.py @@ -0,0 +1,159 @@ +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QApplication +from PyQt6.QtCore import Qt, pyqtSignal, QTimer +from PyQt6.QtGui import QPixmap +from app.core.main_manager import MainManager +from app.ui.widgets.loading_spinner import LoadingSpinner +import app.utils.paths as paths + +class SplashScreen(QWidget): + finished = pyqtSignal(bool) # True si succès, False si échec/interruption + + def __init__(self, parent=None, preload_function=None): + super().__init__(parent) + self.preload_function = preload_function + self.preload_result = True + + self.main_manager = MainManager.get_instance() + self.theme_manager = self.main_manager.get_theme_manager() + self.settings_manager = self.main_manager.get_settings_manager() + self.language_manager = self.main_manager.get_language_manager() + + self.setup_ui() + if self.preload_function: + self.start_preloading() + else: + # Pas de préchargement, fermer immédiatement + QTimer.singleShot(100, lambda: self.finished.emit(True)) + + def setup_ui(self): + # Configuration de la fenêtre + self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setFixedSize(2000, 2000) + + # Layout principal + layout = QVBoxLayout(self) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.setSpacing(60) + layout.setContentsMargins(80, 80, 80, 80) + + # Image splash + self.image_label = QLabel() + self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.load_splash_image() + layout.addWidget(self.image_label) + + # Texte de progression + self.progress_label = QLabel("Chargement...") + self.progress_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.progress_label.setStyleSheet("font-size: 14px; color: #666;") + layout.addWidget(self.progress_label) + + # Spinner de chargement + self.spinner = LoadingSpinner(50, self) + spinner_layout = QVBoxLayout() + spinner_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + spinner_layout.addWidget(self.spinner) + layout.addLayout(spinner_layout) + + # Appliquer le thème + self.apply_theme() + + # Centrer la fenêtre + self.center_on_screen() + + def start_preloading(self): + """Démarre le préchargement avec un délai pour permettre l'affichage du splash""" + # Laisser le temps au splash de s'afficher + QTimer.singleShot(200, self.do_preloading) + + def do_preloading(self): + """Effectue le préchargement dans le thread principal""" + try: + # Fonction callback pour mettre à jour le texte + def progress_callback(text): + self.progress_label.setText(text) + # Traiter les événements pour que l'UI se mette à jour + QApplication.processEvents() + + # Appeler la fonction de préchargement + success = self.preload_function(progress_callback) + self.preload_result = success + + except Exception: + self.preload_result = False + + # Attendre un peu puis fermer + QTimer.singleShot(300, self.close_splash) + + def close_splash(self): + """Ferme le splash screen et émet le signal""" + if hasattr(self, 'spinner'): + self.spinner.stop() + self.finished.emit(self.preload_result) + self.close() + + def load_splash_image(self): + """Charge l'image splash depuis la config""" + try: + splash_image_path = paths.get_asset_path(self.settings_manager.get_config("splash_image")) + if splash_image_path: + # Essayer le chemin depuis la config + if not splash_image_path.startswith('/') and not splash_image_path.startswith('\\') and ':' not in splash_image_path: + # Chemin relatif, le résoudre depuis le dossier assets + splash_image_path = paths.get_asset_path(splash_image_path) + + pixmap = QPixmap(splash_image_path) + if not pixmap.isNull(): + # Redimensionner l'image + scaled_pixmap = pixmap.scaled(400, 300, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + self.image_label.setPixmap(scaled_pixmap) + return + + # Fallback : essayer l'icône par défaut + fallback_path = paths.get_asset_path("icon.png") + pixmap = QPixmap(fallback_path) + if not pixmap.isNull(): + scaled_pixmap = pixmap.scaled(240, 240, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + self.image_label.setPixmap(scaled_pixmap) + else: + # Dernier fallback : texte + self.image_label.setText("🚀") + self.image_label.setStyleSheet("font-size: 48px;") + except Exception: + # En cas d'erreur, afficher un emoji + self.image_label.setText("🚀") + self.image_label.setStyleSheet("font-size: 48px;") + + def apply_theme(self): + """Applique le thème actuel""" + theme = self.theme_manager.get_theme() + + style = f""" + QWidget {{ + background-color: {theme.get_color("background_color")}; + border-radius: 15px; + border: 2px solid {theme.get_color("primary_color")}; + }} + QLabel {{ + color: {theme.get_color("text_color")}; + background: transparent; + border: none; + }} + """ + self.setStyleSheet(style) + + def center_on_screen(self): + """Centre la fenêtre sur l'écran""" + screen = QApplication.primaryScreen() + screen_geometry = screen.geometry() + + x = (screen_geometry.width() - self.width()) // 2 + y = (screen_geometry.height() - self.height()) // 2 + self.move(x, y) + + def show_splash(self): + """Affiche le splash screen""" + self.show() + self.raise_() + self.activateWindow() \ No newline at end of file diff --git a/app/ui/windows/suggestion_window.py b/app/ui/windows/suggestion_window.py new file mode 100644 index 0000000..1852c0c --- /dev/null +++ b/app/ui/windows/suggestion_window.py @@ -0,0 +1,143 @@ +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QPushButton, QLabel, QHBoxLayout, QSizePolicy +from PyQt6.QtCore import Qt, QThread, pyqtSignal +import smtplib, os +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from dotenv import load_dotenv +from app.core.main_manager import MainManager, NotificationType +from typing import Optional +import app.utils.paths as path +# Load environment variables from .env file +load_dotenv(path.resource_path(".env")) + +class EmailSender(QThread): + success = pyqtSignal() + error = pyqtSignal(str) + + def __init__(self, subject: str, message: str) -> None: + super().__init__() + self.subject: str = subject + self.message: str = message + + def run(self) -> None: + try: + # Get email configuration from environment variables + email: Optional[str] = os.getenv('EMAIL_ADDRESS') + password: Optional[str] = os.getenv('EMAIL_PASSWORD') + smtp_server: str = os.getenv('EMAIL_SMTP_SERVER', 'smtp.gmail.com') + smtp_port: int = int(os.getenv('EMAIL_SMTP_PORT', '587')) + + if not email or not password: + self.error.emit("password") + return + + # Create message + msg: MIMEMultipart = MIMEMultipart() + msg['From'] = email + msg['To'] = email + msg['Subject'] = self.subject + + # Add body to email + msg.attach(MIMEText(self.message, 'plain')) + + # Create SMTP session + server: smtplib.SMTP = smtplib.SMTP(smtp_server, smtp_port) + server.starttls() # Enable TLS encryption + # Login with app password + server.login(email, password) + # Send email + text: str = msg.as_string() + server.sendmail(email, email, text) + server.quit() + self.success.emit() + except smtplib.SMTPAuthenticationError: + self.error.emit("email_credentials_error") + except Exception: + self.error.emit("suggestion_send_error") + +class SuggestionWindow(QWidget): + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self.main_manager: MainManager = MainManager.get_instance() + self.language_manager = self.main_manager.get_language_manager() + self.settings_manager = self.main_manager.get_settings_manager() + self.alert_manager = self.main_manager.get_alert_manager() + self.license_manager = self.main_manager.get_license_manager() + + self.observer_manager = self.main_manager.get_observer_manager() + self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language) + + self.email_sender: Optional[EmailSender] = None + + self.setup_ui() + + def setup_ui(self) -> None: + layout: QVBoxLayout = QVBoxLayout(self) + layout.setAlignment(Qt.AlignmentFlag.AlignTop) + layout.setSpacing(20) + layout.setContentsMargins(20, 20, 20, 20) + + # Title + self.title_label: QLabel = QLabel(self.language_manager.get_text("suggestion_text"), self) + self.title_label.setWordWrap(True) + self.title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + layout.addWidget(self.title_label) + + # Text area for suggestion + self.text_edit: QTextEdit = QTextEdit(self) + self.text_edit.setPlaceholderText(self.language_manager.get_text("suggestion_placeholder")) + self.text_edit.setMinimumHeight(150) + self.text_edit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + layout.addWidget(self.text_edit, 1) + + # Button layout + button_layout: QHBoxLayout = QHBoxLayout() + button_layout.addStretch() + + # Send button + self.send_button: QPushButton = QPushButton(self.language_manager.get_text("send_suggestion"), self) + self.send_button.clicked.connect(self.send_suggestion) + button_layout.addWidget(self.send_button) + + layout.addLayout(button_layout) + + def send_suggestion(self) -> None: + message: str = self.text_edit.toPlainText().strip() + + if len(message)<15: + self.alert_manager.show_error("suggestion_too_short") + return + + # Disable send button during sending + self.send_button.setEnabled(False) + self.send_button.setText(self.language_manager.get_text("sending")) + + content = self.settings_manager.get_config('app_name').replace(' ', '_') + + # Ajouter le préfixe "PRIORITAIRE" uniquement si le système de licence est activé ET que l'utilisateur a le support prioritaire + if self.settings_manager.get_config("enable_licensing") and self.license_manager.is_feature_available("priority_support"): + subject = "PRIORITAIRE - "+content + else: + subject = "Suggestion pour "+content + + # Create and start email sender thread + self.email_sender = EmailSender(subject, message) + self.email_sender.success.connect(self.on_email_sent) + self.email_sender.error.connect(self.on_email_error) + self.email_sender.start() + + def on_email_sent(self) -> None: + self.send_button.setEnabled(True) + self.send_button.setText(self.language_manager.get_text("send_suggestion")) + self.alert_manager.show_success("suggestion_sent_success") + self.text_edit.clear() + + def on_email_error(self, error: str) -> None: + self.send_button.setEnabled(True) + self.send_button.setText(self.language_manager.get_text("send_suggestion")) + self.alert_manager.show_error(error) + + def update_language(self) -> None: + self.title_label.setText(self.language_manager.get_text("suggestion_text")) + self.text_edit.setPlaceholderText(self.language_manager.get_text("suggestion_placeholder")) + self.send_button.setText(self.language_manager.get_text("send_suggestion")) \ No newline at end of file diff --git a/app/utils/licence.py b/app/utils/licence.py new file mode 100644 index 0000000..88623eb --- /dev/null +++ b/app/utils/licence.py @@ -0,0 +1,66 @@ +from functools import wraps +from PyQt6.QtWidgets import QMessageBox +from app.core.main_manager import MainManager + +def require_license(feature_id: str, show_upgrade_dialog: bool = True): + """ + Décorateur pour protéger une fonctionnalité premium + + Usage: + @require_license("advanced_export") + def export_to_excel(self): + # Code de la fonctionnalité premium + """ + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + main_manager = MainManager.get_instance() + license_manager = main_manager.get_license_manager() + settings_manager = main_manager.get_settings_manager() + + # Si le système de licence est désactivé, autoriser toutes les fonctionnalités + if not settings_manager.get_config("enable_licensing"): + return func(*args, **kwargs) + + if not license_manager.is_feature_available(feature_id): + if show_upgrade_dialog: + show_upgrade_message(feature_id) + return None + + return func(*args, **kwargs) + return wrapper + return decorator + +def show_upgrade_message(feature_id: str): + """Affiche un message invitant à acheter la version premium""" + main_manager = MainManager.get_instance() + language_manager = main_manager.get_language_manager() + settings_manager = main_manager.get_settings_manager() + + # Si le système de licence est désactivé, ne rien afficher + if not settings_manager.get_config("enable_licensing"): + return + + feature_name = settings_manager.get_config("feature_descriptions", {}).get( + feature_id, feature_id + ) + + msg = QMessageBox() + msg.setIcon(QMessageBox.Icon.Information) + msg.setWindowTitle(language_manager.get_text("premium_feature")) + msg.setText(language_manager.get_text("premium_feature_message").format(feature=feature_name)) + msg.setInformativeText(language_manager.get_text("upgrade_prompt")) + + buy_btn = msg.addButton(language_manager.get_text("buy_now"), QMessageBox.ButtonRole.AcceptRole) + activate_btn = msg.addButton(language_manager.get_text("activate_license"), QMessageBox.ButtonRole.ActionRole) + msg.addButton(language_manager.get_text("cancel"), QMessageBox.ButtonRole.RejectRole) + + msg.exec() + + if msg.clickedButton() == buy_btn: + import webbrowser + webbrowser.open(settings_manager.get_config("purchase_url")) + elif msg.clickedButton() == activate_btn: + from app.ui.windows.activation_window import ActivationWindow + activation_window = ActivationWindow() + activation_window.exec() \ No newline at end of file diff --git a/app/utils/paths.py b/app/utils/paths.py new file mode 100644 index 0000000..991026b --- /dev/null +++ b/app/utils/paths.py @@ -0,0 +1,57 @@ +from os import path, getenv, mkdir +import sys +from platform import system +from pathlib import Path +from typing import Optional + +def resource_path(relative_path: str) -> str: + """ + Get absolute path to resource, works for dev and for PyInstaller bundle. + + PyInstaller stores bundled files in _MEIPASS folder. + """ + try: + base_path: Path = Path(sys._MEIPASS) # PyInstaller temp folder + except AttributeError: + base_path: Path = Path(__file__).parent.parent.parent # Dev environment: source/ folder + + return path.join(base_path, relative_path) + +def get_data_dir() -> str: + return resource_path("data") + +def get_lang_path() -> str: + return path.join(get_data_dir(), "lang") + +def get_asset_path(asset: str) -> str: + return path.join(get_data_dir(), "assets", f"{asset}.png") + +def get_asset_svg_path(asset: str) -> str: + return path.join(get_data_dir(), "assets", f"{asset}.svg") + +def get_user_data_dir(app_name: str) -> str: + home: Path = Path.home() + os: str = system() + + if os == "Windows": + appdata: Optional[str] = getenv('APPDATA') + if appdata: + user_data_path: str = path.join(Path(appdata), app_name) + else: + user_data_path: str = path.join(home, "AppData", "Roaming", app_name) + elif os == "Darwin": + user_data_path: str = path.join(home, "Library", "Application Support", app_name) + else: + user_data_path: str = path.join(home, ".local", "share", app_name) + + if not path.exists(user_data_path): + mkdir(user_data_path) + return user_data_path + +def get_user_temp_icons(app_name: str) -> str: + user_data_path = get_user_data_dir(app_name) + return path.join(user_data_path, "temp", "icons") + +def get_user_temp(app_name: str) -> str: + user_data_path = get_user_data_dir(app_name) + return path.join(user_data_path, "temp") \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..95719dc --- /dev/null +++ b/config.json @@ -0,0 +1,23 @@ +{ + "app_name": "Application", + "app_os": "Windows", + "app_version": "1.0.0", + "architecture": "x64", + "icon_path": "data/assets/icon.ico", + "splash_image": "splash", + "main_script": "main.py", + "git_repo": "https://gitea.louismazin.ovh/LouisMazin/PythonApplicationTemplate", + "enable_licensing": true, + "features_by_license": { + "basic": [ + "support" + ], + "premium": [ + "priority_support" + ], + "enterprise": [ + "priority_support", + "unlimited_key_uses" + ] + } +} \ No newline at end of file diff --git a/data/assets/icon.icns b/data/assets/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..69fd9679720bc2be5b304d6d9f514a78069830e0 GIT binary patch literal 55740 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 bo+o4eknPCd_HtZ73#ck-Ud_F1`N#hO5j$v! literal 0 HcmV?d00001 diff --git a/data/assets/license.svg b/data/assets/license.svg new file mode 100644 index 0000000..d0241b2 --- /dev/null +++ b/data/assets/license.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/data/assets/settings.svg b/data/assets/settings.svg new file mode 100644 index 0000000..3efc063 --- /dev/null +++ b/data/assets/settings.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/data/assets/splash.png b/data/assets/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..bdb7096dfd93d18cd289ea8baefdc99aa4c4438e GIT binary patch literal 54826 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 l1xqF+c6H#7^ + + + + + + + + \ No newline at end of file diff --git a/data/lang/en.json b/data/lang/en.json new file mode 100644 index 0000000..11979a1 --- /dev/null +++ b/data/lang/en.json @@ -0,0 +1,67 @@ +{ + "lang_name": "English", + "language": "Language :", + "theme": "Theme :", + "dark_theme": "Dark Theme", + "light_theme": "Light Theme", + "yes": "Yes", + "no": "No", + "confirmation": "Confirmation", + "information": "Information", + "close": "Close", + "suggestion_text": "Do you have a question or an idea to improve my software? Send me a message!", + "suggestion_placeholder": "Type your message here...", + "send_suggestion": "Send", + "sending": "Sending...", + "success": "Success", + "error": "Error", + "suggestion_sent_success": "Your message has been sent successfully!", + "suggestion_send_error": "Error sending message. Try again later.", + "email_credentials_error": "Email credentials not or bad configured. Please set your email and password in the .env file.", + "suggestion_too_short": "The message must be at least 15 characters long.", + "update_found": "New version available: {latest_tag} \nDo you want to install the update?", + "choose_update_folder": "Choose destination folder", + "downloading_update": "Downloading update...", + "update_downloaded": "Update downloaded to {local_path}", + "update_download_error": "Error downloading update", + "update": "Update", + "version": "Version", + "details": "Details", + "update_details": "Update Details", + "update_aborted": "Update aborted by user", + "loading": "Loading...", + "verifying_license": "Verifying license...", + "checking_updates": "Checking for updates...", + "initializing": "Initializing interface...", + "loading_complete": "Loading complete", + "activate_license": "Activate your license to unlock all features", + "hardware_id_info": "This unique identifier is tied to your hardware. Share it with support if needed.", + "hardware_id_copied": "Hardware ID copied to clipboard!", + "license_key_section": "License Key :", + "enter_license_key": "Enter your license key", + "activate": "Activate", + "buy_license": "Buy License", + "comparaisons": "Version Comparison", + "basic_version": "Basic Version", + "premium_version": "Premium Version", + "enterprise_version": "Enterprise Version", + "support": "Support", + "priority_support": "Priority support", + "unlimited_key_uses": "Unlimited key uses", + "license_active": "License active", + "license_type": "Type", + "license_email": "Email", + "license_expires": "Expires on", + "license_basic_mode": "Basic mode - Activate a license for more features", + "invalid_license_key": "Invalid license key. It must contain at least 16 characters.", + "premium_feature_message": "The feature '{feature}' requires a Premium or Enterprise license.", + "upgrade_prompt": "Would you like to upgrade your license?", + "buy_now": "Buy Now", + "cancel": "Cancel", + "activation_required": "Activation is required to continue.", + "compare_versions": "Compare Versions", + "no_license": "No License", + "tab_suggestions": "Suggestions", + "tab_settings": "Settings", + "tab_licensing": "Licensing" +} \ No newline at end of file diff --git a/data/lang/fr.json b/data/lang/fr.json new file mode 100644 index 0000000..a985795 --- /dev/null +++ b/data/lang/fr.json @@ -0,0 +1,68 @@ +{ + "lang_name": "Français", + "language": "Langue :", + "theme": "Thème :", + "dark_theme": "Thème Sombre", + "light_theme": "Thème Clair", + "yes": "Oui", + "no": "Non", + "confirmation": "Confirmation", + "information": "Information", + "close": "Fermer", + "suggestion_text": "Vous avez une question ou une idée pour améliorer mon logiciel ? Envoyez-moi un message !", + "suggestion_placeholder": "Tapez votre message ici...", + "send_suggestion": "Envoyer", + "sending": "Envoi...", + "success": "Succès", + "error": "Erreur", + "suggestion_sent_success": "Votre message a été envoyé avec succès !", + "suggestion_send_error": "Erreur lors de l'envoi du message. Essayez à nouveau plus tard.", + "email_credentials_error": "Identifiants de messagerie non ou mal configurés. Veuillez définir votre email et mot de passe dans le fichier .env.", + "suggestion_too_short": "Le message doit contenir au moins 15 caractères.", + "update_found": "Nouvelle version disponible : {latest_tag} \nVoulez-vous installer la mise à jour ?", + "choose_update_folder": "Choisissez le dossier de destination", + "downloading_update": "Téléchargement de la mise à jour...", + "update_downloaded": "Mise à jour téléchargée dans {local_path}", + "update_download_error": "Erreur lors du téléchargement de la mise à jour", + "update": "Mise à jour", + "version": "Version", + "details": "Détails", + "update_details": "Détails de la mise à jour", + "update_aborted": "Mise à jour annulée par l'utilisateur", + "loading": "Chargement...", + "verifying_license": "Vérification de la licence...", + "checking_updates": "Vérification des mises à jour...", + "initializing": "Initialisation de l'interface...", + "loading_complete": "Chargement terminé", + "activate_license": "Activez votre licence pour débloquer toutes les fonctionnalités", + "hardware_id_info": "Cet identifiant unique est lié à votre matériel. Partagez-le avec le support si nécessaire.", + "hardware_id_copied": "ID matériel copié dans le presse-papier !", + "license_key_section": "Clé de licence :", + "enter_license_key": "Entrez votre clé de licence", + "activate": "Activer", + "buy_license": "Acheter une licence", + "comparaisons": "Comparaison des versions", + "basic_version": "Version Basique", + "premium_version": "Version Premium", + "enterprise_version": "Version Enterprise", + "support": "Support", + "priority_support": "Support prioritaire", + "unlimited_key_uses": "Utilisations de clé illimitées", + "license_active": "Licence active", + "license_type": "Type", + "license_email": "Email", + "license_expires": "Expire le", + "license_basic_mode": "Mode basique - Activez une licence pour plus de fonctionnalités", + "invalid_license_key": "Clé de licence invalide. Elle doit contenir au moins 16 caractères.", + "premium_feature": "Fonctionnalité Premium", + "premium_feature_message": "La fonctionnalité '{feature}' nécessite une licence Premium ou Enterprise.", + "upgrade_prompt": "Souhaitez-vous mettre à niveau votre licence ?", + "buy_now": "Acheter maintenant", + "cancel": "Annuler", + "activation_required": "L'activation est requise pour continuer.", + "compare_versions": "Comparer les versions", + "no_license": "Pas de licence", + "tab_suggestions": "Suggestions", + "tab_settings": "Paramètres", + "tab_licensing": "Licence" +} \ No newline at end of file diff --git a/data/others/defaults_settings.json b/data/others/defaults_settings.json new file mode 100644 index 0000000..f963074 --- /dev/null +++ b/data/others/defaults_settings.json @@ -0,0 +1,6 @@ +{ + "theme": "dark", + "lang": "fr", + "window_size": {"width": 1000, "height": 600}, + "maximized": true +} \ No newline at end of file diff --git a/data/themes/dark.json b/data/themes/dark.json new file mode 100644 index 0000000..4ee5cd8 --- /dev/null +++ b/data/themes/dark.json @@ -0,0 +1,16 @@ +{ + "theme_name": "dark", + "colors": { + "background_color": "#212121", + "background_secondary_color": "#2C2C2E", + "background_tertiary_color": "#4A4A4A", + "border_color": "#3C3C3E", + "text_color": "#D1D1D6", + "primary_color": "#0A84FF", + "primary_hover_color": "#007AFF", + "icon_selected_color": "#D1D1D6", + "icon_unselected_color": "#4A4A4A", + "icon_selected_border_color": "#D1D1D6", + "icon_hover_color": "#D1D1D6" + } +} \ No newline at end of file diff --git a/data/themes/light.json b/data/themes/light.json new file mode 100644 index 0000000..4fe80b3 --- /dev/null +++ b/data/themes/light.json @@ -0,0 +1,16 @@ +{ + "theme_name": "light", + "colors": { + "background_color": "#FFFFFF", + "background_secondary_color": "#F5F5F5", + "background_tertiary_color": "#E0E0E0", + "border_color": "#1f1f20", + "text_color": "#000000", + "primary_color": "#0A84FF", + "primary_hover_color": "#007AFF", + "icon_selected_color": "#000000", + "icon_unselected_color": "#5D5A5A", + "icon_selected_border_color": "#000000", + "icon_hover_color": "#000000" + } +} \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..7d9f402 --- /dev/null +++ b/main.py @@ -0,0 +1,100 @@ +import sys +import app.utils.paths as paths +from PyQt6.QtWidgets import QApplication +from PyQt6.QtGui import QIcon +from app.ui.main_window import MainWindow +from app.ui.windows.splash_screen import SplashScreen +from app.core.main_manager import MainManager + +preloaded_window = None + +def preload_application(progress_callback, splash=None): + """ + Fonction de préchargement qui s'exécute pendant l'affichage du splash screen + + Args: + progress_callback: Fonction pour mettre à jour le texte de progression + splash: Référence au splash screen (optionnel) + + Returns: + bool: True si tout s'est bien passé, False sinon + """ + global preloaded_window + + try: + main_manager = MainManager.get_instance() + language_manager = main_manager.get_language_manager() + update_manager = main_manager.get_update_manager() + license_manager = main_manager.get_license_manager() + settings_manager = main_manager.get_settings_manager() + + # Vérifier la licence uniquement si le système est activé + if settings_manager.get_config("enable_licensing"): + progress_callback(language_manager.get_text("verifying_license")) + license_manager.verify_license() + + progress_callback(language_manager.get_text("checking_updates")) + + if update_manager.check_for_update(splash, splash): + return False + + progress_callback(language_manager.get_text("initializing")) + + preloaded_window = MainWindow() + + progress_callback(language_manager.get_text("loading_complete")) + + return True + + except Exception as e: + print(f"Error during preload: {e}") + return False + +def main() -> int: + global preloaded_window + + main_manager: MainManager = MainManager.get_instance() + theme_manager = main_manager.get_theme_manager() + settings_manager = main_manager.get_settings_manager() + + app: QApplication = QApplication(sys.argv) + app.setStyleSheet(theme_manager.get_sheet()) + app.setApplicationName(settings_manager.get_config("app_name")) + app.setWindowIcon(QIcon(paths.get_asset_path("icon"))) + + splash_image_path = paths.get_asset_path(settings_manager.get_config("splash_image")) + use_splash = splash_image_path and paths.Path(splash_image_path).exists() + + if use_splash: + splash = SplashScreen(preload_function=lambda callback: preload_application(callback, splash)) + splash.show_splash() + + def on_splash_finished(success): + global preloaded_window + if not success: + app.quit() + return + + if preloaded_window: + preloaded_window.show() + else: + window = MainWindow() + window.show() + + splash.finished.connect(on_splash_finished) + else: + + def dummy_progress(text): + pass + + success = preload_application(dummy_progress) + if not success: + return 0 + + window = preloaded_window if preloaded_window else MainWindow() + window.show() + + return app.exec() + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..390d828 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +PyQt6 +pyinstaller +python-dotenv +requests \ No newline at end of file diff --git a/tools/build.bat b/tools/build.bat new file mode 100644 index 0000000..05c661b --- /dev/null +++ b/tools/build.bat @@ -0,0 +1,91 @@ +@echo off +setlocal enabledelayedexpansion + +REM === PATH SETUP === +set PARENT_DIR=%~dp0.. +set CONFIG_FILE=%PARENT_DIR%\config.json +set ICON_FILE=%PARENT_DIR%\data\assets\icon.png +set ENV_FILE=%PARENT_DIR%\.env + +REM Check if .env file exists +if not exist "%ENV_FILE%" ( + echo [ERROR] .env file not found. Please copy .env.example to .env and configure it. + exit /b 1 +) + +REM === Extract values from config.json === +for /f "delims=" %%i in ('powershell -NoProfile -Command ^ + "Get-Content '%CONFIG_FILE%' | ConvertFrom-Json | Select-Object -ExpandProperty icon_path"') do set ICON_PATH=%%i +for /f "delims=" %%i in ('powershell -NoProfile -Command ^ + "Get-Content '%CONFIG_FILE%' | ConvertFrom-Json | Select-Object -ExpandProperty app_name"') do set APP_NAME=%%i +for /f "delims=" %%i in ('powershell -NoProfile -Command ^ + "Get-Content '%CONFIG_FILE%' | ConvertFrom-Json | Select-Object -ExpandProperty architecture"') do set ARCHITECTURE=%%i + +REM === Extract python path from .env file === +for /f "usebackq tokens=2 delims==" %%i in (`findstr "PYTHON_PATH" "%ENV_FILE%"`) do set SYSTEM_PYTHON=%%i + +set VENV_PATH=%PARENT_DIR%\WINenv_%ARCHITECTURE% +set EXE_NAME=%APP_NAME%.exe +set PYTHON_IN_VENV=%VENV_PATH%\Scripts\python.exe +set BUILD_DIR=%PARENT_DIR%\build +set ZIP_FILE=%BUILD_DIR%\%APP_NAME%.zip + +REM === Verify Python existence === +if not exist "%SYSTEM_PYTHON%" ( + echo [ERROR] Python not found at: %SYSTEM_PYTHON% + exit /b 1 +) + +REM === Check if virtual environment exists === +if not exist "%VENV_PATH%\Scripts\activate.bat" ( + echo [INFO] Virtual environment not found. Creating... + "%SYSTEM_PYTHON%" -m venv "%VENV_PATH%" + "%PYTHON_IN_VENV%" -m pip install --upgrade pip + "%PYTHON_IN_VENV%" -m pip install -r "%PARENT_DIR%\requirements.txt" +) else ( + echo [INFO] Virtual environment found. +) + +REM === Run PyInstaller === +"%PYTHON_IN_VENV%" -m PyInstaller ^ + --distpath "%BUILD_DIR%" ^ + --workpath "%BUILD_DIR%\dist" ^ + --clean ^ + "%PARENT_DIR%\BUILD.spec" + +REM === Clean build cache === +rmdir /s /q "%BUILD_DIR%\dist" + +REM === Create ZIP === +echo [INFO] Creating ZIP archive... + +set TEMP_ZIP_DIR=%BUILD_DIR%\temp_zip + +REM Remove old temp dir if exists +if exist "%TEMP_ZIP_DIR%" rmdir /s /q "%TEMP_ZIP_DIR%" +mkdir "%TEMP_ZIP_DIR%" + +REM Copy compiled app - tout le contenu du build sauf les ZIP existants +move /Y "%BUILD_DIR%\%EXE_NAME%" "%TEMP_ZIP_DIR%\" + +REM Copy config.json +copy "%CONFIG_FILE%" "%TEMP_ZIP_DIR%\" /Y + +REM Copy icon.png +copy "%ICON_FILE%" "%TEMP_ZIP_DIR%\" /Y + +REM Copy data/lang +xcopy /E /I /Y "%PARENT_DIR%\data\lang" "%TEMP_ZIP_DIR%\lang" + +REM Remove old ZIP if exists +if exist "%ZIP_FILE%" del "%ZIP_FILE%" + +REM Create ZIP +powershell -NoProfile -Command "Add-Type -AssemblyName 'System.IO.Compression.FileSystem'; [IO.Compression.ZipFile]::CreateFromDirectory('%TEMP_ZIP_DIR%', '%ZIP_FILE%')" + +REM Remove temp folder +rmdir /s /q "%TEMP_ZIP_DIR%" + +echo [INFO] ZIP created at: %ZIP_FILE% + +endlocal diff --git a/tools/open.bat b/tools/open.bat new file mode 100644 index 0000000..a6c4891 --- /dev/null +++ b/tools/open.bat @@ -0,0 +1,53 @@ +@echo off +setlocal enabledelayedexpansion + +REM Set file paths +set ROOT_DIR=%~dp0.. +set CONFIG_FILE=%ROOT_DIR%\config.json +set REQUIREMENTS=%ROOT_DIR%\requirements.txt +set ENV_FILE=%ROOT_DIR%\.env + +REM Check if .env file exists +if not exist "%ENV_FILE%" ( + echo [ERROR] .env file not found. Please copy .env.example to .env and configure it. + exit /b 1 +) + +REM Extract config.json fields using PowerShell +for /f "delims=" %%i in ('powershell -NoProfile -Command ^ + "Get-Content '%CONFIG_FILE%' | ConvertFrom-Json | Select-Object -ExpandProperty architecture"') do set ARCHITECTURE=%%i + +REM Extract python path from .env file +for /f "usebackq tokens=2 delims==" %%i in (`findstr "PYTHON_PATH" "%ENV_FILE%"`) do set PYTHON_EXEC=%%i + +REM Construct python executable path +set ENV_NAME=WINenv_%ARCHITECTURE% +set ENV_PATH=%ROOT_DIR%\%ENV_NAME% + +if not exist "%PYTHON_EXEC%" ( + echo [ERROR] Python introuvable à: %PYTHON_EXEC% + echo Veuillez vérifier votre .env ou le dossier d'installation. + exit /b 1 +) + +REM Show config info +echo [INFO] Configuration : +echo Python: %PYTHON_EXEC% +echo Env: %ENV_NAME% + +REM Check if virtual environment exists +if not exist "%ENV_PATH%\Scripts" ( + echo [INFO] Environnement virtuel introuvable, création... + "%PYTHON_EXEC%" -m venv "%ENV_PATH%" + echo [INFO] Installation des dépendances... + "%ENV_PATH%\Scripts\python" -m pip install --upgrade pip + "%ENV_PATH%\Scripts\pip" install -r "%REQUIREMENTS%" +) else ( + echo [INFO] Environnement virtuel trouvé. +) +REM Activate and launch VS Code +echo [INFO] Lancement de VS Code... +call "%ENV_PATH%/Scripts/activate.bat" +start code "%ROOT_DIR%" + +exit /b 0