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:
@@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
119
memoire.md
119
memoire.md
@@ -7,16 +7,17 @@ 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
|
||||
|
||||
@@ -25,68 +26,90 @@ Ce n'est PAS un photobooth (pas de capture photo). C'est une station de transfer
|
||||
src/
|
||||
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/
|
||||
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 |
|
||||
|
||||
@@ -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,8 +318,16 @@ 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:
|
||||
config = load_config()
|
||||
pricing = config.get("pricing", {})
|
||||
if pricing.get("enabled", False):
|
||||
price = calculate_price(total, config)
|
||||
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 ''}"
|
||||
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:
|
||||
|
||||
@@ -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)
|
||||
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._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;")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user