Digitale bierlijst

gui.py 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  1. """
  2. Provides the graphical front-end for Piket.
  3. """
  4. import collections
  5. import itertools
  6. import logging
  7. import math
  8. import os
  9. import sys
  10. from typing import Deque, Iterator
  11. import qdarkstyle
  12. # pylint: disable=E0611
  13. from PySide2.QtWidgets import (
  14. QAction,
  15. QActionGroup,
  16. QApplication,
  17. QGridLayout,
  18. QInputDialog,
  19. QLineEdit,
  20. QMainWindow,
  21. QMessageBox,
  22. QPushButton,
  23. QSizePolicy,
  24. QToolBar,
  25. QWidget,
  26. )
  27. from PySide2.QtGui import QIcon
  28. from PySide2.QtCore import QObject, QSize, Qt, Signal, Slot, QUrl
  29. from PySide2.QtMultimedia import QSoundEffect
  30. # pylint: enable=E0611
  31. try:
  32. import dbus
  33. except ImportError:
  34. dbus = None
  35. from piket_client.sound import PLOP_PATH, UNDO_PATH
  36. from piket_client.model import (
  37. Person,
  38. ConsumptionType,
  39. Consumption,
  40. ServerStatus,
  41. NetworkError,
  42. Settlement,
  43. )
  44. import piket_client.logger
  45. LOG = logging.getLogger(__name__)
  46. class NameButton(QPushButton):
  47. """ Wraps a QPushButton to provide a counter. """
  48. consumption_created = Signal(Consumption)
  49. def __init__(self, person: Person, active_id: str, *args, **kwargs) -> None:
  50. self.person = person
  51. self.active_id = active_id
  52. super().__init__(self.current_label, *args, **kwargs)
  53. self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
  54. self.consumption_created.connect(self.window().consumption_added)
  55. self.clicked.connect(self.process_click)
  56. self.setContextMenuPolicy(Qt.CustomContextMenu)
  57. self.customContextMenuRequested.connect(self.confirm_hide)
  58. @Slot(str)
  59. def new_active_id(self, new_id: str) -> None:
  60. """ Change the active ConsumptionType id, update the label. """
  61. self.active_id = new_id
  62. self.setText(self.current_label)
  63. @Slot()
  64. def rebuild(self) -> None:
  65. """ Refresh the Person object and the label. """
  66. self.person = self.person.reload() # type: ignore
  67. self.setText(self.current_label)
  68. @property
  69. def current_count(self) -> int:
  70. """ Return the count of the currently active ConsumptionType for this
  71. Person. """
  72. return self.person.consumptions.get(self.active_id, 0)
  73. @property
  74. def current_label(self) -> str:
  75. """ Return the label to show on the button. """
  76. return f"{self.person.name}\n{self.current_count}"
  77. def process_click(self) -> None:
  78. """ Process a click on this button. """
  79. LOG.debug("Button clicked.")
  80. result = self.person.add_consumption(self.active_id)
  81. if result:
  82. self.window().play_plop()
  83. self.setText(self.current_label)
  84. self.consumption_created.emit(result)
  85. else:
  86. LOG.error("Failed to add consumption", extra={"person": self.person})
  87. def confirm_hide(self) -> None:
  88. LOG.debug("Button right-clicked.")
  89. ok = QMessageBox.warning(
  90. self.window(),
  91. "Persoon verbergen?",
  92. f"Wil je {self.person.name} verbergen?",
  93. QMessageBox.Yes,
  94. QMessageBox.Cancel,
  95. )
  96. if ok == QMessageBox.Yes:
  97. LOG.warning("Hiding person %s", self.person.name)
  98. self.person.set_active(False)
  99. self.parent().init_ui()
  100. class NameButtons(QWidget):
  101. """ Main widget responsible for capturing presses and registering them.
  102. """
  103. new_id_set = Signal(str)
  104. def __init__(self, consumption_type_id: str, *args, **kwargs) -> None:
  105. super().__init__(*args, **kwargs)
  106. self.layout = None
  107. self.layout = QGridLayout()
  108. self.setLayout(self.layout)
  109. self.active_consumption_type_id = consumption_type_id
  110. self.init_ui()
  111. @Slot(str)
  112. def consumption_type_changed(self, new_id: str):
  113. """ Process a change of the consumption type and propagate to the
  114. contained buttons. """
  115. LOG.debug("Consumption type updated in NameButtons.", extra={"new_id": new_id})
  116. self.active_consumption_type_id = new_id
  117. self.new_id_set.emit(new_id)
  118. def init_ui(self) -> None:
  119. """ Initialize UI: build GridLayout, retrieve People and build a button
  120. for each. """
  121. LOG.debug("Initializing NameButtons.")
  122. ps = Person.get_all(True)
  123. assert not isinstance(ps, NetworkError)
  124. num_columns = math.ceil(math.sqrt(len(ps)))
  125. if self.layout:
  126. LOG.debug("Removing %s widgets for rebuild", self.layout.count())
  127. for index in range(self.layout.count()):
  128. item = self.layout.itemAt(0)
  129. LOG.debug("Removing item %s: %s", index, item)
  130. if item:
  131. w = item.widget()
  132. LOG.debug("Person %s", w.person)
  133. self.layout.removeItem(item)
  134. w.deleteLater()
  135. for index, person in enumerate(ps):
  136. button = NameButton(person, self.active_consumption_type_id, self)
  137. self.new_id_set.connect(button.new_active_id)
  138. self.layout.addWidget(button, index // num_columns, index % num_columns)
  139. class PiketMainWindow(QMainWindow):
  140. """ QMainWindow subclass responsible for showing the main application
  141. window. """
  142. consumption_type_changed = Signal(str)
  143. plop_loop: Iterator[QSoundEffect]
  144. undo_loop: Iterator[QSoundEffect]
  145. def __init__(self) -> None:
  146. LOG.debug("Initializing PiketMainWindow.")
  147. super().__init__()
  148. self.main_widget = None
  149. self.dark_theme = True
  150. self.toolbar = None
  151. self.osk = None
  152. self.undo_action = None
  153. self.undo_queue: Deque[Consumption] = collections.deque([], 15)
  154. self.init_ui()
  155. def init_ui(self) -> None:
  156. """ Initialize the UI: construct main widget and toolbar. """
  157. # Connect to dbus, get handle to virtual keyboard
  158. if dbus:
  159. try:
  160. session_bus = dbus.SessionBus()
  161. self.osk = session_bus.get_object(
  162. "org.onboard.Onboard", "/org/onboard/Onboard/Keyboard"
  163. )
  164. except dbus.exceptions.DBusException as exception:
  165. # Onboard not present or dbus broken
  166. self.osk = None
  167. LOG.error("Could not connect to Onboard:")
  168. LOG.exception(exception)
  169. else:
  170. LOG.warning("Onboard disabled due to missing dbus.")
  171. # Go full screen
  172. self.setWindowState(Qt.WindowActive | Qt.WindowFullScreen)
  173. font_metrics = self.fontMetrics()
  174. icon_size = font_metrics.height() * 1.45
  175. # Initialize toolbar
  176. self.toolbar = QToolBar()
  177. assert self.toolbar is not None
  178. self.toolbar.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
  179. self.toolbar.setIconSize(QSize(icon_size, icon_size))
  180. # Left
  181. self.toolbar.addAction(
  182. self.load_icon("add_person.svg"), "+ Naam", self.add_person
  183. )
  184. self.undo_action = self.toolbar.addAction(
  185. self.load_icon("undo.svg"), "Oeps", self.do_undo
  186. )
  187. self.undo_action.setDisabled(True)
  188. self.toolbar.addAction(
  189. self.load_icon("quit.svg"), "Afsluiten", self.confirm_quit
  190. )
  191. self.toolbar.addWidget(self.create_spacer())
  192. # Right
  193. self.toolbar.addAction(
  194. self.load_icon("add_consumption_type.svg"),
  195. "Nieuw",
  196. self.add_consumption_type,
  197. )
  198. self.toolbar.setContextMenuPolicy(Qt.PreventContextMenu)
  199. self.toolbar.setFloatable(False)
  200. self.toolbar.setMovable(False)
  201. self.ct_ag: QActionGroup = QActionGroup(self.toolbar)
  202. self.ct_ag.setExclusive(True)
  203. cts = ConsumptionType.get_all()
  204. if not cts:
  205. self.show_keyboard()
  206. name, ok = QInputDialog.getItem(
  207. self,
  208. "Consumptietype toevoegen",
  209. (
  210. "Dit lijkt de eerste keer te zijn dat Piket start. Wat wil je "
  211. "tellen? Je kunt later meer typen toevoegen."
  212. ),
  213. ["Bier", "Wijn", "Cola"],
  214. current=0,
  215. editable=True,
  216. )
  217. self.hide_keyboard()
  218. if ok and name:
  219. c_type = ConsumptionType(name=name)
  220. c_type = c_type.create()
  221. cts.append(c_type)
  222. else:
  223. QMessageBox.critical(
  224. self,
  225. "Kan niet doorgaan",
  226. (
  227. "Je drukte op 'Annuleren' of voerde geen naam in, dus ik"
  228. "sluit af."
  229. ),
  230. )
  231. sys.exit()
  232. for ct in cts:
  233. action = QAction(
  234. self.load_icon(ct.icon or "beer_bottle.svg"), ct.name, self.ct_ag
  235. )
  236. action.setCheckable(True)
  237. action.setData(str(ct.consumption_type_id))
  238. self.ct_ag.actions()[0].setChecked(True)
  239. [self.toolbar.addAction(a) for a in self.ct_ag.actions()]
  240. self.ct_ag.triggered.connect(self.consumption_type_change)
  241. self.addToolBar(self.toolbar)
  242. # Load sounds
  243. plops = [QSoundEffect(self) for _ in range(7)]
  244. for qse in plops:
  245. qse.setSource(QUrl.fromLocalFile(str(PLOP_PATH)))
  246. self.plop_loop = itertools.cycle(plops)
  247. undos = [QSoundEffect(self) for _ in range(5)]
  248. for qse in undos:
  249. qse.setSource(QUrl.fromLocalFile(str(UNDO_PATH)))
  250. self.undo_loop = itertools.cycle(undos)
  251. # Initialize main widget
  252. self.main_widget = NameButtons(self.ct_ag.actions()[0].data(), self)
  253. self.consumption_type_changed.connect(self.main_widget.consumption_type_changed)
  254. self.setCentralWidget(self.main_widget)
  255. @Slot(QAction)
  256. def consumption_type_change(self, action: QAction):
  257. self.consumption_type_changed.emit(action.data())
  258. def show_keyboard(self) -> None:
  259. """ Show the virtual keyboard, if possible. """
  260. if self.osk:
  261. self.osk.Show()
  262. def hide_keyboard(self) -> None:
  263. """ Hide the virtual keyboard, if possible. """
  264. if self.osk:
  265. self.osk.Hide()
  266. def add_person(self) -> None:
  267. """ Ask for a new Person and register it, then rebuild the central
  268. widget. """
  269. inactive_persons = Person.get_all(False)
  270. assert not isinstance(inactive_persons, NetworkError)
  271. inactive_persons.sort(key=lambda p: p.name)
  272. inactive_names = [p.name for p in inactive_persons]
  273. self.show_keyboard()
  274. name, ok = QInputDialog.getItem(
  275. self,
  276. "Persoon toevoegen",
  277. "Voer de naam van de nieuwe persoon in, of kies uit de lijst.",
  278. inactive_names,
  279. 0,
  280. True,
  281. )
  282. self.hide_keyboard()
  283. if ok and name:
  284. if name in inactive_names:
  285. person = inactive_persons[inactive_names.index(name)]
  286. person.set_active(True)
  287. else:
  288. person = Person(full_name=name, display_name=None,)
  289. person.create()
  290. assert self.main_widget is not None
  291. self.main_widget.init_ui()
  292. def add_consumption_type(self) -> None:
  293. self.show_keyboard()
  294. name, ok = QInputDialog.getItem(
  295. self, "Lijst toevoegen", "Wat wil je strepen?", ["Wijn", "Radler"]
  296. )
  297. self.hide_keyboard()
  298. if ok and name:
  299. ct = ConsumptionType(name=name).create()
  300. assert not isinstance(ct, NetworkError)
  301. action = QAction(
  302. self.load_icon(ct.icon or "beer_bottle.svg"), ct.name, self.ct_ag
  303. )
  304. action.setCheckable(True)
  305. action.setData(str(ct.consumption_type_id))
  306. assert self.toolbar is not None
  307. self.toolbar.addAction(action)
  308. def confirm_quit(self) -> None:
  309. """ Ask for confirmation that the user wishes to quit, then do so. """
  310. ok = QMessageBox.warning(
  311. self,
  312. "Wil je echt afsluiten?",
  313. "Bevestig dat je wilt afsluiten.",
  314. QMessageBox.Yes,
  315. QMessageBox.Cancel,
  316. )
  317. if ok == QMessageBox.Yes:
  318. LOG.warning("Shutdown by user.")
  319. QApplication.instance().quit()
  320. def do_undo(self) -> None:
  321. """ Undo the last marked consumption. """
  322. next(self.undo_loop).play()
  323. to_undo = self.undo_queue.pop()
  324. LOG.warning("Undoing consumption %s", to_undo)
  325. result = to_undo.reverse()
  326. if not result or not result.reversed:
  327. LOG.error("Reversed consumption %s but was not reversed!", to_undo)
  328. self.undo_queue.append(to_undo)
  329. elif not self.undo_queue:
  330. assert self.undo_action is not None
  331. self.undo_action.setDisabled(True)
  332. assert self.main_widget is not None
  333. self.main_widget.init_ui()
  334. @Slot(Consumption)
  335. def consumption_added(self, consumption):
  336. """ Mark an added consumption in the queue. """
  337. self.undo_queue.append(consumption)
  338. self.undo_action.setDisabled(False)
  339. @staticmethod
  340. def create_spacer() -> QWidget:
  341. """ Return an empty QWidget that automatically expands. """
  342. spacer = QWidget()
  343. spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
  344. return spacer
  345. icons_dir = os.path.join(os.path.dirname(__file__), "icons")
  346. def load_icon(self, filename: str) -> QIcon:
  347. """ Return a QtIcon loaded from the given `filename` in the icons
  348. directory. """
  349. if self.dark_theme:
  350. filename = "white_" + filename
  351. icon = QIcon(os.path.join(self.icons_dir, filename))
  352. return icon
  353. def play_plop(self) -> None:
  354. next(self.plop_loop).play()
  355. def main() -> None:
  356. """ Main entry point of GUI client. """
  357. LOG.info("Loading piket_client")
  358. app = QApplication(sys.argv)
  359. # Set dark theme
  360. app.setStyleSheet(qdarkstyle.load_stylesheet_pyside2())
  361. # Enlarge font size
  362. font = app.font()
  363. size = font.pointSize()
  364. font.setPointSize(size * 1.5)
  365. app.setFont(font)
  366. # Test connectivity
  367. server_running = ServerStatus.is_server_running()
  368. if isinstance(server_running, NetworkError):
  369. LOG.critical("Could not connect to server, error %s", server_running.value)
  370. QMessageBox.critical(
  371. None,
  372. "Help er is iets kapot",
  373. "Kan niet starten omdat de server niet reageert, stuur een foto van "
  374. "dit naar Maarten: " + repr(server_running.value),
  375. )
  376. return
  377. # Load main window
  378. main_window = PiketMainWindow()
  379. # Test unsettled consumptions
  380. status = ServerStatus.unsettled_consumptions()
  381. assert not isinstance(status, NetworkError)
  382. unsettled = status.amount
  383. if unsettled > 0:
  384. assert status.first_timestamp is not None
  385. first = status.first_timestamp
  386. first_date = first.strftime("%c")
  387. ok = QMessageBox.information(
  388. None,
  389. "Onafgesloten lijst",
  390. f"Wil je verdergaan met een lijst met {unsettled} onafgesloten "
  391. f"consumpties sinds {first_date}?",
  392. QMessageBox.Yes,
  393. QMessageBox.No,
  394. )
  395. if ok == QMessageBox.No:
  396. main_window.show_keyboard()
  397. name, ok = QInputDialog.getText(
  398. None,
  399. "Lijst afsluiten",
  400. "Voer een naam in voor de lijst of druk op OK. Laat de datum staan.",
  401. QLineEdit.Normal,
  402. f"{first.strftime('%Y-%m-%d')}",
  403. )
  404. main_window.hide_keyboard()
  405. if name and ok:
  406. settlement = Settlement.create(name)
  407. info = [
  408. f'{item["count"]} {item["name"]}'
  409. for item in settlement.consumption_summary.values()
  410. ]
  411. info2 = ", ".join(info)
  412. QMessageBox.information(
  413. None, "Lijst afgesloten", f"VO! Op deze lijst stonden: {info2}"
  414. )
  415. main_window = PiketMainWindow()
  416. main_window.show()
  417. # Let's go
  418. LOG.info("Starting QT event loop.")
  419. app.exec_()
  420. if __name__ == "__main__":
  421. main()