diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5d70c9c..89f359c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,10 +4,11 @@ on: push: branches: - main + - dev workflow_dispatch: concurrency: - group: production_environment + group: ${{ github.ref_name }}_environment cancel-in-progress: true jobs: @@ -23,6 +24,17 @@ jobs: id: lowercase run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT + - name: Set image name + id: image + run: | + if [ "${{ github.ref_name }}" = "dev" ]; then + echo "name=check-in-dev" >> $GITHUB_OUTPUT + echo "extra_tag=ghcr.io/${{ steps.lowercase.outputs.owner }}/check-in-dev:latest" >> $GITHUB_OUTPUT + else + echo "name=check-in" >> $GITHUB_OUTPUT + echo "extra_tag=ghcr.io/${{ steps.lowercase.outputs.owner }}/check-in:latest" >> $GITHUB_OUTPUT + fi + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -39,5 +51,5 @@ jobs: context: . push: true tags: | - ghcr.io/${{ steps.lowercase.outputs.owner }}/check-in:${{ github.sha }} - ghcr.io/${{ steps.lowercase.outputs.owner }}/check-in:latest + ghcr.io/${{ steps.lowercase.outputs.owner }}/${{ steps.image.outputs.name }}:${{ github.sha }} + ${{ steps.image.outputs.extra_tag }} diff --git a/Dockerfile b/Dockerfile index 811fa35..94ff99e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,7 @@ -FROM python:3.13-slim +FROM python:3.13 RUN apt-get update && apt-get install -y \ - python3-tk \ - gcc \ - python3-dev \ + libqt6widgets6 \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/docker-compose.yml b/docker-compose.yml index 6e4fd8d..2f696c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ services: check-in: image: ghcr.io/ucsd-makerspace/check-in:latest + pull_policy: always privileged: true network_mode: host environment: diff --git a/fonts/Montserrat-VariableFont_wght.ttf b/fonts/Montserrat-VariableFont_wght.ttf new file mode 100644 index 0000000..451e692 Binary files /dev/null and b/fonts/Montserrat-VariableFont_wght.ttf differ diff --git a/install.sh b/install.sh deleted file mode 100644 index 1435e7e..0000000 --- a/install.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -echo "Attempting install..." -pip install tk -pip install pathlib -pip install requests -pip install datetime -pip install OAuth2 -echo "Installation complete" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 0f33aaf..e6c0f76 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,6 @@ pyserial==3.5 requests==2.32.5 adafruit-circuitpython-pn532 RPi.GPIO; sys_platform == 'linux' +pyqt6==6.11.0 +qtawesome +pyqt6-sip \ No newline at end of file diff --git a/run_dev.sh b/run_dev.sh new file mode 100755 index 0000000..e190a06 --- /dev/null +++ b/run_dev.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +set -a +source "$(dirname "$0")/.env" +set +a + +for arg in "$@"; do + if [ "$arg" = "--dev" ] || [ "$arg" = "-d" ]; then + export DEV_MODE=1 + break + fi +done + +output_file="log.txt" + +echo "" >> "$output_file" + +date "+%Y-%m-%d %H:%M:%S" >> "$output_file" + +python src/main.py "$@" 2>&1 | tee -a log.txt diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/_client.py b/src/api/_client.py new file mode 100644 index 0000000..a1f848e --- /dev/null +++ b/src/api/_client.py @@ -0,0 +1,12 @@ +import logging +import time + +import requests + + +def _req(method, url, **kwargs): + start = time.time() + resp = requests.request(method, url, **kwargs) + ms = (time.time() - start) * 1000 + logging.info(f"[CLIENT] {method.upper()} {url} -> {resp.status_code} ({ms:.0f}ms)") + return resp diff --git a/src/sheets.py b/src/api/client.py similarity index 54% rename from src/sheets.py rename to src/api/client.py index 307f2af..b411861 100644 --- a/src/sheets.py +++ b/src/api/client.py @@ -2,18 +2,10 @@ import sys import time -import requests - from config import API_BASE_URL +from api._client import _req -def _req(method, url, **kwargs): - start = time.time() - resp = requests.request(method, url, **kwargs) - ms = (time.time() - start) * 1000 - logging.info(f"[CLIENT] {method.upper()} {url} -> {resp.status_code} ({ms:.0f}ms)") - return resp - def check_api_health(retries=3, delay=3): logging.info(API_BASE_URL) @@ -31,7 +23,7 @@ def check_api_health(retries=3, delay=3): sys.exit(1) -class SheetManager: +class ApiClient: def checkin_by_uuid(self, uuid): try: resp = _req("GET", f"{API_BASE_URL}/check-in/uuid/{uuid}", timeout=10) @@ -58,26 +50,50 @@ def set_traffic_light(self, color): def get_traffic_light(self): try: - resp = requests.get(f"{API_BASE_URL}/traffic-light", timeout=5) + resp = _req("GET", f"{API_BASE_URL}/traffic-light", timeout=5) return resp.json().get("color", "off") except Exception as e: logging.error(f"Error getting traffic light: {e}") return "off" - def create_account(self, first_name, last_name, email, pid, rfid): + def lookup_by_pid(self, pid): + """Returns dict with first_name/last_name/email/pid, or None if not found.""" + try: + resp = _req("GET", f"{API_BASE_URL}/accounts/lookup/pid/{pid}", timeout=10) + if resp.status_code == 404: + return None + resp.raise_for_status() + return resp.json() + except Exception as e: + logging.error(f"Error looking up student by pid {pid}: {e}") + return None + + def lookup_by_barcode(self, barcode): + """Returns dict with first_name/last_name/email/pid, or None if not found.""" + try: + resp = _req("GET", f"{API_BASE_URL}/accounts/lookup/barcode/{barcode}", timeout=10) + if resp.status_code == 404: + return None + resp.raise_for_status() + return resp.json() + except Exception as e: + logging.error(f"Error looking up student by barcode: {e}") + return None + + def create_account(self, rfid, *, barcode=None, pid=None, first_name=None, last_name=None, email=None): try: - resp = _req( - "POST", - f"{API_BASE_URL}/accounts", - json={ - "first_name": first_name, - "last_name": last_name, - "email": email, - "pid": pid, - "rfid": rfid, - }, - timeout=30, - ) + payload = {"rfid": rfid} + if barcode: + payload["barcode"] = barcode + if pid: + payload["pid"] = pid + if first_name: + payload["first_name"] = first_name + if last_name: + payload["last_name"] = last_name + if email: + payload["email"] = email + resp = _req("POST", f"{API_BASE_URL}/accounts", json=payload, timeout=30) resp.raise_for_status() return resp.json() except Exception as e: diff --git a/src/api/traffic_light_api.py b/src/api/traffic_light_api.py new file mode 100644 index 0000000..943bcc1 --- /dev/null +++ b/src/api/traffic_light_api.py @@ -0,0 +1,44 @@ +import threading + +from api.client import ApiClient +from hardware.traffic_light import TrafficLight + + +class TrafficLightApi: + def __init__(self, light: TrafficLight, sheets: ApiClient): + self._light = light + self._sheets = sheets + + @property + def connected(self) -> bool: + return self._light.connected + + def drive(self, color: str) -> None: + """Directly set the physical traffic light without posting to the API.""" + if color == "red": + self._light.set_red() + elif color == "green": + self._light.set_green() + elif color == "yellow": + self._light.set_yellow() + else: + self._light.set_off() + + def _post(self, color: str) -> None: + threading.Thread( + target=self._sheets.set_traffic_light, + args=(color,), + daemon=True, + ).start() + + def request_red(self) -> None: + self._post("red") + + def request_green(self) -> None: + self._post("green") + + def request_yellow(self) -> None: + self._post("yellow") + + def request_off(self) -> None: + self._post("off") diff --git a/src/app_context.py b/src/app_context.py new file mode 100644 index 0000000..3ba5551 --- /dev/null +++ b/src/app_context.py @@ -0,0 +1,36 @@ +import threading + +from api.client import ApiClient +from api.traffic_light_api import TrafficLightApi +from hardware.traffic_light import TrafficLight + + +class AppContext: + def __init__(self, sheets: ApiClient, traffic_light: TrafficLightApi): + self.sheets = sheets + self.traffic_light = traffic_light + self.window = None + self.nav = None + self.check_in = None + self.account = None + self.dispatcher = None + self.has_barcode_scanner = False + self._rfid_lock = threading.Lock() + self._rfid: str = "" + + @property + def rfid(self) -> str: + with self._rfid_lock: + return self._rfid + + @rfid.setter + def rfid(self, value: str) -> None: + with self._rfid_lock: + self._rfid = value + + @classmethod + def create(cls, traffic_usb_id=None) -> "AppContext": + sheets = ApiClient() + light = TrafficLight(traffic_usb_id) + traffic = TrafficLightApi(light, sheets) + return cls(sheets, traffic) diff --git a/src/assets/check_in_no_id_assets/button_1.png b/src/assets/check_in_manual/button_check_in.png similarity index 100% rename from src/assets/check_in_no_id_assets/button_1.png rename to src/assets/check_in_manual/button_check_in.png diff --git a/src/assets/check_in_no_id_assets/image_2.png b/src/assets/check_in_no_id_assets/image_2.png deleted file mode 100644 index eb574c4..0000000 Binary files a/src/assets/check_in_no_id_assets/image_2.png and /dev/null differ diff --git a/src/assets/main_page_assets/image_4.png b/src/assets/check_in_rfid/icon_check_in.png similarity index 100% rename from src/assets/main_page_assets/image_4.png rename to src/assets/check_in_rfid/icon_check_in.png diff --git a/src/assets/no_acc_no_waiver_swipe_assets/button_1.png b/src/assets/create_account_barcode/button_fill_manually.png similarity index 100% rename from src/assets/no_acc_no_waiver_swipe_assets/button_1.png rename to src/assets/create_account_barcode/button_fill_manually.png diff --git a/src/assets/manual_fill_assets/image_2.png b/src/assets/create_account_barcode/outline_1.png similarity index 100% rename from src/assets/manual_fill_assets/image_2.png rename to src/assets/create_account_barcode/outline_1.png diff --git a/src/assets/manual_fill_assets/image_3.png b/src/assets/create_account_barcode/outline_2.png similarity index 100% rename from src/assets/manual_fill_assets/image_3.png rename to src/assets/create_account_barcode/outline_2.png diff --git a/src/assets/no_acc_no_waiver_swipe_assets/image_2.png b/src/assets/create_account_manual/outline_1.png similarity index 100% rename from src/assets/no_acc_no_waiver_swipe_assets/image_2.png rename to src/assets/create_account_manual/outline_1.png diff --git a/src/assets/no_acc_no_waiver_swipe_assets/image_3.png b/src/assets/create_account_manual/outline_2.png similarity index 100% rename from src/assets/no_acc_no_waiver_swipe_assets/image_3.png rename to src/assets/create_account_manual/outline_2.png diff --git a/src/assets/manual_fill_assets/button_1.png b/src/assets/create_account_manual/register.png similarity index 100% rename from src/assets/manual_fill_assets/button_1.png rename to src/assets/create_account_manual/register.png diff --git a/src/assets/manual_fill_assets/image_4.png b/src/assets/manual_fill_assets/image_4.png deleted file mode 100644 index 37f2074..0000000 Binary files a/src/assets/manual_fill_assets/image_4.png and /dev/null differ diff --git a/src/assets/manual_fill_assets/image_5.png b/src/assets/manual_fill_assets/image_5.png deleted file mode 100644 index 37f2074..0000000 Binary files a/src/assets/manual_fill_assets/image_5.png and /dev/null differ diff --git a/src/assets/manual_fill_assets/image_6.png b/src/assets/manual_fill_assets/image_6.png deleted file mode 100644 index 581d067..0000000 Binary files a/src/assets/manual_fill_assets/image_6.png and /dev/null differ diff --git a/src/assets/manual_fill_assets/image_7.png b/src/assets/manual_fill_assets/image_7.png deleted file mode 100644 index 581d067..0000000 Binary files a/src/assets/manual_fill_assets/image_7.png and /dev/null differ diff --git a/src/assets/manual_fill_assets/image_8.png b/src/assets/manual_fill_assets/image_8.png deleted file mode 100644 index 581d067..0000000 Binary files a/src/assets/manual_fill_assets/image_8.png and /dev/null differ diff --git a/src/assets/manual_fill_assets/image_9.png b/src/assets/manual_fill_assets/image_9.png deleted file mode 100644 index 581d067..0000000 Binary files a/src/assets/manual_fill_assets/image_9.png and /dev/null differ diff --git a/src/assets/no_acc_no_waiver_swipe_assets/image_4.png b/src/assets/no_acc_no_waiver_swipe_assets/image_4.png deleted file mode 100644 index 37f2074..0000000 Binary files a/src/assets/no_acc_no_waiver_swipe_assets/image_4.png and /dev/null differ diff --git a/src/assets/no_acc_no_waiver_swipe_assets/image_5.png b/src/assets/no_acc_no_waiver_swipe_assets/image_5.png deleted file mode 100644 index 37f2074..0000000 Binary files a/src/assets/no_acc_no_waiver_swipe_assets/image_5.png and /dev/null differ diff --git a/src/assets/qr_codes_assets/image_5.png b/src/assets/qr_codes/qr_waiver.png similarity index 100% rename from src/assets/qr_codes_assets/image_5.png rename to src/assets/qr_codes/qr_waiver.png diff --git a/src/assets/qr_codes_assets/image_4.png b/src/assets/qr_codes/qr_website.png similarity index 100% rename from src/assets/qr_codes_assets/image_4.png rename to src/assets/qr_codes/qr_website.png diff --git a/src/assets/qr_codes_assets/image_3.png b/src/assets/qr_codes_assets/image_3.png deleted file mode 100644 index 7eeb581..0000000 Binary files a/src/assets/qr_codes_assets/image_3.png and /dev/null differ diff --git a/src/assets/shared/image_1.png b/src/assets/shared/background_main.png similarity index 100% rename from src/assets/shared/image_1.png rename to src/assets/shared/background_main.png diff --git a/src/assets/main_page_assets/image_3.png b/src/assets/shared/button_generic.png similarity index 100% rename from src/assets/main_page_assets/image_3.png rename to src/assets/shared/button_generic.png diff --git a/src/assets/check_in_no_id_assets/image_3.png b/src/assets/shared/field.png similarity index 100% rename from src/assets/check_in_no_id_assets/image_3.png rename to src/assets/shared/field.png diff --git a/src/assets/acc_no_waiver_swipe_assets/image_5.png b/src/assets/shared/icon_checked_box.png similarity index 100% rename from src/assets/acc_no_waiver_swipe_assets/image_5.png rename to src/assets/shared/icon_checked_box.png diff --git a/src/assets/qr_codes_assets/image_6.png b/src/assets/shared/icon_home.png similarity index 100% rename from src/assets/qr_codes_assets/image_6.png rename to src/assets/shared/icon_home.png diff --git a/src/assets/acc_no_waiver_swipe_assets/image_6.png b/src/assets/shared/icon_unchecked_box.png similarity index 100% rename from src/assets/acc_no_waiver_swipe_assets/image_6.png rename to src/assets/shared/icon_unchecked_box.png diff --git a/src/assets/shared/image_2.png b/src/assets/shared/outline_full.png similarity index 100% rename from src/assets/shared/image_2.png rename to src/assets/shared/outline_full.png diff --git a/src/assets/acc_no_waiver_swipe_assets/button_1.png b/src/assets/sign_waiver/button_done_scanning.png similarity index 100% rename from src/assets/acc_no_waiver_swipe_assets/button_1.png rename to src/assets/sign_waiver/button_done_scanning.png diff --git a/src/assets/acc_no_waiver_swipe_assets/image_2.png b/src/assets/sign_waiver/outline_1.png similarity index 100% rename from src/assets/acc_no_waiver_swipe_assets/image_2.png rename to src/assets/sign_waiver/outline_1.png diff --git a/src/assets/acc_no_waiver_swipe_assets/image_3.png b/src/assets/sign_waiver/outline_2.png similarity index 100% rename from src/assets/acc_no_waiver_swipe_assets/image_3.png rename to src/assets/sign_waiver/outline_2.png diff --git a/src/assets/acc_no_waiver_swipe_assets/image_4.png b/src/assets/sign_waiver/outline_3.png similarity index 100% rename from src/assets/acc_no_waiver_swipe_assets/image_4.png rename to src/assets/sign_waiver/outline_3.png diff --git a/src/assets/acc_no_waiver_swipe_assets/image_7.png b/src/assets/sign_waiver/qr_waiver.png similarity index 100% rename from src/assets/acc_no_waiver_swipe_assets/image_7.png rename to src/assets/sign_waiver/qr_waiver.png diff --git a/src/assets/waiver_no_acc_swipe_assets/button_1.png b/src/assets/waiver_no_acc_swipe_assets/button_1.png deleted file mode 100644 index 6771e90..0000000 Binary files a/src/assets/waiver_no_acc_swipe_assets/button_1.png and /dev/null differ diff --git a/src/assets/waiver_no_acc_swipe_assets/image_2.png b/src/assets/waiver_no_acc_swipe_assets/image_2.png deleted file mode 100644 index 45147ba..0000000 Binary files a/src/assets/waiver_no_acc_swipe_assets/image_2.png and /dev/null differ diff --git a/src/assets/waiver_no_acc_swipe_assets/image_3.png b/src/assets/waiver_no_acc_swipe_assets/image_3.png deleted file mode 100644 index d42add7..0000000 Binary files a/src/assets/waiver_no_acc_swipe_assets/image_3.png and /dev/null differ diff --git a/src/assets/waiver_no_acc_swipe_assets/image_4.png b/src/assets/waiver_no_acc_swipe_assets/image_4.png deleted file mode 100644 index 37f2074..0000000 Binary files a/src/assets/waiver_no_acc_swipe_assets/image_4.png and /dev/null differ diff --git a/src/assets/waiver_no_acc_swipe_assets/image_5.png b/src/assets/waiver_no_acc_swipe_assets/image_5.png deleted file mode 100644 index 19cd880..0000000 Binary files a/src/assets/waiver_no_acc_swipe_assets/image_5.png and /dev/null differ diff --git a/src/controllers/__init__.py b/src/controllers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/controllers/account_controller.py b/src/controllers/account_controller.py new file mode 100644 index 0000000..f91ed7f --- /dev/null +++ b/src/controllers/account_controller.py @@ -0,0 +1,75 @@ +import logging + +from PyQt6.QtCore import QTimer +from PyQt6.QtWidgets import QApplication + + +class AccountController: + def __init__(self, ctx): + self.ctx = ctx + + def go_to_review_from_barcode(self, barcode): + self.ctx.nav.show_status("Looking up student...") + QApplication.processEvents() + + student = self.ctx.sheets.lookup_by_barcode(barcode) + self.ctx.nav.hide_status() + + if student is None: + self.ctx.nav.show_status("Student not found. Please enter your details manually.") + QTimer.singleShot(3000, self.ctx.nav.hide_status) + return + + self.ctx.nav.go_to_create_account_review( + pid=student["pid"], + first_name=student["first_name"], + last_name=student["last_name"], + email=student["email"], + ) + + def go_to_review_from_pid(self, pid): + self.ctx.nav.show_status("Looking up student...") + QApplication.processEvents() + + student = self.ctx.sheets.lookup_by_pid(pid) + self.ctx.nav.hide_status() + + if student is None: + self.ctx.nav.show_status("Student not found. Please check your PID.") + QTimer.singleShot(3000, self.ctx.nav.hide_status) + return + + self.ctx.nav.go_to_create_account_review( + pid=pid, + first_name=student["first_name"], + last_name=student["last_name"], + email=student["email"], + ) + + def create_account_from_review(self, *, first_name, last_name, email, pid): + if pid: + self._create(pid=pid) + else: + self._create(first_name=first_name, last_name=last_name, email=email) + + def _create(self, *, barcode=None, pid=None, first_name=None, last_name=None, email=None): + self.ctx.nav.show_status("Account creation in progress!") + QApplication.processEvents() + + result = self.ctx.sheets.create_account( + self.ctx.rfid, + barcode=barcode, + pid=pid, + first_name=first_name, + last_name=last_name, + email=email, + ) + self.ctx.nav.hide_status() + + if result is None: + self.ctx.nav.show_status("ERROR! Could not create account, please try manually.") + QTimer.singleShot(3000, self.ctx.nav.hide_status) + return + + logging.info("Account creation succeeded") + self.ctx.nav.pop() diff --git a/src/controllers/barcode_scanner_controller.py b/src/controllers/barcode_scanner_controller.py new file mode 100644 index 0000000..494a6cb --- /dev/null +++ b/src/controllers/barcode_scanner_controller.py @@ -0,0 +1,60 @@ +import logging +import time +from threading import Thread + +from screens.check_in_manual import CheckInManual +from screens.create_account_barcode import CreateAccountBarcode +from screens.create_account_manual import CreateAccountManual + + +class BarcodeScannerController: + def __init__(self, ctx): + self.ctx = ctx + + def start(self, scanner): + thread = Thread(target=self._run, args=(scanner,), daemon=True) + thread.start() + + def _run(self, scanner): + logging.info("Now reading barcodes") + scanner_error = False + try: + while True: + if scanner_error: + time.sleep(0.5) + if scanner.reconnect(): + logging.info("Barcode scanner reconnected") + scanner_error = False + continue + + try: + barcode = scanner.read_barcode() + except OSError as e: + logging.error("Barcode scanner disconnected: %s", e) + scanner_error = True + continue + + if barcode is None: + continue + + logging.debug("Raw barcode received: %r", barcode) + + if not scanner.is_valid(barcode): + logging.warning("Invalid barcode rejected: %r", barcode) + continue + + logging.info("Barcode scanned: %r", barcode) + curr_frame = self.ctx.nav.get_curr_frame() + + if curr_frame == CheckInManual: + self.ctx.dispatcher.call.emit( + lambda b=barcode: self.ctx.check_in.handle_by_pid(b) + ) + elif curr_frame in (CreateAccountBarcode, CreateAccountManual): + self.ctx.dispatcher.call.emit( + lambda b=barcode: self.ctx.account.go_to_review_from_barcode(b) + ) + else: + logging.debug("Barcode scanned on unhandled screen: %s", curr_frame) + except Exception as e: + logging.exception("Barcode scanner thread crashed: %s", e) diff --git a/src/controllers/check_in_controller.py b/src/controllers/check_in_controller.py new file mode 100644 index 0000000..9df6fd3 --- /dev/null +++ b/src/controllers/check_in_controller.py @@ -0,0 +1,58 @@ +import logging + +from PyQt6.QtCore import QTimer + +from screens.user_welcome import UserWelcome +from screens.transition_screen import TransitionScreen + + +class CheckInController: + def __init__(self, ctx): + self.ctx = ctx + + def handle_by_uuid(self, tag): + # Called from background thread — dispatch to main thread via signal. + self.ctx.dispatcher.call.emit( + lambda: self._run_check_in(tag, self.ctx.sheets.checkin_by_uuid) + ) + + def handle_by_pid(self, pid): + # Called on main thread (button click or barcode dispatcher). + self._run_check_in(pid, self.ctx.sheets.checkin_by_pid) + + def _run_check_in(self, identifier, check_fn, welcome_message="Welcome back"): + result = check_fn(identifier) + status = result.get("status") + + if status == "api_error": + logging.error("API error during check-in") + self.ctx.traffic_light.request_red() + self.ctx.nav.show_status("System error, please let staff know.") + QTimer.singleShot(4000, self.ctx.nav.hide_status) + return + + if status == "no_account": + logging.info(f"No account found for {identifier}") + self.ctx.traffic_light.request_red() + if not self.ctx.has_barcode_scanner: + self.ctx.nav.get_frame(TransitionScreen).display( + "Looks like you don't have an account.\nUse the other kiosk to set one up!" + ) + QTimer.singleShot(6000, self.ctx.nav.back_to_main) + return + self.ctx.nav.go_to_create_account( + on_done=lambda: self._run_check_in( + identifier, check_fn, welcome_message="Thank you for registering" + ) + ) + return + + if status == "no_waiver": + logging.info(f"No waiver for {identifier}") + self.ctx.traffic_light.request_yellow() + self.ctx.nav.go_to_sign_waiver() + return + + logging.info(f"Check-in successful: {result['name']}") + self.ctx.traffic_light.request_green() + self.ctx.nav.get_frame(UserWelcome).display_name(result["name"], welcome_message) diff --git a/src/controllers/navigation_controller.py b/src/controllers/navigation_controller.py new file mode 100644 index 0000000..f1d758d --- /dev/null +++ b/src/controllers/navigation_controller.py @@ -0,0 +1,179 @@ +import uuid + +from PyQt6.QtCore import QTimer, Qt +from PyQt6.QtWidgets import QLabel + +from screens.check_in_rfid import CheckInRFID +from screens.transition_screen import TransitionScreen +from screens.create_account_barcode import CreateAccountBarcode +from screens.create_account_manual import CreateAccountManual +from screens.create_account_no_pid import CreateAccountNoPid +from screens.create_account_review import CreateAccountReview +from screens.sign_waiver import SignWaiver +from screens.check_in_manual import CheckInManual +from screens.qr_codes import QRCodes +from screens.user_welcome import UserWelcome + + +class NavigationController: + def __init__(self, window, ctx, dev_mode=False): + self.ctx = ctx + self._window = window + self._stacked = window.stacked + self._frames = {} + self._curr = None + self._frame_uuid = uuid.uuid4().hex + self._on_done_stack = [] + self._dev_overlay = None + + self._timeouts = { + SignWaiver: 30000, + QRCodes: 30000, + } + + for F in ( + CheckInRFID, + TransitionScreen, + CreateAccountBarcode, + CreateAccountManual, + CreateAccountNoPid, + CreateAccountReview, + SignWaiver, + CheckInManual, + QRCodes, + UserWelcome, + ): + frame = F(self) + self._frames[F] = frame + self._stacked.addWidget(frame) + + # Status overlay — floats over the stacked widget at the bottom + self._status_label = QLabel("", window.central) + self._status_label.setGeometry(40, 628, 1200, 56) + self._status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._status_label.setStyleSheet( + "color: #F5F0E6;" + "font: bold 18pt Montserrat;" + "background-color: rgba(0, 0, 0, 170);" + "border-radius: 10px;" + "border: none;" + ) + self._status_label.hide() + self._status_label.raise_() + + if dev_mode: + from screens.components.dev_overlay import DevOverlay + self._dev_overlay = DevOverlay(window, self) + + self.show_frame(CheckInRFID) + + # ------------------------------------------------------------------ + # Core frame switching + # ------------------------------------------------------------------ + + def show_frame(self, screen_class): + if self._curr is not None: + self._frames[self._curr].on_hide() + self._curr = screen_class + self._frame_uuid = uuid.uuid4().hex + self._stacked.setCurrentWidget(self._frames[screen_class]) + self._frames[screen_class].on_show() + + if self._dev_overlay is not None: + self._dev_overlay.update(screen_class) + + if screen_class in self._timeouts: + uid = self._frame_uuid + QTimer.singleShot( + self._timeouts[screen_class], + lambda: self._on_timeout(uid), + ) + + def get_frame(self, screen_class): + return self._frames[screen_class] + + def get_curr_frame(self): + return self._curr + + def after(self, ms, fn): + QTimer.singleShot(ms, fn) + + # ------------------------------------------------------------------ + # Status overlay + # ------------------------------------------------------------------ + + def show_status(self, text): + self._status_label.setText(text) + self._status_label.show() + self._status_label.raise_() + + def hide_status(self): + self._status_label.hide() + + # ------------------------------------------------------------------ + # Stack-based flow + # ------------------------------------------------------------------ + + def push(self, screen_class, on_done=None): + self._on_done_stack.append(on_done) + self.show_frame(screen_class) + + def pop(self): + cb = self._on_done_stack.pop() if self._on_done_stack else None + if cb: + cb() + else: + self.back_to_main() + + # ------------------------------------------------------------------ + # Named navigations + # ------------------------------------------------------------------ + + def back_to_main(self): + self._on_done_stack.clear() + self.ctx.rfid = "" + self.ctx.traffic_light.request_off() + self.show_frame(CheckInRFID) + + def go_to_no_id(self): + self.get_frame(CheckInManual).clear_entries() + self.show_frame(CheckInManual) + + def go_to_create_account_manual(self): + self.get_frame(CreateAccountManual).clear_entries() + self.show_frame(CreateAccountManual) + + def go_to_create_account_no_pid(self): + self.get_frame(CreateAccountNoPid).clear_entries() + self.show_frame(CreateAccountNoPid) + + def go_to_create_account_review(self, pid="", first_name="", last_name="", email=""): + pid_locked = bool(pid) + self.get_frame(CreateAccountReview).setup( + first_name=first_name, + last_name=last_name, + email=email, + pid=pid, + pid_locked=pid_locked, + ) + self.show_frame(CreateAccountReview) + + def go_to_create_account(self, on_done): + self.get_frame(TransitionScreen).display( + "Looks like you don't have an account,\nlet's set one up!" + ) + QTimer.singleShot(3000, lambda: self.push(CreateAccountBarcode, on_done=on_done)) + + def go_to_sign_waiver(self): + self.get_frame(TransitionScreen).display( + "Looks like you haven't signed\nthe waiver yet,\nlet's fix that!" + ) + QTimer.singleShot(3000, lambda: self.show_frame(SignWaiver)) + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _on_timeout(self, uid): + if uid == self._frame_uuid: + self.back_to_main() diff --git a/src/controllers/rfid_reader_controller.py b/src/controllers/rfid_reader_controller.py new file mode 100644 index 0000000..5557645 --- /dev/null +++ b/src/controllers/rfid_reader_controller.py @@ -0,0 +1,103 @@ +import time +import socket +import logging +from threading import Thread + +from PyQt6.QtCore import QTimer + +from screens.create_account_manual import CreateAccountManual + + +class RfidReaderController: + def __init__(self, ctx): + self.ctx = ctx + self._no_wifi_shown = False + + def start(self, reader): + thread = Thread(target=self._run, args=(reader,)) + thread.start() + if self.ctx.traffic_light.connected: + poller = Thread(target=self._poll_traffic_light, daemon=True) + poller.start() + + def _run(self, reader): + logging.info("Now reading ID cards") + last_tag = 0 + last_time = 0 + scanner_error = False + while True: + if scanner_error: + time.sleep(1.0) + if reader.reconnect(): + logging.info("Card reader reconnected") + scanner_error = False + continue + + try: + in_waiting = reader.get_ser_in_waiting() + except OSError as e: + if not scanner_error: + logging.error("Card reader disconnected, disabling until reconnection: %s", e) + scanner_error = True + continue + + if in_waiting >= 14: + if not self._is_connected(): + logging.info("ERROR wifi is not connected") + if not self._no_wifi_shown: + self._no_wifi_shown = True + self.ctx.dispatcher.call.emit(self._show_wifi_error) + continue + + self.ctx.dispatcher.call.emit( + lambda: self.ctx.nav.get_frame(CreateAccountManual).clear_entries() + ) + tag = reader.grab_rfid() + + if " " in tag: + continue + + if tag == last_tag and not reader.can_scan_again(last_time): + logging.debug("Suppressing repeat scan") + continue + + s_reason = reader.check_rfid(tag) + + if s_reason != "good": + logging.debug(s_reason) + continue + else: + logging.debug("RFID Check Succeeded") + + self.ctx.rfid = tag + self.ctx.check_in.handle_by_uuid(tag) + + last_tag = tag + last_time = time.time() + + def _poll_traffic_light(self): + last_color = None + while True: + time.sleep(0.1) + color = self.ctx.sheets.get_traffic_light() + if color != last_color: + last_color = color + self.ctx.traffic_light.drive(color) + + def _show_wifi_error(self): + self.ctx.nav.show_status( + "ERROR! Connection cannot be established, please let staff know." + ) + QTimer.singleShot(4000, self._clear_wifi_error) + + def _clear_wifi_error(self): + self.ctx.nav.hide_status() + self._no_wifi_shown = False + + def _is_connected(self, host="8.8.8.8", port=53, timeout=3): + try: + socket.setdefaulttimeout(timeout) + socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect((host, port)) + return True + except socket.error: + return False diff --git a/src/core/__init__.py b/src/core/__init__.py deleted file mode 100644 index 32f632f..0000000 --- a/src/core/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# This is empty to ensure the directory is recognized as a package. -# You can add any necessary imports or initializations here if needed. \ No newline at end of file diff --git a/src/core/handle_check_in.py b/src/core/handle_check_in.py deleted file mode 100644 index 9d686cb..0000000 --- a/src/core/handle_check_in.py +++ /dev/null @@ -1,53 +0,0 @@ -import global_ -import logging -from tkinter import Label -from core.write_checkin import write_checkin - - -def handle_check_in(tag): - result = global_.sheets.checkin_by_uuid(tag) - status = result.get("status") - - def update_ui(): - from screens.MainPage import MainPage - from screens.NoAccNoWaiver import NoAccNoWaiver - from screens.NoAccNoWaiverSwipe import NoAccNoWaiverSwipe - from screens.AccNoWaiver import AccNoWaiver - from screens.AccNoWaiverSwipe import AccNoWaiverSwipe - from screens.UserWelcome import UserWelcome - - if status == "api_error": - logging.error("API error during check-in") - global_.traffic_light.set_red() - error_label = Label( - global_.app.canvas, - text="System error, please let staff know.", - bg="#153246", fg="white", font=("Arial", 25), - ) - error_label.place(relx=0.5, rely=0.1, anchor="center") - error_label.after(4000, error_label.destroy) - return - - if status == "no_account": - logging.info(f"User {tag} not found.") - global_.traffic_light.set_red() - global_.app.show_frame(NoAccNoWaiver) - global_.app.after(3000, lambda: global_.app.show_frame(NoAccNoWaiverSwipe)) - return - - if status == "no_waiver": - logging.info(f"User {tag} does not have waiver.") - global_.traffic_light.set_yellow() - global_.app.show_frame(AccNoWaiver) - global_.app.after(3000, lambda: global_.app.show_frame(AccNoWaiverSwipe)) - return - - logging.info(f"User found: {result['name']}") - global_.traffic_light.set_green() - global_.app.get_frame(UserWelcome).displayName(result["name"]) - write_checkin({ - "Name": result["name"], - "Student ID": result["student_id"], - }, tag) - - global_.app.after(0, update_ui) diff --git a/src/core/render_ports.py b/src/core/render_ports.py deleted file mode 100644 index 6609188..0000000 --- a/src/core/render_ports.py +++ /dev/null @@ -1,16 +0,0 @@ -import serial.tools.list_ports - -SHARED_VID = 0x1A86 -TRAFFIC_LOCATION = "1-1.1.2" - - -def get_usb_ids(): - reader_usb_id = None - traffic_usb_id = None - for p in serial.tools.list_ports.comports(): - if p.vid == SHARED_VID: - if p.location == TRAFFIC_LOCATION: - traffic_usb_id = p.device - else: - reader_usb_id = p.device - return reader_usb_id, traffic_usb_id diff --git a/src/core/write_checkin.py b/src/core/write_checkin.py deleted file mode 100644 index 720dc98..0000000 --- a/src/core/write_checkin.py +++ /dev/null @@ -1,27 +0,0 @@ -import os -import json -from datetime import datetime as dt - -LOG_BASE_PATH = "assets/logs" - -def write_checkin(curr_user, tag): - now = dt.now() - year = now.strftime("%Y") - month = now.strftime("%m") - day = now.strftime("%d") - timestamp = now.isoformat(timespec="seconds") - - log_dir = os.path.join(LOG_BASE_PATH, year, month) - os.makedirs(log_dir, exist_ok=True) - - log_path = os.path.join(log_dir, f"{day}.log") - log_entry = { - "tag": tag, - "name": curr_user.get("Name", ""), - "pid": curr_user.get("Student ID", ""), - "timestamp": timestamp, - } - - with open(log_path, "a", encoding="utf-8") as f: - json.dump(log_entry, f) - f.write("\n") \ No newline at end of file diff --git a/src/dispatcher.py b/src/dispatcher.py new file mode 100644 index 0000000..bb55188 --- /dev/null +++ b/src/dispatcher.py @@ -0,0 +1,9 @@ +from PyQt6.QtCore import QObject, pyqtSignal + + +class MainThreadDispatcher(QObject): + call = pyqtSignal(object) + + def __init__(self): + super().__init__() + self.call.connect(lambda fn: fn()) diff --git a/src/get_info_from_pid.py b/src/get_info_from_pid.py deleted file mode 100644 index 76b4924..0000000 --- a/src/get_info_from_pid.py +++ /dev/null @@ -1,69 +0,0 @@ -import logging -import time - -import requests - -from config import API_BASE_URL - - -def _req(method, url, **kwargs): - start = time.time() - resp = requests.request(method, url, **kwargs) - ms = (time.time() - start) * 1000 - logging.info(f"[CLIENT] {method.upper()} {url} -> {resp.status_code} ({ms:.0f}ms)") - return resp - - -class contact_client: - def get_student_info(self, barcode): - try: - resp = _req("GET", f"{API_BASE_URL}/students/barcode/{barcode}", timeout=5) - if not resp.ok: - return False - d = resp.json() - return [d["first_name"], d["last_name"], d["emails"], d["pid"], d["first_enr_term"], d["last_enr_term"]] - except Exception as e: - logging.error(f"Error fetching student by barcode: {e}") - return False - - def get_student_info_pid(self, pid): - try: - resp = _req("GET", f"{API_BASE_URL}/students/pid/{pid}", timeout=5) - if not resp.ok: - return False - d = resp.json() - return [d["first_name"], d["last_name"], d["emails"], d["pid"], d["first_enr_term"], d["last_enr_term"]] - except Exception as e: - logging.error(f"Error fetching student by pid: {e}") - return False - - # TODO: I assume this was probably to add in support for employee checkin, when reimplemented - # TODO: it should be started with an implementation on the api side - # # not yet tested, still need to be authorized access to employeeData API. - # def get_staff_info(self, pid): - # if self.token["expires_at"] < time.time() + 60: - # self.token = self.oauth2_client.fetch_token( - # api_url + "token", grant_type="client_credentials" - # ) - # url = api_url + "employee_data/v1/employees/" + str(pid) - # token = self.token["access_token"] - # response = self.safe_get(url, token) - # if not response.ok: - # return False - # email = response.json()["officialEmail"] - # fname = response.json()["firstName"] - # lname = response.json()["lastName"] - # return [fname, lname, [email]] - # - # def safe_get(self, url, token, retries=2): - # for _ in range(retries): - # try: - # response = requests.get( - # url, headers={"Authorization": f"Bearer {token}"}, timeout=4 - # ) - # if response.ok: - # return response - # except (requests.exceptions.Timeout, requests.exceptions.ConnectionError): - # pass - # time.sleep(0.5) # small pause before retry - # return False diff --git a/src/global_.py b/src/global_.py deleted file mode 100644 index 1f9e8c3..0000000 --- a/src/global_.py +++ /dev/null @@ -1,48 +0,0 @@ -import threading -from sheets import SheetManager -from traffic import TrafficLight - - -class _TrafficProxy: - def __init__(self, light, sheets_mgr): - self._light = light - self._sheets = sheets_mgr - - @property - def connected(self): - return self._light.ser is not None - - def _post(self, color): - threading.Thread( - target=self._sheets.set_traffic_light, - args=(color,), - daemon=True, - ).start() - - def set_red(self): - self._post("red") - - def set_green(self): - self._post("green") - - def set_yellow(self): - self._post("yellow") - - def set_off(self): - self._post("off") - - -def init(traffic_usb_id=None): - global rfid, sheets, app, traffic_light - sheets = SheetManager() - traffic_light = _TrafficProxy(TrafficLight(traffic_usb_id), sheets) - - -def setRFID(new_rfid): - global rfid - rfid = new_rfid - - -def setApp(new_app): - global app - app = new_app diff --git a/src/gui.py b/src/gui.py deleted file mode 100644 index 6b4f3e4..0000000 --- a/src/gui.py +++ /dev/null @@ -1,112 +0,0 @@ -import uuid -import tkinter as tk -from pathlib import Path -import global_ - -ASSETS_PATH = Path(__file__).parent / "assets" / "shared" - - -################################################# -# Acts as the controller and the user interface # -################################################# -class gui(tk.Tk): - def __init__(self, *args, **kwargs): - tk.Tk.__init__(self, *args, **kwargs) - - self.title("Check-In") - self.geometry("1280x720") - self.bind("", self._on_map) - - # Single shared canvas — background is always painted here, never redrawn - self.canvas = tk.Canvas( - self, - bg="#153246", - height=720, - width=1280, - bd=0, - highlightthickness=0, - ) - self.canvas.pack(fill="both", expand=True) - - # Load background images once and draw them permanently - self._bg_photos = [] - bg1 = tk.PhotoImage(file=str(ASSETS_PATH / "image_1.png")) - self._bg_photos.append(bg1) - self.canvas.create_image(640.0, 360.0, image=bg1) - - bg2 = tk.PhotoImage(file=str(ASSETS_PATH / "image_2.png")) - self._bg_photos.append(bg2) - self.canvas.create_image(639.333984375, 359.333984375, image=bg2) - - self.frames = {} - self.curr_frame = None - self.frame_uuid = uuid.uuid4().hex - - from screens.MainPage import MainPage - from screens.AccNoWaiver import AccNoWaiver - from screens.AccNoWaiverSwipe import AccNoWaiverSwipe - from screens.ManualFill import ManualFill - from screens.CheckInNoId import CheckInNoId - from screens.NoAccCheckInOnly import NoAccCheckInOnly - from screens.NoAccNoWaiver import NoAccNoWaiver - from screens.NoAccNoWaiverSwipe import NoAccNoWaiverSwipe - from screens.QRCodes import QRCodes - from screens.UserThank import UserThank - from screens.UserWelcome import UserWelcome - from screens.WaiverNoAcc import WaiverNoAcc - from screens.WaiverNoAccSwipe import WaiverNoAccSwipe - - self._timeouts = { - AccNoWaiverSwipe: 30000, - QRCodes: 30000, - NoAccNoWaiverSwipe: 30000, - } - - for F in ( - MainPage, - AccNoWaiver, - AccNoWaiverSwipe, - ManualFill, - CheckInNoId, - NoAccCheckInOnly, - NoAccNoWaiver, - NoAccNoWaiverSwipe, - QRCodes, - UserThank, - UserWelcome, - WaiverNoAcc, - WaiverNoAccSwipe, - ): - self.frames[F] = F(self.canvas, self) - - self.show_frame(MainPage) - - def _on_map(self, event): - self.unbind("") - self.attributes("-fullscreen", True) - - def timeout_fn(self, curr_uuid): - from screens.MainPage import MainPage - if curr_uuid == self.frame_uuid: - self.show_frame(MainPage) - global_.traffic_light.set_off() - - def show_frame(self, cont): - if self.curr_frame is not None: - self.frames[self.curr_frame].hide() - self.curr_frame = cont - self.frame_uuid = uuid.uuid4().hex - self.frames[cont].show() - - if cont in self._timeouts: - curr_uuid = self.frame_uuid - self.after(self._timeouts[cont], lambda: self.timeout_fn(curr_uuid)) - - def get_frame(self, cont): - return self.frames[cont] - - def get_curr_frame(self): - return self.curr_frame - - def start(self): - self.mainloop() diff --git a/src/hardware/__init__.py b/src/hardware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/hardware/barcode_scanner.py b/src/hardware/barcode_scanner.py new file mode 100644 index 0000000..f376ace --- /dev/null +++ b/src/hardware/barcode_scanner.py @@ -0,0 +1,43 @@ +import serial +import logging +from os.path import exists + + +class BarcodeScanner: + def __init__(self, usb_id): + self._usb_id = usb_id + self._ser = None + self._connect() + + def _connect(self): + self._ser = serial.Serial(self._usb_id, baudrate=9600, timeout=0.1) + self._ser.reset_input_buffer() + logging.info("Barcode scanner connected at %s", self._usb_id) + + def reconnect(self): + if not exists(self._usb_id): + return False + try: + self._connect() + return True + except Exception: + self._ser = None + return False + + def read_barcode(self): + """Read one barcode from the scanner. Returns stripped string or None.""" + # Use read_until(\r) to handle scanners that terminate with CR only + line = self._ser.read_until(b"\r") + if not line: + return None + barcode = line.decode("ascii", errors="ignore").strip() + # Strip Codabar start/stop characters (A, B, C, D) from both ends + barcode = barcode.strip("ABCDabcd") + return barcode if barcode else None + + def is_valid(self, barcode): + if not barcode: + return False + if len(barcode) > 32: + return False + return True diff --git a/src/reader.py b/src/hardware/rfid_reader.py similarity index 60% rename from src/reader.py rename to src/hardware/rfid_reader.py index 7b83ea5..d29d404 100644 --- a/src/reader.py +++ b/src/hardware/rfid_reader.py @@ -11,24 +11,32 @@ class Reader(Thread): - def __init__(self, usb_id="/dev/ttyUSB0"): + def __init__(self, usb_id): super().__init__() self._usb_id = usb_id self._pn532 = None self._pending_tag = None - if usb_id is None: - logging.error("No card reader USB ID configured, exiting") - sys.exit(1) if not exists(usb_id): logging.error("Card reader not found at %s, exiting", usb_id) sys.exit(1) - self._init_pn532() - logging.info("Card reader init finished") + for attempt in range(1, 6): + try: + self._init_pn532() + logging.info("Card reader init finished") + break + except Exception as e: + logging.warning("Card reader init attempt %d/5 failed: %s", attempt, e) + if attempt == 5: + logging.error("Card reader failed to initialize after 5 attempts, exiting") + sys.exit(1) + time.sleep(attempt * 0.5) def _init_pn532(self): uart = serial.Serial(self._usb_id, baudrate=115200, timeout=0.1) + uart.reset_input_buffer() + uart.reset_output_buffer() + time.sleep(0.1) self._pn532 = PN532_UART(uart, debug=False) - self._pn532.SAM_configuration() def reconnect(self): if not exists(self._usb_id): @@ -36,33 +44,34 @@ def reconnect(self): try: self._init_pn532() return True - except Exception: + except Exception as e: + logging.warning("Card reader reconnect attempt failed: %s", e) self._pn532 = None return False - def getSerInWaiting(self): + def get_ser_in_waiting(self): try: uid = self._pn532.read_passive_target(timeout=0.1) except Exception as e: raise OSError(f"PN532 error: {e}") if uid: self._pending_tag = "".join(f"{b:02X}" for b in uid) - time.sleep(0.01) # let any remaining in-flight bytes arrive - self._pn532._uart.reset_input_buffer() # and flush them + time.sleep(0.01) + self._pn532._uart.reset_input_buffer() return expected_characters self._pending_tag = None return 0 - def grabRFID(self): + def grab_rfid(self): tag = self._pending_tag self._pending_tag = None logging.info("Parsed tag: " + str(tag)) return str(tag) - def checkRFID(self, tag): + def check_rfid(self, tag): if not tag or len(tag) != expected_characters: return "Tag was not the expected number of chars" return "good" - def canScanAgain(self, lastTime): + def can_scan_again(self, lastTime): return time.time() - lastTime > 3 diff --git a/src/traffic.py b/src/hardware/traffic_light.py similarity index 92% rename from src/traffic.py rename to src/hardware/traffic_light.py index ace092a..6f3ce61 100644 --- a/src/traffic.py +++ b/src/hardware/traffic_light.py @@ -11,6 +11,10 @@ def __init__(self, addr=None, baud=115200): self.ser = serial.Serial(addr, baud) self.ser.reset_input_buffer() + @property + def connected(self) -> bool: + return self.ser is not None + def set_off(self): if self.ser: self.ser.write(b"off\n") diff --git a/src/hardware/usb_ports.py b/src/hardware/usb_ports.py new file mode 100644 index 0000000..ce624af --- /dev/null +++ b/src/hardware/usb_ports.py @@ -0,0 +1,32 @@ +import logging +from dataclasses import dataclass + +import serial.tools.list_ports + +SHARED_VID = 0x1A86 +TRAFFIC_LOCATION = "1-1.1.2" +BARCODE_VID = 0x9901 + + +@dataclass +class UsbIds: + reader: str | None + traffic_light: str | None + barcode: str | None + + +def get_usb_ids() -> UsbIds: + reader = None + traffic_light = None + barcode = None + for p in serial.tools.list_ports.comports(): + logging.debug("USB port: %s vid=%s desc=%s", p.device, hex(p.vid) if p.vid else None, p.description) + if p.vid == SHARED_VID: + if p.location == TRAFFIC_LOCATION: + traffic_light = p.device + else: + reader = p.device + elif p.vid == BARCODE_VID: + barcode = p.device + logging.info("USB detected — reader: %s, traffic_light: %s, barcode: %s", reader, traffic_light, barcode) + return UsbIds(reader, traffic_light, barcode) diff --git a/src/main.py b/src/main.py index c8e6c85..fb8a6b5 100644 --- a/src/main.py +++ b/src/main.py @@ -1,168 +1,87 @@ -from tkinter import Label -from gui import gui -from reader import * -from swipe import swipe -from sheets import * -from threading import Thread -from screens.MainPage import MainPage -from screens.ManualFill import ManualFill -from screens.CheckInNoId import CheckInNoId -from utils import utils -from core.handle_check_in import handle_check_in -from core.render_ports import get_usb_ids -import global_ -import socket +import sys import logging import argparse +import os from sys import stdout -from sheets import check_api_health - -def is_connected(host="8.8.8.8", port=53, timeout=3): - """ - Host: 8.8.8.8 (google-public-dns-a.google.com) - OpenPort: 53/tcp - Service: domain (DNS/TCP) - """ - try: - socket.setdefaulttimeout(timeout) - socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect((host, port)) - return True - except socket.error as ex: - return False - -############################################################## -# This acts as the main loop of the program, ran in a thread # -############################################################## - -no_wifi_shown = False - -def myLoop(app, reader): - global no_wifi_shown, no_wifi - logging.info("Now reading ID cards") - last_tag = 0 - last_time = 0 - scanner_error = False - while True: - if scanner_error: - time.sleep(0.1) - if reader.reconnect(): - logging.info("Card reader reconnected") - scanner_error = False - continue - - try: - in_waiting = reader.getSerInWaiting() - except OSError as e: - if not scanner_error: - logging.error("Card reader disconnected, disabling until reconnection: %s", e) - scanner_error = True - continue - - if in_waiting >= 14: - if not is_connected(): - logging.info("ERROR wifi is not connected") - if not no_wifi_shown: - no_wifi_shown = True - no_wifi = Label( - app.canvas, - text="ERROR! Connection cannot be established, please let staff know.", - bg="#153246", fg="white", font=("Arial", 25), - ) - no_wifi.place(relx=0.5, rely=0.1, anchor="center") - no_wifi.after(4000, lambda: destroyNoWifiError(no_wifi)) - continue - - app.get_frame(ManualFill).clearEntries() - tag = reader.grabRFID() - - if " " in tag: - continue - - if tag == last_tag and not reader.canScanAgain(last_time): - logging.debug("Suppressing repeat scan") - continue - - s_reason = reader.checkRFID(tag) - - if s_reason != "good": - logging.debug(s_reason) - continue - else: - logging.debug("RFID Check Succeeded") - - global_.setRFID(tag) - handle_check_in(tag) - - last_tag = tag - last_time = time.time() - -def trafficLightPoller(): - last_color = None - light = global_.traffic_light._light - while True: - time.sleep(0.1) - color = global_.sheets.get_traffic_light() - if color != last_color: - last_color = color - if color == "red": - light.set_red() - elif color == "green": - light.set_green() - elif color == "yellow": - light.set_yellow() - else: - light.set_off() - - -def destroyNoWifiError(no_wifi): - global no_wifi_shown - no_wifi.destroy() - no_wifi_shown = False - -def clearAndReturn(): - global_.traffic_light.set_off() - global_.app.show_frame(MainPage) - global_.app.get_frame(ManualFill).clearEntries() - global_.app.get_frame(CheckInNoId).clearEntries() + +from PyQt6.QtWidgets import QApplication + +from window import CheckInWindow +from dispatcher import MainThreadDispatcher +from controllers.navigation_controller import NavigationController +from controllers.barcode_scanner_controller import BarcodeScannerController +from hardware.barcode_scanner import BarcodeScanner +from controllers.check_in_controller import CheckInController +from controllers.account_controller import AccountController +from controllers.rfid_reader_controller import RfidReaderController +from hardware.rfid_reader import Reader +from screens.create_account_manual import CreateAccountManual +from screens.create_account_no_pid import CreateAccountNoPid +from screens.create_account_review import CreateAccountReview +from screens.check_in_manual import CheckInManual +from hardware.usb_ports import get_usb_ids +from app_context import AppContext +from api.client import check_api_health + + +def clear_and_return(ctx: AppContext): + ctx.nav.back_to_main() + ctx.nav.get_frame(CreateAccountManual).clear_entries() + ctx.nav.get_frame(CreateAccountNoPid).clear_entries() + ctx.nav.get_frame(CreateAccountReview).clear_entries() + ctx.nav.get_frame(CheckInManual).clear_entries() + if __name__ == "__main__": parser = argparse.ArgumentParser( description="Makerspace Check-in System", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) - - parser.add_argument( - "-v", - "--verbose", - action="store_true", - help="Increase verbosity (print debug info)", - ) - + parser.add_argument("-v", "--verbose", action="store_true", help="Increase verbosity (print debug info)") + parser.add_argument("-d", "--dev", action="store_true", help="Enable dev mode with on-screen navigation overlay") args = parser.parse_args() - config = vars(args) - if config["verbose"]: + if args.verbose: logging.basicConfig(level=logging.DEBUG, stream=stdout) else: logging.basicConfig(level=logging.INFO) - reader_usb_id, traffic_usb_id = get_usb_ids() + dev_mode = args.dev or os.environ.get("DEV_MODE") == "1" + + # QApplication must be created before any QWidget or QObject subclass + app = QApplication(sys.argv) + + # Restore default SIGINT so Ctrl+C terminates the process + import signal + signal.signal(signal.SIGINT, signal.SIG_DFL) + + usb = get_usb_ids() check_api_health() - global_.init(traffic_usb_id) - app = gui() - global_.setApp(app) - global_.traffic_light.set_off() - - sw = swipe() - reader = Reader(reader_usb_id) - util = utils() - thread = Thread(target=myLoop, args=(app, reader)) - logging.info("Starting thread") - thread.start() - if global_.traffic_light.connected: - poller = Thread(target=trafficLightPoller, daemon=True) - poller.start() - app.bind("", lambda i: sw.keyboardPress(i)) - app.bind("", lambda i: clearAndReturn()) + ctx = AppContext.create(usb.traffic_light) + ctx.dispatcher = MainThreadDispatcher() + + window = CheckInWindow() + nav = NavigationController(window, ctx, dev_mode=dev_mode) + ctx.window = window + ctx.nav = nav + ctx.check_in = CheckInController(ctx) + ctx.account = AccountController(ctx) + ctx.traffic_light.request_off() + + window.set_escape_handler(lambda: clear_and_return(ctx)) + + reader = Reader(usb.reader) + card_reader = RfidReaderController(ctx) + card_reader.start(reader) + + if usb.barcode: + ctx.has_barcode_scanner = True + barcode_scanner = BarcodeScanner(usb.barcode) + barcode_controller = BarcodeScannerController(ctx) + barcode_controller.start(barcode_scanner) + else: + logging.warning("No barcode scanner found, barcode scanning disabled") + logging.info("Made it to app start") - app.start() \ No newline at end of file + window.start() + sys.exit(0) diff --git a/src/main_checkin_only.py b/src/main_checkin_only.py deleted file mode 100644 index 3456ce7..0000000 --- a/src/main_checkin_only.py +++ /dev/null @@ -1,139 +0,0 @@ -from datetime import datetime as dt -from core.handle_check_in import handle_check_in -from core.render_ports import get_usb_ids -from gui import * -from reader import * -import json -from sheets import * -from threading import Thread -from UserWelcome import * -from ManualFill import * -from CheckInNoId import * -from swipe import * -from tkinter import * -import global_ -import socket -import logging -import argparse -from sheets import check_api_health - - -def is_connected(host="8.8.8.8", port=53, timeout=3): - """ - Host: 8.8.8.8 (google-public-dns-a.google.com) - OpenPort: 53/tcp - Service: domain (DNS/TCP) - """ - try: - socket.setdefaulttimeout(timeout) - socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect((host, port)) - return True - except socket.error as ex: - return False - - -############################################################## -# This acts as the main loop of the program, ran in a thread # -############################################################## - -no_wifi_shown = False - - -def myLoop(app, reader): - global no_wifi_shown, no_wifi - logging.info("Now reading ID cards") - last_tag = 0 - last_time = 0 - while True: - time.sleep(0.1) - in_waiting = reader.getSerInWaiting() - tag = 0 - - if in_waiting >= 14: - if not is_connected(): - logging.info("ERROR wifi is not connected") - if not no_wifi_shown: - no_wifi_shown = True - no_wifi = Label( - app.get_frame(MainPage), - text="ERROR! Connection cannot be established, please let staff know.", - font=("Arial", 25), - ) - no_wifi.pack(pady=40) - no_wifi.after(4000, lambda: destroyNoWifiError(no_wifi)) - continue - - tag_read_start = perf_counter() - tag = reader.grabRFID() - tag_read_end = perf_counter() - - if " " in tag: - continue - - if tag == last_tag and not reader.canScanAgain(last_time): - logging.debug("Suppressing repeat scan") - continue - - s_reason = reader.checkRFID(tag) - - if s_reason != "good": - logging.debug(s_reason) - continue - else: - logging.debug("RFID Check Succeeded") - - global_.setRFID(tag) - handle_check_in(tag, contact, util) - - last_tag = tag - last_time = time.time() - - reader.readSerial() - -def destroyNoWifiError(no_wifi): - global no_wifi_shown - no_wifi.destroy() - no_wifi_shown = False - - -def clearAndReturn(): - global_.app.show_frame(MainPage) - global_.app.get_frame(ManualFill).clearEntries() - global_.app.get_frame(CheckInNoId).clearEntries() - - -if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="Makerspace Check-in System", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - ) - - parser.add_argument( - "-v", - "--verbose", - action="store_true", - help="Increase verbosity (print debug info)", - ) - - args = parser.parse_args() - config = vars(args) - - if config["verbose"]: - logging.basicConfig(level=logging.DEBUG) - else: - logging.basicConfig(level=logging.INFO) - - reader_usb_id, traffic_usb_id = get_usb_ids() - check_api_health() - global_.init(traffic_usb_id) - app = gui() - global_.setApp(app) - - reader = Reader() - util = utils() - thread = Thread(target=myLoop, args=(app, reader)) - logging.info("Starting thread") - thread.start() - app.bind("", lambda i: clearAndReturn()) - logging.info("Made it to app start") - app.start() diff --git a/src/run b/src/run deleted file mode 100755 index ab07bed..0000000 --- a/src/run +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -set -a -source "$(dirname "$0")/../.env" -set +a - -output_file="log.txt" - -echo "" >> "$output_file" - -date "+%Y-%m-%d %H:%M:%S" >> "$output_file" - -python main.py 2>&1 | tee -a log.txt \ No newline at end of file diff --git a/src/run_verbose b/src/run_verbose deleted file mode 100755 index e94a274..0000000 --- a/src/run_verbose +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -set -a -source "$(dirname "$0")/../.env" -set +a - -output_file="log.txt" - -echo "" >> "$output_file" - -date "+%Y-%m-%d %H:%M:%S" >> "$output_file" - -python main.py -v 2>&1 | tee -a log.txt \ No newline at end of file diff --git a/src/screens/AccNoWaiver.py b/src/screens/AccNoWaiver.py deleted file mode 100644 index 899da8d..0000000 --- a/src/screens/AccNoWaiver.py +++ /dev/null @@ -1,10 +0,0 @@ -from .screen import Screen - - -class AccNoWaiver(Screen): - def _build(self, controller): - self._text( - 169.0, 258.0, anchor="nw", - text="Looks like you haven't signed\n the waiver, let's solve that", - fill="#F5F0E6", font=("Montserrat", 64 * -1), - ) diff --git a/src/screens/AccNoWaiverSwipe.py b/src/screens/AccNoWaiverSwipe.py deleted file mode 100644 index ea877c3..0000000 --- a/src/screens/AccNoWaiverSwipe.py +++ /dev/null @@ -1,54 +0,0 @@ -from pathlib import Path -from tkinter import Button -from .screen import Screen -import global_ - -ASSETS_PATH = Path(__file__).parent.parent / "assets" / "acc_no_waiver_swipe_assets" - - -class AccNoWaiverSwipe(Screen): - def _build(self, controller): - img2 = self._photo(ASSETS_PATH / "image_2.png") - self._image(1042.0, 359.0, image=img2) - - img3 = self._photo(ASSETS_PATH / "image_3.png") - self._image(408.0, 76.0, image=img3) - - img4 = self._photo(ASSETS_PATH / "image_4.png") - self._image(408.0, 429.0, image=img4) - - img5 = self._photo(ASSETS_PATH / "image_5.png") - self._image(395.0, 70.0, image=img5) - - img6 = self._photo(ASSETS_PATH / "image_6.png") - self._image(750.0, 70.0, image=img6) - - img7 = self._photo(ASSETS_PATH / "image_7.png") - self._image(1042.0, 328.0, image=img7) - - self._text( - 37.0, 45.0, anchor="nw", - text="Account Status:", fill="#F5F0E6", font=("Montserrat", 40 * -1), - ) - self._text( - 430.0, 45.0, anchor="nw", - text="Waiver Status:", fill="#F5F0E6", font=("Montserrat", 40 * -1), - ) - self._text( - 45.0, 270.0, anchor="nw", - text="Please scan the QR code\non the right and sign our \n waiver", - fill="#F5F0E6", font=("Montserrat", 48 * -1), - ) - - btn_img = self._photo(ASSETS_PATH / "button_1.png") - btn = Button( - self.canvas, image=btn_img, - borderwidth=0, highlightthickness=0, - command=self._back_to_main, relief="flat", - ) - self._window(875.0, 581.0, btn, width=344, height=71) - - def _back_to_main(self): - from .MainPage import MainPage - global_.traffic_light.set_off() - global_.app.show_frame(MainPage) diff --git a/src/screens/CheckInNoId.py b/src/screens/CheckInNoId.py deleted file mode 100644 index 1cd5d26..0000000 --- a/src/screens/CheckInNoId.py +++ /dev/null @@ -1,101 +0,0 @@ -from pathlib import Path -from tkinter import Button, Entry, StringVar, END -from .screen import Screen -import global_ -import logging - -ASSETS_PATH = Path(__file__).parent.parent / "assets" / "check_in_no_id_assets" - - -######################################################## -# This is the frame where users will manually check in # -######################################################## - -class CheckInNoId(Screen): - def _build(self, controller): - from .NoAccCheckInOnly import NoAccCheckInOnly - from .NoAccNoWaiverSwipe import NoAccNoWaiverSwipe - from .UserWelcome import UserWelcome - from .AccNoWaiver import AccNoWaiver - - self.loading_text_id = None - self.pid = StringVar() - - img2 = self._photo(ASSETS_PATH / "image_2.png") - self._image(640.0, 360.0, image=img2) - - img3 = self._photo(ASSETS_PATH / "image_3.png") - self._image(640.0, 424.0, image=img3) - - self._text( - 212.0, 120.0, anchor="nw", - text="If you have already made an\naccount, scan your UCSD barcode\nor enter your PID manually", - fill="#F5F0E6", font=("Montserrat", 48 * -1), justify="center", - ) - self._text( - 605.0, 480.0, anchor="nw", - text="PID", fill="#F5F0E6", font=("Montserrat", 24 * -1), - ) - - btn_img = self._photo(ASSETS_PATH / "button_1.png") - btn = Button( - self.canvas, image=btn_img, - borderwidth=0, highlightthickness=0, - command=lambda: self._call_check_in(controller), relief="flat", - ) - self._window(465.0, 598.0, btn, width=349, height=71) - - self.pid_entry = Entry(self.canvas, textvariable=self.pid, width=40, font=52) - self._window(420.0, 412.0, self.pid_entry) - - def displayLoading(self): - if self.loading_text_id is None: - self.loading_text_id = self.canvas.create_text( - 420.0, 545.0, anchor="nw", - text="PLEASE WAIT: LOADING...", - fill="#FF0000", font=("Montserrat", 36 * -1, "bold"), justify="center", - ) - - def clearEntries(self): - self.pid_entry.delete(0, END) - - def updateEntries(self, pid): - self.pid_entry.insert(0, pid) - - def _call_check_in(self, controller): - from .NoAccCheckInOnly import NoAccCheckInOnly - from .NoAccNoWaiverSwipe import NoAccNoWaiverSwipe - from .UserWelcome import UserWelcome - from .AccNoWaiver import AccNoWaiver - from .MainPage import MainPage - - pid = self.pid_entry.get() - if not pid: - return - - self.displayLoading() - self.canvas.update_idletasks() - self.clearEntries() - - result = global_.sheets.checkin_by_pid(pid) - status = result.get("status") - - if self.loading_text_id is not None: - self.canvas.delete(self.loading_text_id) - self.loading_text_id = None - - if status == "no_account": - logging.info("Manual check-in: user account not found") - controller.show_frame(NoAccCheckInOnly) - controller.after(5000, lambda: controller.show_frame(MainPage)) - return - - if status == "no_waiver": - logging.info(f"Manual check-in: no waiver for {result.get('name', pid)}") - controller.show_frame(AccNoWaiver) - controller.after(3000, lambda: controller.show_frame(NoAccNoWaiverSwipe)) - return - - logging.info(f"Manual check-in for {result['name']}") - global_.traffic_light.set_green() - global_.app.get_frame(UserWelcome).displayName(result["name"]) diff --git a/src/screens/MainPage.py b/src/screens/MainPage.py deleted file mode 100644 index c05e873..0000000 --- a/src/screens/MainPage.py +++ /dev/null @@ -1,54 +0,0 @@ -from pathlib import Path -from tkinter import Button -from .screen import Screen -import global_ - -ASSETS_PATH = Path(__file__).parent.parent / "assets" / "main_page_assets" - - -class MainPage(Screen): - def _build(self, controller): - from .QRCodes import QRCodes - from .CheckInNoId import CheckInNoId - - logo = self._photo(ASSETS_PATH / "image_3.png") - self._image(88.0, 90.0, image=logo) - - self._text( - 336.0, 602.0, anchor="nw", - text="Please tap ID on the black box to start", - fill="#F5F0E6", font=("Montserrat", 32 * -1), - ) - self._text( - 67.0, 270.0, anchor="nw", - text="UCSD Makerspace", - fill="#F5F0E6", font=("Montserrat", 113 * -1, "bold"), - ) - self._text( - 77.0, 377.66796875, anchor="nw", - text="Welcome Desk", - fill="#F5F0E6", font=("Montserrat", 73 * -1), - ) - - btn1_img = self._photo(ASSETS_PATH / "image_4.png") - btn1 = Button( - self.canvas, image=btn1_img, bg="#153246", - command=lambda: controller.show_frame(QRCodes), - relief="flat", highlightthickness=0, bd=0, - ) - self._window(53.0, 55.0, btn1) - - btn2 = Button( - self.canvas, image=logo, text="No\nID", compound="center", - bg="#153246", fg="white", - command=lambda: self._go_to_no_id(controller), - relief="flat", highlightthickness=0, bd=0, - font=("Montserrat", 36 * -1), - ) - self._window(1130.0, 40.0, btn2) - - def _go_to_no_id(self, controller): - from .CheckInNoId import CheckInNoId - no_id = global_.app.get_frame(CheckInNoId) - no_id.clearEntries() - controller.show_frame(CheckInNoId) diff --git a/src/screens/ManualFill.py b/src/screens/ManualFill.py deleted file mode 100644 index 44e9f54..0000000 --- a/src/screens/ManualFill.py +++ /dev/null @@ -1,122 +0,0 @@ -from pathlib import Path -from tkinter import Button, Entry, StringVar, END -from .screen import Screen -from utils import utils -import logging -import timeit - -ASSETS_PATH = Path(__file__).parent.parent / "assets" / "manual_fill_assets" - - -####################################################### -# This is the frame where users will type information # -####################################################### - -class ManualFill(Screen): - def _build(self, controller): - self.first_name = StringVar() - self.last_name = StringVar() - self.email = StringVar() - self.pid = StringVar() - - img2 = self._photo(ASSETS_PATH / "image_2.png") - self._image(640.0, 76.0, image=img2) - - img3 = self._photo(ASSETS_PATH / "image_3.png") - self._image(640.0, 430.0, image=img3) - - img4 = self._photo(ASSETS_PATH / "image_4.png") - self._image(605.0, 77.0, image=img4) - - img5 = self._photo(ASSETS_PATH / "image_5.png") - self._image(1010.0, 77.0, image=img5) - - img6 = self._photo(ASSETS_PATH / "image_6.png") - self._image(640.0, 542.0, image=img6) - - img7 = self._photo(ASSETS_PATH / "image_7.png") - self._image(640.0, 440.0, image=img7) - - img8 = self._photo(ASSETS_PATH / "image_8.png") - self._image(640.0, 339.0, image=img8) - - img9 = self._photo(ASSETS_PATH / "image_9.png") - self._image(640.0, 239.0, image=img9) - - self._text( - 250.0, 45.0, anchor="nw", - text="Account Status:", fill="#F5F0E6", font=("Montserrat", 40 * -1), - ) - self._text( - 670.0, 45.0, anchor="nw", - text="Waiver Status:", fill="#F5F0E6", font=("Montserrat", 40 * -1), - ) - self._text( - 565.0, 177.0, anchor="nw", - text="First Name", fill="#F5F0E6", font=("Montserrat", 24 * -1), - ) - self._text( - 565.0, 278.0, anchor="nw", - text="Last Name", fill="#F5F0E6", font=("Montserrat", 24 * -1), - ) - self._text( - 595.0, 379.0, anchor="nw", - text="Email", fill="#F5F0E6", font=("Montserrat", 24 * -1), - ) - self._text( - 605.0, 480.0, anchor="nw", - text="PID", fill="#F5F0E6", font=("Montserrat", 24 * -1), - ) - - btn_img = self._photo(ASSETS_PATH / "button_1.png") - btn = Button( - self.canvas, image=btn_img, - borderwidth=0, highlightthickness=0, - command=self._call_account_creation, relief="flat", - ) - self._window(465.0, 598.0, btn, width=349, height=71) - - self.first_name_entry = Entry(self.canvas, textvariable=self.first_name, width=40, font=52) - self._window(420.0, 227.0, self.first_name_entry) - - self.last_name_entry = Entry(self.canvas, textvariable=self.last_name, width=40, font=52) - self._window(420.0, 327.0, self.last_name_entry) - - self.email_entry = Entry(self.canvas, textvariable=self.email, width=40, font=52) - self._window(420.0, 428.0, self.email_entry) - - self.pid_entry = Entry(self.canvas, textvariable=self.pid, width=40, font=52) - self._window(420.0, 530.0, self.pid_entry) - - def getEntries(self): - return [ - self.first_name.get(), - self.last_name.get(), - self.email.get(), - self.pid.get(), - ] - - def clearEntries(self): - self.first_name_entry.delete(0, END) - self.last_name_entry.delete(0, END) - self.email_entry.delete(0, END) - self.pid_entry.delete(0, END) - - def updateEntries(self, fname, lname, email, pid): - self.first_name_entry.insert(0, fname) - self.last_name_entry.insert(0, lname) - self.email_entry.insert(0, email) - self.pid_entry.insert(0, pid) - - def _call_account_creation(self): - util = utils() - data = self.getEntries() - self.clearEntries() - try: - delay = timeit.timeit( - lambda: util.createAccount(data[0], data[1], data[2], data[3], ManualFill), - number=1, - ) - logging.debug(f"Time to create account: {delay}") - except Exception: - logging.warning("Error occurred trying to create a user account", exc_info=True) diff --git a/src/screens/NoAccCheckInOnly.py b/src/screens/NoAccCheckInOnly.py deleted file mode 100644 index e9dde38..0000000 --- a/src/screens/NoAccCheckInOnly.py +++ /dev/null @@ -1,10 +0,0 @@ -from .screen import Screen - - -class NoAccCheckInOnly(Screen): - def _build(self, controller): - self._text( - 160.0, 180.0, anchor="nw", - text="Looks like you don't have an\n account, please scan your ID\nat the main desk", - fill="#F5F0E6", font=("Montserrat", 64 * -1), - ) diff --git a/src/screens/NoAccNoWaiver.py b/src/screens/NoAccNoWaiver.py deleted file mode 100644 index 5377ebe..0000000 --- a/src/screens/NoAccNoWaiver.py +++ /dev/null @@ -1,10 +0,0 @@ -from .screen import Screen - - -class NoAccNoWaiver(Screen): - def _build(self, controller): - self._text( - 80.0, 180.0, anchor="nw", - text="Looks like your card isn't registered, \n let's set up your account.", - fill="#F5F0E6", font=("Montserrat", 64 * -1), - ) diff --git a/src/screens/NoAccNoWaiverSwipe.py b/src/screens/NoAccNoWaiverSwipe.py deleted file mode 100644 index 57c86c2..0000000 --- a/src/screens/NoAccNoWaiverSwipe.py +++ /dev/null @@ -1,48 +0,0 @@ -from pathlib import Path -from tkinter import Button -from .screen import Screen -import global_ - -ASSETS_PATH = Path(__file__).parent.parent / "assets" / "no_acc_no_waiver_swipe_assets" - - -class NoAccNoWaiverSwipe(Screen): - def _build(self, controller): - img2 = self._photo(ASSETS_PATH / "image_2.png") - self._image(640.0, 76.0, image=img2) - - img3 = self._photo(ASSETS_PATH / "image_3.png") - self._image(640.0, 430.0, image=img3) - - img4 = self._photo(ASSETS_PATH / "image_4.png") - self._image(576.0, 65.0, image=img4) - - img5 = self._photo(ASSETS_PATH / "image_5.png") - self._image(1030.0, 65.0, image=img5) - - self._text( - 303.0, 350.0, anchor="nw", - text="Please scan your ID barcode", - fill="#F5F0E6", font=("Montserrat", 48 * -1), - ) - self._text( - 215.0, 45.0, anchor="nw", - text="Account Status:", fill="#F5F0E6", font=("Montserrat", 40 * -1), - ) - self._text( - 690.0, 45.0, anchor="nw", - text="Waiver Status:", fill="#F5F0E6", font=("Montserrat", 40 * -1), - ) - - btn_img = self._photo(ASSETS_PATH / "button_1.png") - btn = Button( - self.canvas, image=btn_img, - borderwidth=0, highlightthickness=0, - command=lambda: self._go_to_manual_fill(controller), relief="flat", - ) - self._window(465.0, 554.0, btn, width=349, height=71) - - def _go_to_manual_fill(self, controller): - from .ManualFill import ManualFill - global_.app.get_frame(ManualFill).clearEntries() - controller.show_frame(ManualFill) diff --git a/src/screens/QRCodes.py b/src/screens/QRCodes.py deleted file mode 100644 index e05205d..0000000 --- a/src/screens/QRCodes.py +++ /dev/null @@ -1,36 +0,0 @@ -from pathlib import Path -from tkinter import Button -from .screen import Screen - -ASSETS_PATH = Path(__file__).parent.parent / "assets" / "qr_codes_assets" - - -class QRCodes(Screen): - def _build(self, controller): - from .MainPage import MainPage - - img3 = self._photo(ASSETS_PATH / "image_3.png") - self._image(88.0, 90.0, image=img3) - - img4 = self._photo(ASSETS_PATH / "image_4.png") - self._image(421.0, 360.0, image=img4) - - img5 = self._photo(ASSETS_PATH / "image_5.png") - self._image(859.0, 360.0, image=img5) - - self._text( - 335.0, 551.0, anchor="nw", - text="Website", fill="#F5F0E6", font=("Montserrat", 40 * -1), - ) - self._text( - 788.0, 557.0, anchor="nw", - text="Waiver", fill="#F5F0E6", font=("Montserrat", 40 * -1), - ) - - btn_img = self._photo(ASSETS_PATH / "image_6.png") - btn = Button( - self.canvas, image=btn_img, bg="#153246", - command=lambda: controller.show_frame(MainPage), - relief="flat", - ) - self._window(53.0, 55.0, btn) diff --git a/src/screens/UserThank.py b/src/screens/UserThank.py deleted file mode 100644 index e5ff541..0000000 --- a/src/screens/UserThank.py +++ /dev/null @@ -1,46 +0,0 @@ -from .screen import Screen -import global_ - - -class UserThank(Screen): - def _build(self, controller): - self._text( - 99.33203125, 259.33203125, anchor="nw", - text="Thank you for registering", - fill="#F5F0E6", font=("Montserrat", 45 * -1), - ) - self._text( - 429.0, 550.0, anchor="nw", - text="UCSD Makerspace", - fill="#F5F0E6", font=("Montserrat", 45 * -1), - ) - - def hide(self): - super().hide() - self.canvas.delete("thank") - - def displayName(self, name, nextPage): - from .MainPage import MainPage - global_.app.show_frame(UserThank) - - if nextPage == MainPage: - global_.traffic_light.set_green() - else: - global_.traffic_light.set_yellow() - - self.canvas.create_text( - 99.0, 323.0, anchor="nw", - text=name, - fill="#F5F0E6", - font=("Montserrat", 73 * -1), - tag="thank", - ) - - self.canvas.after(4500, lambda: self.canvas.delete("thank")) - global_.app.after(4000, lambda: self._go_to_next(nextPage)) - - def _go_to_next(self, nextPage): - from .MainPage import MainPage - global_.app.show_frame(nextPage) - if nextPage == MainPage: - global_.traffic_light.set_off() diff --git a/src/screens/UserWelcome.py b/src/screens/UserWelcome.py deleted file mode 100644 index 387463f..0000000 --- a/src/screens/UserWelcome.py +++ /dev/null @@ -1,58 +0,0 @@ -from .screen import Screen -import global_ - - -class UserWelcome(Screen): - def _build(self, controller): - self.last_name = None - self.offset = 0 - - self._text( - 99.33203125, 259.33203125, anchor="nw", - text="Welcome back", - fill="#F5F0E6", font=("Montserrat", 45 * -1), - ) - - def hide(self): - super().hide() - # Clean up any dynamic name items when leaving this screen - self.canvas.delete("welcome") - self.last_name = None - self.offset = 0 - - def displayName(self, name): - if name == self.last_name: - return - - self.last_name = name - - from .MainPage import MainPage - global_.app.show_frame(UserWelcome) - - text_id = self.canvas.create_text( - 99.0, - 323.0 + self.offset, - anchor="nw", - text=name, - fill="#F5F0E6", - font=("Montserrat", 73 * -1), - tag="welcome", - ) - - self.offset += 73 - self.canvas.after(3000, lambda: self._remove_name(text_id)) - - def _remove_name(self, text_id): - from .MainPage import MainPage - self.canvas.delete(text_id) - self.offset -= 73 - - for text in self.canvas.find_withtag("welcome"): - coords = self.canvas.coords(text) - if coords[1] > 323.0: - self.canvas.move(text, 0, -73) - - if not self.canvas.find_withtag("welcome"): - self.last_name = None - global_.traffic_light.set_off() - global_.app.show_frame(MainPage) diff --git a/src/screens/WaiverNoAcc.py b/src/screens/WaiverNoAcc.py deleted file mode 100644 index a1029a6..0000000 --- a/src/screens/WaiverNoAcc.py +++ /dev/null @@ -1,10 +0,0 @@ -from .screen import Screen - - -class WaiverNoAcc(Screen): - def _build(self, controller): - self._text( - 191.0, 258.0, anchor="nw", - text="Looks like you don't have an\n account, let's solve that", - fill="#F5F0E6", font=("Montserrat", 64 * -1), - ) diff --git a/src/screens/WaiverNoAccSwipe.py b/src/screens/WaiverNoAccSwipe.py deleted file mode 100644 index 8c841c3..0000000 --- a/src/screens/WaiverNoAccSwipe.py +++ /dev/null @@ -1,48 +0,0 @@ -from pathlib import Path -from tkinter import Button -from .screen import Screen -import global_ - -ASSETS_PATH = Path(__file__).parent.parent / "assets" / "waiver_no_acc_swipe_assets" - - -class WaiverNoAccSwipe(Screen): - def _build(self, controller): - img2 = self._photo(ASSETS_PATH / "image_2.png") - self._image(640.0, 76.0, image=img2) - - img3 = self._photo(ASSETS_PATH / "image_3.png") - self._image(640.0, 430.0, image=img3) - - img4 = self._photo(ASSETS_PATH / "image_4.png") - self._image(576.0, 65.0, image=img4) - - img5 = self._photo(ASSETS_PATH / "image_5.png") - self._image(1030.0, 65.0, image=img5) - - self._text( - 420.0, 350.0, anchor="nw", - text="Please scan your ID barcode", - fill="#F5F0E6", font=("Montserrat", 48 * -1), - ) - self._text( - 215.0, 45.0, anchor="nw", - text="Account Status:", fill="#F5F0E6", font=("Montserrat", 40 * -1), - ) - self._text( - 690.0, 45.0, anchor="nw", - text="Waiver Status:", fill="#F5F0E6", font=("Montserrat", 40 * -1), - ) - - btn_img = self._photo(ASSETS_PATH / "button_1.png") - btn = Button( - self.canvas, image=btn_img, - borderwidth=0, highlightthickness=0, - command=lambda: self._go_to_manual_fill(controller), relief="flat", - ) - self._window(465.0, 554.0, btn, width=349, height=71) - - def _go_to_manual_fill(self, controller): - from .ManualFill import ManualFill - global_.app.get_frame(ManualFill).clearEntries() - controller.show_frame(ManualFill) diff --git a/src/screens/base.py b/src/screens/base.py new file mode 100644 index 0000000..25934bc --- /dev/null +++ b/src/screens/base.py @@ -0,0 +1,20 @@ +from PyQt6.QtWidgets import QWidget + + +class Screen(QWidget): + def __init__(self, controller): + super().__init__() + self.controller = controller + self._build(controller) + + def _build(self, controller): + """Subclasses build their UI here instead of in __init__.""" + pass + + def on_show(self): + """Called by NavigationController when this screen becomes visible.""" + pass + + def on_hide(self): + """Called by NavigationController just before this screen is hidden.""" + pass diff --git a/src/screens/check_in_manual.py b/src/screens/check_in_manual.py new file mode 100644 index 0000000..d520f65 --- /dev/null +++ b/src/screens/check_in_manual.py @@ -0,0 +1,86 @@ +from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel +from PyQt6.QtCore import Qt +from .base import Screen +from .components.outline_frame import OutlineFrame +from .components.styled_button import StyledButton, home_button, INNER_MARGIN, OUTER_MARGIN +from .components.styled_entry import StyledEntry + + +class CheckInManual(Screen): + def _build(self, controller): + outer = QVBoxLayout(self) + outer.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN) + outer.setSpacing(0) + + outline = OutlineFrame() + outer.addWidget(outline) + + inner = QVBoxLayout(outline) + inner.setContentsMargins(INNER_MARGIN, INNER_MARGIN, INNER_MARGIN, INNER_MARGIN) + inner.setSpacing(0) + + top_row = QHBoxLayout() + top_row.addWidget(home_button(lambda: controller.back_to_main())) + top_row.addStretch() + inner.addLayout(top_row) + + inner.addStretch(2) + + instruction = QLabel( + "Enter your UCSD PID below\n" + "to check in" + ) + instruction.setStyleSheet( + "color: #F5F0E6; font: 36pt Montserrat;" + "background: transparent; border: none;" + ) + instruction.setAlignment(Qt.AlignmentFlag.AlignHCenter) + instruction.setWordWrap(True) + inner.addWidget(instruction) + + inner.addStretch(1) + + pid_label = QLabel("PID") + pid_label.setStyleSheet( + "color: #F5F0E6; font: 18pt Montserrat;" + "background: transparent; border: none;" + ) + pid_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) + inner.addWidget(pid_label) + + entry_row = QHBoxLayout() + self.pid_entry = StyledEntry() + self.pid_entry.setMaximumWidth(800) + entry_row.addStretch() + entry_row.addWidget(self.pid_entry) + entry_row.addStretch() + inner.addLayout(entry_row) + + inner.addStretch(2) + + btn_row = QHBoxLayout() + check_in_btn = StyledButton("Check In") + check_in_btn.setFixedWidth(349) + check_in_btn.clicked.connect(lambda: self._call_check_in(controller)) + btn_row.addStretch() + btn_row.addWidget(check_in_btn) + btn_row.addStretch() + inner.addLayout(btn_row) + + def on_hide(self): + self.pid_entry.clearFocus() + + def clear_entries(self): + self.pid_entry.clear() + + def update_entries(self, pid): + self.pid_entry.setText(pid) + + def _call_check_in(self, controller): + pid = self.pid_entry.text().strip() + if not pid: + return + controller.show_status("PLEASE WAIT: LOADING...") + self.clear_entries() + self.controller.ctx.check_in.handle_by_pid(pid) + controller.hide_status() diff --git a/src/screens/check_in_rfid.py b/src/screens/check_in_rfid.py new file mode 100644 index 0000000..49c4157 --- /dev/null +++ b/src/screens/check_in_rfid.py @@ -0,0 +1,67 @@ +from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel +from PyQt6.QtCore import Qt +import qtawesome as qta +from .base import Screen +from .components.outline_frame import OutlineFrame +from .components.styled_button import StyledButton, NAV_BTN_SIZE, NAV_ICON_SIZE, INNER_MARGIN, OUTER_MARGIN +from .qr_codes import QRCodes + + +class CheckInRFID(Screen): + def _build(self, controller): + outer = QVBoxLayout(self) + outer.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN) + outer.setSpacing(0) + + outline = OutlineFrame() + outer.addWidget(outline) + + inner = QVBoxLayout(outline) + inner.setContentsMargins(INNER_MARGIN, INNER_MARGIN, INNER_MARGIN, INNER_MARGIN) + inner.setSpacing(0) + + top_row = QHBoxLayout() + top_row.setContentsMargins(0, 0, 0, 0) + + qr_btn = StyledButton(ghost=True) + qr_btn.setIcon(qta.icon('mdi.qrcode-scan', color='#F5F0E6')) + qr_btn.setIconSize(NAV_ICON_SIZE) + qr_btn.setFixedSize(NAV_BTN_SIZE, NAV_BTN_SIZE) + qr_btn.clicked.connect(lambda: controller.show_frame(QRCodes)) + + no_id_btn = StyledButton("No ID", font_size=20, ghost=True) + no_id_btn.setFixedSize(NAV_BTN_SIZE, NAV_BTN_SIZE) + no_id_btn.clicked.connect(lambda: controller.go_to_no_id()) + + top_row.addWidget(qr_btn) + top_row.addStretch() + top_row.addWidget(no_id_btn) + inner.addLayout(top_row) + + inner.addStretch(2) + + title = QLabel("UCSD Makerspace") + title.setStyleSheet( + "color: #F5F0E6; font: bold 80pt Montserrat;" + "background: transparent; border: none;" + ) + title.setAlignment(Qt.AlignmentFlag.AlignHCenter) + inner.addWidget(title) + + subtitle = QLabel("Welcome Desk") + subtitle.setStyleSheet( + "color: #F5F0E6; font: 55pt Montserrat;" + "background: transparent; border: none;" + ) + subtitle.setAlignment(Qt.AlignmentFlag.AlignHCenter) + inner.addWidget(subtitle) + + inner.addStretch(3) + + instruction = QLabel("Please tap ID on the blue box to start") + instruction.setStyleSheet( + "color: #F5F0E6; font: 24pt Montserrat;" + "background: transparent; border: none;" + ) + instruction.setAlignment(Qt.AlignmentFlag.AlignHCenter) + inner.addWidget(instruction) diff --git a/src/screens/components/__init__.py b/src/screens/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/screens/components/canvas_entry.py b/src/screens/components/canvas_entry.py new file mode 100644 index 0000000..b295008 --- /dev/null +++ b/src/screens/components/canvas_entry.py @@ -0,0 +1,80 @@ +import tkinter as tk + +_focused = None + + +class CanvasEntry: + def __init__(self, canvas, x, y, w, h, font, fg="#F5F0E6"): + self.canvas = canvas + self._x = x + self._y = y + + canvas.configure(insertbackground=fg, insertontime=600, insertofftime=400) + + self._hit_id = canvas.create_rectangle( + x - w / 2, y - h / 2, x + w / 2, y + h / 2, + fill="", outline="", state="hidden", + ) + self._text_id = canvas.create_text( + x, y, text="", fill=fg, font=font, + anchor="center", state="hidden", + ) + + canvas.tag_bind(self._hit_id, "", self._on_click) + canvas.tag_bind(self._text_id, "", self._on_click) + + @property + def item_ids(self): + return [self._hit_id, self._text_id] + + def _on_click(self, event=None): + if getattr(self, '_readonly', False): + return + global _focused + if _focused and _focused is not self: + _focused._blur() + _focused = self + self.canvas.focus_set() + self.canvas.focus(self._text_id) + self.canvas.bind("", _dispatch_key) + if event: + idx = self.canvas.index(self._text_id, f"@{event.x},{event.y}") + self.canvas.icursor(self._text_id, idx) + else: + self.canvas.icursor(self._text_id, tk.END) + + def _blur(self): + global _focused + if _focused is self: + _focused = None + self.canvas.focus("") + + @classmethod + def blur_all(cls): + global _focused + if _focused: + _focused._blur() + + def get(self): + return self.canvas.itemcget(self._text_id, "text") + + def delete(self, start, end=None): + self.canvas.dchars(self._text_id, 0, tk.END) + + def insert(self, index, text): + self.canvas.insert(self._text_id, index, text) + + def set_readonly(self, readonly: bool): + self._readonly = readonly + color = "#C8C0B0" if readonly else "#F5F0E6" + self.canvas.itemconfigure(self._text_id, fill=color) + + +def _dispatch_key(event): + if _focused: + if event.keysym == "BackSpace": + idx = _focused.canvas.index(_focused._text_id, tk.INSERT) + if idx > 0: + _focused.canvas.dchars(_focused._text_id, idx - 1, idx - 1) + elif event.char and event.char.isprintable(): + _focused.canvas.insert(_focused._text_id, tk.INSERT, event.char) diff --git a/src/screens/components/dev_overlay.py b/src/screens/components/dev_overlay.py new file mode 100644 index 0000000..2fcbcf6 --- /dev/null +++ b/src/screens/components/dev_overlay.py @@ -0,0 +1,162 @@ +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton +from PyQt6.QtCore import Qt, QTimer + +from screens.check_in_rfid import CheckInRFID +from screens.create_account_barcode import CreateAccountBarcode +from screens.create_account_manual import CreateAccountManual +from screens.create_account_no_pid import CreateAccountNoPid +from screens.create_account_review import CreateAccountReview +from screens.sign_waiver import SignWaiver +from screens.check_in_manual import CheckInManual +from screens.qr_codes import QRCodes +from screens.user_welcome import UserWelcome +from screens.transition_screen import TransitionScreen + +_DEV_NAME = "Dev User" +_DEV_EMAIL = "devuser@ucsd.edu" +_DEV_PID = "A12345678" +_DEV_RFID = "1a2b3c4d5e6f7g" +_THANK_MSG = "Thank you for registering" + + +def _sim_no_account_success(nav): + nav.ctx.rfid = _DEV_RFID + if not nav.ctx.has_barcode_scanner: + nav.get_frame(TransitionScreen).display( + "Looks like you don't have an account.\nUse the other kiosk to set one up!" + ) + QTimer.singleShot(6000, nav.back_to_main) + return + def on_done(): + nav.ctx.traffic_light.request_green() + nav.get_frame(UserWelcome).display_name(_DEV_NAME, _THANK_MSG) + nav.go_to_create_account(on_done=on_done) + + +def _sim_no_account_needs_waiver(nav): + nav.ctx.rfid = _DEV_RFID + if not nav.ctx.has_barcode_scanner: + nav.get_frame(TransitionScreen).display( + "Looks like you don't have an account.\nUse the other kiosk to set one up!" + ) + QTimer.singleShot(6000, nav.back_to_main) + return + nav.go_to_create_account(on_done=nav.go_to_sign_waiver) + + +def _sim_barcode_swipe(nav): + nav.go_to_create_account_review( + pid=_DEV_PID, + first_name=_DEV_NAME.split()[0], + last_name=_DEV_NAME.split()[1], + email=_DEV_EMAIL, + ) + + +TRANSITIONS = { + CheckInRFID: [ + ("QR Codes", lambda nav: nav.show_frame(QRCodes)), + ("No ID", lambda nav: nav.go_to_no_id()), + ("card: success", lambda nav: nav.get_frame(UserWelcome).display_name(_DEV_NAME)), + ("card: no account [→ success]", _sim_no_account_success), + ("card: no account [→ waiver]", _sim_no_account_needs_waiver), + ("card: no waiver", lambda nav: nav.go_to_sign_waiver()), + ], + QRCodes: [ + ("← Main", lambda nav: nav.back_to_main()), + ], + CheckInManual: [ + ("← Main", lambda nav: nav.back_to_main()), + ("PID: success", lambda nav: nav.get_frame(UserWelcome).display_name(_DEV_NAME)), + ("PID: no account [→ success]", _sim_no_account_success), + ("PID: no account [→ waiver]", _sim_no_account_needs_waiver), + ("PID: no waiver", lambda nav: nav.go_to_sign_waiver()), + ], + CreateAccountBarcode: [ + ("sim barcode swipe", _sim_barcode_swipe), + ("manual fill", lambda nav: nav.go_to_create_account_manual()), + ("← Main", lambda nav: nav.back_to_main()), + ], + CreateAccountManual: [ + ("→ review (pid lookup)", lambda nav: nav.ctx.account.go_to_review_from_pid(_DEV_PID)), + ("→ no-pid screen", lambda nav: nav.go_to_create_account_no_pid()), + ("← Main", lambda nav: nav.back_to_main()), + ], + CreateAccountNoPid: [ + ("submit", lambda nav: nav.pop()), + ("← Main", lambda nav: nav.back_to_main()), + ], + CreateAccountReview: [ + ("submit", lambda nav: nav.pop()), + ("← Main", lambda nav: nav.back_to_main()), + ], + SignWaiver: [ + ("← Main", lambda nav: nav.back_to_main()), + ], +} + + +class DevOverlay(QWidget): + + def __init__(self, window, nav): + super().__init__(window.central) + self._nav = nav + self._stacked = window.stacked + self._buttons: list[QPushButton] = [] + + self.setStyleSheet("QWidget { background-color: #1a1a2e; }") + + layout = QVBoxLayout(self) + layout.setContentsMargins(6, 6, 6, 6) + layout.setSpacing(2) + + header = QLabel("DEV NAV") + header.setStyleSheet( + "color: #aaaaaa; font: bold 9pt Courier;" + "background: transparent; border: none;" + ) + header.setAlignment(Qt.AlignmentFlag.AlignHCenter) + layout.addWidget(header) + + self._layout = layout + + def update(self, screen_class): + while self._layout.count() > 1: + item = self._layout.takeAt(1) + w = item.widget() + if w: + w.setParent(None) + self._buttons.clear() + + for label, action in TRANSITIONS.get(screen_class, []): + btn = QPushButton(label) + btn.setStyleSheet(""" + QPushButton { + background-color: #2a2a4e; + color: white; + font: 9pt Courier; + padding: 3px 6px; + border: none; + text-align: left; + } + QPushButton:hover { background-color: #4a4a8e; } + """) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.clicked.connect(lambda checked, a=action: a(self._nav)) + self._layout.addWidget(btn) + self._buttons.append(btn) + + QTimer.singleShot(0, self._refresh) + + def _refresh(self): + self.adjustSize() + self._reposition() + self.raise_() + self.show() + + def _reposition(self): + s = self._stacked + self.move( + s.x() + s.width() - self.width() - 10, + s.y() + s.height() - self.height() - 10, + ) diff --git a/src/screens/components/outline_frame.py b/src/screens/components/outline_frame.py new file mode 100644 index 0000000..53b2eb9 --- /dev/null +++ b/src/screens/components/outline_frame.py @@ -0,0 +1,30 @@ +from PyQt6.QtWidgets import QFrame +from PyQt6.QtGui import QPainter, QPen, QColor, QPainterPath +from PyQt6.QtCore import Qt, QRectF + + +class OutlineFrame(QFrame): + + def __init__(self, parent=None, radius=20): + super().__init__(parent) + self._radius = radius + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + half_pen = 2 + rect = QRectF(self.rect()).adjusted(half_pen, half_pen, -half_pen, -half_pen) + + path = QPainterPath() + path.addRoundedRect(rect, self._radius, self._radius) + + painter.fillPath(path, QColor(0, 0, 0, 35)) + + pen = QPen(QColor(240, 240, 240, 200)) + pen.setWidth(4) + pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawPath(path) diff --git a/src/screens/components/styled_button.py b/src/screens/components/styled_button.py new file mode 100644 index 0000000..8d41563 --- /dev/null +++ b/src/screens/components/styled_button.py @@ -0,0 +1,82 @@ +from PyQt6.QtWidgets import QPushButton +from PyQt6.QtGui import QPainter, QPainterPath, QColor, QPen, QFont +from PyQt6.QtCore import Qt, QRectF, QRect +import qtawesome as qta + +from .theme import OUTER_MARGIN, INNER_MARGIN, NAV_BTN_SIZE, NAV_ICON_SIZE + +__all__ = ["StyledButton", "home_button", "OUTER_MARGIN", "INNER_MARGIN", "NAV_BTN_SIZE", "NAV_ICON_SIZE"] + + +class StyledButton(QPushButton): + + def __init__(self, text="", parent=None, font_size=30, ghost=False, radius=20): + super().__init__(text, parent) + self._font_size = font_size + self._ghost = ghost + self._radius = radius + self._hovered = False + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setMinimumHeight(65) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setStyleSheet("background: transparent; border: none;") + + def enterEvent(self, event): + self._hovered = True + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self._hovered = False + self.update() + super().leaveEvent(event) + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + if self._ghost: + half_pen = 2 + rect = QRectF(self.rect()).adjusted(half_pen, half_pen, -half_pen, -half_pen) + path = QPainterPath() + path.addRoundedRect(rect, self._radius, self._radius) + + painter.fillPath(path, QColor(255, 255, 255, 30) if self._hovered else QColor(0, 0, 0, 0)) + + pen = QPen(QColor(240, 240, 240, 200)) + pen.setWidth(4) + pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawPath(path) + + ico = self.icon() + if not ico.isNull(): + sz = self.iconSize() + ico.paint(painter, QRect( + (self.width() - sz.width()) // 2, + (self.height() - sz.height()) // 2, + sz.width(), sz.height(), + )) + else: + painter.setFont(QFont("Montserrat", self._font_size)) + painter.setPen(QColor("#F5F0E6")) + painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, self.text()) + else: + rect = QRectF(self.rect()).adjusted(1, 1, -1, -1) + path = QPainterPath() + path.addRoundedRect(rect, self._radius, self._radius) + painter.fillPath(path, QColor("#E8E4DA") if self._hovered else QColor("#F5F0E6")) + + painter.setFont(QFont("Montserrat", self._font_size)) + painter.setPen(QColor("#4EBEEE")) + painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, self.text()) + + +def home_button(on_click): + btn = StyledButton(ghost=True) + btn.setIcon(qta.icon('fa5s.home', color='#F5F0E6')) + btn.setIconSize(NAV_ICON_SIZE) + btn.setFixedSize(NAV_BTN_SIZE, NAV_BTN_SIZE) + btn.clicked.connect(on_click) + return btn diff --git a/src/screens/components/styled_entry.py b/src/screens/components/styled_entry.py new file mode 100644 index 0000000..4eb44c3 --- /dev/null +++ b/src/screens/components/styled_entry.py @@ -0,0 +1,30 @@ +from PyQt6.QtWidgets import QLineEdit +from PyQt6.QtCore import Qt + + +class StyledEntry(QLineEdit): + + def __init__(self, parent=None, font_size=20): + super().__init__(parent) + self._font_size = font_size + self.setMinimumHeight(54) + self._apply_style(readonly=False) + + def _apply_style(self, readonly: bool): + text_color = "#C8C0B0" if readonly else "#F5F0E6" + self.setStyleSheet(f""" + QLineEdit {{ + background-color: rgba(0, 0, 0, 80); + border: 2px solid #F5F0E6; + border-radius: 12px; + color: {text_color}; + font: {self._font_size}pt Montserrat; + padding: 6px 14px; + selection-background-color: #4EBEEE; + selection-color: #153246; + }} + """) + + def set_readonly(self, readonly: bool): + self.setReadOnly(readonly) + self._apply_style(readonly) diff --git a/src/screens/components/theme.py b/src/screens/components/theme.py new file mode 100644 index 0000000..7f736c9 --- /dev/null +++ b/src/screens/components/theme.py @@ -0,0 +1,7 @@ +from PyQt6.QtCore import QSize + +OUTER_MARGIN = 14 +INNER_MARGIN = 24 + +NAV_BTN_SIZE = 100 +NAV_ICON_SIZE = QSize(52, 52) diff --git a/src/screens/create_account_barcode.py b/src/screens/create_account_barcode.py new file mode 100644 index 0000000..f7ae479 --- /dev/null +++ b/src/screens/create_account_barcode.py @@ -0,0 +1,45 @@ +from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel +from PyQt6.QtCore import Qt +from .base import Screen +from .components.outline_frame import OutlineFrame +from .components.styled_button import StyledButton, home_button, INNER_MARGIN, OUTER_MARGIN + + +class CreateAccountBarcode(Screen): + def _build(self, controller): + outer = QVBoxLayout(self) + outer.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN) + outer.setSpacing(0) + + outline = OutlineFrame() + outer.addWidget(outline) + + inner = QVBoxLayout(outline) + inner.setContentsMargins(INNER_MARGIN, INNER_MARGIN, INNER_MARGIN, INNER_MARGIN) + inner.setSpacing(0) + + top_row = QHBoxLayout() + top_row.addWidget(home_button(lambda: controller.back_to_main())) + top_row.addStretch() + inner.addLayout(top_row) + + inner.addStretch(3) + + instruction = QLabel("Please scan your ID barcode") + instruction.setStyleSheet( + "color: #F5F0E6; font: 36pt Montserrat;" + "background: transparent; border: none;" + ) + instruction.setAlignment(Qt.AlignmentFlag.AlignHCenter) + inner.addWidget(instruction) + + inner.addStretch(3) + + btn_row = QHBoxLayout() + fill_btn = StyledButton("Fill Manually") + fill_btn.setFixedWidth(349) + fill_btn.clicked.connect(lambda: controller.go_to_create_account_manual()) + btn_row.addStretch() + btn_row.addWidget(fill_btn) + btn_row.addStretch() + inner.addLayout(btn_row) diff --git a/src/screens/create_account_manual.py b/src/screens/create_account_manual.py new file mode 100644 index 0000000..c9d6d1b --- /dev/null +++ b/src/screens/create_account_manual.py @@ -0,0 +1,77 @@ +from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel +from PyQt6.QtCore import Qt +from .base import Screen +from .components.outline_frame import OutlineFrame +from .components.styled_button import StyledButton, home_button, INNER_MARGIN, OUTER_MARGIN +from .components.styled_entry import StyledEntry + + +class CreateAccountManual(Screen): + def _build(self, controller): + outer = QVBoxLayout(self) + outer.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN) + outer.setSpacing(0) + + outline = OutlineFrame() + outer.addWidget(outline) + + inner = QVBoxLayout(outline) + inner.setContentsMargins(INNER_MARGIN, INNER_MARGIN, INNER_MARGIN, INNER_MARGIN) + inner.setSpacing(0) + + top_row = QHBoxLayout() + top_row.addWidget(home_button(lambda: controller.back_to_main())) + top_row.addStretch() + inner.addLayout(top_row) + + inner.addStretch(3) + + pid_label = QLabel("PID") + pid_label.setStyleSheet( + "color: #F5F0E6; font: 18pt Montserrat;" + "background: transparent; border: none;" + ) + pid_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) + inner.addWidget(pid_label) + + entry_row = QHBoxLayout() + self.pid_entry = StyledEntry() + self.pid_entry.setMaximumWidth(800) + entry_row.addStretch() + entry_row.addWidget(self.pid_entry) + entry_row.addStretch() + inner.addLayout(entry_row) + + inner.addStretch(2) + + btn_row = QHBoxLayout() + register_btn = StyledButton("Register") + register_btn.setFixedWidth(349) + register_btn.clicked.connect(self._go_to_review) + btn_row.addStretch() + btn_row.addWidget(register_btn) + btn_row.addStretch() + inner.addLayout(btn_row) + + inner.addSpacing(12) + + no_pid_row = QHBoxLayout() + no_pid_btn = StyledButton("I don't have a PID →") + no_pid_btn.setFixedWidth(349) + no_pid_btn.setMinimumHeight(80) + no_pid_btn.clicked.connect(lambda: controller.go_to_create_account_no_pid()) + no_pid_row.addStretch() + no_pid_row.addWidget(no_pid_btn) + no_pid_row.addStretch() + inner.addLayout(no_pid_row) + + def on_hide(self): + self.pid_entry.clearFocus() + + def clear_entries(self): + self.pid_entry.clear() + + def _go_to_review(self): + pid = self.pid_entry.text().strip() + self.clear_entries() + self.controller.ctx.account.go_to_review_from_pid(pid) diff --git a/src/screens/create_account_no_pid.py b/src/screens/create_account_no_pid.py new file mode 100644 index 0000000..f45f349 --- /dev/null +++ b/src/screens/create_account_no_pid.py @@ -0,0 +1,82 @@ +from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel +from PyQt6.QtCore import Qt +import logging +from .base import Screen +from .components.outline_frame import OutlineFrame +from .components.styled_button import StyledButton, home_button, INNER_MARGIN, OUTER_MARGIN +from .components.styled_entry import StyledEntry + + +class CreateAccountNoPid(Screen): + def _build(self, controller): + outer = QVBoxLayout(self) + outer.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN) + outer.setSpacing(0) + + outline = OutlineFrame() + outer.addWidget(outline) + + inner = QVBoxLayout(outline) + inner.setContentsMargins(INNER_MARGIN, INNER_MARGIN, INNER_MARGIN, INNER_MARGIN) + inner.setSpacing(0) + + top_row = QHBoxLayout() + top_row.addWidget(home_button(lambda: controller.back_to_main())) + top_row.addStretch() + inner.addLayout(top_row) + + inner.addStretch(1) + + def _field_row(label_text): + lbl = QLabel(label_text) + lbl.setStyleSheet( + "color: #F5F0E6; font: 18pt Montserrat;" + "background: transparent; border: none;" + ) + lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + inner.addWidget(lbl) + + row = QHBoxLayout() + entry = StyledEntry() + entry.setMaximumWidth(800) + row.addStretch() + row.addWidget(entry) + row.addStretch() + inner.addLayout(row) + inner.addSpacing(10) + return entry + + self.first_name_entry = _field_row("First Name") + self.last_name_entry = _field_row("Last Name") + self.email_entry = _field_row("Email") + + inner.addStretch(1) + + btn_row = QHBoxLayout() + register_btn = StyledButton("Register") + register_btn.setFixedWidth(349) + register_btn.clicked.connect(self._submit) + btn_row.addStretch() + btn_row.addWidget(register_btn) + btn_row.addStretch() + inner.addLayout(btn_row) + + def on_hide(self): + for entry in (self.first_name_entry, self.last_name_entry, self.email_entry): + entry.clearFocus() + + def clear_entries(self): + for entry in (self.first_name_entry, self.last_name_entry, self.email_entry): + entry.clear() + + def _submit(self): + first = self.first_name_entry.text().strip() + last = self.last_name_entry.text().strip() + email = self.email_entry.text().strip() + self.clear_entries() + try: + self.controller.ctx.account.create_account_from_review( + first_name=first, last_name=last, email=email, pid="" + ) + except Exception: + logging.warning("Error occurred trying to create a user account", exc_info=True) diff --git a/src/screens/create_account_review.py b/src/screens/create_account_review.py new file mode 100644 index 0000000..7beb70e --- /dev/null +++ b/src/screens/create_account_review.py @@ -0,0 +1,99 @@ +from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel +from PyQt6.QtCore import Qt +import logging +from .base import Screen +from .components.outline_frame import OutlineFrame +from .components.styled_button import StyledButton, home_button, INNER_MARGIN, OUTER_MARGIN +from .components.styled_entry import StyledEntry + + +class CreateAccountReview(Screen): + def _build(self, controller): + outer = QVBoxLayout(self) + outer.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN) + outer.setSpacing(0) + + outline = OutlineFrame() + outer.addWidget(outline) + + inner = QVBoxLayout(outline) + inner.setContentsMargins(INNER_MARGIN, INNER_MARGIN, INNER_MARGIN, INNER_MARGIN) + inner.setSpacing(0) + + top_row = QHBoxLayout() + top_row.addWidget(home_button(lambda: controller.back_to_main())) + top_row.addStretch() + inner.addLayout(top_row) + + inner.addSpacing(8) + + def _field_row(label_text): + lbl = QLabel(label_text) + lbl.setStyleSheet( + "color: #F5F0E6; font: 18pt Montserrat;" + "background: transparent; border: none;" + ) + lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + inner.addWidget(lbl) + + row = QHBoxLayout() + entry = StyledEntry() + entry.setMaximumWidth(800) + row.addStretch() + row.addWidget(entry) + row.addStretch() + inner.addLayout(row) + inner.addSpacing(8) + return entry + + self.first_name_entry = _field_row("First Name") + self.last_name_entry = _field_row("Last Name") + self.email_entry = _field_row("Email") + self.pid_entry = _field_row("PID") + + inner.addStretch(1) + + btn_row = QHBoxLayout() + register_btn = StyledButton("Register") + register_btn.setFixedWidth(349) + register_btn.clicked.connect(self._submit) + btn_row.addStretch() + btn_row.addWidget(register_btn) + btn_row.addStretch() + inner.addLayout(btn_row) + + def setup(self, first_name="", last_name="", email="", pid="", pid_locked=False): + self.clear_entries() + if first_name: + self.first_name_entry.setText(first_name) + if last_name: + self.last_name_entry.setText(last_name) + if email: + self.email_entry.setText(email) + if pid: + self.pid_entry.setText(pid) + self.pid_entry.set_readonly(pid_locked) + + def on_hide(self): + for entry in (self.first_name_entry, self.last_name_entry, + self.email_entry, self.pid_entry): + entry.clearFocus() + + def clear_entries(self): + for entry in (self.first_name_entry, self.last_name_entry, + self.email_entry, self.pid_entry): + entry.clear() + self.pid_entry.set_readonly(False) + + def _submit(self): + first = self.first_name_entry.text().strip() + last = self.last_name_entry.text().strip() + email = self.email_entry.text().strip() + pid = self.pid_entry.text().strip() + self.clear_entries() + try: + self.controller.ctx.account.create_account_from_review( + first_name=first, last_name=last, email=email, pid=pid + ) + except Exception: + logging.warning("Error occurred trying to create a user account", exc_info=True) diff --git a/src/screens/qr_codes.py b/src/screens/qr_codes.py new file mode 100644 index 0000000..823e77b --- /dev/null +++ b/src/screens/qr_codes.py @@ -0,0 +1,59 @@ +from pathlib import Path +from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel +from PyQt6.QtGui import QPixmap +from PyQt6.QtCore import Qt +from .base import Screen +from .components.outline_frame import OutlineFrame +from .components.styled_button import home_button, INNER_MARGIN, OUTER_MARGIN + +ASSETS_PATH = Path(__file__).parent.parent / "assets" / "qr_codes" + + +class QRCodes(Screen): + def _build(self, controller): + outer = QVBoxLayout(self) + outer.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN) + outer.setSpacing(0) + + outline = OutlineFrame() + outer.addWidget(outline) + + inner = QVBoxLayout(outline) + inner.setContentsMargins(INNER_MARGIN, INNER_MARGIN, INNER_MARGIN, INNER_MARGIN) + inner.setSpacing(0) + + top_row = QHBoxLayout() + top_row.addWidget(home_button(lambda: controller.back_to_main())) + top_row.addStretch() + inner.addLayout(top_row) + + inner.addStretch(1) + + qr_row = QHBoxLayout() + qr_row.setSpacing(80) + + def _qr_col(image_path, caption): + col = QVBoxLayout() + col.setSpacing(12) + img = QLabel() + px = QPixmap(str(image_path)) + img.setPixmap(px) + img.setAlignment(Qt.AlignmentFlag.AlignHCenter) + img.setStyleSheet("background: transparent; border: none;") + lbl = QLabel(caption) + lbl.setStyleSheet( + "color: #F5F0E6; font: 30pt Montserrat;" + "background: transparent; border: none;" + ) + lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + col.addWidget(img) + col.addWidget(lbl) + return col + + qr_row.addStretch() + qr_row.addLayout(_qr_col(ASSETS_PATH / "qr_website.png", "Website")) + qr_row.addLayout(_qr_col(ASSETS_PATH / "qr_waiver.png", "Waiver")) + qr_row.addStretch() + inner.addLayout(qr_row) + + inner.addStretch(1) diff --git a/src/screens/screen.py b/src/screens/screen.py deleted file mode 100644 index 054629c..0000000 --- a/src/screens/screen.py +++ /dev/null @@ -1,62 +0,0 @@ -from tkinter import PhotoImage - - -class Screen: - def __init__(self, canvas, controller): - self.canvas = canvas - self.controller = controller - self._items = [] - self._windows = [] - self._photos = [] - self._build(controller) - self.hide() - - # ------------------------------------------------------------------ - # Helpers for subclasses - # ------------------------------------------------------------------ - - def _photo(self, path): - img = PhotoImage(file=str(path)) - self._photos.append(img) - return img - - def _image(self, x, y, **kwargs): - item = self.canvas.create_image(x, y, **kwargs) - self._items.append(item) - return item - - def _text(self, x, y, **kwargs): - item = self.canvas.create_text(x, y, **kwargs) - self._items.append(item) - return item - - def _window(self, x, y, widget, width=None, height=None): - """Embed a tk widget into the canvas. x, y = top-left corner.""" - kw = dict(anchor="nw", window=widget) - if width is not None: - kw["width"] = width - if height is not None: - kw["height"] = height - item = self.canvas.create_window(x, y, **kw) - self._windows.append(item) - return item - - # ------------------------------------------------------------------ - # Visibility - # ------------------------------------------------------------------ - - def show(self): - for item in self._items: - self.canvas.itemconfigure(item, state="normal") - for win in self._windows: - self.canvas.itemconfigure(win, state="normal") - - def hide(self): - for item in self._items: - self.canvas.itemconfigure(item, state="hidden") - for win in self._windows: - self.canvas.itemconfigure(win, state="hidden") - - # Subclasses implement this instead of __init__ - def _build(self, controller): - pass diff --git a/src/screens/sign_waiver.py b/src/screens/sign_waiver.py new file mode 100644 index 0000000..f012d20 --- /dev/null +++ b/src/screens/sign_waiver.py @@ -0,0 +1,72 @@ +from pathlib import Path +from PyQt6.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel +from PyQt6.QtGui import QPixmap +from PyQt6.QtCore import Qt +from .base import Screen +from .components.outline_frame import OutlineFrame +from .components.styled_button import StyledButton, OUTER_MARGIN, INNER_MARGIN + +ASSETS_PATH = Path(__file__).parent.parent / "assets" / "sign_waiver" + + +class SignWaiver(Screen): + def _build(self, controller): + outer = QVBoxLayout(self) + outer.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN) + outer.setSpacing(0) + + outline = OutlineFrame() + outer.addWidget(outline) + + root = QVBoxLayout(outline) + root.setContentsMargins(INNER_MARGIN, INNER_MARGIN, INNER_MARGIN, INNER_MARGIN) + root.setSpacing(0) + + content = QHBoxLayout() + content.setContentsMargins(50, 0, 50, 0) + content.setSpacing(20) + + left = QVBoxLayout() + left.setSpacing(0) + + left.addStretch(1) + + instruction = QLabel( + "Please scan the QR code\non the right and sign the waiver" + ) + instruction.setStyleSheet( + "color: #F5F0E6; font: 36pt Montserrat;" + "background: transparent; border: none;" + ) + instruction.setAlignment(Qt.AlignmentFlag.AlignHCenter) + instruction.setWordWrap(True) + left.addWidget(instruction) + + left.addStretch(2) + + content.addLayout(left, stretch=1) + + right = QVBoxLayout() + right.setSpacing(0) + right.addStretch() + + qr_px = QPixmap(str(ASSETS_PATH / "qr_waiver.png")) + qr_px = qr_px.scaled(320, 320, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + qr_label = QLabel() + qr_label.setPixmap(qr_px) + qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + qr_label.setStyleSheet("background: transparent; border: none;") + right.addWidget(qr_label) + + right.addSpacing(24) + + done_btn = StyledButton("Done Scanning") + done_btn.setFixedWidth(280) + done_btn.clicked.connect(lambda: controller.back_to_main()) + right.addWidget(done_btn, alignment=Qt.AlignmentFlag.AlignHCenter) + + right.addStretch() + + content.addLayout(right, stretch=1) + + root.addLayout(content) diff --git a/src/screens/transition_screen.py b/src/screens/transition_screen.py new file mode 100644 index 0000000..4130371 --- /dev/null +++ b/src/screens/transition_screen.py @@ -0,0 +1,35 @@ +from PyQt6.QtWidgets import QVBoxLayout, QLabel +from PyQt6.QtCore import Qt +from .base import Screen +from .components.outline_frame import OutlineFrame +from .components.theme import INNER_MARGIN, OUTER_MARGIN + + +class TransitionScreen(Screen): + def _build(self, controller): + outer = QVBoxLayout(self) + outer.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN) + outer.setSpacing(0) + + outline = OutlineFrame() + outer.addWidget(outline) + + inner = QVBoxLayout(outline) + inner.setContentsMargins(INNER_MARGIN, INNER_MARGIN, INNER_MARGIN, INNER_MARGIN) + + inner.addStretch() + + self._msg_label = QLabel("") + self._msg_label.setStyleSheet( + "color: #F5F0E6; font: 48pt Montserrat;" + "background: transparent; border: none;" + ) + self._msg_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self._msg_label.setWordWrap(True) + inner.addWidget(self._msg_label) + + inner.addStretch() + + def display(self, message): + self._msg_label.setText(message) + self.controller.show_frame(TransitionScreen) diff --git a/src/screens/user_welcome.py b/src/screens/user_welcome.py new file mode 100644 index 0000000..bdd977b --- /dev/null +++ b/src/screens/user_welcome.py @@ -0,0 +1,80 @@ +from PyQt6.QtWidgets import QVBoxLayout, QLabel +from PyQt6.QtCore import Qt, QTimer +from .base import Screen +from .components.outline_frame import OutlineFrame +from .components.theme import INNER_MARGIN, OUTER_MARGIN + + +class UserWelcome(Screen): + def _build(self, controller): + self._last_name = None + self._active_labels: set = set() + + outer = QVBoxLayout(self) + outer.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN) + outer.setSpacing(0) + + outline = OutlineFrame() + outer.addWidget(outline) + + inner = QVBoxLayout(outline) + inner.setContentsMargins(INNER_MARGIN, INNER_MARGIN, INNER_MARGIN, INNER_MARGIN) + inner.setSpacing(0) + + inner.addStretch() + + self._msg_label = QLabel("Welcome back") + self._msg_label.setStyleSheet( + "color: #F5F0E6; font: 38pt Montserrat;" + "background: transparent; border: none;" + ) + self._msg_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) + inner.addWidget(self._msg_label) + + inner.addSpacing(8) + + self._names_layout = QVBoxLayout() + self._names_layout.setContentsMargins(0, 0, 0, 0) + self._names_layout.setSpacing(0) + inner.addLayout(self._names_layout) + + inner.addStretch() + + def on_hide(self): + self._active_labels.clear() + while self._names_layout.count(): + item = self._names_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + self._msg_label.setText("Welcome back") + self._last_name = None + + def display_name(self, name, message="Welcome back"): + if name == self._last_name: + return + + self._last_name = name + self._msg_label.setText(message) + self.controller.show_frame(UserWelcome) + + label = QLabel(name) + label.setStyleSheet( + "color: #F5F0E6; font: bold 70pt Montserrat;" + "background: transparent; border: none;" + ) + label.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self._names_layout.addWidget(label) + self._active_labels.add(label) + + QTimer.singleShot(3000, lambda: self._remove_name(label)) + + def _remove_name(self, label): + if label not in self._active_labels: + return + self._active_labels.discard(label) + self._names_layout.removeWidget(label) + label.deleteLater() + + if not self._active_labels: + self._last_name = None + self.controller.back_to_main() diff --git a/src/swipe.py b/src/swipe.py deleted file mode 100644 index a212fc0..0000000 --- a/src/swipe.py +++ /dev/null @@ -1,143 +0,0 @@ -import tkinter -from screens.ManualFill import ManualFill -from screens.NoAccNoWaiverSwipe import NoAccNoWaiverSwipe -from screens.WaiverNoAccSwipe import WaiverNoAccSwipe -from screens.AccNoWaiverSwipe import AccNoWaiverSwipe -from screens.CheckInNoId import CheckInNoId -from get_info_from_pid import contact_client -from utils import utils -import global_ -import logging - -############################################ -# This class helps handle reading magswipe # -############################################ - -swipe_error_shown = False - - -class swipe: - def __init__(self): - global id_string - id_string = "" - - def keyboardPress(self, key): - util = utils() - global id_string, swipe_error_shown - curr_frame = global_.app.get_curr_frame() - - if curr_frame not in (NoAccNoWaiverSwipe, WaiverNoAccSwipe, CheckInNoId): - return - - id_string += key.char - logging.debug("The array is now: " + repr(str(id_string))) - - if id_string.endswith("\r"): - if util.IDVet(id_string) == "bad": - id_string = "" - if not swipe_error_shown: - swipe_error_shown = True - canvas = global_.app.canvas - id_error = tkinter.Label( - canvas, text="Error, please scan again", - bg="#153246", fg="white", font=("Arial", 20), - ) - id_error.place(relx=0.5, rely=0.85, anchor="center") - id_error_2 = id_error # single label serves both swipe screens - id_error.after(1500, lambda: self.destroySwipeError(id_error)) - id_error_2.after(1500, lambda: self.destroySwipeError(id_error_2)) - return - - self.swipeCard(id_string) - id_string = "" - - def pullUser(self, barcode, u_type): - # This function takes in the User's ID and - # if they are a Student or Staff - # and runs David's query funciton accordingly - # It returns a list containing: - # [fname, lname, [emails]] - u_info = [] - - logging.info(f"Card barcode read is: {barcode}. Trying to pull user...") - - contact = contact_client() - try: - if u_type == "Staff": - u_info = contact.get_staff_info(barcode) - elif u_type == "Student": - u_info = contact.get_student_info(barcode) - except Exception as e: - logging.warning( - "An exception has ocurred with pulling user information", exc_info=True - ) - return None - if not u_info: - logging.info("Student search returned False, returning...") - return - - logging.info(f"Info pull succeeded:\n {u_info[0]}, {u_info[1]}, {u_info[3]}") - return u_info - - def swipeCard(self, id_string): - # Grabs the input from the global swipe entry - # Deletes text from the entry box - # Checks if any of the ID is a letter - # If so return - # Calls magswipe() on the entered string - - user_card_number = id_string.strip() - - # u_info = self.magSwipe(id_string) - - # u_type = u_info[0] - # u_id = u_info[1] - # u_id = u_id.replace("+E?", "")[:9] - - # u_data is a list containing the user type and their ID - u_data = self.pullUser(user_card_number, "Student") - if not u_data: - logging.info("Student search returned False, returning...") - return - # if u_type == "Student": - # u_id = "A" + u_id - if global_.app.get_curr_frame() == CheckInNoId: - global_.app.get_frame(CheckInNoId).clearEntries() - global_.app.get_frame(CheckInNoId).updateEntries(u_data[3]) - return - - email_to_use = "" if len(u_data[2]) == 0 else u_data[2][0] - for email in u_data[2]: - if email.endswith("@ucsd.edu"): - email_to_use = email - - manfill = global_.app.get_frame(ManualFill) - manfill.clearEntries() - logging.info( - f"Filling data with {u_data[0]} {u_data[1]} {email_to_use} {u_data[3]}" - ) - manfill.updateEntries(u_data[0], u_data[1], email_to_use, u_data[3]) - - global_.app.show_frame(ManualFill) - - def magSwipe(self, ID): - # Makes a new empty string - # Takes only chars 3-11 from the card swipe text - # Returns student or staff ID - - u_type = "" - - if ID[2] == "9": - u_type = "Student" - elif ID[2] == "0": - u_type = "Staff" - - s = "" - for c in range(3, 11): - s += ID[c] - return [u_type, s] - - def destroySwipeError(self, id_error): - global swipe_error_shown - id_error.destroy() - swipe_error_shown = False diff --git a/src/utils.py b/src/utils.py deleted file mode 100644 index 4f2fa0a..0000000 --- a/src/utils.py +++ /dev/null @@ -1,130 +0,0 @@ -from datetime import datetime -import time -import global_ -import tkinter -from screens.MainPage import MainPage -from screens.AccNoWaiverSwipe import AccNoWaiverSwipe -from screens.UserThank import UserThank -import logging - -###################################################### -# Utilities that I couldn't get to fit anywhere else # -###################################################### - - -class utils: - def __init__(self) -> None: - pass - - def emailCheck(self, email): - validations = ( - (lambda s: "@" in s, "Email is invalid"), - (lambda s: "." in s, "Email is invalid"), - ) - - for valid, message in validations: - if not valid(email): - return message - - return "good" - - def nameCheck(self, fname, lname): - if len(fname) == 0 or len(lname) == 0: - return "Name was not entered" - - return "good" - - def IDCheck(self, user_id): - if len(user_id) <= 2 or len(user_id) > 12: - return "PID was not entered correctly" - return "good" - - def IDVet(self, id_check): - if any(i.isalpha() for i in id_check): - return "bad" - - if len(id_check) >= 16: - return "bad" - - return "good" - - def getDatetime(self): - return datetime.now().strftime("%m/%d/%Y %H:%M:%S") - - def createAccount(self, fname, lname, email, pid, ManualFill): - start = time.perf_counter() - idValid = self.IDCheck(pid) - emailValid = self.emailCheck(email) - nameValid = self.nameCheck(fname, lname) - - canvas = global_.app.canvas - - for validation in (idValid, emailValid, nameValid): - if validation != "good": - invalidID = tkinter.Label( - canvas, text=validation, bg="#153246", fg="white", font=("Arial", 20) - ) - invalidID.place(relx=0.5, rely=0.83, anchor="center") - invalidID.after(3000, lambda: invalidID.destroy()) - return - - end1 = time.perf_counter() - logging.debug(f"Time to validate info: {end1 - start}") - - inProgress = tkinter.Label( - canvas, - text="Account creation in progress!", - bg="#153246", fg="white", font=("Arial", 25), - ) - inProgress.place(relx=0.5, rely=0.87, anchor="center") - global_.app.update() - - full_name = fname + " " + lname - logging.info(f"Creating user account for {full_name}") - - no_wifi = tkinter.Label( - canvas, - text="ERROR! Connection cannot be established, please let staff know.", - bg="#153246", fg="white", font=("Arial", 25), - ) - - end2 = time.perf_counter() - logging.debug(f"Time to structure row entries: {end2 - end1}") - - retries = 1 - while retries < 6: - try: - result = global_.sheets.create_account(fname, lname, email, pid, global_.rfid) - end3 = time.perf_counter() - logging.debug(f"Time to create account: {end3 - end2}") - - if result is None: - raise Exception("Account creation returned no result") - - break - except Exception as e: - logging.warning("Exception occurred while in account creation") - logging.exception("Exception occurred while in account creation") - no_wifi.place(relx=0.5, rely=0.91, anchor="center") - global_.app.update() - time.sleep(retries) - retries += 1 - - no_wifi.destroy() - - if retries == 6: - global_.app.show_frame(MainPage) - inProgress.destroy() - return - - end4 = time.perf_counter() - logging.debug(f"Total time to send data: {end4 - end2}") - - checkin_result = global_.sheets.checkin_by_uuid(global_.rfid) - toGoTo = AccNoWaiverSwipe if checkin_result.get("status") == "no_waiver" else MainPage - - end5 = time.perf_counter() - logging.debug(f"Time to check waiver via check-in: {end5 - end4}") - - global_.app.get_frame(UserThank).displayName(full_name, toGoTo) - inProgress.destroy() diff --git a/src/window.py b/src/window.py new file mode 100644 index 0000000..5d81643 --- /dev/null +++ b/src/window.py @@ -0,0 +1,67 @@ +from pathlib import Path +from PyQt6.QtWidgets import QMainWindow, QWidget, QStackedWidget +from PyQt6.QtGui import QFontDatabase, QPainter, QPixmap, QColor +from PyQt6.QtCore import QTimer, Qt + +ASSETS_PATH = Path(__file__).parent / "assets" / "shared" + + +class _RootWidget(QWidget): + """Central widget that paints background_main.png centered on the dark base color.""" + + def __init__(self, parent=None): + super().__init__(parent) + bg_path = ASSETS_PATH / "background_main.png" + self._bg = QPixmap(str(bg_path)) if bg_path.exists() else QPixmap() + + def paintEvent(self, event): + painter = QPainter(self) + # Dark base fill + painter.fillRect(self.rect(), QColor("#153246")) + # Background image centered + if not self._bg.isNull(): + x = (self.width() - self._bg.width()) // 2 + y = (self.height() - self._bg.height()) // 2 + painter.drawPixmap(x, y, self._bg) + + +class CheckInWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("Check-In") + self.setFixedSize(1280, 720) + + # Load Montserrat if bundled; falls back to system font + fonts_dir = Path(__file__).parent.parent / "fonts" + if fonts_dir.exists(): + for font_file in fonts_dir.glob("*.ttf"): + QFontDatabase.addApplicationFont(str(font_file)) + + self.central = _RootWidget() + self.setCentralWidget(self.central) + + # Stacked widget fills the central widget; transparent so bg shows through + self.stacked = QStackedWidget(self.central) + self.stacked.setGeometry(0, 0, 1280, 720) + self.stacked.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.stacked.setStyleSheet("background: transparent;") + + self._escape_handler = None + + def set_escape_handler(self, fn): + self._escape_handler = fn + + def keyPressEvent(self, event): + if event.key() == Qt.Key.Key_Escape and self._escape_handler: + self._escape_handler() + else: + super().keyPressEvent(event) + + def after(self, ms, fn): + """Drop-in replacement for tkinter's window.after().""" + QTimer.singleShot(ms, fn) + + def start(self): + from PyQt6.QtWidgets import QApplication + self.showFullScreen() + QApplication.instance().exec() diff --git a/test.py b/test.py deleted file mode 100644 index adadd57..0000000 --- a/test.py +++ /dev/null @@ -1,11 +0,0 @@ -from tkinter import Tk, Label - -root=Tk() - -def key_pressed(event): - print(event.char) - return - -root.bind("",key_pressed) - -root.mainloop() \ No newline at end of file