usawa

Signed, immutable accounting.
Info | Log | Files | Refs | Submodules | LICENSE

entry_details_view.py (19322B)


      1 import logging
      2 import threading
      3 from gi.repository import Gtk, Adw, Pango, Gdk, GdkPixbuf, GLib, Gio
      4 import threading
      5 import tempfile
      6 import subprocess
      7 import logging
      8 from datetime import datetime
      9 
     10 
     11 logg = logging.getLogger("gui.entry_details_view")
     12 
     13 
     14 def create_entry_details_page(
     15     entry, nav_view, entry_controller, toast_overlay, fetch_fn
     16 ):
     17     """Create an entry details page for the navigation stack"""
     18     page = Adw.NavigationPage(title="Entry Details", tag=f"entry-{entry.serial}")
     19     view = EntryDetailsView(entry, nav_view, entry_controller, toast_overlay, fetch_fn)
     20     page.set_child(view)
     21     return page
     22 
     23 
     24 class EntryDetailsView(Gtk.Box):
     25     """Entry details view"""
     26 
     27     def __init__(self, entry, nav_view, entry_controller, toast_overlay, fetch_fn):
     28         super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0)
     29         self.entry = entry
     30         self.nav_view = nav_view
     31         self.entry_controller = entry_controller
     32         self.toast_overlay = toast_overlay
     33         self.fetch_fn = fetch_fn
     34         self._build_ui()
     35 
     36     def _build_ui(self):
     37         self.append(self._create_header())
     38 
     39         scrolled = Gtk.ScrolledWindow()
     40         scrolled.set_vexpand(True)
     41         scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
     42 
     43         content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20)
     44         content.set_margin_top(20)
     45         content.set_margin_bottom(20)
     46         content.set_margin_start(20)
     47         content.set_margin_end(20)
     48 
     49         content.append(self._create_entry_details_section())
     50         content.append(self._create_transaction_section())
     51         content.append(self._create_attachments_section())
     52 
     53         scrolled.set_child(content)
     54         self.append(scrolled)
     55 
     56     def _create_header(self):
     57         header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
     58         header_box.set_margin_top(12)
     59         header_box.set_margin_bottom(12)
     60         header_box.set_margin_start(12)
     61         header_box.set_margin_end(12)
     62         header_box.add_css_class("toolbar")
     63 
     64         back_btn = Gtk.Button()
     65         back_btn.set_icon_name("go-previous-symbolic")
     66         back_btn.add_css_class("flat")
     67         back_btn.connect("clicked", lambda b: self.nav_view.pop())
     68         header_box.append(back_btn)
     69 
     70         spacer = Gtk.Box()
     71         spacer.set_hexpand(True)
     72         header_box.append(spacer)
     73 
     74         export_btn = Gtk.Button()
     75         export_btn.set_label("Export")
     76         export_btn.set_tooltip_text("Export this entry to XML")
     77         export_btn.add_css_class("suggested-action")
     78         export_btn.connect("clicked", self._on_export_clicked)
     79         header_box.append(export_btn)
     80 
     81         return header_box
     82 
     83     def _create_entry_details_section(self):
     84         section_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
     85 
     86         header = Gtk.Label(label="ENTRY DETAILS")
     87         header.set_halign(Gtk.Align.START)
     88         header.add_css_class("heading")
     89         section_box.append(header)
     90 
     91         grid = Gtk.Grid()
     92         grid.set_column_spacing(16)
     93         grid.set_row_spacing(12)
     94         grid.set_column_homogeneous(True)
     95 
     96         _add_field_to_grid(grid, "Serial number", str(self.entry.serial), 0, 0)
     97         _add_field_to_grid(grid, "Transaction reference(uuid)", self.entry.tx_ref, 0, 1)
     98         _add_field_to_grid(grid, "Transaction date", self.entry.tx_date, 1, 0)
     99         _add_field_to_grid(
    100             grid,
    101             "Date registered",
    102             (
    103                 self.entry.tx_date_rg.strftime("%Y-%m-%d %H:%M:%S")
    104                 if self.entry.tx_date_rg
    105                 else ""
    106             ),
    107             1,
    108             1,
    109         )
    110 
    111         parent_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
    112         parent_label = Gtk.Label(label="Parent Digest")
    113         parent_label.set_halign(Gtk.Align.START)
    114         parent_label.add_css_class("dim-label")
    115         parent_label.add_css_class("caption")
    116         parent_box.append(parent_label)
    117 
    118         parent_value = Gtk.Label(label=self.entry.parent_digest)
    119         parent_value.set_halign(Gtk.Align.START)
    120         parent_value.set_selectable(True)
    121         parent_value.set_wrap(False)
    122         parent_value.set_ellipsize(Pango.EllipsizeMode.END)
    123         parent_value.set_max_width_chars(70)
    124         parent_value.add_css_class("monospace")
    125         parent_box.append(parent_value)
    126 
    127         grid.attach(parent_box, 0, 2, 2, 1)
    128         signer_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
    129         signer_label = Gtk.Label(label="Signers")
    130         signer_label.set_halign(Gtk.Align.START)
    131         signer_label.add_css_class("dim-label")
    132         signer_label.add_css_class("caption")
    133         signer_box.append(signer_label)
    134 
    135         signers = self.entry.signers_raw
    136 
    137         if len(signers) == 1:
    138             pubkey = signers[0]
    139             short_key = f"{pubkey[:8]}...{pubkey[-6:]}"
    140 
    141             signer_value = Gtk.Label(label=short_key)
    142             signer_value.set_halign(Gtk.Align.START)
    143             signer_value.set_selectable(True)
    144             signer_value.set_tooltip_text(pubkey)
    145             signer_value.add_css_class("monospace")
    146 
    147             signer_box.append(signer_value)
    148 
    149         elif len(signers) > 1:
    150             expander = Gtk.Expander(label=f"{len(signers)} signers")
    151 
    152             key_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
    153 
    154             for pubkey in signers:
    155                 short_key = f"{pubkey[:8]}...{pubkey[-6:]}"
    156                 key_label = Gtk.Label(label=short_key)
    157                 key_label.set_halign(Gtk.Align.START)
    158                 key_label.set_selectable(True)
    159                 key_label.set_tooltip_text(pubkey)
    160                 key_label.add_css_class("monospace")
    161                 key_list.append(key_label)
    162 
    163             expander.set_child(key_list)
    164             signer_box.append(expander)
    165         else:
    166             none_label = Gtk.Label(label="No signatures")
    167             none_label.set_halign(Gtk.Align.START)
    168             signer_box.append(none_label)
    169         grid.attach(signer_box, 0, 3, 1, 1)
    170 
    171         auth_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
    172         auth_label = Gtk.Label(label="Authentication State")
    173         auth_label.set_halign(Gtk.Align.START)
    174         auth_label.add_css_class("dim-label")
    175         auth_label.add_css_class("caption")
    176         auth_box.append(auth_label)
    177 
    178         auth_badge = _create_auth_badge(self.entry.auth_state)
    179         auth_badge.set_halign(Gtk.Align.START)
    180         auth_box.append(auth_badge)
    181         grid.attach(auth_box, 1, 3, 1, 1)
    182 
    183         desc_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
    184         desc_label = Gtk.Label(label="Description")
    185         desc_label.set_halign(Gtk.Align.START)
    186         desc_label.add_css_class("dim-label")
    187         desc_label.add_css_class("caption")
    188         desc_box.append(desc_label)
    189 
    190         desc_value = Gtk.Label(label=self.entry.description)
    191         desc_value.set_halign(Gtk.Align.START)
    192         desc_value.set_wrap(True)
    193         desc_box.append(desc_value)
    194         grid.attach(desc_box, 0, 4, 2, 1)
    195 
    196         section_box.append(grid)
    197         return section_box
    198 
    199     def _create_transaction_section(self):
    200         section_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
    201 
    202         header = Gtk.Label(label="TRANSACTION DETAILS")
    203         header.set_halign(Gtk.Align.START)
    204         header.add_css_class("heading")
    205         section_box.append(header)
    206 
    207         amount_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
    208         amount_label = Gtk.Label(label="Amount")
    209         amount_label.set_halign(Gtk.Align.START)
    210         amount_label.add_css_class("dim-label")
    211         amount_label.add_css_class("caption")
    212         amount_box.append(amount_label)
    213 
    214         amount_value = Gtk.Label(label=self.entry.amount)
    215         amount_value.set_halign(Gtk.Align.START)
    216         amount_value.add_css_class("title-1")
    217         amount_box.append(amount_value)
    218         section_box.append(amount_box)
    219 
    220         ledger_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=16)
    221         ledger_box.set_homogeneous(True)
    222 
    223         logg.debug("Created EntryItem in TX Section: %s", self.entry)
    224 
    225         ledger_box.append(
    226             _create_ledger_card(
    227                 "Source",
    228                 self.entry.source_unit,
    229                 self.entry.source_type,
    230                 self.entry.source_path,
    231                 is_source=True,
    232             )
    233         )
    234         ledger_box.append(
    235             _create_ledger_card(
    236                 "Destination",
    237                 self.entry.dest_unit,
    238                 self.entry.dest_type,
    239                 self.entry.dest_path,
    240                 is_source=False,
    241             )
    242         )
    243 
    244         section_box.append(ledger_box)
    245         return section_box
    246 
    247     def _create_attachments_section(self):
    248         section_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
    249 
    250         header = Gtk.Label(label="ATTACHMENTS")
    251         header.set_halign(Gtk.Align.START)
    252         header.add_css_class("heading")
    253         section_box.append(header)
    254 
    255         attachments_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
    256 
    257         if self.entry.attachments_raw:
    258             for asset in self.entry.attachments_raw:
    259                 attach_card = _create_attachment_card(
    260                     asset.slug or "Unnamed",
    261                     asset.mime or "unknown",
    262                     on_click=lambda f, a=asset: _open_attachment_viewer(
    263                         self.get_root(), a, fetch_fn=self.fetch_fn
    264                     ),
    265                 )
    266                 attachments_box.append(attach_card)
    267 
    268         section_box.append(attachments_box)
    269         return section_box
    270 
    271     def _on_export_clicked(self, button):
    272         """Handle export button click"""
    273         logg.info(f"Export entry #{self.entry.serial} clicked")
    274 
    275         file_dialog = Gtk.FileDialog()
    276         file_dialog.set_title(f"Export Entry #{self.entry.serial}")
    277 
    278         default_name = f"entry_{self.entry.serial:05d}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xml"
    279         file_dialog.set_initial_name(default_name)
    280 
    281         filters = Gio.ListStore.new(Gtk.FileFilter)
    282 
    283         xml_filter = Gtk.FileFilter()
    284         xml_filter.set_name("XML Files")
    285         xml_filter.add_pattern("*.xml")
    286         filters.append(xml_filter)
    287 
    288         all_filter = Gtk.FileFilter()
    289         all_filter.set_name("All Files")
    290         all_filter.add_pattern("*")
    291         filters.append(all_filter)
    292 
    293         file_dialog.set_filters(filters)
    294         file_dialog.set_default_filter(xml_filter)
    295 
    296         file_dialog.save(parent=self.get_root(), callback=self._on_export_file_selected)
    297 
    298     def _on_export_file_selected(self, dialog, result):
    299         """Handle file selection for entry export"""
    300         try:
    301             file = dialog.save_finish(result)
    302 
    303             if file:
    304                 file_path = file.get_path()
    305                 logg.info(f"Exporting entry #{self.entry.serial} to: {file_path}")
    306 
    307                 success, error_msg = self.entry_controller.export_entry(
    308                     self.entry.serial, file_path
    309                 )
    310 
    311                 if success:
    312                     self._show_success_toast(
    313                         f"Entry #{self.entry.serial} exported to {file_path}"
    314                     )
    315                 else:
    316                     self._show_error_dialog("Export Failed", error_msg)
    317 
    318         except Exception as e:
    319             logg.debug(f"Export cancelled or failed: {e}")
    320 
    321     def _show_success_toast(self, message):
    322         """Show success toast notification"""
    323         toast = Adw.Toast.new(message)
    324         toast.set_timeout(3)
    325         self.toast_overlay.add_toast(toast)
    326 
    327 
    328 def _add_field_to_grid(grid, label_text, value_text, row, col):
    329     """Helper to add a field to the grid"""
    330     field_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
    331 
    332     label = Gtk.Label(label=label_text)
    333     label.set_halign(Gtk.Align.START)
    334     label.add_css_class("dim-label")
    335     label.add_css_class("caption")
    336     field_box.append(label)
    337 
    338     value = Gtk.Label(label=value_text)
    339     value.set_halign(Gtk.Align.START)
    340     value.add_css_class("monospace")
    341     value.set_selectable(True)
    342     field_box.append(value)
    343 
    344     grid.attach(field_box, col, row, 1, 1)
    345 
    346 
    347 def _create_auth_badge(auth_state):
    348     """Create an authentication state badge"""
    349     badge = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
    350     badge.set_margin_top(4)
    351     badge.set_margin_bottom(4)
    352     badge.set_margin_start(8)
    353     badge.set_margin_end(8)
    354     badge.add_css_class("badge")
    355 
    356     icon = Gtk.Label()
    357     text = Gtk.Label()
    358     text.add_css_class("caption")
    359 
    360     if auth_state == "trusted":
    361         badge.add_css_class("auth-trusted")
    362         icon.set_text("✓")
    363         text.set_text("Trusted")
    364     elif auth_state == "not_trusted":
    365         badge.add_css_class("auth-not-trusted")
    366         icon.set_text("⚠")
    367         text.set_text("Not Trusted")
    368     elif auth_state == "unknown":
    369         badge.add_css_class("auth-unknown")
    370         icon.set_text("?")
    371         text.set_text("Unknown Key")
    372     elif auth_state == "invalid":
    373         badge.add_css_class("auth-invalid")
    374         icon.set_text("✗")
    375         text.set_text("Invalid")
    376     else:
    377         badge.add_css_class("auth-unsigned")
    378         icon.set_text("○")
    379         text.set_text("No Key")
    380 
    381     badge.append(icon)
    382     badge.append(text)
    383     return badge
    384 
    385 
    386 def _create_ledger_card(title, unit, account_type, path, is_source=True):
    387     """Create a source or destination card"""
    388     card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
    389     card.add_css_class("card")
    390     card.set_margin_top(8)
    391     card.set_margin_bottom(8)
    392     card.set_margin_start(8)
    393     card.set_margin_end(8)
    394 
    395     header = Gtk.Label(label=title)
    396     header.set_halign(Gtk.Align.START)
    397     header.add_css_class("heading")
    398     header.set_margin_top(8)
    399     header.set_margin_start(8)
    400     card.append(header)
    401 
    402     fields_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
    403     fields_box.set_margin_start(8)
    404     fields_box.set_margin_end(8)
    405     fields_box.set_margin_bottom(8)
    406 
    407     _add_label_value_pair(fields_box, "Account Unit:", unit)
    408     _add_label_value_pair(fields_box, "Account Type:", account_type)
    409     _add_label_value_pair(fields_box, "Account Path:", path)
    410 
    411     card.append(fields_box)
    412     return card
    413 
    414 
    415 def _add_label_value_pair(container, label_text, value_text):
    416     """Add a label:value pair to a container"""
    417     row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
    418 
    419     label = Gtk.Label(label=label_text)
    420     label.set_halign(Gtk.Align.START)
    421     label.add_css_class("dim-label")
    422     row.append(label)
    423 
    424     value = Gtk.Label(label=value_text)
    425     value.set_halign(Gtk.Align.START)
    426     value.add_css_class("monospace")
    427     value.set_hexpand(True)
    428     row.append(value)
    429 
    430     container.append(row)
    431 
    432 
    433 def _create_attachment_card(filename, metadata, on_click=None):
    434     """Create an attachment card"""
    435     card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
    436     card.add_css_class("card")
    437     card.set_margin_top(8)
    438     card.set_margin_bottom(8)
    439     card.set_size_request(200, -1)
    440 
    441     if on_click:
    442         gesture = Gtk.GestureClick.new()
    443         gesture.connect("pressed", lambda gesture, n_press, x, y: on_click(filename))
    444         card.add_controller(gesture)
    445         card.set_cursor(Gdk.Cursor.new_from_name("pointer"))
    446 
    447     header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
    448     header_box.set_margin_top(12)
    449     header_box.set_margin_start(12)
    450     header_box.set_margin_end(12)
    451 
    452     icon = Gtk.Image.new_from_icon_name("text-x-generic-symbolic")
    453     header_box.append(icon)
    454 
    455     name_label = Gtk.Label(label=filename)
    456     name_label.set_halign(Gtk.Align.START)
    457     name_label.add_css_class("caption")
    458     name_label.set_ellipsize(Pango.EllipsizeMode.MIDDLE)
    459     header_box.append(name_label)
    460 
    461     card.append(header_box)
    462 
    463     meta_label = Gtk.Label(label=metadata)
    464     meta_label.set_halign(Gtk.Align.START)
    465     meta_label.add_css_class("dim-label")
    466     meta_label.add_css_class("caption")
    467     meta_label.set_margin_start(12)
    468     meta_label.set_margin_bottom(12)
    469     card.append(meta_label)
    470 
    471     return card
    472 
    473 
    474 def _on_attachment_fetched(parent_window, asset, bytes_data):
    475     """Handle fetched attachment data and open appropriate viewer"""
    476     if bytes_data is None:
    477         _show_error_dialog(
    478             parent_window,
    479             "Failed to load attachment",
    480             "Could not retrieve asset data for {}.".format(asset.slug),
    481         )
    482         return
    483 
    484     mime = asset.mime or ""
    485     if mime.startswith("image/"):
    486         _show_image_viewer(parent_window, asset.slug, bytes_data)
    487     elif mime == "application/pdf":
    488         _show_pdf_viewer(parent_window, asset.slug, bytes_data)
    489     elif mime.startswith("text/") or mime in (
    490         "application/json",
    491         "application/xml",
    492     ):
    493         _show_text_viewer(parent_window, asset.slug, bytes_data)
    494     else:
    495         _show_unsupported_dialog(parent_window, asset.slug, mime)
    496 
    497 
    498 def _fetch_attachment_data(parent_window, asset, fetch_fn):
    499     """Fetch data and schedule callback on main thread"""
    500     bytes_data = fetch_fn(asset.digest)
    501     GLib.idle_add(_on_attachment_fetched, parent_window, asset, bytes_data)
    502 
    503 
    504 def _open_attachment_viewer(parent_window, asset, fetch_fn):
    505     """Fetch bytes in a background thread then open the appropriate viewer."""
    506     threading.Thread(
    507         target=_fetch_attachment_data,
    508         args=(parent_window, asset, fetch_fn),
    509         daemon=True,
    510     ).start()
    511 
    512 
    513 def _show_image_viewer(parent, title, data):
    514     dialog = Gtk.Window(title=title)
    515     dialog.set_transient_for(parent)
    516     dialog.set_modal(True)
    517     dialog.set_default_size(800, 600)
    518 
    519     loader = GdkPixbuf.PixbufLoader()
    520     loader.write(data)
    521     loader.close()
    522     pixbuf = loader.get_pixbuf()
    523 
    524     scroll = Gtk.ScrolledWindow()
    525     image = Gtk.Picture.new_for_pixbuf(pixbuf)
    526     image.set_content_fit(Gtk.ContentFit.CONTAIN)
    527     scroll.set_child(image)
    528     dialog.set_child(scroll)
    529     dialog.present()
    530 
    531 
    532 def _show_text_viewer(parent, title, data):
    533     dialog = Gtk.Window(title=title)
    534     dialog.set_transient_for(parent)
    535     dialog.set_modal(True)
    536     dialog.set_default_size(800, 600)
    537 
    538     scroll = Gtk.ScrolledWindow()
    539     text_view = Gtk.TextView()
    540     text_view.set_editable(False)
    541     text_view.set_monospace(True)
    542     text_view.get_buffer().set_text(data.decode("utf-8", errors="replace"))
    543     scroll.set_child(text_view)
    544     dialog.set_child(scroll)
    545     dialog.present()
    546 
    547 
    548 def _show_pdf_viewer(parent, title, data):
    549     with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
    550         f.write(data)
    551         tmp_path = f.name
    552     subprocess.Popen(["evince", tmp_path])
    553 
    554 
    555 def _show_unsupported_dialog(parent, filename, mime):
    556     dialog = Gtk.AlertDialog()
    557     dialog.set_message(f"Cannot preview '{filename}'")
    558     dialog.set_detail(f"No viewer available for type: {mime}")
    559     dialog.show(parent)
    560 
    561 
    562 def _show_error_dialog(self, title, message):
    563     dialog = Adw.MessageDialog(
    564         transient_for=self.get_root(), heading=title, body=message
    565     )
    566     dialog.add_response("ok", "OK")
    567     dialog.set_response_appearance("ok", Adw.ResponseAppearance.DESTRUCTIVE)
    568     dialog.connect("response", lambda d, response: d.close())
    569 
    570     dialog.present()