From 4fbd3073e220c111dfbe8512886238f22b4252ec Mon Sep 17 00:00:00 2001 From: Jakob Meier Date: Sat, 23 Mar 2024 08:58:09 +0100 Subject: [PATCH] basic app implementation --- .gitignore | 6 ++ choochoo/__init__.py | 0 choochoo/application.py | 17 ++++ choochoo/deutschlandticket.py | 95 +++++++++++++++++++ choochoo/home.py | 166 +++++++++++++++++++++++++++++++++ choochoo/ticketview.py | 122 ++++++++++++++++++++++++ choochoo/utils.py | 60 ++++++++++++ choochoo/widgets/daterow.py | 30 ++++++ choochoo/widgets/iconbutton.py | 25 +++++ choochoo/window.py | 72 ++++++++++++++ main.py | 13 +++ 11 files changed, 606 insertions(+) create mode 100644 .gitignore create mode 100644 choochoo/__init__.py create mode 100644 choochoo/application.py create mode 100644 choochoo/deutschlandticket.py create mode 100644 choochoo/home.py create mode 100644 choochoo/ticketview.py create mode 100644 choochoo/utils.py create mode 100644 choochoo/widgets/daterow.py create mode 100644 choochoo/widgets/iconbutton.py create mode 100644 choochoo/window.py create mode 100644 main.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9eb43ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +pythondir +demo.py +barcode.png +**/__pycache__ +build/ +.flatpak-builder diff --git a/choochoo/__init__.py b/choochoo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/choochoo/application.py b/choochoo/application.py new file mode 100644 index 0000000..8e64b19 --- /dev/null +++ b/choochoo/application.py @@ -0,0 +1,17 @@ +import gi +gi.require_version('Gtk', '4.0') +gi.require_version('Adw', '1') +from gi.repository import Gtk, Adw, Gio, GLib + +from choochoo.window import MainWindow + +class Application(Adw.Application): + def __init__(self, **kwargs): + super().__init__(**kwargs) + # set name + GLib.set_application_name("ChooChoo") + self.connect('activate', self.on_activate) + + def on_activate(self, app): + self.win = MainWindow(application=app) + self.win.present() diff --git a/choochoo/deutschlandticket.py b/choochoo/deutschlandticket.py new file mode 100644 index 0000000..24e8145 --- /dev/null +++ b/choochoo/deutschlandticket.py @@ -0,0 +1,95 @@ +import requests +from datetime import datetime, date +import os +import json +from choochoo.utils import get_data_dir +from typing import Self + +class Ticket(): + id: str + subscriber_id: str + subscription_id: str + first_name: str + last_name: str + birthdate: date + barcode: str + issued_at: datetime + valid_from: datetime + valid_until: datetime + price: int + product_id: str + wallet_code: str + ticket_number: str + # store raw json data as well + # so we do not have to perform serialization + raw: str + + def __init__(self, json_data): + self.raw = json_data + self.id = json_data["id"] + self.subscriber_id = json_data["subscriber_id"] + self.subscription_id = json_data["subscription_id"] + self.first_name = json_data["first_name"] + self.last_name = json_data["last_name"] + self.birthdate = date.fromisoformat(json_data["birthdate"]) + self.barcode = json_data["barcode_code"] + self.issued_at = datetime.fromisoformat(json_data["issued_at"]) + self.valid_from = datetime.fromisoformat(json_data["valid_from"]) + self.valid_until = datetime.fromisoformat(json_data["valid_until"]) + self.price = json_data["price"] + self.product_id = json_data["product_id"] + self.wallet_code = json_data["wallet_code"] + self.ticket_number = json_data["ticket_number"] + + def list() -> list[Self]: + base = get_data_dir() + candidates = [ f for f in os.listdir(base) if os.path.isfile(os.path.join(base, f)) and f.endswith(".json")] + results = [] + for f in candidates: + path = os.path.join(base, f) + ticket = Ticket.from_file(path) + if not ticket is None: + results.append(ticket) + return results + + def from_id(fid: str) -> (None | Self): + base = get_data_dir() + path = os.path.join(base, f"{fid}.json") + return Ticket.from_file(path) + + def from_file(path: str) -> (None | Self): + handle = open(path, "r") + try: + cont = handle.read() + data = json.loads(cont) + return Ticket(data) + finally: + handle.close() + return None + + def from_network(tnumber: str, tsurname: str) -> (None | Self): + url = f"https://deutschlandticket.de/api/v2/subscription/tickets/{tnumber}?surname={tsurname}" + r = requests.get(url, headers={ + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0", + }) + if r.ok: + try: + return Ticket(r.json()) + except Exception as e: + return None + return None + + def get_id(self): + date = self.valid_from + return f"DTicket_{date.year}-{date.month:02}_{self.first_name}-{self.last_name}" + + def save(self): + tid = self.get_id() + name = f"{tid}.json" + fpath = os.path.join(get_data_dir(), name) + handle = open(fpath, "w", encoding="utf-8") + text = json.dumps(self.raw) + try: + handle.write(text) + finally: + handle.close() diff --git a/choochoo/home.py b/choochoo/home.py new file mode 100644 index 0000000..9edaf70 --- /dev/null +++ b/choochoo/home.py @@ -0,0 +1,166 @@ +import gi +gi.require_version('Gtk', '4.0') +gi.require_version('Adw', '1') +from gi.repository import Gtk, Adw, Gio, GLib +from datetime import datetime, timezone +from gettext import gettext as _ + +from choochoo.widgets.iconbutton import IconButton +from choochoo.utils import pass_me, get_data_dir, get_month_name +from choochoo.deutschlandticket import Ticket + +class HomeScreen(Adw.NavigationPage): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.set_title(_("Tickets")) + self.view = Adw.ToolbarView() + self.header_bar = Adw.HeaderBar() + # add browse servers button + self.add_server_button = IconButton("", "list-add-symbolic", tooltip=_("Add Ticket")) + self.add_server_button.connect("clicked", lambda _: self.add_ticket_dialog()) + self.header_bar.pack_start(self.add_server_button) + self.reload_button = IconButton("", "view-refresh-symbolic", tooltip=_("Refresh Tickets")) + self.reload_button.connect("clicked", lambda _: self.update()) + # add menu to top bar + self.menu_button = Gtk.MenuButton() + self.menu_button.set_icon_name("open-menu-symbolic") + model = Gio.Menu() + model.append(_("About"), "win.about") + self.menu_popover = Gtk.PopoverMenu() + self.menu_popover.set_menu_model(model) + self.menu_button.set_popover(self.menu_popover) + self.header_bar.pack_end(self.menu_button) + self.header_bar.pack_end(self.reload_button) + self.view.add_top_bar(self.header_bar) + self.overlay = Adw.ToastOverlay() + self.set_child(self.view) + self.view.set_content(self.overlay) + self.update() + def add_ticket_dialog(self): + diag = Adw.AlertDialog() + # set dialog cta + diag.set_heading(_("Add Ticket")) + diag.set_body(_("The following information is required to add a ticket")) + # dialog input fields + inp_group = Adw.PreferencesGroup() + tnumber = Adw.EntryRow() + tnumber.set_title(_("Ticket number (format: DTxxxxxx)")) + tsurname = Adw.EntryRow() + tsurname.set_title(_("Last name (as shown on ticket)")) + inp_group.add(tnumber) + inp_group.add(tsurname) + diag.set_extra_child(inp_group) + # setup dialog actions + diag.add_response("cancel", _("Cancel")) + diag.add_response("add", _("Add")) + diag.set_close_response("cancel") + diag.set_default_response("cancel") + diag.set_response_appearance("cancel", Adw.ResponseAppearance.DESTRUCTIVE) + diag.set_response_appearance("add", Adw.ResponseAppearance.SUGGESTED) + # show dialog + diag.choose(self, None, pass_me(self.add_ticket_dialog_finish, tnumber, tsurname)) + def show_toast(self, title): + toast = Adw.Toast() + toast.set_title(title) + toast.set_timeout(2) + self.overlay.add_toast(toast) + def add_ticket_dialog_finish(self, diag, ready, tnumber, tsurname): + action = diag.choose_finish(ready) + if action == "add": + # show loading toast + GLib.idle_add(self.show_toast, _("Fetching ticket information")) + # fetch ticket data + tnum = tnumber.get_text() + tsur = tsurname.get_text() + ticket = Ticket.from_network(tnum, tsur) + if ticket is None: + GLib.idle_add(self.show_toast, _("Unable to add the ticket.")) + return + # save ticket + ticket.save() + GLib.idle_add(self.show_toast, _("Done")) + self.update() + def update(self): + # show spinner + # NOTE: not quite sure if this is ever visible + spinner = Gtk.Spinner() + spinner.set_size_request(50, 50) + spinner.start() + cb = Gtk.CenterBox() + cb.set_center_widget(spinner) + self.overlay.set_child(cb) + # load tickets + tickets = Ticket.list() + if not tickets: + # show empty screen + status = Adw.StatusPage() + status.set_title(_("*crickets chirping*")) + status.set_description(_("You haven't added any tickets")) + status.set_icon_name("weather-few-clouds-night-symbolic") + icon_button = IconButton(_("Add Ticket"), "list-add-symbolic") + icon_button.connect("clicked", lambda _: self.add_ticket_dialog()) + icon_button.set_margin_bottom(6) + box = Gtk.CenterBox() + box.set_center_widget(icon_button) + status.set_child(box) + self.overlay.set_child(status) + else: + # show ticket list + clamp = Adw.Clamp() + self.overlay.set_child(clamp) + page = Adw.PreferencesPage() + clamp.set_child(page) + + # add groups so we can dynamically add items to them + group_valid = Adw.PreferencesGroup() + group_valid.set_title(_("Valid")) + group_valid.set_description(_("These tickets are currently valid")) + + group_future = Adw.PreferencesGroup() + group_future.set_title(_("Future")) + group_future.set_description(_("These tickets are not yet valid. They will be automatically moved to valid tickets.")) + + group_invalid = Adw.PreferencesGroup() + group_invalid.set_title(_("Invalid")) + group_invalid.set_description(_("These tickets are no longer valid. You can delete them")) + + has_future = False + has_valid = False + has_invalid = False + + # sort tickets (by valid_from) + tickets.sort(key=lambda t: t.valid_from) + # loop through tickets + for ticket in tickets: + row = Adw.ActionRow() + # XXX: should this be translatable? maybe different languages have a different order? + row.set_title(f"{ticket.first_name} {ticket.last_name}") + row.set_subtitle(_("Active for {month_name} {year}").format(month_name = get_month_name(ticket.valid_from.month), year = ticket.valid_from.year)) + row.set_action_name("win.view_ticket") + row.set_action_target_value(GLib.Variant("s", ticket.get_id())) + row.set_activatable(True) + row.add_suffix(Gtk.Image.new_from_icon_name("go-next-symbolic")) + now = datetime.now(timezone.utc) + if ticket.valid_from > now: + # future + group_future.add(row) + has_future = True + elif ticket.valid_until < now: + # past + group_invalid.add(row) + has_invalid = True + else: + # ticket is currently valid + group_valid.add(row) + has_valid = True + # only show groups that have content + # NOTE: these should never all three be False, + # because that would imply that there are no tickets, + # but this code can only be reached if there is at least one ticket + if has_valid: + page.add(group_valid) + if has_future: + page.add(group_future) + if has_invalid: + page.add(group_invalid) + self.overlay.set_child(clamp) diff --git a/choochoo/ticketview.py b/choochoo/ticketview.py new file mode 100644 index 0000000..e63d12d --- /dev/null +++ b/choochoo/ticketview.py @@ -0,0 +1,122 @@ +import gi +gi.require_version('Gtk', '4.0') +gi.require_version('Adw', '1') +from gi.repository import Gtk, Adw, Gio, GLib, Gdk +from gettext import gettext as _ +import os +import io +import base64 +import treepoem + +from choochoo.widgets.iconbutton import IconButton +from choochoo.widgets.daterow import DateRow +from choochoo.deutschlandticket import Ticket + +class TicketScreen(Adw.NavigationPage): + ticket: Ticket = None + def __init__(self, ticket: Ticket, *args, **kwargs): + super().__init__(*args, **kwargs) + self.set_title(_("My Ticket")) + self.ticket = ticket + + self.view = Adw.ToolbarView() + self.header_bar = Adw.HeaderBar() + self.reload_button = IconButton("", "view-refresh-symbolic", tooltip=_("Refresh Tickets")) + self.reload_button.connect("clicked", lambda _: self.fetch()) + # TODO: delete button + self.header_bar.pack_end(self.reload_button) + self.view.add_top_bar(self.header_bar) + self.overlay = Adw.ToastOverlay() + self.set_child(self.view) + self.view.set_content(self.overlay) + self.update() + + def generate_code(self): + barcode = self.ticket.barcode + + buf = base64.b64decode(barcode) + bin = buf.decode("latin-1") + + img = treepoem.generate_barcode( + barcode_type="azteccode", + data=bin, + options={ "binaryText": True }) + return img.convert("1") + + def show_code(self): + img = self.generate_code() + f = io.BytesIO() + img.save(f, format="png") + buf = GLib.Bytes.new(f.getvalue()) + texture = Gdk.Texture.new_from_bytes(buf) + pic = Gtk.Picture.new_for_paintable(texture) + pic.set_keep_aspect_ratio(True) + pic.set_can_shrink(True) + pic.set_margin_top(12) + pic.set_margin_bottom(12) + pic.set_margin_start(12) + pic.set_margin_end(12) + self.qrbox.set_child(pic) + + def update(self): + page = Gtk.Box.new(Gtk.Orientation.VERTICAL,12) + code_group = Adw.PreferencesGroup() + code_group.set_title(_("D-Ticket")) + code_group.set_description(_("Show this at the ticket inspection")) + spinner = Gtk.Spinner() + spinner.set_size_request(32, 32) + spinner.start() + spinner.set_margin_top(12) + spinner.set_margin_bottom(12) + cb = Gtk.CenterBox() + self.qrbox = Adw.Bin() + self.qrbox.add_css_class("card") + self.qrbox.set_child(spinner) + cb.set_center_widget(self.qrbox) + # TODO: this takes a while, consider starting a separate thread + self.show_code() + page.append(code_group) + page.append(cb) + connection_group = Adw.PreferencesGroup() + connection_group.set_title(_("Ticket details")) + connection_group.set_description(_("Validity and payment information")) + # show issued on, valid from/until rows + issued_row = DateRow(_("Issued on"), self.ticket.issued_at) + connection_group.add(issued_row) + from_row = DateRow(_("Valid from"), self.ticket.valid_from) + connection_group.add(from_row) + until_row = DateRow(_("Valid until"), self.ticket.valid_until) + connection_group.add(until_row) + page.append(connection_group) + personal_group = Adw.PreferencesGroup() + personal_group.set_title(_("Personal details")) + personal_group.set_description(_("Who is traveling")) + # show person name + birthday + name_row = Adw.ActionRow() + name_row.set_title(_("Name")) + name_label = Gtk.Label() + # XXX: should this be translatable? maybe different languages have a different order? + name_label.set_label(f"{self.ticket.first_name} {self.ticket.last_name}") + name_row.add_suffix(name_label) + personal_group.add(name_row) + birthday_row = DateRow(_("Birthdate"), self.ticket.birthdate) + personal_group.add(birthday_row) + page.append(personal_group) + hint_group = Adw.PreferencesGroup() + hint_group.set_title(_("Important")) + hint_group.set_description(_("Only valid with an offical ID (e.g identity card). This must be shown at the inspection.")) + # terms of services + terms_row = Adw.ActionRow() + terms_row.set_title(_("The terms and conditions of the respective carrier apply")) + terms_row.set_subtitle(_("You can find these under: {url}").format(url = "www.DieBefoerderer.de")) + terms_row.add_suffix(Gtk.Image.new_from_icon_name("go-next-symbolic")) + # open DieBeförderer.de in browser on click + terms_row.connect("activated", lambda _:Gtk.UriLauncher.new(uri="https://www.diebefoerderer.de/").launch()) + terms_row.set_activatable(True) + hint_group.add(terms_row) + page.append(hint_group) + clamp = Adw.Clamp() + scroll = Gtk.ScrolledWindow() + scroll.set_child(page) + clamp.set_child(scroll) + self.overlay.set_child(clamp) diff --git a/choochoo/utils.py b/choochoo/utils.py new file mode 100644 index 0000000..d378f18 --- /dev/null +++ b/choochoo/utils.py @@ -0,0 +1,60 @@ +import gi +gi.require_version('Gdk', '4.0') +from gi.repository import Gio,GLib +from functools import cache +import os +from gettext import gettext as _ + +def is_flatpak(): + return os.path.exists(os.path.join(GLib.get_user_runtime_dir(), 'flatpak-info')) + +@cache +def get_data_dir(): + data_dir_path = GLib.get_user_data_dir() + + if not is_flatpak(): + base_path = data_dir_path + data_dir_path = os.path.join(base_path, 'choochoo') + + if not os.path.exists(data_dir_path): + os.mkdir(data_dir_path) + + return data_dir_path + +def pass_me(func, *args): + """ + Pass additional arguments to lambda functions + """ + return lambda *a: func(*a, *args) +def many(*funcs): + """ + Make multiple function steps easier to read + """ + return [funcs] + +def get_month_name(month:int) -> str: + if month == 1: + return "January" + if month == 2: + return "February" + if month == 3: + return "March" + if month == 4: + return "April" + if month == 5: + return "May" + if month == 6: + return "June" + if month == 7: + return "July" + if month == 8: + return "August" + if month == 9: + return "September" + if month == 10: + return "October" + if month == 11: + return "November" + if month == 12: + return "December" + return str(month) diff --git a/choochoo/widgets/daterow.py b/choochoo/widgets/daterow.py new file mode 100644 index 0000000..f776f45 --- /dev/null +++ b/choochoo/widgets/daterow.py @@ -0,0 +1,30 @@ +import sys +import gi +gi.require_version('Gtk', '4.0') +gi.require_version('Adw', '1') +from gi.repository import Gtk, Adw +from datetime import date, datetime +from gettext import gettext as _ + +class DateRow(Adw.ActionRow): + def __init__(self, title, stamp:(date | datetime), subtitle=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.set_title(title) + if not subtitle is None: + self.set_subtitle(subtitle) + self.suf = Gtk.Label() + if isinstance(stamp, datetime): + self.suf.set_label(_("{day:02}.{month:02}.{year} {hour:02}:{minute:02}").format( + day = stamp.day, + month = stamp.month, + year = stamp.year, + hour = stamp.hour, + minute = stamp.minute + )) + elif isinstance(stamp, date): + self.suf.set_label(_("{day:02}.{month:02}.{year}").format( + day = stamp.day, + month = stamp.month, + year = stamp.year, + )) + self.add_suffix(self.suf) diff --git a/choochoo/widgets/iconbutton.py b/choochoo/widgets/iconbutton.py new file mode 100644 index 0000000..f0b4b12 --- /dev/null +++ b/choochoo/widgets/iconbutton.py @@ -0,0 +1,25 @@ +import sys +import gi +gi.require_version('Gtk', '4.0') +gi.require_version('Adw', '1') +from gi.repository import Gtk, Adw + +class IconButton(Gtk.Button): + def __init__(self, name, icon, tooltip=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.inner = Adw.ButtonContent() + self.inner.set_can_shrink(True) + self.set_can_shrink(True) + self.set_child(self.inner) + self.update(name, icon, tooltip) + def set_icon(self, icon): + self.inner.set_icon_name(icon) + def set_name(self, name): + self.inner.set_label(name) + def set_tooltip(self, tooltip): + self.set_tooltip_text(tooltip) + def update(self, name, icon, tooltip=None): + self.set_name(name) + self.set_icon(icon) + if not tooltip is None: + self.set_tooltip(tooltip) diff --git a/choochoo/window.py b/choochoo/window.py new file mode 100644 index 0000000..bfadfcc --- /dev/null +++ b/choochoo/window.py @@ -0,0 +1,72 @@ +import sys +import gi +gi.require_version('Gtk', '4.0') +gi.require_version('Adw', '1') +from gi.repository import Gtk, Adw, Gio, GLib +from gettext import gettext as _ + +from choochoo.home import HomeScreen +from choochoo.ticketview import TicketScreen +from choochoo.deutschlandticket import Ticket + +class MainWindow(Adw.ApplicationWindow): + # Opens the about app screen + def open_about(self,action,prefs): + dialog = Adw.AboutWindow() + dialog.set_application_name("ChooChoo") + dialog.set_version("0.1.0") + dialog.set_developer_name("Jakob Meier (@comcloudway)") + dialog.set_license_type(Gtk.License(Gtk.License.GPL_3_0)) + dialog.set_comments(_("Unofficial DeutschlandTicket App")) + dialog.set_website("https://codeberg.org/comcloudway/ChooChoo") + dialog.set_issue_url("https://codeberg.org/comcloudway/ChooChoo/issues") + #dialog.add_credit_section("Contributors", ["Name1 url"]) + #dialog.set_translator_credits("Name1 url") + dialog.set_copyright("© 2024 Jakob Meier (@comcloudway)") + dialog.set_developers(["Jakob Meier (@comcloudway)"]) + # NOTE: icon must be uploaded in ~/.local/share/icons or /usr/share/icons + dialog.set_application_icon("icu.ccw.ChooChoo") + dialog.set_visible(True) + + # navigate back to the home screen + def go_home(self,action,prefs): + self.view.pop_to_page(self.home) + self.home.go_home() + + def view_ticket(self, action, prefs): + fid = prefs[:] + ticket = Ticket.from_id(fid) + self.view.push(TicketScreen(ticket)) + + def __init__(self, application, *args, **kwargs): + super().__init__(application=application,*args, **kwargs) + self.view = Adw.NavigationView() + self.set_content(self.view) + self.home = HomeScreen() + self.view.add(self.home) + + # when using breakpoints, we manually have to request a size + self.set_default_size(width=int(1366 / 2), height=int(768 / 2)) + self.set_size_request(width=360, height=200) + + # TODO: consider removing this + # configure breakpoints for each subscreen + self.breakpoint = Adw.Breakpoint() + self.breakpoint.set_condition(Adw.BreakpointCondition.parse("max-width: 550sp")) + self.add_breakpoint(self.breakpoint) + + # Add actions + self.reg_action("about", self.open_about) + self.reg_action("home", self.go_home) + self.reg_action("view_ticket", self.view_ticket, variant="s") + + def reg_action(self, name, func, variant=None, target=None): + vtype = None + if not variant is None: + vtype = GLib.VariantType.new(variant) + act = Gio.SimpleAction.new(name, vtype) + act.connect("activate", func) + if target is None: + self.add_action(act) + else: + target.add_action(act) diff --git a/main.py b/main.py new file mode 100644 index 0000000..4b2e696 --- /dev/null +++ b/main.py @@ -0,0 +1,13 @@ +# manual quick-run script +# NOTE: translations do not work here +import sys +from choochoo.application import Application +Application.application_id = "icu.ccw.choochoo" +app = Application() + +try: + status = app.run(sys.argv) +except SystemExit as e: + status = e.code + +sys.exit(status) -- 2.38.5