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()