diff --git a/frontend/config.py b/frontend/config.py index e54b6d1..f51fbba 100644 --- a/frontend/config.py +++ b/frontend/config.py @@ -21,4 +21,6 @@ class DISPLAY_MODES(Enum): RECIPE_SELECTION = 3 EDIT_RECIPE = 4 DO_RECIPE = 5 - EDIT_STEP = 6 \ No newline at end of file + EDIT_STEP = 6 + MENU = 7 + BLUETOOTH_PAIRING = 10 \ No newline at end of file diff --git a/frontend/views/bluetooth_pairing.py b/frontend/views/bluetooth_pairing.py new file mode 100644 index 0000000..e6c6524 --- /dev/null +++ b/frontend/views/bluetooth_pairing.py @@ -0,0 +1,80 @@ +from typing import Callable, Optional, Tuple + +from PIL import ImageDraw, Image + +from .base import View +from .button_interface import ButtonInterface +from .draw_utils import draw_bluetooth_icon + + +class BluetoothPairingView(View, ButtonInterface): + """Shows a 'waiting for connection' screen while BT pairing is active. + + ``start_command`` is called once when the view is first rendered, to + trigger pairing mode on the hardware side. + ``stop_command`` is called when the user cancels (left long press). + ``on_connected(device_name)`` is called by external code when a device + has paired; it transitions back by calling ``deactivate_command``. + ``deactivate_command`` returns to the previous screen. + """ + + def __init__(self, parent, im_size, center, + start_command: Optional[Callable] = None, + stop_command: Optional[Callable] = None, + on_connected: Optional[Callable[[str], None]] = None, + deactivate_command: Optional[Callable] = None): + self.start_command = start_command + self.stop_command = stop_command + self.on_connected = on_connected + self.deactivate_command = deactivate_command + self._frame = 0 # used for the waiting animation + self._started = False + super().__init__(parent, im_size, center) + + # ------------------------------------------------------------------ + + def update_weight(self, weight: float) -> Image.Image: + if not self._started: + self._started = True + if self.start_command: + self.start_command() + + im = self.bkg_im.copy() + draw = ImageDraw.Draw(im) + cx = self.size[0] // 2 + + # Large BT icon centred + draw_bluetooth_icon(draw, (cx, 52), size=20) + + draw.text((cx - 28, 80), "Pairing", fill='black') + + # Three animated dots + self._frame = (self._frame + 1) % 6 + for i in range(3): + r = 3 if i < self._frame // 2 + 1 else 1 + draw.circle((cx - 8 + i * 8, 100), r, fill='black') + + return im + + def notify_connected(self, device_name: str = ''): + """Call this from the outside when a BT device has connected.""" + if self.on_connected: + self.on_connected(device_name) + self._cancel() + + # ------------------------------------------------------------------ + + def _cancel(self): + if self.stop_command: + self.stop_command() + if self.deactivate_command: + self.deactivate_command() + + def left_long_press(self): + self._cancel() + + def has_button(self) -> Tuple[bool, bool, bool, bool]: + return False, True, False, False + + def render_left_long_press(self, draw, x, y): + draw.text((x, y - 5), 'Cancel', fill='black') diff --git a/frontend/views/button_interface.py b/frontend/views/button_interface.py index c4648f0..c800905 100644 --- a/frontend/views/button_interface.py +++ b/frontend/views/button_interface.py @@ -34,4 +34,8 @@ class ButtonInterface: pass def render_both_long_press(self, draw, x, y): + pass + + def render_status_icons(self, draw): + """Called once per frame to render persistent status icons (e.g. BT indicator).""" pass \ No newline at end of file diff --git a/frontend/views/buttons_manager.py b/frontend/views/buttons_manager.py index 64b71dd..0bbf092 100644 --- a/frontend/views/buttons_manager.py +++ b/frontend/views/buttons_manager.py @@ -68,6 +68,8 @@ class ButtonsManager(View): self.current_view.render_both_long_press(draw, self.size[0] // 2, self.size[1] - 10) + self.current_view.render_status_icons(draw) + return im ############ BUTTON ACTIONS ########### diff --git a/frontend/views/draw_utils.py b/frontend/views/draw_utils.py index a7f3ea6..6bcf7d0 100644 --- a/frontend/views/draw_utils.py +++ b/frontend/views/draw_utils.py @@ -10,3 +10,17 @@ def draw_long_press(draw, position): x, y = position for i in range(0, 5, 2): draw.circle((x + i, y), 2, fill='black') + +def draw_bluetooth_icon(draw, position, size=6, color='black'): + """Draw a minimal Bluetooth icon (stylised B with two pointed flanges).""" + x, y = position + h = size # half-height of the centre line + w = size // 2 # half-width of the diamond cross + # Vertical spine + draw.line([(x, y - h), (x, y + h)], fill=color, width=1) + # Upper arm: spine top -> right tip -> spine midpoint + draw.line([(x, y - h), (x + w, y - h // 2)], fill=color, width=1) + draw.line([(x + w, y - h // 2), (x, y)], fill=color, width=1) + # Lower arm: spine midpoint -> right tip -> spine bottom + draw.line([(x, y), (x + w, y + h // 2)], fill=color, width=1) + draw.line([(x + w, y + h // 2), (x, y + h)], fill=color, width=1) diff --git a/frontend/views/main_view.py b/frontend/views/main_view.py index 37362c0..6e62aaf 100644 --- a/frontend/views/main_view.py +++ b/frontend/views/main_view.py @@ -5,18 +5,23 @@ from tkinter import Frame, Canvas, ttk, PhotoImage from PIL import ImageChops from ..config import DISPLAY_TYPES, DISPLAY_MODES -from .draw_utils import draw_clock +from .draw_utils import draw_clock, draw_bluetooth_icon from . import NumberView, CircleView, TimerView, TextView from .button_interface import ButtonInterface from .buttons_manager import ButtonsManager from .recipes import RecipeSelection, RecipeManager, EditRecipe, EditStep, StepType, Step +from .menu import MenuView +from .bluetooth_pairing import BluetoothPairingView class MainView(tk.Frame, ButtonInterface): def __init__(self, parent, tare_command=None, calibrate_command=None, + bt_connected_getter=None, + bt_start_pairing_command=None, + bt_stop_pairing_command=None, **kwargs): super().__init__(parent, **kwargs) self.curr_mode = DISPLAY_MODES.MAIN @@ -24,6 +29,9 @@ class MainView(tk.Frame, ButtonInterface): self.timer_view = None # Timer view is always active self.tare_command = tare_command self.calibrate_command = calibrate_command + self.bt_connected_getter = bt_connected_getter + self.bt_start_pairing_command = bt_start_pairing_command + self.bt_stop_pairing_command = bt_stop_pairing_command self.actions = Frame(self) self.actions.pack() @@ -75,6 +83,10 @@ class MainView(tk.Frame, ButtonInterface): return self.enter_recipe_selection() + def both_long_press(self): + if self.curr_mode == DISPLAY_MODES.MAIN: + self.enter_menu() + def render_left_press(self, draw, x, y): if self.curr_mode == DISPLAY_MODES.MAIN: @@ -106,6 +118,32 @@ class MainView(tk.Frame, ButtonInterface): if self.curr_mode == DISPLAY_MODES.MAIN: draw.text((x, y - 5), "R", fill='black') + def render_both_long_press(self, draw, x, y): + if self.curr_mode == DISPLAY_MODES.MAIN: + draw.text((x - 12, y - 5), "Menu", fill='black') + + def render_status_icons(self, draw): + if self.bt_connected_getter and self.bt_connected_getter(): + draw_bluetooth_icon(draw, (self.im_size[0] - 8, 8), size=5) + + + def enter_menu(self): + self.curr_mode = DISPLAY_MODES.MENU + self.buttons.current_view = MenuView(self, + self.im_size, self.center, + bluetooth_pair_command=self.start_bluetooth_pairing, + deactivate_command=self.enter_main_mode) + self.refresh(0.0) + + def start_bluetooth_pairing(self): + self.curr_mode = DISPLAY_MODES.BLUETOOTH_PAIRING + self.buttons.current_view = BluetoothPairingView( + self, self.im_size, self.center, + start_command=self.bt_start_pairing_command, + stop_command=self.bt_stop_pairing_command, + deactivate_command=self.enter_menu, + ) + self.refresh(0.0) def enter_main_mode(self): self.curr_mode = DISPLAY_MODES.MAIN diff --git a/frontend/views/menu.py b/frontend/views/menu.py new file mode 100644 index 0000000..3c2ecec --- /dev/null +++ b/frontend/views/menu.py @@ -0,0 +1,61 @@ +from typing import Tuple + +from .base import View +from .button_interface import ButtonInterface +from .list_select import ListItem, CarouselView + +from PIL import ImageDraw, Image + + +def _make_text_item(text): + return ListItem(lambda draw, pos, fill, t=text, **kw: draw.text(pos, str(t), fill=fill, **kw)) + + +class MenuView(View, ButtonInterface): + + def __init__(self, parent, im_size, center, + bluetooth_pair_command=None, + deactivate_command=None): + self.deactivate_command = deactivate_command + self.bluetooth_pair_command = bluetooth_pair_command + self.item_list = CarouselView(render_height=124, large_font_size=20) + self._actions = [ + ("BT Pair", self.bluetooth_pair_command), + ] + self.item_list.items = [_make_text_item(label) for label, _ in self._actions] + super().__init__(parent, im_size, center) + + def update_weight(self, weight: float) -> Image.Image: + im = self.bkg_im.copy() + draw = ImageDraw.Draw(im) + self.item_list.render(draw, y_start=10) + return im + + def left_press(self): + self.item_list.select_previous() + + def left_long_press(self): + self.deactivate_command() + + def right_press(self): + self.item_list.select_next() + + def right_long_press(self): + _, action = self._actions[self.item_list.selected_index] + if action: + action() + + def has_button(self) -> Tuple[bool, bool, bool, bool]: + return True, True, True, True + + def render_left_press(self, draw, x, y): + draw.regular_polygon((x, y + 2, 5), 3, fill='black') + + def render_left_long_press(self, draw, x, y): + draw.text((x, y - 5), 'Back', fill='black') + + def render_right_press(self, draw, x, y): + draw.regular_polygon((x, y + 4, 5), 3, fill='black', rotation=180) + + def render_right_long_press(self, draw, x, y): + draw.text((x - 30, y - 5), 'Select', fill='black') diff --git a/test b/test deleted file mode 100644 index e69de29..0000000