Digitale bierlijst

gui.py 16KB

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