Tarification + écran récap avec prix

Config admin :
- Tarification activable avec paliers (ex: 1-5 = 0.50€, 6-10 = 0.40€)
- Fonction calculate_price() utilitaire

Galerie :
- Affiche "X impressions — Y.YY €" quand tarification activée

Écran impression/récap :
- Galerie des photos sélectionnées avec miniatures + quantité (xN)
- Prix total en haut
- Choix du format d'impression
- Bouton IMPRIMER

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 23:54:10 +01:00
parent 2f96447919
commit c2c27b1892
5 changed files with 277 additions and 175 deletions

View File

@@ -12,7 +12,12 @@
"Bash(ssh:*)", "Bash(ssh:*)",
"Bash(grep -r \"npm\\\\|nginx\\\\|proxy\\\\|reverse\" /home/jules/Documents/Projet/*/memoire.md)", "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 ''''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:*)"
] ]
} }
} }

View File

@@ -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. 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 ## 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) - **Traitement image** : Pillow (effets, redimensionnement, export)
- **Impression** : CUPS via pycups - **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) - **QR** : qrcode (Python)
## Matériel ## Matériel
- **Prototype** : Raspberry Pi 3B+ (1GB RAM) — en stock chez Jules - **Prototype** : Raspberry Pi 3B+ (1GB RAM) — en stock chez Jules
- **Cible** : Portable vers RPi 4 ou 5 sans aucun changement de code - **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) - **Imprimante sublimation** : à acheter (pistes : DNP DS620A, Mitsubishi CP-D90DW)
- **Pas de capture photo** — les utilisateurs apportent leurs propres photos - **Pas de capture photo** — les utilisateurs apportent leurs propres photos
## Architecture ## Architecture
``` ```
src/ src/
main.py — Point d'entrée (--kiosk pour plein écran) main.py — Point d'entrée (--kiosk pour plein écran)
services/ 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/ 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/ screens/
home_screen.py — Accueil, 4 boutons import, status USB live home_screen.py — Accueil, 4 modes, config admin (appui long 5s)
import_screen.py — Galerie de photos (grille scrollable) import_screen.py — Galerie photos (cartes, quantités, pastille ratio)
editor_screen.py — Éditeur complet (voir fonctionnalités ci-dessous) editor_screen.py — Éditeur (crop ratio, rotation, filtres, goodies, multi-pose, texte)
print_screen.py — Prévisualisation + choix format + impression print_screen.py — Choix format, recadrage ratio, impression
widgets/ widgets/
crop_overlay.py — Rectangle de crop avec poignées tactiles + grille des tiers crop_overlay.py — Rectangle de crop ratio verrouillé, bord à bord
goodies_panel.py — Panneau latéral stickers/cadres (charge depuis assets/) goodies_panel.py — Panneau stickers/cadres
multipose_dialog.py — 5 templates multi-pose avec prévisualisation multipose_dialog.py — Templates multi-pose
assets/ config_dialog.py — Config admin complète
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.)
``` ```
## Fonctionnalités implémentées (2026-03-21) ## 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 ### Modes d'import
- **Recadrage** : overlay interactif, grille des tiers, poignées tactiles aux coins, zone assombrie - **USB** : polling montages, scan récursif, bouton vert + compteur
- **Rotation** : 90° par clic (transformation réelle, pas juste visuelle) - **QR Code** : mini QR sur le bouton, serveur Flask upload, bouton vert quand photos reçues → galerie directe
- **Filtres Pillow** : N&B, sépia, vintage (désaturation+contraste), contraste - **WiFi** : même serveur Flask, affiche nom AP + mot de passe
- **Goodies** : panneau latéral toggle, stickers draggable (20% taille scène), cadres overlay - **Email** : IMAP polling configurable, affiche adresse mail
- **Multi-pose** : 5 templates (2h, 2v, 3 gauche, 4 grille, 3 bande), remplissage auto depuis galerie
- **Texte** : éditable + draggable, police 32pt blanche ### Galerie
- **Annuler** : reset à l'image originale - Cartes à taille fixe, miniatures centrées (QPushButton + icon)
- **Export** : scène complète → PNG haute résolution (fichier temporaire) - Clic sur la photo → ouvre l'éditeur avec cadre de recadrage auto si ratio ≠ 3:2
- **Impression** : écran prévisualisation + sélection format (10x15, 13x18, 15x20 cm) - 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 ## Dev
- **Repo Gitea** : https://git.copydev.fr/jules/photostation - **Repo Gitea** : https://git.copydev.fr/jules/photostation
- **Token Gitea** : `4bc923c47cf363a866daa1e1a6cf22bc44a78bc5` (claude-photostation) - **Token Gitea** : `4bc923c47cf363a866daa1e1a6cf22bc44a78bc5` (claude-photostation)
- **LXC dev** : ID 164, hostname `photostation`, IP `192.168.111.164` - **LXC dev** : ID 164, hostname `photostation`, IP `192.168.111.164`
- Debian 12, 2GB RAM, 2 cores - 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/` - Code dans `/opt/photostation/`
- Xvfb :99 (1024x600) + VNC port 5900 (sans mot de passe) - Xvfb :99 (1280x1024) + VNC port 5900 (sans mot de passe)
- Lancer l'app : `source venv/bin/activate && DISPLAY=:99 PYTHONPATH=src python3 src/main.py` - 50 photos test dans `/media/usb_test/` (bind mount simulé)
- **Lancement local** : `python3 src/main.py` (fenêtré) ou `python3 src/main.py --kiosk` - 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` - **Git config** : user `Jules`, email `contact@copydev.fr`
- **Credentials git** : stockés dans `/tmp/.git-cred-photostation`
## TODO ## TODO
- [ ] Peupler assets/goodies/ et assets/frames/ avec des stickers et cadres - [ ] Tarification : prix en € dans la galerie, tarif unitaire + paliers configurables
- [ ] Impression CUPS réelle (pycups, détection imprimante, envoi job) - [ ] Écran récap après Valider : photos sélectionnées + quantités + prix total
- [ ] WiFi hotspot (hostapd + dnsmasq + Flask page upload) - [ ] Impression CUPS réelle
- [ ] QR code (génération lien vers page upload) - [ ] Hotspot WiFi captif (hostapd + dnsmasq — RPi spécifique)
- [ ] Email (réception SMTP) - [ ] Peupler assets/goodies/ et assets/frames/
- [ ] Thème/style plus abouti
- [ ] Tests sur RPi 3 réel - [ ] Tests sur RPi 3 réel
## Formats d'impression ## Formats d'impression
- 10x15 cm | Format | Ratio | Dimensions cm |
- 13x18 cm |--------|-------|---------------|
- 15x20 cm | 10x15 | 1.5 (3:2) | 10 × 15 |
| 13x18 | 1.385 | 13 × 18 |
## Notes | 15x20 | 1.333 (4:3) | 15 × 20 |
- 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

