Skip to content

Commit 762f6b6

Browse files
Merge pull request #510 from AndreWohnsland/dev
Add random cocktail option
2 parents a966ea7 + 67f3375 commit 762f6b6

File tree

14 files changed

+386
-42
lines changed

14 files changed

+386
-42
lines changed

AGENTS.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
- **v1 (Qt)**: Desktop app using PyQt6, run via `runme.py` or `just qt`
1111
- **v2 (Web)**: React frontend + FastAPI backend, run via `just api` + `just web`
12+
- Unless specified otherwise, features should target both versions
1213

1314
## Project Structure
1415

@@ -38,3 +39,27 @@ Version-specific code:
3839
- `src/config/` - Configuration management module
3940
- `custom_config.yaml` - User customization file
4041
- `web_client/.env.*` - Web client environment settings
42+
43+
## Singletons & Global State
44+
45+
Key modules use singletons — never instantiate new copies, always import the existing instance:
46+
47+
- **Via `__new__`**: `MachineController` (`src/machine/controller.py`), `NFCPaymentService` (`src/service/nfc_payment_service.py`)
48+
- **Module-level instances**: `DB_COMMANDER` (`src/database_commander.py`), `CONFIG` / `shared` (`src/config/config_manager.py`), `DIALOG_HANDLER` (`src/dialog_handler.py`), `ADDONS` (`src/programs/addons.py`)
49+
50+
## Running Checks
51+
52+
- `just python-check` - ruff lint + format check + ty type check
53+
- `just python-test` - pytest with coverage
54+
- `just python-fix` - auto-fix lint and formatting
55+
- `just web-lint` / `just web-format` - web client checks
56+
57+
## Translations
58+
59+
- **Python**: User-facing text uses `DialogHandler` with keys from `src/language.yaml`
60+
- **Web client**: i18next with translation files in `web_client/src/locales/`
61+
62+
## Database
63+
64+
- SQLite + SQLAlchemy ORM, models in `src/db_models.py`
65+
- Tests use in-memory SQLite (`sqlite:///:memory:`) via the `db_commander` fixture

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
AGENTS.md

