diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 59aba62..0ce18db 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,12 @@ "Bash(ssh:*)", "Bash(grep -r \"npm\\\\|nginx\\\\|proxy\\\\|reverse\" /home/jules/Documents/Projet/*/memoire.md)", "Bash(ssh -i ~/.ssh/id_ed25519 root@192.168.111.4 \"pct exec 112 -- bash -c ''''cat /etc/nginx-proxy-manager/.env 2>/dev/null; ls /opt/npm/ 2>/dev/null; ls /app/ 2>/dev/null; docker ps 2>/dev/null | grep nginx''''\")", - "Bash(ssh -i ~/.ssh/id_ed25519 root@192.168.111.4 \"pct exec 112 -- bash -c ''''a2dissite photobooth && systemctl reload apache2'''' 2>/dev/null\")" + "Bash(ssh -i ~/.ssh/id_ed25519 root@192.168.111.4 \"pct exec 112 -- bash -c ''''a2dissite photobooth && systemctl reload apache2'''' 2>/dev/null\")", + "Bash(grep -ri \"nginx\\\\|proxy.*manager\\\\|admin.*email\\\\|192.168.111.5\" /home/jules/Documents/Projet/*/memoire.md)", + "Bash(NPM_TOKEN=\"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhcGkiLCJhdHRycyI6eyJpZCI6MX0sInNjb3BlIjpbInVzZXIiXSwiZXhwaXJlc0luIjoiMWQiLCJqdGkiOiJaWmJxODdaRXBRSUo2U2VaIiwiaWF0IjoxNzc0MTI5MDE3LCJleHAiOjE3NzQyMTU0MTd9.hzKhlMgFj2rKqRuFs_eFe8y8Fe32X0JP7aQfdxbvrw_9gKsyQboUhZI3ke_dqvHNj8UxVd1cwIc7V7HxwHAIOT6W1nAleTXDxoT4VEVcuPOeWWaZ_dJ-gyp7yJ4nwaD6S4hc6F_obrKlhshccsvfAlePV_DLWzbcq0i4-oyrvEE2zo12oiQE_6ujGp73YVE8kZkSFuIVZMHF7Iz550ZC9hfUydYx-SDFEXyS8RNUyO6CMLwsaPd06EkHA2uWj9b3OBYAYUHhkIPW-cFAn7vibAlIE3yxm4NwUGf2t1pPjwii_1CPXV23L1lhqJWt2NRViQvnOTEH5wx2kNnTzutgeg\")", + "Bash(__NEW_LINE_e8c68a48c01d6eb7__ curl -s -X POST \"http://192.168.111.5:81/api/nginx/proxy-hosts\" -H \"Authorization: Bearer $NPM_TOKEN\" -H \"Content-Type: application/json\" -d '{:*)", + "Bash(__NEW_LINE_1d670e35bbf0ad79__ curl -s -X POST \"http://192.168.111.5:81/api/nginx/proxy-hosts\" -H \"Authorization: Bearer $NPM_TOKEN\" -H \"Content-Type: application/json\" -d '{:*)", + "Bash(grep:*)" ] } } diff --git a/memoire.md b/memoire.md index 08b23aa..29545e7 100644 --- a/memoire.md +++ b/memoire.md @@ -7,86 +7,109 @@ Borne d'impression photo tactile. Les utilisateurs importent leurs photos (USB, Ce n'est PAS un photobooth (pas de capture photo). C'est une station de transfert/impression : les utilisateurs arrivent avec leurs propres photos et la borne permet de les éditer et imprimer. ## Stack technique -- **Interface** : PyQt6 / QGraphicsScene (natif, léger, tactile — choisi au lieu de Chromium pour économiser la RAM sur RPi 3) +- **Interface** : PyQt6 / QGraphicsScene (natif, léger, tactile) - **Traitement image** : Pillow (effets, redimensionnement, export) - **Impression** : CUPS via pycups -- **Hotspot WiFi** : hostapd + dnsmasq + Flask (mini serveur upload) +- **Serveur upload** : Flask (thread daemon, page upload mobile-friendly) +- **Email** : IMAP polling (thread daemon) - **QR** : qrcode (Python) ## Matériel - **Prototype** : Raspberry Pi 3B+ (1GB RAM) — en stock chez Jules - **Cible** : Portable vers RPi 4 ou 5 sans aucun changement de code -- **Écran tactile** : en stock chez Jules (modèle à déterminer) +- **Écran tactile** : en stock, 10" 4/3 visé (interface responsive) - **Imprimante sublimation** : à acheter (pistes : DNP DS620A, Mitsubishi CP-D90DW) - **Pas de capture photo** — les utilisateurs apportent leurs propres photos ## Architecture ``` src/ - main.py — Point d'entrée (--kiosk pour plein écran) + main.py — Point d'entrée (--kiosk pour plein écran) services/ - usb_watcher.py — Détection USB par polling (findmnt + scan récursif) + usb_watcher.py — Détection USB par polling + upload_server.py — Serveur Flask d'upload (WiFi + QR) + imap_watcher.py — Polling IMAP pour réception email ui/ - main_window.py — Navigation QStackedWidget (4 écrans) + style.py — Styles responsive (tailles en % de la fenêtre) + main_window.py — Navigation QStackedWidget (4 écrans) screens/ - home_screen.py — Accueil, 4 boutons import, status USB live - import_screen.py — Galerie de photos (grille scrollable) - editor_screen.py — Éditeur complet (voir fonctionnalités ci-dessous) - print_screen.py — Prévisualisation + choix format + impression + home_screen.py — Accueil, 4 modes, config admin (appui long 5s) + import_screen.py — Galerie photos (cartes, quantités, pastille ratio) + editor_screen.py — Éditeur (crop ratio, rotation, filtres, goodies, multi-pose, texte) + print_screen.py — Choix format, recadrage ratio, impression widgets/ - crop_overlay.py — Rectangle de crop avec poignées tactiles + grille des tiers - goodies_panel.py — Panneau latéral stickers/cadres (charge depuis assets/) - multipose_dialog.py — 5 templates multi-pose avec prévisualisation -assets/ - goodies/ — Stickers PNG (à peupler) - frames/ — Cadres photo PNG (à peupler) - fonts/ — Polices custom -data/ - photos/ — Photos importées (gitignored) - exports/ — Exports PNG (gitignored) -config/ — Configuration (imprimante, WiFi, etc.) + crop_overlay.py — Rectangle de crop ratio verrouillé, bord à bord + goodies_panel.py — Panneau stickers/cadres + multipose_dialog.py — Templates multi-pose + config_dialog.py — Config admin complète ``` ## Fonctionnalités implémentées (2026-03-21) -- **Import USB** : polling montages, scan récursif photos (jpg/png/bmp/tiff/webp/heic), bouton vert + compteur quand clé détectée -- **Galerie** : grille 4 colonnes avec miniatures cliquables -- **Recadrage** : overlay interactif, grille des tiers, poignées tactiles aux coins, zone assombrie -- **Rotation** : 90° par clic (transformation réelle, pas juste visuelle) -- **Filtres Pillow** : N&B, sépia, vintage (désaturation+contraste), contraste -- **Goodies** : panneau latéral toggle, stickers draggable (20% taille scène), cadres overlay -- **Multi-pose** : 5 templates (2h, 2v, 3 gauche, 4 grille, 3 bande), remplissage auto depuis galerie -- **Texte** : éditable + draggable, police 32pt blanche -- **Annuler** : reset à l'image originale -- **Export** : scène complète → PNG haute résolution (fichier temporaire) -- **Impression** : écran prévisualisation + sélection format (10x15, 13x18, 15x20 cm) + +### Modes d'import +- **USB** : polling montages, scan récursif, bouton vert + compteur +- **QR Code** : mini QR sur le bouton, serveur Flask upload, bouton vert quand photos reçues → galerie directe +- **WiFi** : même serveur Flask, affiche nom AP + mot de passe +- **Email** : IMAP polling configurable, affiche adresse mail + +### Galerie +- Cartes à taille fixe, miniatures centrées (QPushButton + icon) +- Clic sur la photo → ouvre l'éditeur avec cadre de recadrage auto si ratio ≠ 3:2 +- Boutons − / quantité / + centrés sous chaque photo +- Pastille orange (point) si ratio ≠ 3:2 +- Résumé "X photos — Y impressions" +- Bouton Valider → écran impression + +### Éditeur +- Recadrage ratio verrouillé 3:2 (auto si ratio ≠ 3:2), bord à bord +- Rotation 90°, filtres (N&B, sépia, vintage, contraste) +- Multi-pose (5 templates, même photo dupliquée, crop to fill) +- Goodies (stickers draggable, cadres overlay) +- Texte éditable draggable +- Export scène → PNG haute résolution + +### Impression +- Formats activables par format dans la config (10x15, 13x18, 15x20) +- Imprimante CUPS assignable par format +- Cadre de recadrage au ratio du format, bord à bord, déplaçable + +### Config admin (appui long 5s sur "Photostation") +- WiFi : nom AP, mot de passe +- Serveur upload : IP d'écoute, port, URL publique (QR code) +- Email IMAP : activer/désactiver, serveur, port, user, password, dossier, intervalle +- Formats d'impression : checkbox par format + imprimante CUPS +- Sauvegardé dans config/settings.json + +### Interface +- Responsive : toutes tailles en % de la fenêtre +- Fonctionne en 800x480, 1024x768, 1280x1024, 1920x1080 +- Mode kiosk sans WM (FramelessWindowHint + setGeometry) +- Guard anti-boucle de zoom dans resizeEvent ## Dev - **Repo Gitea** : https://git.copydev.fr/jules/photostation - **Token Gitea** : `4bc923c47cf363a866daa1e1a6cf22bc44a78bc5` (claude-photostation) - **LXC dev** : ID 164, hostname `photostation`, IP `192.168.111.164` - Debian 12, 2GB RAM, 2 cores - - Python 3 + PyQt6 6.10.2 + Pillow 12.1.1 + venv dans `/opt/photostation/venv/` + - Python 3 + PyQt6 6.10.2 + Pillow 12.1.1 + Flask 3.1.3 + venv `/opt/photostation/venv/` - Code dans `/opt/photostation/` - - Xvfb :99 (1024x600) + VNC port 5900 (sans mot de passe) - - Lancer l'app : `source venv/bin/activate && DISPLAY=:99 PYTHONPATH=src python3 src/main.py` -- **Lancement local** : `python3 src/main.py` (fenêtré) ou `python3 src/main.py --kiosk` + - Xvfb :99 (1280x1024) + VNC port 5900 (sans mot de passe) + - 50 photos test dans `/media/usb_test/` (bind mount simulé) + - Scripts test : `test_gallery.py`, `test_editor.py`, `test_multipose.py`, `test_print.py` +- **NPM** : `photo.copydev.fr` → `192.168.111.164:8080` (proxy id=58, cert wildcard *.copydev.fr) - **Git config** : user `Jules`, email `contact@copydev.fr` -- **Credentials git** : stockés dans `/tmp/.git-cred-photostation` ## TODO -- [ ] Peupler assets/goodies/ et assets/frames/ avec des stickers et cadres -- [ ] Impression CUPS réelle (pycups, détection imprimante, envoi job) -- [ ] WiFi hotspot (hostapd + dnsmasq + Flask page upload) -- [ ] QR code (génération lien vers page upload) -- [ ] Email (réception SMTP) -- [ ] Thème/style plus abouti +- [ ] Tarification : prix en € dans la galerie, tarif unitaire + paliers configurables +- [ ] Écran récap après Valider : photos sélectionnées + quantités + prix total +- [ ] Impression CUPS réelle +- [ ] Hotspot WiFi captif (hostapd + dnsmasq — RPi spécifique) +- [ ] Peupler assets/goodies/ et assets/frames/ - [ ] Tests sur RPi 3 réel ## Formats d'impression -- 10x15 cm -- 13x18 cm -- 15x20 cm - -## Notes -- Fichiers `backend/` et `frontend/` dans le repo = version alternative (probablement générée par un autre onglet Claude). Notre approche est dans `src/` (PyQt6). -- Les libs xcb nécessaires sur le LXC : libxcb-cursor0, libxcb-xinerama0, libxcb-icccm4, libxcb-keysyms1, libxcb-render-util0, libxcb-shape0, libxcb-xfixes0, libxkbcommon-x11-0, libfontconfig1 +| Format | Ratio | Dimensions cm | +|--------|-------|---------------| +| 10x15 | 1.5 (3:2) | 10 × 15 | +| 13x18 | 1.385 | 13 × 18 | +| 15x20 | 1.333 (4:3) | 15 × 20 | diff --git a/src/ui/screens/import_screen.py b/src/ui/screens/import_screen.py index e589b5f..872342b 100644 --- a/src/ui/screens/import_screen.py +++ b/src/ui/screens/import_screen.py @@ -8,6 +8,7 @@ from PyQt6.QtCore import Qt, QSize from PyQt6.QtGui import QFont, QPixmap, QIcon from ui.style import scale, scaled_font, Sizes, back_btn_style, green_btn +from ui.widgets.config_dialog import load_config, calculate_price class PhotoCard(QFrame): @@ -317,9 +318,17 @@ class ImportScreen(QWidget): total = sum(c.quantity for c in self.cards) selected = sum(1 for c in self.cards if c.quantity > 0) if total > 0: - self.summary_label.setText( - f"{selected} photo{'s' if selected > 1 else ''} sélectionnée{'s' if selected > 1 else ''} — {total} impression{'s' if total > 1 else ''}" - ) + config = load_config() + pricing = config.get("pricing", {}) + if pricing.get("enabled", False): + price = calculate_price(total, config) + self.summary_label.setText( + f"{total} impression{'s' if total > 1 else ''} — {price:.2f} €" + ) + else: + self.summary_label.setText( + f"{selected} photo{'s' if selected > 1 else ''} — {total} impression{'s' if total > 1 else ''}" + ) self.summary_label.setStyleSheet("color: #4CAF50; font-weight: bold;") else: self.summary_label.setText("Appuyez sur + pour sélectionner des photos à imprimer") diff --git a/src/ui/screens/print_screen.py b/src/ui/screens/print_screen.py index 6703b64..88b5f46 100644 --- a/src/ui/screens/print_screen.py +++ b/src/ui/screens/print_screen.py @@ -1,21 +1,19 @@ -"""Écran d'impression — choix du format, recadrage au ratio, impression.""" +"""Écran d'impression — récap photos + format + recadrage + prix.""" from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPushButton, - QLabel, QGraphicsScene, QGraphicsView, QGraphicsPixmapItem + QLabel, QScrollArea, QGridLayout, QGraphicsScene, QGraphicsView ) -from PyQt6.QtCore import Qt, QRectF -from PyQt6.QtGui import QFont, QPixmap, QImage, QPainter, QColor +from PyQt6.QtCore import Qt, QRectF, QSize +from PyQt6.QtGui import QFont, QPixmap, QImage, QPainter, QColor, QIcon -from ui.widgets.config_dialog import load_config -from ui.widgets.crop_overlay import CropOverlay +from ui.widgets.config_dialog import load_config, calculate_price from ui.style import scale, scaled_font, Sizes, back_btn_style, green_btn, blue_btn -# Ratios par format (largeur / hauteur, paysage) FORMAT_RATIOS = { - "10x15": 1.5, # 15/10 - "13x18": 1.385, # 18/13 - "15x20": 1.333, # 20/15 + "10x15": 1.5, + "13x18": 1.385, + "15x20": 1.333, } @@ -23,11 +21,8 @@ class PrintScreen(QWidget): def __init__(self, main_window): super().__init__() self.main_window = main_window - self.image_path = None - self.pixmap_item = None - self.crop_overlay = None + self.print_list = [] self.selected_format = None - self.print_list = [] # [(path, quantity), ...] self._setup_ui() def _setup_ui(self): @@ -41,34 +36,35 @@ class PrintScreen(QWidget): self.back_btn.clicked.connect(self._go_back) header.addWidget(self.back_btn) - self.title = QLabel("Choisissez le format et recadrez") + self.title = QLabel("Récapitulatif") self.title.setAlignment(Qt.AlignmentFlag.AlignCenter) header.addWidget(self.title, 1) header.addSpacing(100) layout.addLayout(header) + # Résumé prix + self.price_label = QLabel("") + self.price_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.price_label) + # Boutons de format self.format_bar = QHBoxLayout() self.format_bar.setSpacing(10) self.format_buttons = {} layout.addLayout(self.format_bar) - # Zone de recadrage (QGraphicsScene) - self.scene = QGraphicsScene() - self.view = QGraphicsView(self.scene) - self.view.setRenderHint(QPainter.RenderHint.Antialiasing) - self.view.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) - self.view.setStyleSheet("background-color: #333; border: none;") - self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - layout.addWidget(self.view, 1) + # Grille récap scrollable (photos sélectionnées uniquement) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + scroll.setStyleSheet("QScrollArea { border: none; }") - # Info recadrage - self.info_label = QLabel("Déplacez le cadre pour recadrer votre photo") - self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.info_label.setStyleSheet("color: #999;") - layout.addWidget(self.info_label) + self.recap_widget = QWidget() + self.recap_layout = QGridLayout(self.recap_widget) + self.recap_layout.setSpacing(8) + scroll.setWidget(self.recap_widget) + layout.addWidget(scroll, 1) # Bouton imprimer self.print_btn = QPushButton("IMPRIMER") @@ -82,21 +78,23 @@ class PrintScreen(QWidget): def resizeEvent(self, event): super().resizeEvent(event) - self._apply_responsive() + if not hasattr(self, '_styling_print'): + self._apply_responsive() def _apply_responsive(self): + self._styling_print = True + self.back_btn.setFont(scaled_font(self, Sizes.BUTTON_TEXT, bold=True)) self.back_btn.setMinimumHeight(scale(self, Sizes.BTN_HEIGHT)) self.back_btn.setStyleSheet(back_btn_style(self)) self.title.setFont(scaled_font(self, Sizes.HEADING, bold=True)) - self.info_label.setFont(scaled_font(self, Sizes.HINT)) + self.price_label.setFont(scaled_font(self, Sizes.BODY_LARGE, bold=True)) self.print_btn.setFont(scaled_font(self, 4.0, bold=True)) self.print_btn.setMinimumSize(scale(self, 35), scale(self, Sizes.BTN_HEIGHT * 1.5)) self.print_btn.setStyleSheet(green_btn(self)) - # Style boutons format for fmt, btn in self.format_buttons.items(): btn.setFont(scaled_font(self, Sizes.BUTTON_TEXT)) btn.setMinimumHeight(scale(self, Sizes.BTN_HEIGHT)) @@ -105,24 +103,22 @@ class PrintScreen(QWidget): else: btn.setStyleSheet(blue_btn(self)) - def set_image(self, path): - """Charge une image et affiche les formats disponibles.""" - self.image_path = path - self.print_list = [(path, 1)] - self._load_formats() - self._load_image() + self._styling_print = False def set_print_list(self, print_list): """Reçoit la liste [(path, qty), ...] depuis la galerie.""" self.print_list = print_list - if print_list: - self.image_path = print_list[0][0] - self._load_formats() - self._load_image() + self._load_formats() + self._render_recap() + + def set_image(self, path): + """Compatibilité — une seule photo.""" + self.print_list = [(path, 1)] + self._load_formats() + self._render_recap() def _load_formats(self): - """Charge les formats activés depuis la config.""" - # Clear existing buttons + """Charge les formats activés.""" while self.format_bar.count(): item = self.format_bar.takeAt(0) if item.widget(): @@ -140,12 +136,9 @@ class PrintScreen(QWidget): btn.clicked.connect(lambda checked, f=fmt_name: self._select_format(f)) self.format_bar.addWidget(btn) self.format_buttons[fmt_name] = btn - - # Sélectionner le premier par défaut if self.selected_format is None: self.selected_format = fmt_name - # Si aucun format activé, activer 10x15 par défaut if not self.format_buttons: btn = QPushButton("10x15 cm") btn.clicked.connect(lambda: self._select_format("10x15")) @@ -155,103 +148,88 @@ class PrintScreen(QWidget): self._apply_responsive() - def _load_image(self): - """Charge l'image dans la scène et place le cadre de recadrage.""" - if not self.image_path: - return - self.scene.clear() - self.crop_overlay = None - pixmap = QPixmap(self.image_path) - if pixmap.isNull(): - return - self.pixmap_item = self.scene.addPixmap(pixmap) - self.scene.setSceneRect(QRectF(pixmap.rect())) - self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) - self._place_crop_overlay() - def _select_format(self, fmt_name): - """Change le format sélectionné et met à jour le cadre.""" self.selected_format = fmt_name self._apply_responsive() - self._place_crop_overlay() - def _place_crop_overlay(self): - """Place le cadre de recadrage au ratio du format sélectionné.""" - if not self.pixmap_item: - return + def _render_recap(self): + """Affiche la grille récap des photos sélectionnées.""" + # Clear + while self.recap_layout.count(): + item = self.recap_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() - # Supprimer l'ancien overlay - if self.crop_overlay: - self.scene.removeItem(self.crop_overlay) - self.crop_overlay = None + total_qty = sum(q for _, q in self.print_list) + config = load_config() + pricing = config.get("pricing", {}) - ratio = FORMAT_RATIOS.get(self.selected_format, 1.5) - img_rect = self.pixmap_item.boundingRect() - img_w = img_rect.width() - img_h = img_rect.height() - - # Déterminer orientation : paysage ou portrait selon l'image - img_ratio = img_w / img_h - if img_ratio < 1: - # Image portrait → cadre portrait (inverser le ratio) - ratio = 1.0 / ratio - - # Le plus grand possible : bord à bord, pas de marge - if img_w / img_h > ratio: - crop_h = img_h - crop_w = crop_h * ratio + # Prix + if pricing.get("enabled", False): + price = calculate_price(total_qty, config) + self.price_label.setText(f"{total_qty} impression{'s' if total_qty > 1 else ''} — {price:.2f} €") + self.price_label.setStyleSheet("color: #4CAF50; font-weight: bold; font-size: 18px;") else: - crop_w = img_w - crop_h = crop_w / ratio + self.price_label.setText(f"{total_qty} impression{'s' if total_qty > 1 else ''}") + self.price_label.setStyleSheet("color: #333; font-weight: bold;") - # Centrer - x = (img_w - crop_w) / 2 - y = (img_h - crop_h) / 2 + # Grille de photos + cols = 4 + h = self.window().height() if self.window() else 768 + thumb_h = max(80, int(h * 0.12)) + thumb_w = max(110, int(thumb_h * 1.45)) - crop_rect = QRectF(x, y, crop_w, crop_h) - self.crop_overlay = CropOverlay(crop_rect) - self.crop_overlay.set_bounds(img_rect) - self.scene.addItem(self.crop_overlay) + for i, (path, qty) in enumerate(self.print_list): + row, col = divmod(i, cols) - self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) + card = QWidget() + card.setStyleSheet("background: white; border: 2px solid #ddd; border-radius: 8px;") + card_layout = QVBoxLayout(card) + card_layout.setContentsMargins(4, 4, 4, 4) + card_layout.setSpacing(2) + + # Miniature + thumb = QPushButton() + thumb.setFixedSize(thumb_w, thumb_h) + thumb.setStyleSheet("border: none; background: #f8f8f8; border-radius: 4px;") + pixmap = QPixmap(path) + if not pixmap.isNull(): + if pixmap.width() > 400: + pixmap = pixmap.scaledToWidth(400, Qt.TransformationMode.SmoothTransformation) + scaled = pixmap.scaled( + thumb_w - 4, thumb_h - 4, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation + ) + thumb.setIcon(QIcon(scaled)) + thumb.setIconSize(QSize(thumb_w - 4, thumb_h - 4)) + card_layout.addWidget(thumb) + + # Quantité + qty_label = QLabel(f"x{qty}") + qty_label.setFont(QFont("", max(10, int(h * 0.018)), QFont.Weight.Bold)) + qty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + qty_label.setStyleSheet("color: #4CAF50; border: none;") + card_layout.addWidget(qty_label) + + self.recap_layout.addWidget(card, row, col) def _go_back(self): self.main_window.show_import() def _print(self): - if not self.image_path or not self.selected_format: + if not self.print_list or not self.selected_format: return config = load_config() formats = config.get("formats", {}) fmt_conf = formats.get(self.selected_format, {}) printer = fmt_conf.get("printer", "") - ratio = FORMAT_RATIOS.get(self.selected_format, 1.5) - # Récupérer le rectangle de crop - if self.crop_overlay: - crop_rect = self.crop_overlay.get_crop_rect().toRect() - else: - crop_rect = None - - # Pour chaque photo de la liste for path, qty in self.print_list: - pixmap = QPixmap(path) - if pixmap.isNull(): - continue - - # Appliquer le crop si défini (utiliser le même crop relatif) - if crop_rect: - cropped = pixmap.copy(crop_rect) - else: - cropped = pixmap - - # TODO: envoyer à CUPS print(f"[Impression] {path} x{qty} → {self.selected_format} cm" - f" (printer: {printer or 'défaut'}," - f" crop: {crop_rect.width()}x{crop_rect.height() if crop_rect else 'none'})") + f" (printer: {printer or 'défaut'})") - # Feedback total = sum(q for _, q in self.print_list) - self.info_label.setText(f"Impression lancée — {total} photo{'s' if total > 1 else ''}") - self.info_label.setStyleSheet("color: #4CAF50; font-weight: bold;") + self.price_label.setText(f"Impression lancée — {total} photo{'s' if total > 1 else ''}") + self.price_label.setStyleSheet("color: #4CAF50; font-weight: bold; font-size: 18px;") diff --git a/src/ui/widgets/config_dialog.py b/src/ui/widgets/config_dialog.py index 8eea49c..65fe14d 100644 --- a/src/ui/widgets/config_dialog.py +++ b/src/ui/widgets/config_dialog.py @@ -37,6 +37,16 @@ DEFAULT_CONFIG = { "13x18": {"enabled": False, "printer": "", "ratio": 1.385}, "15x20": {"enabled": False, "printer": "", "ratio": 1.333}, }, + # Tarification + "pricing": { + "enabled": True, + "currency": "€", + "tiers": [ + {"from": 1, "to": 5, "price": 0.50}, + {"from": 6, "to": 10, "price": 0.40}, + {"from": 11, "to": 999, "price": 0.30}, + ], + }, } @@ -51,6 +61,25 @@ def load_config(): return dict(DEFAULT_CONFIG) +def calculate_price(quantity, config=None): + """Calcule le prix total pour une quantité donnée selon les paliers.""" + if config is None: + config = load_config() + pricing = config.get("pricing", DEFAULT_CONFIG.get("pricing", {})) + if not pricing.get("enabled", False) or quantity <= 0: + return 0.0 + tiers = pricing.get("tiers", []) + # Trouver le palier applicable + unit_price = 0.0 + for tier in tiers: + if tier["from"] <= quantity <= tier["to"]: + unit_price = tier["price"] + break + if unit_price == 0.0 and tiers: + unit_price = tiers[-1]["price"] + return quantity * unit_price + + def save_config(config): os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) with open(CONFIG_PATH, "w") as f: @@ -235,6 +264,47 @@ class ConfigDialog(QDialog): self.format_widgets[fmt_name] = {"checkbox": cb, "printer": printer} row += 1 + # ── Tarification ── + grid.addWidget(self._section_title("Tarification"), row, 0, 1, 2) + row += 1 + + pricing = self.config.get("pricing", DEFAULT_CONFIG["pricing"]) + + grid.addWidget(self._label("Activer :"), row, 0) + self.pricing_enabled_cb = QCheckBox("Afficher les prix") + self.pricing_enabled_cb.setFont(QFont("", 12)) + self.pricing_enabled_cb.setChecked(pricing.get("enabled", True)) + grid.addWidget(self.pricing_enabled_cb, row, 1) + row += 1 + + self.tier_widgets = [] + tiers = pricing.get("tiers", []) + for i, tier in enumerate(tiers): + lbl = QLabel(f"De {tier['from']} à {tier['to']} :") + lbl.setFont(QFont("", 12)) + lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + grid.addWidget(lbl, row, 0) + + price_row = QHBoxLayout() + from_sb = self._spinbox(tier["from"], 1, 999) + to_sb = self._spinbox(tier["to"], 1, 999) + price_input = self._input(f"{tier['price']:.2f}", "0.50") + price_input.setMaximumWidth(80) + price_row.addWidget(QLabel("de")) + price_row.addWidget(from_sb) + price_row.addWidget(QLabel("à")) + price_row.addWidget(to_sb) + price_row.addWidget(QLabel("=")) + price_row.addWidget(price_input) + price_row.addWidget(QLabel("€/photo")) + + tier_container = QWidget() + tier_container.setLayout(price_row) + grid.addWidget(tier_container, row, 1) + + self.tier_widgets.append({"from": from_sb, "to": to_sb, "price": price_input}) + row += 1 + scroll.setWidget(content) layout.addWidget(scroll, 1) @@ -286,6 +356,23 @@ class ConfigDialog(QDialog): formats[fmt_name]["enabled"] = widgets["checkbox"].isChecked() formats[fmt_name]["printer"] = widgets["printer"].text() self.config["formats"] = formats + # Tarification + tiers = [] + for tw in self.tier_widgets: + try: + price = float(tw["price"].text().replace(",", ".")) + except ValueError: + price = 0.0 + tiers.append({ + "from": tw["from"].value(), + "to": tw["to"].value(), + "price": price, + }) + self.config["pricing"] = { + "enabled": self.pricing_enabled_cb.isChecked(), + "currency": "€", + "tiers": tiers, + } save_config(self.config) self.accept()