View File

@@ -8,6 +8,7 @@ from PyQt6.QtCore import Qt, QSize
from PyQt6.QtGui import QFont, QPixmap, QIcon from PyQt6.QtGui import QFont, QPixmap, QIcon
from ui.style import scale, scaled_font, Sizes, back_btn_style, green_btn 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): class PhotoCard(QFrame):
@@ -317,9 +318,17 @@ class ImportScreen(QWidget):
total = sum(c.quantity for c in self.cards) total = sum(c.quantity for c in self.cards)
selected = sum(1 for c in self.cards if c.quantity > 0) selected = sum(1 for c in self.cards if c.quantity > 0)
if total > 0: if total > 0:
self.summary_label.setText( config = load_config()
f"{selected} photo{'s' if selected > 1 else ''} sélectionnée{'s' if selected > 1 else ''} — {total} impression{'s' if total > 1 else ''}" 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;") self.summary_label.setStyleSheet("color: #4CAF50; font-weight: bold;")
else: else:
self.summary_label.setText("Appuyez sur + pour sélectionner des photos à imprimer") self.summary_label.setText("Appuyez sur + pour sélectionner des photos à imprimer")

View File

@@ -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 ( from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QGraphicsScene, QGraphicsView, QGraphicsPixmapItem QLabel, QScrollArea, QGridLayout, QGraphicsScene, QGraphicsView
) )
from PyQt6.QtCore import Qt, QRectF from PyQt6.QtCore import Qt, QRectF, QSize
from PyQt6.QtGui import QFont, QPixmap, QImage, QPainter, QColor from PyQt6.QtGui import QFont, QPixmap, QImage, QPainter, QColor, QIcon
from ui.widgets.config_dialog import load_config from ui.widgets.config_dialog import load_config, calculate_price
from ui.widgets.crop_overlay import CropOverlay
from ui.style import scale, scaled_font, Sizes, back_btn_style, green_btn, blue_btn from ui.style import scale, scaled_font, Sizes, back_btn_style, green_btn, blue_btn
# Ratios par format (largeur / hauteur, paysage)
FORMAT_RATIOS = { FORMAT_RATIOS = {
"10x15": 1.5, # 15/10 "10x15": 1.5,
"13x18": 1.385, # 18/13 "13x18": 1.385,
"15x20": 1.333, # 20/15 "15x20": 1.333,
} }
@@ -23,11 +21,8 @@ class PrintScreen(QWidget):
def __init__(self, main_window): def __init__(self, main_window):
super().__init__() super().__init__()
self.main_window = main_window self.main_window = main_window
self.image_path = None self.print_list = []
self.pixmap_item = None
self.crop_overlay = None
self.selected_format = None self.selected_format = None
self.print_list = [] # [(path, quantity), ...]
self._setup_ui() self._setup_ui()
def _setup_ui(self): def _setup_ui(self):
@@ -41,34 +36,35 @@ class PrintScreen(QWidget):
self.back_btn.clicked.connect(self._go_back) self.back_btn.clicked.connect(self._go_back)
header.addWidget(self.back_btn) header.addWidget(self.back_btn)
self.title = QLabel("Choisissez le format et recadrez") self.title = QLabel("Récapitulatif")
self.title.setAlignment(Qt.AlignmentFlag.AlignCenter) self.title.setAlignment(Qt.AlignmentFlag.AlignCenter)
header.addWidget(self.title, 1) header.addWidget(self.title, 1)
header.addSpacing(100) header.addSpacing(100)
layout.addLayout(header) 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 # Boutons de format
self.format_bar = QHBoxLayout() self.format_bar = QHBoxLayout()
self.format_bar.setSpacing(10) self.format_bar.setSpacing(10)
self.format_buttons = {} self.format_buttons = {}
layout.addLayout(self.format_bar) layout.addLayout(self.format_bar)
# Zone de recadrage (QGraphicsScene) # Grille récap scrollable (photos sélectionnées uniquement)
self.scene = QGraphicsScene() scroll = QScrollArea()
self.view = QGraphicsView(self.scene) scroll.setWidgetResizable(True)
self.view.setRenderHint(QPainter.RenderHint.Antialiasing) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.view.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) scroll.setStyleSheet("QScrollArea { border: none; }")
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)
# Info recadrage self.recap_widget = QWidget()
self.info_label = QLabel("Déplacez le cadre pour recadrer votre photo") self.recap_layout = QGridLayout(self.recap_widget)
self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.recap_layout.setSpacing(8)
self.info_label.setStyleSheet("color: #999;") scroll.setWidget(self.recap_widget)
layout.addWidget(self.info_label) layout.addWidget(scroll, 1)
# Bouton imprimer # Bouton imprimer
self.print_btn = QPushButton("IMPRIMER") self.print_btn = QPushButton("IMPRIMER")
@@ -82,21 +78,23 @@ class PrintScreen(QWidget):
def resizeEvent(self, event): def resizeEvent(self, event):
super().resizeEvent(event) super().resizeEvent(event)
self._apply_responsive() if not hasattr(self, '_styling_print'):
self._apply_responsive()
def _apply_responsive(self): def _apply_responsive(self):
self._styling_print = True
self.back_btn.setFont(scaled_font(self, Sizes.BUTTON_TEXT, bold=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.setMinimumHeight(scale(self, Sizes.BTN_HEIGHT))
self.back_btn.setStyleSheet(back_btn_style(self)) self.back_btn.setStyleSheet(back_btn_style(self))
self.title.setFont(scaled_font(self, Sizes.HEADING, bold=True)) 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.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.setMinimumSize(scale(self, 35), scale(self, Sizes.BTN_HEIGHT * 1.5))
self.print_btn.setStyleSheet(green_btn(self)) self.print_btn.setStyleSheet(green_btn(self))
# Style boutons format
for fmt, btn in self.format_buttons.items(): for fmt, btn in self.format_buttons.items():
btn.setFont(scaled_font(self, Sizes.BUTTON_TEXT)) btn.setFont(scaled_font(self, Sizes.BUTTON_TEXT))
btn.setMinimumHeight(scale(self, Sizes.BTN_HEIGHT)) btn.setMinimumHeight(scale(self, Sizes.BTN_HEIGHT))
@@ -105,24 +103,22 @@ class PrintScreen(QWidget):
else: else:
btn.setStyleSheet(blue_btn(self)) btn.setStyleSheet(blue_btn(self))
def set_image(self, path): self._styling_print = False
"""Charge une image et affiche les formats disponibles."""
self.image_path = path
self.print_list = [(path, 1)]
self._load_formats()
self._load_image()
def set_print_list(self, print_list): def set_print_list(self, print_list):
"""Reçoit la liste [(path, qty), ...] depuis la galerie.""" """Reçoit la liste [(path, qty), ...] depuis la galerie."""
self.print_list = print_list self.print_list = print_list
if print_list: self._load_formats()
self.image_path = print_list[0][0] self._render_recap()
self._load_formats()
self._load_image() def set_image(self, path):
"""Compatibilité — une seule photo."""
self.print_list = [(path, 1)]
self._load_formats()
self._render_recap()
def _load_formats(self): def _load_formats(self):
"""Charge les formats activés depuis la config.""" """Charge les formats activés."""
# Clear existing buttons
while self.format_bar.count(): while self.format_bar.count():
item = self.format_bar.takeAt(0) item = self.format_bar.takeAt(0)
if item.widget(): if item.widget():
@@ -140,12 +136,9 @@ class PrintScreen(QWidget):
btn.clicked.connect(lambda checked, f=fmt_name: self._select_format(f)) btn.clicked.connect(lambda checked, f=fmt_name: self._select_format(f))
self.format_bar.addWidget(btn) self.format_bar.addWidget(btn)
self.format_buttons[fmt_name] = btn self.format_buttons[fmt_name] = btn
# Sélectionner le premier par défaut
if self.selected_format is None: if self.selected_format is None:
self.selected_format = fmt_name self.selected_format = fmt_name
# Si aucun format activé, activer 10x15 par défaut
if not self.format_buttons: if not self.format_buttons:
btn = QPushButton("10x15 cm") btn = QPushButton("10x15 cm")
btn.clicked.connect(lambda: self._select_format("10x15")) btn.clicked.connect(lambda: self._select_format("10x15"))
@@ -155,103 +148,88 @@ class PrintScreen(QWidget):
self._apply_responsive() 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): def _select_format(self, fmt_name):
"""Change le format sélectionné et met à jour le cadre."""
self.selected_format = fmt_name self.selected_format = fmt_name
self._apply_responsive() self._apply_responsive()
self._place_crop_overlay()
def _place_crop_overlay(self): def _render_recap(self):
"""Place le cadre de recadrage au ratio du format sélectionné.""" """Affiche la grille récap des photos sélectionnées."""
if not self.pixmap_item: # Clear
return while self.recap_layout.count():
item = self.recap_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
# Supprimer l'ancien overlay total_qty = sum(q for _, q in self.print_list)
if self.crop_overlay: config = load_config()
self.scene.removeItem(self.crop_overlay) pricing = config.get("pricing", {})
self.crop_overlay = None
ratio = FORMAT_RATIOS.get(self.selected_format, 1.5) # Prix
img_rect = self.pixmap_item.boundingRect() if pricing.get("enabled", False):
img_w = img_rect.width() price = calculate_price(total_qty, config)
img_h = img_rect.height() 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;")
# 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
else: else:
crop_w = img_w self.price_label.setText(f"{total_qty} impression{'s' if total_qty > 1 else ''}")
crop_h = crop_w / ratio self.price_label.setStyleSheet("color: #333; font-weight: bold;")
# Centrer # Grille de photos
x = (img_w - crop_w) / 2 cols = 4
y = (img_h - crop_h) / 2 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) for i, (path, qty) in enumerate(self.print_list):
self.crop_overlay = CropOverlay(crop_rect) row, col = divmod(i, cols)
self.crop_overlay.set_bounds(img_rect)
self.scene.addItem(self.crop_overlay)
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): def _go_back(self):
self.main_window.show_import() self.main_window.show_import()
def _print(self): def _print(self):
if not self.image_path or not self.selected_format: if not self.print_list or not self.selected_format:
return return
config = load_config() config = load_config()
formats = config.get("formats", {}) formats = config.get("formats", {})
fmt_conf = formats.get(self.selected_format, {}) fmt_conf = formats.get(self.selected_format, {})
printer = fmt_conf.get("printer", "") 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: 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" print(f"[Impression] {path} x{qty} → {self.selected_format} cm"
f" (printer: {printer or 'défaut'}," f" (printer: {printer or 'défaut'})")
f" crop: {crop_rect.width()}x{crop_rect.height() if crop_rect else 'none'})")
# Feedback
total = sum(q for _, q in self.print_list) 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.price_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.setStyleSheet("color: #4CAF50; font-weight: bold; font-size: 18px;")

View File

@@ -37,6 +37,16 @@ DEFAULT_CONFIG = {
"13x18": {"enabled": False, "printer": "", "ratio": 1.385}, "13x18": {"enabled": False, "printer": "", "ratio": 1.385},
"15x20": {"enabled": False, "printer": "", "ratio": 1.333}, "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) 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): def save_config(config):
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
with open(CONFIG_PATH, "w") as f: with open(CONFIG_PATH, "w") as f:
@@ -235,6 +264,47 @@ class ConfigDialog(QDialog):
self.format_widgets[fmt_name] = {"checkbox": cb, "printer": printer} self.format_widgets[fmt_name] = {"checkbox": cb, "printer": printer}
row += 1 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) scroll.setWidget(content)
layout.addWidget(scroll, 1) layout.addWidget(scroll, 1)
@@ -286,6 +356,23 @@ class ConfigDialog(QDialog):
formats[fmt_name]["enabled"] = widgets["checkbox"].isChecked() formats[fmt_name]["enabled"] = widgets["checkbox"].isChecked()
formats[fmt_name]["printer"] = widgets["printer"].text() formats[fmt_name]["printer"] = widgets["printer"].text()
self.config["formats"] = formats 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) save_config(self.config)
self.accept() self.accept()