tbr-deal-finder 0.2.0__py3-none-any.whl → 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. tbr_deal_finder/__init__.py +1 -5
  2. tbr_deal_finder/__main__.py +7 -0
  3. tbr_deal_finder/book.py +28 -8
  4. tbr_deal_finder/cli.py +15 -28
  5. tbr_deal_finder/config.py +3 -3
  6. tbr_deal_finder/desktop_updater.py +147 -0
  7. tbr_deal_finder/gui/__init__.py +0 -0
  8. tbr_deal_finder/gui/main.py +754 -0
  9. tbr_deal_finder/gui/pages/__init__.py +1 -0
  10. tbr_deal_finder/gui/pages/all_books.py +100 -0
  11. tbr_deal_finder/gui/pages/all_deals.py +63 -0
  12. tbr_deal_finder/gui/pages/base_book_page.py +290 -0
  13. tbr_deal_finder/gui/pages/book_details.py +604 -0
  14. tbr_deal_finder/gui/pages/latest_deals.py +376 -0
  15. tbr_deal_finder/gui/pages/settings.py +390 -0
  16. tbr_deal_finder/migrations.py +26 -0
  17. tbr_deal_finder/queries/latest_unknown_book_sync.sql +5 -0
  18. tbr_deal_finder/retailer/amazon.py +60 -9
  19. tbr_deal_finder/retailer/amazon_custom_auth.py +79 -0
  20. tbr_deal_finder/retailer/audible.py +2 -1
  21. tbr_deal_finder/retailer/chirp.py +55 -11
  22. tbr_deal_finder/retailer/kindle.py +68 -44
  23. tbr_deal_finder/retailer/librofm.py +58 -21
  24. tbr_deal_finder/retailer/models.py +31 -1
  25. tbr_deal_finder/retailer_deal.py +69 -37
  26. tbr_deal_finder/tracked_books.py +76 -8
  27. tbr_deal_finder/utils.py +74 -3
  28. tbr_deal_finder/version_check.py +40 -0
  29. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.2.dist-info}/METADATA +19 -88
  30. tbr_deal_finder-0.3.2.dist-info/RECORD +38 -0
  31. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.2.dist-info}/entry_points.txt +1 -0
  32. tbr_deal_finder-0.2.0.dist-info/RECORD +0 -24
  33. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.2.dist-info}/WHEEL +0 -0
  34. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,754 @@