src/config/config_manager.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ class ConfigManager:
117117
MAKER_USE_RECIPE_VOLUME: bool = False
118118
# Option to add the single ingredient option to the maker pane
119119
MAKER_ADD_SINGLE_INGREDIENT: bool = False
120+
# Option to show a random cocktail tile in the maker tab
121+
MAKER_RANDOM_COCKTAIL: bool = False
120122
# List of normal (non-addressable) LED configurations
121123
LED_NORMAL: ClassVar[list[NormalLedConfig]] = []
122124
# List of WS281x (addressable) LED configurations
@@ -225,6 +227,7 @@ def __init__(self) -> None:
225227
"MAKER_CHECK_INTERNET": BoolType(check_name="Check Internet"),
226228
"MAKER_USE_RECIPE_VOLUME": BoolType(check_name="Use Recipe Volume"),
227229
"MAKER_ADD_SINGLE_INGREDIENT": BoolType(check_name="Can Spend Single Ingredient"),
230+
"MAKER_RANDOM_COCKTAIL": BoolType(check_name="Random Cocktail Option"),
228231
"LED_NORMAL": ListType(
229232
DictType(
230233
{

src/language.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,12 @@ ui:
449449
nfc_scan_to_proceed:
450450
en: 'Scan NFC to proceed'
451451
de: 'NFC scannen um fortzufahren'
452+
random_cocktail_label:
453+
en: 'Random Cocktail'
454+
de: 'Zufalls-Cocktail'
455+
random_be_surprised:
456+
en: 'Be Surprised!'
457+
de: 'Lass dich überraschen!'
452458

453459
cocktail_selection:
454460
# best is to use some space before to get a padding for icon
@@ -762,6 +768,9 @@ ui:
762768
MAKER_ADD_SINGLE_INGREDIENT:
763769
en: 'Adds the option to also spend a single ingredient to the cocktails in the maker tab'
764770
de: 'Fügt eine Option zu den Cocktails in dem Maker Tab hinzu, auch einzelne Zutaten ausgeben zu können'
771+
MAKER_RANDOM_COCKTAIL:
772+
en: 'Adds a random cocktail tile to the maker tab that picks a surprise cocktail on prepare'
773+
de: 'Fügt eine Zufalls-Cocktail Kachel zum Maker Tab hinzu, die beim Zubereiten einen Überraschungscocktail wählt'
765774
LED_NORMAL:
766775
en: 'List with config for each normal (non-addressable) LED: pin, default on state, preparation state (on/off/effects)'
767776
de: 'Liste mit Einstellung für jeden normalen (nicht ansteuerbaren) LED: Pin, Standard an-Zustand, Zustand bei Zubereitung (an/aus/Effekte)'

src/ui/cocktail_view.py

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,37 @@ def _square_size() -> int:
4545
return int(cfg.UI_WIDTH / (n_columns * 1.17))
4646

4747

48+
def generate_random_image_block(cocktails: list[Cocktail], mainscreen: MainScreen) -> QVBoxLayout:
49+
"""Generate an image block for the random cocktail tile."""
50+
square_size = _square_size()
51+
header_font_size = round(square_size / 15.8)
52+
header_height = round(square_size / 6.3)
53+
random_label = UI_LANGUAGE.get_translation("random_cocktail_label", "main_window")
54+
button = create_button(
55+
random_label,
56+
font_size=header_font_size,
57+
min_h=0,
58+
max_h=header_height,
59+
max_w=square_size,
60+
css_class="btn-inverted btn-half-top",
61+
)
62+
label = ClickableLabel(random_label)
63+
label.setProperty("cssClass", "cocktail-picture-view")
64+
pixmap = QPixmap(str(DEFAULT_COCKTAIL_IMAGE))
65+
label.setPixmap(pixmap)
66+
label.setScaledContents(True)
67+
label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
68+
label.setMinimumSize(square_size, square_size)
69+
label.setMaximumSize(square_size, square_size)
70+
layout = QVBoxLayout()
71+
layout.setSpacing(0)
72+
layout.addWidget(button)
73+
layout.addWidget(label)
74+
button.clicked.connect(lambda _, c=cocktails: mainscreen.open_random_cocktail_detail(c))
75+
label.clicked.connect(lambda c=cocktails: mainscreen.open_random_cocktail_detail(c))
76+
return layout
77+
78+
4879
def generate_image_block(cocktail: Cocktail | None, mainscreen: MainScreen) -> QVBoxLayout:
4980
"""Generate a image block for the given cocktail."""
5081
# those factors are taken from calculations based on the old static values
@@ -193,16 +224,22 @@ def _populate_cocktails_grid(self) -> None:
193224
cocktails = [c for c in cocktails if c.is_allowed]
194225
# sort cocktails by name
195226
cocktails.sort(key=lambda x: x.name.lower())
227+
# optionally prepend the random cocktail tile
228+
offset = 0
229+
if cfg.MAKER_RANDOM_COCKTAIL and cocktails:
230+
block = generate_random_image_block(cocktails, self.mainscreen)
231+
self.grid.addLayout(block, 0, 0)
232+
offset = 1
196233
# fill the grid with n_columns columns, then go to another row
197-
for i in range(0, len(cocktails), n_columns):
198-
for j in range(n_columns):
199-
if i + j >= len(cocktails):
200-
break
201-
block = generate_image_block(cocktails[i + j], self.mainscreen)
202-
self.grid.addLayout(block, i // n_columns, j)
234+
for idx, cocktail in enumerate(cocktails):
235+
pos = idx + offset
236+
row = pos // n_columns
237+
col = pos % n_columns
238+
block = generate_image_block(cocktail, self.mainscreen)
239+
self.grid.addLayout(block, row, col)
203240
# Optionally add the single ingredient block after all cocktails
204241
if cfg.MAKER_ADD_SINGLE_INGREDIENT and not cfg.payment_enabled:
205-
total = len(cocktails)
242+
total = len(cocktails) + offset
206243
row = total // n_columns
207244
col = total % n_columns
208245
block = generate_image_block(None, self.mainscreen)

src/ui/setup_cocktail_selection.py

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import random
34
from typing import TYPE_CHECKING
45

56
from PyQt6.QtGui import QFont, QPixmap
@@ -9,6 +10,7 @@
910
from src.database_commander import DB_COMMANDER
1011
from src.dialog_handler import UI_LANGUAGE
1112
from src.display_controller import DP_CONTROLLER
13+
from src.filepath import DEFAULT_COCKTAIL_IMAGE
1214
from src.image_utils import find_cocktail_image
1315
from src.models import Cocktail, Ingredient
1416
from src.ui.creation_utils import LARGE_FONT, create_button, create_label, set_underline
@@ -23,12 +25,20 @@
2325
class CocktailSelection(QDialog, Ui_CocktailSelection):
2426
"""Class for the Cocktail selection view."""
2527

26-
def __init__(self, mainscreen: MainScreen, cocktail: Cocktail) -> None:
28+
def __init__(
29+
self,
30+
mainscreen: MainScreen,
31+
cocktail: Cocktail,
32+
random_mode: bool = False,
33+
random_pool: list[Cocktail] | None = None,
34+
) -> None:
2735
super().__init__(parent=mainscreen)
2836
self.setupUi(self)
2937
DP_CONTROLLER.initialize_window_object(self)
3038
self.cocktail = cocktail
3139
self.mainscreen = mainscreen
40+
self.random_mode = random_mode
41+
self.random_pool = random_pool or []
3242
# Store references for dynamic button label updates
3343
self._volume_buttons: list[tuple[int, QPushButton]] = [] # list of (volume, button) tuples
3444
# build the image
@@ -94,8 +104,54 @@ def set_cocktail(self, cocktail: Cocktail) -> None:
94104
self.cocktail = cocktail
95105
self._set_image()
96106

107+
def update_random_display(self) -> None:
108+
"""Update the display for random cocktail mode."""
109+
pixmap = QPixmap(str(DEFAULT_COCKTAIL_IMAGE))
110+
self.image_container.setPixmap(pixmap)
111+
random_label = UI_LANGUAGE.get_translation("random_cocktail_label", "main_window")
112+
surprise_label = UI_LANGUAGE.get_translation("random_be_surprised", "main_window")
113+
self.LAlkoholname.setText(random_label)
114+
self.LAlkoholgehalt.setText("?")
115+
self.LMenge.setText("")
116+
# hide alcohol low/high buttons
117+
self.increase_alcohol.setVisible(False)
118+
self.decrease_alcohol.setVisible(False)
119+
# show virgin toggle only if any cocktail in pool has virgin_available
120+
has_virgin = any(c.virgin_available for c in self.random_pool)
121+
self.virgin_toggle.setVisible(has_virgin)
122+
self.virgin_toggle.setChecked(False)
123+
# show surprise message in first ingredient label, clear rest
124+
fields_ingredient = self.get_labels_maker_ingredients()
125+
fields_volume = self.get_labels_maker_volume()
126+
for field_ingredient, field_volume in zip(fields_ingredient, fields_volume):
127+
field_ingredient.setText("")
128+
field_volume.setText("")
129+
if fields_ingredient:
130+
fields_ingredient[0].setText(surprise_label)
131+
self._update_volume_button_labels(random_cocktail=True)
132+
133+
def _prepare_random_cocktail(self, amount: int) -> None:
134+
"""Pick a random cocktail from the pool and prepare it."""
135+
if self.is_virgin:
136+
pool = [c for c in self.random_pool if c.virgin_available]
137+
else:
138+
pool = [c for c in self.random_pool if not c.only_virgin]
139+
if not pool:
140+
return
141+
chosen = random.choice(pool)
142+
# Re-fetch from DB for up-to-date data
143+
db_cocktail = DB_COMMANDER.get_cocktail(chosen.id)
144+
if db_cocktail is not None:
145+
chosen = db_cocktail
146+
self.cocktail = chosen
147+
self._scale_cocktail(amount)
148+
qt_prepare_flow(self.mainscreen, self.cocktail)
149+
97150
def _prepare_cocktail(self, amount: int) -> None:
98151
"""Prepare the cocktail and switches to the maker screen, if successful."""
152+
if self.random_mode:
153+
self._prepare_random_cocktail(amount)
154+
return
99155
# same applies here, need to refetch the cocktail from db
100156
db_cocktail = DB_COMMANDER.get_cocktail(self.cocktail.id)
101157
if db_cocktail is not None:
@@ -245,7 +301,8 @@ def _toggle_virgin(self, _: bool) -> None:
245301
"""Toggle the virgin option."""
246302
self.decrease_alcohol.setChecked(False)
247303
self.increase_alcohol.setChecked(False)
248-
self.update_cocktail_data()
304+
if not self.random_mode:
305+
self.update_cocktail_data()
249306

250307
def _adjust_preparation_buttons(self) -> None:
251308
"""Decide if to use a single or multiple buttons and adjusts the text accordingly.
@@ -293,14 +350,16 @@ def _adjust_preparation_buttons(self) -> None:
293350
# Set initial button labels
294351
self._update_volume_button_labels()
295352

296-
def _update_volume_button_labels(self) -> None:
353+
def _update_volume_button_labels(self, random_cocktail: bool = False) -> None:
297354
"""Update the labels of volume buttons, recalculating prices if payment is active."""
298355
for volume, button in self._volume_buttons:
299356
volume_converted = self._decide_rounding(volume * cfg.EXP_MAKER_FACTOR, 20)
300357
label = f"{volume_converted}"
301358
if cfg.payment_enabled:
302359
multiplier = cfg.PAYMENT_VIRGIN_MULTIPLIER / 100 if self.is_virgin else 1.0
303360
price = self.cocktail.current_price(cfg.PAYMENT_PRICE_ROUNDING, volume, price_multiplier=multiplier)
361+
if random_cocktail:
362+
price = "?"
304363
price_str = f"{price}".rstrip("0").rstrip(".")
305364
label += f": {price_str}€"
306365
button.setText(label)

src/ui/setup_mainwindow.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -228,22 +228,34 @@ def _handle_restricted_mode_click(self, index: int) -> None:
228228
# Remove the event filter since it's no longer needed
229229
self.tabWidget.tabBar().removeEventFilter(self) # type: ignore
230230

231-
def open_cocktail_detail(self, cocktail: Cocktail) -> None:
232-
"""Open the cocktail selection screen."""
231+
def _cleanup_cocktail_selection(self) -> None:
232+
"""Clean up any existing cocktail selection widget."""
233233
if self.cocktail_selection is not None:
234-
# Clean up all internal layouts and widgets recursively
235234
DP_CONTROLLER.delete_items_of_layout(self.cocktail_selection.layout())
236-
# Remove from stacked widget
237235
self.container_maker.removeWidget(self.cocktail_selection)
238-
# Schedule for deletion and remove reference
239236
self.cocktail_selection.deleteLater()
240237
self.cocktail_selection = None
238+
239+
def open_cocktail_detail(self, cocktail: Cocktail) -> None:
240+
"""Open the cocktail selection screen."""
241+
self._cleanup_cocktail_selection()
241242
self.cocktail_selection = CocktailSelection(self, cocktail)
242243
self.container_maker.addWidget(self.cocktail_selection)
243244
self.cocktail_selection.set_cocktail(cocktail)
244245
self.cocktail_selection.update_cocktail_data()
245246
self.switch_to_cocktail_detail()
246247

248+
def open_random_cocktail_detail(self, cocktails: list[Cocktail]) -> None:
249+
"""Open the cocktail selection screen in random mode."""
250+
if not cocktails:
251+
return
252+
self._cleanup_cocktail_selection()
253+
# Use the first cocktail as a placeholder; actual cocktail is chosen at prepare time
254+
self.cocktail_selection = CocktailSelection(self, cocktails[0], random_mode=True, random_pool=cocktails)
255+
self.container_maker.addWidget(self.cocktail_selection)
256+
self.cocktail_selection.update_random_display()
257+
self.switch_to_cocktail_detail()
258+
247259
def switch_to_cocktail_detail(self) -> None:
248260
if self.cocktail_selection is None:
249261
return

web_client/src/components/cocktail/CocktailList.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type React from 'react';
22
import { useState } from 'react';
33
import { useTranslation } from 'react-i18next';
4+
import { FaQuestion } from 'react-icons/fa';
45
import { MdNoDrinks } from 'react-icons/md';
56
import Modal from 'react-modal';
67
import { useCocktails } from '../../api/cocktails';
@@ -15,6 +16,7 @@ import LockScreen from '../common/LockScreen';
1516
import SearchBar from '../common/SearchBar';
1617
import UserDisplay from '../common/UserDisplay';
1718
import CocktailSelection from './CocktailSelection';
19+
import RandomCocktailSelection from './RandomCocktailSelection';
1820
import SingleIngredientSelection from './SingleIngredientSelection';
1921

2022
const CocktailList: React.FC = () => {
@@ -23,6 +25,7 @@ const CocktailList: React.FC = () => {
2325
const { data: cocktails, error, isLoading } = useCocktails(true, config.MAKER_MAX_HAND_INGREDIENTS ?? 0);
2426
const [selectedCocktail, setSelectedCocktail] = useState<Cocktail | null>(null);
2527
const [singleIngredientOpen, setSingleIngredientOpen] = useState(false);
28+
const [randomCocktailOpen, setRandomCocktailOpen] = useState(false);
2629
const [search, setSearch] = useState<string | null>(null);
2730
const [showOnlyVirginPossible, setShowOnlyVirginPossible] = useState(false);
2831
const { t } = useTranslation();
@@ -99,6 +102,25 @@ const CocktailList: React.FC = () => {
99102
</div>
100103
</div>
101104
<div className='flex flex-wrap gap-3 justify-center items-center w-full mb-4'>
105+
{config.MAKER_RANDOM_COCKTAIL && displayedCocktails && displayedCocktails.length > 0 && !search && (
106+
<button
107+
className='border-2 border-primary active:border-secondary rounded-xl box-border overflow-hidden min-w-56 max-w-64 basis-1 grow text-xl font-bold bg-primary active:bg-secondary text-background'
108+
onClick={() => setRandomCocktailOpen(true)}
109+
type='button'
110+
>
111+
<p className='text-center py-1 flex items-center justify-center'>
112+
<FaQuestion className='mr-2' />
113+
{t('cocktails.randomCocktail')}
114+
</p>
115+
<div className='relative w-full' style={{ paddingTop: '100%' }}>
116+
<img
117+
src={`${API_URL}/static/default/default.jpg`}
118+
alt={t('cocktails.randomCocktail')}
119+
className='absolute top-0 left-0 w-full h-full object-cover'
120+
/>
121+
</div>
122+
</button>
123+
)}
102124
{displayedCocktails
103125
?.sort((a, b) => a.name.localeCompare(b.name))
104126
.map((cocktail) => {
@@ -176,6 +198,19 @@ const CocktailList: React.FC = () => {
176198
<Modal isOpen={singleIngredientOpen} className='modal slim' overlayClassName='overlay z-20' preventScroll={true}>
177199
<SingleIngredientSelection onClose={() => setSingleIngredientOpen(false)} />
178200
</Modal>
201+
<Modal
202+
isOpen={randomCocktailOpen}
203+
onRequestClose={() => setRandomCocktailOpen(false)}
204+
contentLabel='Random Cocktail'
205+
className='modal'
206+
overlayClassName='overlay z-20'
207+
preventScroll={true}
208+
>
209+
<RandomCocktailSelection
210+
handleCloseModal={() => setRandomCocktailOpen(false)}
211+
cocktails={displayedCocktails || []}
212+
/>
213+
</Modal>
179214
</div>
180215
);
181216
};

0 commit comments

Comments
 (0)