commit 8351f328119928e71a13eb3827747cb26d1a805a Author: LouisMazin Date: Sun Jan 18 16:40:28 2026 +0100 Initial commit 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 0000000..69fd967 Binary files /dev/null and b/data/assets/icon.icns differ diff --git a/data/assets/icon.ico b/data/assets/icon.ico new file mode 100644 index 0000000..2e25305 Binary files /dev/null and b/data/assets/icon.ico differ diff --git a/data/assets/icon.png b/data/assets/icon.png new file mode 100644 index 0000000..76b8d9a Binary files /dev/null and b/data/assets/icon.png differ 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 0000000..bdb7096 Binary files /dev/null and b/data/assets/splash.png differ diff --git a/data/assets/suggestion.svg b/data/assets/suggestion.svg new file mode 100644 index 0000000..3db29ec --- /dev/null +++ b/data/assets/suggestion.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ 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