1
+ import asyncio
2
+ import os
3
+ import base64
4
+
5
+ import flet as ft
6
+
7
+ from tbr_deal_finder.config import Config
8
+ from tbr_deal_finder.book import Book, BookFormat
9
+ from tbr_deal_finder.migrations import make_migrations
10
+ from tbr_deal_finder.retailer import RETAILER_MAP
11
+ from tbr_deal_finder.retailer.models import Retailer
12
+ from tbr_deal_finder.retailer_deal import get_latest_deals
13
+ from tbr_deal_finder.desktop_updater import check_for_desktop_updates
14
+
15
+ from tbr_deal_finder.gui.pages.settings import SettingsPage
16
+ from tbr_deal_finder.gui.pages.all_deals import AllDealsPage
17
+ from tbr_deal_finder.gui.pages.latest_deals import LatestDealsPage
18
+ from tbr_deal_finder.gui.pages.all_books import AllBooksPage
19
+ from tbr_deal_finder.gui.pages.book_details import BookDetailsPage
20
+
21
+
22
+ class TBRDealFinderApp:
23
+ def __init__(self, page: ft.Page):
24
+ self.page = page
25
+ self.config = None
26
+ self.current_page = "all_deals"
27
+ self.selected_book = None
28
+ self.update_info = None # Store update information
29
+ self.nav_disabled = False # Track navigation disabled state
30
+
31
+ # Initialize pages
32
+ self.settings_page = SettingsPage(self)
33
+ self.all_deals_page = AllDealsPage(self)
34
+ self.latest_deals_page = LatestDealsPage(self)
35
+ self.all_books_page = AllBooksPage(self)
36
+ self.book_details_page = BookDetailsPage(self)
37
+
38
+ self.setup_page()
39
+ self.load_config()
40
+ self.build_layout()
41
+ self.check_for_updates_silently()
42
+
43
+ def setup_page(self):
44
+ """Configure the main page settings"""
45
+ self.page.title = "TBR Deal Finder"
46
+ self.page.theme_mode = ft.ThemeMode.DARK
47
+ self.page.padding = 0
48
+ self.page.spacing = 0
49
+ self.page.window.width = 1200
50
+ self.page.window.height = 800
51
+ self.page.window.min_width = 800
52
+ self.page.window.min_height = 600
53
+
54
+ def load_config(self):
55
+ """Load configuration or create default"""
56
+ try:
57
+ self.config = Config.load()
58
+ except FileNotFoundError:
59
+ # Will prompt for config setup
60
+ self.config = None
61
+
62
+ def load_logo_as_base64(self):
63
+ """Load the logo image and convert to base64"""
64
+ try:
65
+ # Get the path relative to the main script location
66
+ logo_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets", "icon.png")
67
+ if not os.path.exists(logo_path):
68
+ # Try alternative path for packaged app
69
+ logo_path = os.path.join("assets", "icon.png")
70
+
71
+ if os.path.exists(logo_path):
72
+ with open(logo_path, "rb") as image_file:
73
+ encoded_string = base64.b64encode(image_file.read()).decode()
74
+ return encoded_string # Return just the base64 string, not the data URL
75
+ except Exception as e:
76
+ print(f"Could not load logo: {e}")
77
+ return None
78
+
79
+ def refresh_navigation(self):
80
+ """Refresh the navigation container to update the update indicator"""
81
+ # Rebuild just the navigation part
82
+ logo_base64 = self.load_logo_as_base64()
83
+
84
+ # Create logo widget or fallback
85
+ if logo_base64:
86
+ logo_widget = ft.Image(
87
+ src_base64=logo_base64,
88
+ width=80,
89
+ height=80,
90
+ fit=ft.ImageFit.CONTAIN
91
+ )
92
+ else:
93
+ logo_widget = ft.Icon(
94
+ ft.Icons.BOOK,
95
+ size=64,
96
+ color=ft.Colors.BLUE_400
97
+ )
98
+
99
+ # Create bottom section with logo and update indicator
100
+ bottom_section_widgets = [logo_widget]
101
+
102
+ # Add update indicator if update is available
103
+ if self.update_info:
104
+ update_indicator = ft.Container(
105
+ content=ft.Text(
106
+ "Update Available",
107
+ size=11,
108
+ color=ft.Colors.ORANGE_400,
109
+ weight=ft.FontWeight.BOLD,
110
+ text_align=ft.TextAlign.CENTER
111
+ ),
112
+ padding=ft.padding.only(top=8, left=4, right=4, bottom=4),
113
+ alignment=ft.alignment.center,
114
+ on_click=lambda e: self.show_update_notification(),
115
+ border_radius=4,
116
+ bgcolor=ft.Colors.ORANGE_50,
117
+ border=ft.border.all(1, ft.Colors.ORANGE_400)
118
+ )
119
+ bottom_section_widgets.append(update_indicator)
120
+
121
+ # Update the bottom container content
122
+ self.nav_container.content.controls[1].content = ft.Column(
123
+ bottom_section_widgets,
124
+ horizontal_alignment=ft.CrossAxisAlignment.CENTER,
125
+ spacing=0
126
+ )
127
+
128
+ self.page.update()
129
+
130
+ def build_layout(self):
131
+ """Build the main application layout"""
132
+ # Top app bar with settings cog
133
+ app_bar = ft.AppBar(
134
+ title=ft.Text("TBR Deal Finder", size=20, weight=ft.FontWeight.BOLD, color=ft.Colors.WHITE60),
135
+ center_title=False,
136
+ bgcolor=ft.Colors.BLUE_GREY_900,
137
+ actions=[
138
+ ft.IconButton(
139
+ icon=ft.Icons.SETTINGS,
140
+ tooltip="Settings",
141
+ on_click=self.show_settings
142
+ )
143
+ ]
144
+ )
145
+
146
+ # Load logo as base64
147
+ logo_base64 = self.load_logo_as_base64()
148
+
149
+ # Create logo widget or fallback
150
+ if logo_base64:
151
+ logo_widget = ft.Image(
152
+ src_base64=logo_base64,
153
+ width=80,
154
+ height=80,
155
+ fit=ft.ImageFit.CONTAIN
156
+ )
157
+ else:
158
+ # Fallback to an icon if logo can't be loaded
159
+ logo_widget = ft.Icon(
160
+ ft.Icons.BOOK,
161
+ size=64,
162
+ color=ft.Colors.BLUE_400
163
+ )
164
+
165
+ # Navigation rail (left sidebar)
166
+ nav_rail = ft.NavigationRail(
167
+ selected_index=0,
168
+ label_type=ft.NavigationRailLabelType.ALL,
169
+ min_width=200,
170
+ min_extended_width=200,
171
+ group_alignment=-1.0,
172
+ destinations=[
173
+ ft.NavigationRailDestination(
174
+ icon=ft.Icons.LOCAL_OFFER,
175
+ selected_icon=ft.Icons.LOCAL_OFFER_OUTLINED,
176
+ label="All Deals"
177
+ ),
178
+ ft.NavigationRailDestination(
179
+ icon=ft.Icons.NEW_RELEASES,
180
+ selected_icon=ft.Icons.NEW_RELEASES_OUTLINED,
181
+ label="Latest Deals"
182
+ ),
183
+ ft.NavigationRailDestination(
184
+ icon=ft.Icons.LIBRARY_BOOKS,
185
+ selected_icon=ft.Icons.LOCAL_LIBRARY_OUTLINED,
186
+ label="My Books"
187
+ ),
188
+ ],
189
+ on_change=self.nav_changed,
190
+ expand=True # This allows NavigationRail to expand within the Column
191
+ )
192
+
193
+ # Store reference for later use
194
+ self.nav_rail = nav_rail
195
+
196
+ # Create bottom section with logo and update indicator
197
+ bottom_section_widgets = [logo_widget]
198
+
199
+ # Add update indicator if update is available
200
+ if self.update_info:
201
+ update_indicator = ft.Container(
202
+ content=ft.Text(
203
+ "Update Available",
204
+ size=11,
205
+ color=ft.Colors.ORANGE_400,
206
+ weight=ft.FontWeight.BOLD,
207
+ text_align=ft.TextAlign.CENTER
208
+ ),
209
+ padding=ft.padding.only(top=8, left=4, right=4, bottom=4),
210
+ alignment=ft.alignment.center,
211
+ on_click=lambda e: self.show_update_notification(),
212
+ border_radius=4,
213
+ bgcolor=ft.Colors.ORANGE_50,
214
+ border=ft.border.all(1, ft.Colors.ORANGE_400)
215
+ )
216
+ bottom_section_widgets.append(update_indicator)
217
+
218
+ # Create navigation container with logo at bottom
219
+ self.nav_container = ft.Container(
220
+ content=ft.Column([
221
+ nav_rail,
222
+ ft.Container(
223
+ content=ft.Column(
224
+ bottom_section_widgets,
225
+ horizontal_alignment=ft.CrossAxisAlignment.CENTER,
226
+ spacing=0
227
+ ),
228
+ padding=ft.padding.only(bottom=20),
229
+ alignment=ft.alignment.center
230
+ )
231
+ ],
232
+ spacing=0,
233
+ expand=True), # Column should expand vertically
234
+ width=200 # Fixed width, no horizontal expand
235
+ )
236
+
237
+ # Main content area
238
+ self.content_area = ft.Container(
239
+ content=self.get_current_page_content(),
240
+ expand=True,
241
+ padding=20
242
+ )
243
+
244
+ # Main layout with sidebar and content
245
+ main_layout = ft.Row(
246
+ [
247
+ self.nav_container,
248
+ ft.VerticalDivider(width=1),
249
+ self.content_area
250
+ ],
251
+ expand=True,
252
+ spacing=0
253
+ )
254
+
255
+ # Add everything to page
256
+ self.page.appbar = app_bar
257
+ self.page.add(main_layout)
258
+ self.page.update()
259
+
260
+ def nav_changed(self, e):
261
+ """Handle navigation rail selection changes"""
262
+ # Prevent navigation if disabled
263
+ if self.nav_disabled:
264
+ # Reset to current page selection to prevent visual change
265
+ current_indices = {"all_deals": 0, "latest_deals": 1, "all_books": 2}
266
+ self.nav_rail.selected_index = current_indices.get(self.current_page, 0)
267
+ # Reapply disabled state after page update
268
+ self.nav_rail.disabled = True
269
+ self.page.update()
270
+ return
271
+
272
+ destinations = ["all_deals", "latest_deals", "all_books"]
273
+ if e.control.selected_index < len(destinations):
274
+ self.current_page = destinations[e.control.selected_index]
275
+
276
+ # Only clear all page states when clicking on Latest Deals
277
+ # Other pages maintain their state when navigated to
278
+ if self.current_page == "latest_deals":
279
+ self.refresh_all_pages()
280
+
281
+ self.update_content()
282
+
283
+ def update_content(self):
284
+ """Update the main content area"""
285
+ self.content_area.content = self.get_current_page_content()
286
+ self.page.update()
287
+
288
+ def refresh_current_page(self):
289
+ """Refresh the current page by clearing its state and reloading data"""
290
+ if self.current_page == "all_deals":
291
+ self.all_deals_page.refresh_page_state()
292
+ elif self.current_page == "latest_deals":
293
+ self.latest_deals_page.refresh_page_state()
294
+ elif self.current_page == "all_books":
295
+ self.all_books_page.refresh_page_state()
296
+
297
+ def refresh_all_pages(self):
298
+ """Refresh all pages by clearing their state and reloading data"""
299
+ self.all_deals_page.refresh_page_state()
300
+ self.latest_deals_page.refresh_page_state()
301
+ self.all_books_page.refresh_page_state()
302
+
303
+ def disable_navigation(self):
304
+ """Disable navigation rail during background operations"""
305
+ self.nav_disabled = True
306
+ if hasattr(self, 'nav_rail'):
307
+ self.nav_rail.disabled = True
308
+ self.page.update()
309
+
310
+ def enable_navigation(self):
311
+ """Enable navigation rail after background operations complete"""
312
+ if not self.nav_disabled:
313
+ return
314
+
315
+ self.nav_disabled = False
316
+ if hasattr(self, 'nav_rail'):
317
+ self.nav_rail.disabled = False
318
+ self.page.update()
319
+
320
+ def get_current_page_content(self):
321
+ """Get content for the current page"""
322
+ if self.config is None and self.current_page != "settings":
323
+ return self.get_config_prompt()
324
+
325
+ if self.current_page == "all_deals":
326
+ return self.all_deals_page.build()
327
+ elif self.current_page == "latest_deals":
328
+ return self.latest_deals_page.build()
329
+ elif self.current_page == "all_books":
330
+ return self.all_books_page.build()
331
+ elif self.current_page == "book_details":
332
+ return self.book_details_page.build()
333
+ elif self.current_page == "settings":
334
+ return self.settings_page.build()
335
+ else:
336
+ return ft.Text("Page not found")
337
+
338
+ def get_config_prompt(self):
339
+ """Show config setup prompt when no config exists"""
340
+ self.disable_navigation()
341
+ return ft.Container(
342
+ content=ft.Column([
343
+ ft.Icon(ft.Icons.SETTINGS, size=64, color=ft.Colors.GREY_400),
344
+ ft.Text(
345
+ "Welcome to TBR Deal Finder!",
346
+ size=24,
347
+ weight=ft.FontWeight.BOLD,
348
+ text_align=ft.TextAlign.CENTER
349
+ ),
350
+ ft.Text(
351
+ "You need to configure your settings before getting started.",
352
+ size=16,
353
+ color=ft.Colors.GREY_600,
354
+ text_align=ft.TextAlign.CENTER
355
+ ),
356
+ ft.ElevatedButton(
357
+ "Configure Settings",
358
+ icon=ft.Icons.SETTINGS,
359
+ on_click=self.show_settings
360
+ )
361
+ ],
362
+ alignment=ft.MainAxisAlignment.CENTER,
363
+ horizontal_alignment=ft.CrossAxisAlignment.CENTER,
364
+ spacing=20),
365
+ alignment=ft.alignment.center
366
+ )
367
+
368
+ def show_settings(self, e=None):
369
+ """Show settings page"""
370
+ self.current_page = "settings"
371
+ self.nav_rail.selected_index = None # Deselect nav items
372
+ self.update_content()
373
+
374
+ def show_book_details(self, book: Book, format_type: BookFormat = None):
375
+ """Show book details page"""
376
+ self.selected_book = book
377
+
378
+ # Set the initial format if specified
379
+ if format_type is not None and format_type != BookFormat.NA:
380
+ self.book_details_page.set_initial_format(format_type)
381
+ else:
382
+ # Reset selected format so it uses default logic
383
+ self.book_details_page.selected_format = None
384
+
385
+ self.current_page = "book_details"
386
+ self.nav_rail.selected_index = None
387
+ self.update_content()
388
+
389
+ def go_back_to_deals(self):
390
+ """Return to deals page from book details"""
391
+ self.current_page = "all_deals"
392
+ self.nav_rail.selected_index = 0
393
+ # Refresh the page when returning to it
394
+ self.refresh_current_page()
395
+ self.update_content()
396
+
397
+ def config_updated(self, new_config: Config):
398
+ """Handle config updates"""
399
+ self.config = new_config
400
+ if self.current_page == "settings":
401
+ self.current_page = "all_deals"
402
+ self.nav_rail.selected_index = 0
403
+ # Refresh the page when returning from settings
404
+ self.refresh_current_page()
405
+ self.update_content()
406
+
407
+ async def run_latest_deals(self):
408
+ """Run the latest deals check with progress tracking using GUI auth"""
409
+ if not self.config:
410
+ return False
411
+
412
+ try:
413
+ # First authenticate all retailers using GUI dialogs
414
+ await self.auth_all_configured_retailers()
415
+ # Then fetch the deals (retailers should already be authenticated)
416
+ return await get_latest_deals(self.config)
417
+ except Exception as e:
418
+ return False
419
+
420
+ async def auth_all_configured_retailers(self):
421
+ for retailer_str in self.config.tracked_retailers:
422
+ retailer = RETAILER_MAP[retailer_str]()
423
+
424
+ # Skip if already authenticated
425
+ if retailer.user_is_authed():
426
+ continue
427
+
428
+ # Use GUI auth instead of CLI auth
429
+ await self.show_auth_dialog(retailer)
430
+
431
+ async def show_auth_dialog(self, retailer: Retailer):
432
+ """Show authentication dialog for retailer login"""
433
+
434
+ auth_context = retailer.gui_auth_context
435
+ title = auth_context.title
436
+ fields = auth_context.fields
437
+ message = auth_context.message
438
+ user_copy_context = auth_context.user_copy_context
439
+ pop_up_type = auth_context.pop_up_type
440
+
441
+ # Store the dialog reference at instance level temporarily
442
+ self._auth_dialog_result = None
443
+ self._auth_dialog_complete = False
444
+
445
+ def close_dialog():
446
+ dialog.open = False
447
+ self.page.update()
448
+
449
+ async def handle_submit(e=None):
450
+ form_data = {}
451
+ for field in fields:
452
+ field_name = field["name"]
453
+ field_ref = field.get("ref")
454
+ if field_ref:
455
+ form_data[field_name] = field_ref.value
456
+
457
+ try:
458
+ result = await retailer.gui_auth(form_data)
459
+ if result:
460
+ close_dialog()
461
+ self._auth_dialog_result = True
462
+ self._auth_dialog_complete = True
463
+ else:
464
+ # Show error in dialog
465
+ error_text.value = "Login failed, please try again"
466
+ error_text.visible = True
467
+ self.page.update()
468
+ except Exception as ex:
469
+ self._auth_dialog_result = False
470
+ self._auth_dialog_complete = True
471
+
472
+ # Build dialog with error text
473
+ error_text = ft.Text("", color=ft.Colors.RED, visible=False)
474
+ content_controls = [error_text]
475
+
476
+ if message:
477
+ content_controls.append(
478
+ ft.Text(message, selectable=True)
479
+ )
480
+
481
+ # Add user copy context if available
482
+ if user_copy_context:
483
+ def copy_to_clipboard(e):
484
+ self.page.set_clipboard(user_copy_context)
485
+ copy_button.text = "Copied!"
486
+ copy_button.icon = ft.Icons.CHECK
487
+ self.page.update()
488
+ # Reset button after 2 seconds
489
+ import threading
490
+ def reset_button():
491
+ import time
492
+ time.sleep(.25)
493
+ copy_button.text = "Copy"
494
+ copy_button.icon = ft.Icons.COPY
495
+ copy_button.update()
496
+ threading.Thread(target=reset_button, daemon=True).start()
497
+
498
+ copy_button = ft.ElevatedButton(
499
+ "Copy",
500
+ icon=ft.Icons.COPY,
501
+ on_click=copy_to_clipboard,
502
+ style=ft.ButtonStyle(
503
+ bgcolor=ft.Colors.BLUE_100,
504
+ color=ft.Colors.BLUE_900
505
+ )
506
+ )
507
+
508
+ content_controls.extend([
509
+ ft.Text("Copy this:", weight=ft.FontWeight.BOLD, size=12),
510
+ ft.Container(
511
+ content=ft.Text(
512
+ user_copy_context,
513
+ selectable=True,
514
+ size=11,
515
+ color=ft.Colors.GREY_700,
516
+ height=80
517
+ ),
518
+ bgcolor=ft.Colors.GREY_100,
519
+ padding=10,
520
+ border_radius=5,
521
+ border=ft.border.all(1, ft.Colors.GREY_300)
522
+ ),
523
+ copy_button,
524
+ ft.Divider()
525
+ ])
526
+
527
+ if fields and pop_up_type == "form":
528
+ for field in fields:
529
+ field_type = field.get("type", "text")
530
+ field_ref = ft.TextField(
531
+ label=field["label"],
532
+ password=field_type == "password",
533
+ keyboard_type=ft.KeyboardType.EMAIL if field_type == "email" else ft.KeyboardType.TEXT,
534
+ autofocus=field == fields[0], # Focus first field
535
+ height=60
536
+
537
+ )
538
+ field["ref"] = field_ref # Store reference
539
+ content_controls.append(field_ref)
540
+
541
+ # Dialog actions
542
+ actions = []
543
+ if pop_up_type == "form" and fields:
544
+ actions.extend([
545
+ ft.ElevatedButton("Login", on_click=handle_submit)
546
+ ])
547
+ else:
548
+ actions.append(
549
+ ft.TextButton("OK", on_click=close_dialog)
550
+ )
551
+
552
+ # Create dialog
553
+ dialog = ft.AlertDialog(
554
+ title=ft.Text(title) if title else None,
555
+ content=ft.Column(
556
+ content_controls,
557
+ width=400,
558
+ height=None,
559
+ scroll=ft.ScrollMode.AUTO, # Enable scrolling
560
+ spacing=10
561
+ ),
562
+ actions=actions,
563
+ modal=True
564
+ )
565
+
566
+ # Show dialog
567
+ self.page.overlay.append(dialog)
568
+ dialog.open = True
569
+ self.page.update()
570
+
571
+ # Poll for completion
572
+ while not self._auth_dialog_complete:
573
+ await asyncio.sleep(0.1)
574
+
575
+ result = self._auth_dialog_result
576
+
577
+ # Clean up
578
+ self._auth_dialog_result = None
579
+ self._auth_dialog_complete = False
580
+
581
+ return result
582
+
583
+ def check_for_updates_silently(self):
584
+ """Check for updates silently without showing dialogs - only update the indicator"""
585
+ try:
586
+ update_info = check_for_desktop_updates()
587
+
588
+ if update_info:
589
+ self.update_info = update_info
590
+ else:
591
+ self.update_info = None
592
+
593
+ self.refresh_navigation() # Update the navigation indicator
594
+ except Exception as e:
595
+ # Silently fail - don't show errors for automatic update checks
596
+ print(f"Silent update check failed: {e}")
597
+
598
+ def check_for_updates_manual(self):
599
+ """Check for updates manually when user clicks button."""
600
+
601
+ # Check for updates
602
+ update_info = check_for_desktop_updates()
603
+
604
+ if update_info:
605
+ # Update available - show banner
606
+ self.update_info = update_info
607
+ self.refresh_navigation() # Update the navigation to show indicator
608
+ self.show_update_notification()
609
+ else:
610
+ # No update available - show up-to-date message
611
+ # Clear any existing update info and refresh navigation
612
+ self.update_info = None
613
+ self.refresh_navigation() # Update the navigation to hide indicator
614
+ self.show_up_to_date_message()
615
+
616
+ def show_update_notification(self):
617
+ """Show update notification dialog."""
618
+ if not self.update_info:
619
+ return
620
+
621
+ def close_dialog(e):
622
+ dialog.open = False
623
+ self.page.update()
624
+
625
+ def view_release_and_close(e):
626
+ self.view_release_notes(e)
627
+ close_dialog(e)
628
+
629
+ def download_and_close(e):
630
+ self.download_update(e)
631
+ close_dialog(e)
632
+
633
+ # Create update dialog
634
+ dialog = ft.AlertDialog(
635
+ title=ft.Row([
636
+ ft.Icon(ft.Icons.SYSTEM_UPDATE, color=ft.Colors.BLUE, size=30),
637
+ ft.Text("Update Available", weight=ft.FontWeight.BOLD)
638
+ ], spacing=10),
639
+ content=ft.Column([
640
+ ft.Text(f"Version {self.update_info['version']} is now available!"),
641
+ ft.Text("Would you like to download the update?", color=ft.Colors.GREY_600)
642
+ ], spacing=10, tight=True),
643
+ actions=[
644
+ ft.TextButton("View Release Notes", on_click=view_release_and_close),
645
+ ft.ElevatedButton("Download Update", on_click=download_and_close),
646
+ ft.TextButton("Later", on_click=close_dialog),
647
+ ],
648
+ modal=True
649
+ )
650
+
651
+ self.page.overlay.append(dialog)
652
+ dialog.open = True
653
+ self.page.update()
654
+
655
+ def show_up_to_date_message(self):
656
+ """Show message that app is up to date."""
657
+ def close_dialog(e):
658
+ dialog.open = False
659
+ self.page.update()
660
+
661
+ # Create up-to-date dialog
662
+ dialog = ft.AlertDialog(
663
+ title=ft.Row([
664
+ ft.Icon(ft.Icons.CHECK_CIRCLE, color=ft.Colors.GREEN, size=30),
665
+ ft.Text("Up to Date", weight=ft.FontWeight.BOLD)
666
+ ], spacing=10),
667
+ content=ft.Text("You're running the latest version!"),
668
+ actions=[
669
+ ft.ElevatedButton("OK", on_click=close_dialog),
670
+ ],
671
+ modal=True
672
+ )
673
+
674
+ self.page.overlay.append(dialog)
675
+ dialog.open = True
676
+ self.page.update()
677
+
678
+ def view_release_notes(self, e):
679
+ """Open release notes in browser."""
680
+ if self.update_info:
681
+ import webbrowser
682
+ webbrowser.open(self.update_info['release_url'])
683
+
684
+ def download_update(self, e):
685
+ """Handle update download."""
686
+ if not self.update_info or not self.update_info.get('download_url'):
687
+ return
688
+
689
+ # For now, open download URL in browser
690
+ # In a more advanced implementation, you could download in-app
691
+ import webbrowser
692
+ webbrowser.open(self.update_info['download_url'])
693
+
694
+ # Show download instructions
695
+ self.show_download_instructions()
696
+
697
+ def show_download_instructions(self):
698
+ """Show instructions for installing the downloaded update."""
699
+ def close_dialog(e):
700
+ dialog.open = False
701
+ self.page.update()
702
+
703
+ instructions = {
704
+ "darwin": "1. Download will start in your browser\n2. Open the downloaded .dmg file\n3. Drag the app to Applications folder\n4. Restart the application",
705
+ "windows": "1. Download will start in your browser\n2. Run the downloaded .exe installer\n3. Follow the installation wizard\n4. Restart the application",
706
+ }
707
+
708
+ import platform
709
+ current_platform = platform.system().lower()
710
+ instruction_text = instructions.get(current_platform, "Download the update and follow installation instructions.")
711
+
712
+ dialog = ft.AlertDialog(
713
+ title=ft.Text("Update Installation"),
714
+ content=ft.Column([
715
+ ft.Text(f"Version {self.update_info['version']} Download Instructions:"),
716
+ ft.Text(instruction_text, selectable=True),
717
+ ft.Divider(),
718
+ ft.Text("Release Notes:", weight=ft.FontWeight.BOLD),
719
+ ft.Text(
720
+ self.update_info.get('release_notes', 'No release notes available.')[:500] +
721
+ ('...' if len(self.update_info.get('release_notes', '')) > 500 else ''),
722
+ selectable=True
723
+ )
724
+ ], width=400, height=300, scroll=ft.ScrollMode.AUTO),
725
+ actions=[
726
+ ft.TextButton("OK", on_click=close_dialog)
727
+ ],
728
+ modal=True
729
+ )
730
+
731
+ self.page.overlay.append(dialog)
732
+ dialog.open = True
733
+ self.page.update()
734
+
735
+
736
+
737
+ def check_for_updates_button(self):
738
+ """Check for updates when button is clicked."""
739
+ self.check_for_updates_manual()
740
+
741
+
742
+ def main():
743
+ """Main entry point for the GUI application"""
744
+ os.environ.setdefault("ENTRYPOINT", "GUI")
745
+ make_migrations()
746
+
747
+ def app_main(page: ft.Page):
748
+ TBRDealFinderApp(page)
749
+
750
+ ft.app(target=app_main)
751
+
752
+
753
+ if __name__ == "__main__":
754
+ main()