A => .gitignore +6 -0
@@ 1,6 @@
+pythondir
+demo.py
+barcode.png
+**/__pycache__
+build/
+.flatpak-builder
A => choochoo/__init__.py +0 -0
A => choochoo/application.py +17 -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()
A => choochoo/deutschlandticket.py +95 -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()
A => choochoo/home.py +166 -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)
A => choochoo/ticketview.py +122 -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)
A => choochoo/utils.py +60 -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)
A => choochoo/widgets/daterow.py +30 -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)
A => choochoo/widgets/iconbutton.py +25 -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)
A => choochoo/window.py +72 -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)
A => main.py +13 -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)