~comcloudway/choochoo

4fbd3073e220c111dfbe8512886238f22b4252ec — Jakob Meier 5 months ago
basic app implementation
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)