bauiv1lib.store.browser
UI for browsing the store.
1# Released under the MIT License. See LICENSE for details. 2# 3"""UI for browsing the store.""" 4# pylint: disable=too-many-lines 5from __future__ import annotations 6 7import os 8import time 9import copy 10import math 11import logging 12import weakref 13import datetime 14from enum import Enum 15from threading import Thread 16from typing import TYPE_CHECKING, override 17 18from efro.util import utc_now 19from efro.error import CommunicationError 20import bacommon.cloud 21import bauiv1 as bui 22 23if TYPE_CHECKING: 24 from typing import Any, Callable, Sequence 25 26MERCH_LINK_KEY = 'Merch Link' 27 28 29class StoreBrowserWindow(bui.MainWindow): 30 """Window for browsing the store.""" 31 32 class TabID(Enum): 33 """Our available tab types.""" 34 35 EXTRAS = 'extras' 36 MAPS = 'maps' 37 MINIGAMES = 'minigames' 38 CHARACTERS = 'characters' 39 ICONS = 'icons' 40 41 def __init__( 42 self, 43 transition: str | None = 'in_right', 44 origin_widget: bui.Widget | None = None, 45 show_tab: StoreBrowserWindow.TabID | None = None, 46 minimal_toolbars: bool = False, 47 ): 48 # pylint: disable=too-many-statements 49 # pylint: disable=too-many-locals 50 from bauiv1lib.tabs import TabRow 51 from bauiv1 import SpecialChar 52 53 app = bui.app 54 assert app.classic is not None 55 uiscale = app.ui_v1.uiscale 56 57 bui.set_analytics_screen('Store Window') 58 59 self.button_infos: dict[str, dict[str, Any]] | None = None 60 self.update_buttons_timer: bui.AppTimer | None = None 61 self._status_textwidget_update_timer = None 62 63 self._show_tab = show_tab 64 self._width = 1670 if uiscale is bui.UIScale.SMALL else 1040 65 self._x_inset = x_inset = 310 if uiscale is bui.UIScale.SMALL else 0 66 self._height = ( 67 538 68 if uiscale is bui.UIScale.SMALL 69 else 645 if uiscale is bui.UIScale.MEDIUM else 800 70 ) 71 self._current_tab: StoreBrowserWindow.TabID | None = None 72 extra_top = 30 if uiscale is bui.UIScale.SMALL else 0 73 74 self.request: Any = None 75 self._r = 'store' 76 self._last_buy_time: float | None = None 77 78 super().__init__( 79 root_widget=bui.containerwidget( 80 size=(self._width, self._height + extra_top), 81 toolbar_visibility=( 82 'menu_store' 83 if (uiscale is bui.UIScale.SMALL or minimal_toolbars) 84 else 'menu_full' 85 ), 86 scale=( 87 1.3 88 if uiscale is bui.UIScale.SMALL 89 else 0.9 if uiscale is bui.UIScale.MEDIUM else 0.8 90 ), 91 stack_offset=( 92 (0, 10) 93 if uiscale is bui.UIScale.SMALL 94 else (0, 0) if uiscale is bui.UIScale.MEDIUM else (0, 0) 95 ), 96 ), 97 transition=transition, 98 origin_widget=origin_widget, 99 ) 100 101 self._back_button = btn = bui.buttonwidget( 102 parent=self._root_widget, 103 position=(70 + x_inset, self._height - 74), 104 size=(140, 60), 105 scale=1.1, 106 autoselect=True, 107 label=bui.Lstr(resource='backText'), 108 button_type='back', 109 on_activate_call=self.main_window_back, 110 ) 111 112 if uiscale is bui.UIScale.SMALL: 113 self._back_button.delete() 114 bui.containerwidget( 115 edit=self._root_widget, on_cancel_call=self.main_window_back 116 ) 117 backbuttonspecial = True 118 else: 119 bui.containerwidget(edit=self._root_widget, cancel_button=btn) 120 backbuttonspecial = False 121 122 if ( 123 app.classic.platform in ['mac', 'ios'] 124 and app.classic.subplatform == 'appstore' 125 ): 126 bui.buttonwidget( 127 parent=self._root_widget, 128 position=(self._width * 0.5 - 70, 16), 129 size=(230, 50), 130 scale=0.65, 131 on_activate_call=bui.WeakCall(self._restore_purchases), 132 color=(0.35, 0.3, 0.4), 133 selectable=False, 134 textcolor=(0.55, 0.5, 0.6), 135 label=bui.Lstr( 136 resource='getTicketsWindow.restorePurchasesText' 137 ), 138 ) 139 140 bui.textwidget( 141 parent=self._root_widget, 142 position=( 143 self._width * 0.5, 144 self._height - (53 if uiscale is bui.UIScale.SMALL else 44), 145 ), 146 size=(0, 0), 147 color=app.ui_v1.title_color, 148 scale=1.5, 149 h_align='center', 150 v_align='center', 151 text=bui.Lstr(resource='storeText'), 152 maxwidth=290, 153 ) 154 155 if not backbuttonspecial: 156 bui.buttonwidget( 157 edit=self._back_button, 158 button_type='backSmall', 159 size=(60, 60), 160 label=bui.charstr(SpecialChar.BACK), 161 ) 162 163 scroll_buffer_h = 130 + 2 * x_inset 164 tab_buffer_h = 250 + 2 * x_inset 165 166 tabs_def = [ 167 (self.TabID.EXTRAS, bui.Lstr(resource=f'{self._r}.extrasText')), 168 (self.TabID.MAPS, bui.Lstr(resource=f'{self._r}.mapsText')), 169 ( 170 self.TabID.MINIGAMES, 171 bui.Lstr(resource=f'{self._r}.miniGamesText'), 172 ), 173 ( 174 self.TabID.CHARACTERS, 175 bui.Lstr(resource=f'{self._r}.charactersText'), 176 ), 177 (self.TabID.ICONS, bui.Lstr(resource=f'{self._r}.iconsText')), 178 ] 179 180 self._tab_row = TabRow( 181 self._root_widget, 182 tabs_def, 183 pos=(tab_buffer_h * 0.5, self._height - 130), 184 size=(self._width - tab_buffer_h, 50), 185 on_select_call=self._set_tab, 186 ) 187 188 self._purchasable_count_widgets: dict[ 189 StoreBrowserWindow.TabID, dict[str, Any] 190 ] = {} 191 192 # Create our purchasable-items tags and have them update over time. 193 for tab_id, tab in self._tab_row.tabs.items(): 194 pos = tab.position 195 size = tab.size 196 button = tab.button 197 rad = 10 198 center = (pos[0] + 0.1 * size[0], pos[1] + 0.9 * size[1]) 199 img = bui.imagewidget( 200 parent=self._root_widget, 201 position=(center[0] - rad * 1.04, center[1] - rad * 1.15), 202 size=(rad * 2.2, rad * 2.2), 203 texture=bui.gettexture('circleShadow'), 204 color=(1, 0, 0), 205 ) 206 txt = bui.textwidget( 207 parent=self._root_widget, 208 position=center, 209 size=(0, 0), 210 h_align='center', 211 v_align='center', 212 maxwidth=1.4 * rad, 213 scale=0.6, 214 shadow=1.0, 215 flatness=1.0, 216 ) 217 rad = 20 218 sale_img = bui.imagewidget( 219 parent=self._root_widget, 220 position=(center[0] - rad, center[1] - rad), 221 size=(rad * 2, rad * 2), 222 draw_controller=button, 223 texture=bui.gettexture('circleZigZag'), 224 color=(0.5, 0, 1.0), 225 ) 226 sale_title_text = bui.textwidget( 227 parent=self._root_widget, 228 position=(center[0], center[1] + 0.24 * rad), 229 size=(0, 0), 230 h_align='center', 231 v_align='center', 232 draw_controller=button, 233 maxwidth=1.4 * rad, 234 scale=0.6, 235 shadow=0.0, 236 flatness=1.0, 237 color=(0, 1, 0), 238 ) 239 sale_time_text = bui.textwidget( 240 parent=self._root_widget, 241 position=(center[0], center[1] - 0.29 * rad), 242 size=(0, 0), 243 h_align='center', 244 v_align='center', 245 draw_controller=button, 246 maxwidth=1.4 * rad, 247 scale=0.4, 248 shadow=0.0, 249 flatness=1.0, 250 color=(0, 1, 0), 251 ) 252 self._purchasable_count_widgets[tab_id] = { 253 'img': img, 254 'text': txt, 255 'sale_img': sale_img, 256 'sale_title_text': sale_title_text, 257 'sale_time_text': sale_time_text, 258 } 259 self._tab_update_timer = bui.AppTimer( 260 1.0, bui.WeakCall(self._update_tabs), repeat=True 261 ) 262 self._update_tabs() 263 264 if uiscale is bui.UIScale.SMALL: 265 first_tab_button = self._tab_row.tabs[tabs_def[0][0]].button 266 last_tab_button = self._tab_row.tabs[tabs_def[-1][0]].button 267 bui.widget( 268 edit=first_tab_button, 269 left_widget=bui.get_special_widget('back_button'), 270 ) 271 bui.widget( 272 edit=last_tab_button, 273 right_widget=bui.get_special_widget('squad_button'), 274 ) 275 276 self._scroll_width = self._width - scroll_buffer_h 277 self._scroll_height = self._height - 180 278 279 self._scrollwidget: bui.Widget | None = None 280 self._status_textwidget: bui.Widget | None = None 281 self._restore_state() 282 283 def _restore_purchases(self) -> None: 284 from bauiv1lib import account 285 286 plus = bui.app.plus 287 assert plus is not None 288 if plus.accounts.primary is None: 289 account.show_sign_in_prompt() 290 else: 291 plus.restore_purchases() 292 293 def _update_tabs(self) -> None: 294 assert bui.app.classic is not None 295 store = bui.app.classic.store 296 297 if not self._root_widget: 298 return 299 for tab_id, tab_data in list(self._purchasable_count_widgets.items()): 300 sale_time = store.get_available_sale_time(tab_id.value) 301 302 if sale_time is not None: 303 bui.textwidget( 304 edit=tab_data['sale_title_text'], 305 text=bui.Lstr(resource='store.saleText'), 306 ) 307 bui.textwidget( 308 edit=tab_data['sale_time_text'], 309 text=bui.timestring(sale_time / 1000.0, centi=False), 310 ) 311 bui.imagewidget(edit=tab_data['sale_img'], opacity=1.0) 312 count = 0 313 else: 314 bui.textwidget(edit=tab_data['sale_title_text'], text='') 315 bui.textwidget(edit=tab_data['sale_time_text'], text='') 316 bui.imagewidget(edit=tab_data['sale_img'], opacity=0.0) 317 count = store.get_available_purchase_count(tab_id.value) 318 319 if count > 0: 320 bui.textwidget(edit=tab_data['text'], text=str(count)) 321 bui.imagewidget(edit=tab_data['img'], opacity=1.0) 322 else: 323 bui.textwidget(edit=tab_data['text'], text='') 324 bui.imagewidget(edit=tab_data['img'], opacity=0.0) 325 326 def _set_tab(self, tab_id: TabID) -> None: 327 if self._current_tab is tab_id: 328 return 329 self._current_tab = tab_id 330 331 # We wanna preserve our current tab between runs. 332 cfg = bui.app.config 333 cfg['Store Tab'] = tab_id.value 334 cfg.commit() 335 336 # Update tab colors based on which is selected. 337 self._tab_row.update_appearance(tab_id) 338 339 # (Re)create scroll widget. 340 if self._scrollwidget: 341 self._scrollwidget.delete() 342 343 self._scrollwidget = bui.scrollwidget( 344 parent=self._root_widget, 345 highlight=False, 346 position=( 347 (self._width - self._scroll_width) * 0.5, 348 self._height - self._scroll_height - 79 - 48, 349 ), 350 size=(self._scroll_width, self._scroll_height), 351 claims_left_right=True, 352 claims_tab=True, 353 selection_loops_to_parent=True, 354 ) 355 356 # NOTE: this stuff is modified by the _Store class. 357 # Should maybe clean that up. 358 self.button_infos = {} 359 self.update_buttons_timer = None 360 361 # Show status over top. 362 if self._status_textwidget: 363 self._status_textwidget.delete() 364 self._status_textwidget = bui.textwidget( 365 parent=self._root_widget, 366 position=(self._width * 0.5, self._height * 0.5), 367 size=(0, 0), 368 color=(1, 0.7, 1, 0.5), 369 h_align='center', 370 v_align='center', 371 text=bui.Lstr(resource=f'{self._r}.loadingText'), 372 maxwidth=self._scroll_width * 0.9, 373 ) 374 375 class _Request: 376 def __init__(self, window: StoreBrowserWindow): 377 self._window = weakref.ref(window) 378 data = {'tab': tab_id.value} 379 bui.apptimer(0.1, bui.WeakCall(self._on_response, data)) 380 381 def _on_response(self, data: dict[str, Any] | None) -> None: 382 # FIXME: clean this up. 383 # pylint: disable=protected-access 384 window = self._window() 385 if window is not None and (window.request is self): 386 window.request = None 387 window._on_response(data) 388 389 # Kick off a server request. 390 self.request = _Request(self) 391 392 # Actually start the purchase locally. 393 def _purchase_check_result( 394 self, item: str, is_ticket_purchase: bool, result: dict[str, Any] | None 395 ) -> None: 396 plus = bui.app.plus 397 assert plus is not None 398 if result is None: 399 bui.getsound('error').play() 400 bui.screenmessage( 401 bui.Lstr(resource='internal.unavailableNoConnectionText'), 402 color=(1, 0, 0), 403 ) 404 else: 405 if is_ticket_purchase: 406 if result['allow']: 407 price = plus.get_v1_account_misc_read_val( 408 'price.' + item, None 409 ) 410 if ( 411 price is None 412 or not isinstance(price, int) 413 or price <= 0 414 ): 415 print( 416 'Error; got invalid local price of', 417 price, 418 'for item', 419 item, 420 ) 421 bui.getsound('error').play() 422 else: 423 bui.getsound('click01').play() 424 plus.in_game_purchase(item, price) 425 else: 426 if result['reason'] == 'versionTooOld': 427 bui.getsound('error').play() 428 bui.screenmessage( 429 bui.Lstr( 430 resource='getTicketsWindow.versionTooOldText' 431 ), 432 color=(1, 0, 0), 433 ) 434 else: 435 bui.getsound('error').play() 436 bui.screenmessage( 437 bui.Lstr( 438 resource='getTicketsWindow.unavailableText' 439 ), 440 color=(1, 0, 0), 441 ) 442 # Real in-app purchase. 443 else: 444 if result['allow']: 445 plus.purchase(item) 446 else: 447 if result['reason'] == 'versionTooOld': 448 bui.getsound('error').play() 449 bui.screenmessage( 450 bui.Lstr( 451 resource='getTicketsWindow.versionTooOldText' 452 ), 453 color=(1, 0, 0), 454 ) 455 else: 456 bui.getsound('error').play() 457 bui.screenmessage( 458 bui.Lstr( 459 resource='getTicketsWindow.unavailableText' 460 ), 461 color=(1, 0, 0), 462 ) 463 464 def _do_purchase_check( 465 self, item: str, is_ticket_purchase: bool = False 466 ) -> None: 467 app = bui.app 468 if app.classic is None: 469 logging.warning('_do_purchase_check() requires classic.') 470 return 471 472 # Here we ping the server to ask if it's valid for us to 473 # purchase this. Better to fail now than after we've 474 # paid locally. 475 476 app.classic.master_server_v1_get( 477 'bsAccountPurchaseCheck', 478 { 479 'item': item, 480 'platform': app.classic.platform, 481 'subplatform': app.classic.subplatform, 482 'version': app.env.engine_version, 483 'buildNumber': app.env.engine_build_number, 484 'purchaseType': 'ticket' if is_ticket_purchase else 'real', 485 }, 486 callback=bui.WeakCall( 487 self._purchase_check_result, item, is_ticket_purchase 488 ), 489 ) 490 491 def buy(self, item: str) -> None: 492 """Attempt to purchase the provided item.""" 493 from bauiv1lib import account 494 from bauiv1lib.confirm import ConfirmWindow 495 496 assert bui.app.classic is not None 497 store = bui.app.classic.store 498 499 plus = bui.app.plus 500 assert plus is not None 501 502 # Prevent pressing buy within a few seconds of the last press 503 # (gives the buttons time to disable themselves and whatnot). 504 curtime = bui.apptime() 505 if ( 506 self._last_buy_time is not None 507 and (curtime - self._last_buy_time) < 2.0 508 ): 509 bui.getsound('error').play() 510 else: 511 if plus.get_v1_account_state() != 'signed_in': 512 account.show_sign_in_prompt() 513 else: 514 self._last_buy_time = curtime 515 516 # Merch is a special case - just a link. 517 if item == 'merch': 518 url = bui.app.config.get('Merch Link') 519 if isinstance(url, str): 520 bui.open_url(url) 521 522 # Pro is an actual IAP, and the rest are ticket purchases. 523 elif item == 'pro': 524 bui.getsound('click01').play() 525 526 # Purchase either pro or pro_sale depending on whether 527 # there is a sale going on. 528 self._do_purchase_check( 529 'pro' 530 if store.get_available_sale_time('extras') is None 531 else 'pro_sale' 532 ) 533 else: 534 price = plus.get_v1_account_misc_read_val( 535 'price.' + item, None 536 ) 537 our_tickets = plus.get_v1_account_ticket_count() 538 if price is not None and our_tickets < price: 539 bui.getsound('error').play() 540 print('FIXME - show not-enough-tickets info.') 541 # gettickets.show_get_tickets_prompt() 542 else: 543 544 def do_it() -> None: 545 self._do_purchase_check( 546 item, is_ticket_purchase=True 547 ) 548 549 bui.getsound('swish').play() 550 ConfirmWindow( 551 bui.Lstr( 552 resource='store.purchaseConfirmText', 553 subs=[ 554 ( 555 '${ITEM}', 556 store.get_store_item_name_translated( 557 item 558 ), 559 ) 560 ], 561 ), 562 width=400, 563 height=120, 564 action=do_it, 565 ok_text=bui.Lstr( 566 resource='store.purchaseText', 567 fallback_resource='okText', 568 ), 569 ) 570 571 def _print_already_own(self, charname: str) -> None: 572 bui.screenmessage( 573 bui.Lstr( 574 resource=f'{self._r}.alreadyOwnText', 575 subs=[('${NAME}', charname)], 576 ), 577 color=(1, 0, 0), 578 ) 579 bui.getsound('error').play() 580 581 def update_buttons(self) -> None: 582 """Update our buttons.""" 583 # pylint: disable=too-many-statements 584 # pylint: disable=too-many-branches 585 # pylint: disable=too-many-locals 586 from bauiv1 import SpecialChar 587 588 assert bui.app.classic is not None 589 store = bui.app.classic.store 590 591 plus = bui.app.plus 592 assert plus is not None 593 594 if not self._root_widget: 595 return 596 597 sales_raw = plus.get_v1_account_misc_read_val('sales', {}) 598 sales = {} 599 try: 600 # Look at the current set of sales; filter any with time remaining. 601 for sale_item, sale_info in list(sales_raw.items()): 602 to_end = ( 603 datetime.datetime.fromtimestamp( 604 sale_info['e'], datetime.UTC 605 ) 606 - utc_now() 607 ).total_seconds() 608 if to_end > 0: 609 sales[sale_item] = { 610 'to_end': to_end, 611 'original_price': sale_info['op'], 612 } 613 except Exception: 614 logging.exception('Error parsing sales.') 615 616 assert self.button_infos is not None 617 for b_type, b_info in self.button_infos.items(): 618 if b_type == 'merch': 619 purchased = False 620 elif b_type in ['upgrades.pro', 'pro']: 621 assert bui.app.classic is not None 622 purchased = bui.app.classic.accounts.have_pro() 623 else: 624 purchased = plus.get_v1_account_product_purchased(b_type) 625 626 sale_opacity = 0.0 627 sale_title_text: str | bui.Lstr = '' 628 sale_time_text: str | bui.Lstr = '' 629 630 if purchased: 631 title_color = (0.8, 0.7, 0.9, 1.0) 632 color = (0.63, 0.55, 0.78) 633 extra_image_opacity = 0.5 634 call = bui.WeakCall(self._print_already_own, b_info['name']) 635 price_text = '' 636 price_text_left = '' 637 price_text_right = '' 638 show_purchase_check = True 639 description_color: Sequence[float] = (0.4, 1.0, 0.4, 0.4) 640 description_color2: Sequence[float] = (0.0, 0.0, 0.0, 0.0) 641 price_color = (0.5, 1, 0.5, 0.3) 642 else: 643 title_color = (0.7, 0.9, 0.7, 1.0) 644 color = (0.4, 0.8, 0.1) 645 extra_image_opacity = 1.0 646 call = b_info['call'] if 'call' in b_info else None 647 if b_type == 'merch': 648 price_text = '' 649 price_text_left = '' 650 price_text_right = '' 651 elif b_type in ['upgrades.pro', 'pro']: 652 sale_time = store.get_available_sale_time('extras') 653 if sale_time is not None: 654 priceraw = plus.get_price('pro') 655 price_text_left = ( 656 priceraw if priceraw is not None else '?' 657 ) 658 priceraw = plus.get_price('pro_sale') 659 price_text_right = ( 660 priceraw if priceraw is not None else '?' 661 ) 662 sale_opacity = 1.0 663 price_text = '' 664 sale_title_text = bui.Lstr(resource='store.saleText') 665 sale_time_text = bui.timestring( 666 sale_time / 1000.0, centi=False 667 ) 668 else: 669 priceraw = plus.get_price('pro') 670 price_text = priceraw if priceraw is not None else '?' 671 price_text_left = '' 672 price_text_right = '' 673 else: 674 price = plus.get_v1_account_misc_read_val( 675 'price.' + b_type, 0 676 ) 677 678 # Color the button differently if we cant afford this. 679 if plus.get_v1_account_state() == 'signed_in': 680 if plus.get_v1_account_ticket_count() < price: 681 color = (0.6, 0.61, 0.6) 682 price_text = bui.charstr(bui.SpecialChar.TICKET) + str( 683 plus.get_v1_account_misc_read_val( 684 'price.' + b_type, '?' 685 ) 686 ) 687 price_text_left = '' 688 price_text_right = '' 689 690 # TESTING: 691 if b_type in sales: 692 sale_opacity = 1.0 693 price_text_left = bui.charstr(SpecialChar.TICKET) + str( 694 sales[b_type]['original_price'] 695 ) 696 price_text_right = price_text 697 price_text = '' 698 sale_title_text = bui.Lstr(resource='store.saleText') 699 sale_time_text = bui.timestring( 700 sales[b_type]['to_end'], centi=False 701 ) 702 703 description_color = (0.5, 1.0, 0.5) 704 description_color2 = (0.3, 1.0, 1.0) 705 price_color = (0.2, 1, 0.2, 1.0) 706 show_purchase_check = False 707 708 if 'title_text' in b_info: 709 bui.textwidget(edit=b_info['title_text'], color=title_color) 710 if 'purchase_check' in b_info: 711 bui.imagewidget( 712 edit=b_info['purchase_check'], 713 opacity=1.0 if show_purchase_check else 0.0, 714 ) 715 if 'price_widget' in b_info: 716 bui.textwidget( 717 edit=b_info['price_widget'], 718 text=price_text, 719 color=price_color, 720 ) 721 if 'price_widget_left' in b_info: 722 bui.textwidget( 723 edit=b_info['price_widget_left'], text=price_text_left 724 ) 725 if 'price_widget_right' in b_info: 726 bui.textwidget( 727 edit=b_info['price_widget_right'], text=price_text_right 728 ) 729 if 'price_slash_widget' in b_info: 730 bui.imagewidget( 731 edit=b_info['price_slash_widget'], opacity=sale_opacity 732 ) 733 if 'sale_bg_widget' in b_info: 734 bui.imagewidget( 735 edit=b_info['sale_bg_widget'], opacity=sale_opacity 736 ) 737 if 'sale_title_widget' in b_info: 738 bui.textwidget( 739 edit=b_info['sale_title_widget'], text=sale_title_text 740 ) 741 if 'sale_time_widget' in b_info: 742 bui.textwidget( 743 edit=b_info['sale_time_widget'], text=sale_time_text 744 ) 745 if 'button' in b_info: 746 bui.buttonwidget( 747 edit=b_info['button'], color=color, on_activate_call=call 748 ) 749 if 'extra_backings' in b_info: 750 for bck in b_info['extra_backings']: 751 bui.imagewidget( 752 edit=bck, color=color, opacity=extra_image_opacity 753 ) 754 if 'extra_images' in b_info: 755 for img in b_info['extra_images']: 756 bui.imagewidget(edit=img, opacity=extra_image_opacity) 757 if 'extra_texts' in b_info: 758 for etxt in b_info['extra_texts']: 759 bui.textwidget(edit=etxt, color=description_color) 760 if 'extra_texts_2' in b_info: 761 for etxt in b_info['extra_texts_2']: 762 bui.textwidget(edit=etxt, color=description_color2) 763 if 'descriptionText' in b_info: 764 bui.textwidget( 765 edit=b_info['descriptionText'], color=description_color 766 ) 767 768 def _on_response(self, data: dict[str, Any] | None) -> None: 769 # pylint: disable=too-many-statements 770 771 assert bui.app.classic is not None 772 cstore = bui.app.classic.store 773 774 # clear status text.. 775 if self._status_textwidget: 776 self._status_textwidget.delete() 777 self._status_textwidget_update_timer = None 778 779 if data is None: 780 self._status_textwidget = bui.textwidget( 781 parent=self._root_widget, 782 position=(self._width * 0.5, self._height * 0.5), 783 size=(0, 0), 784 scale=1.3, 785 transition_delay=0.1, 786 color=(1, 0.3, 0.3, 1.0), 787 h_align='center', 788 v_align='center', 789 text=bui.Lstr(resource=f'{self._r}.loadErrorText'), 790 maxwidth=self._scroll_width * 0.9, 791 ) 792 else: 793 794 class _Store: 795 def __init__( 796 self, 797 store_window: StoreBrowserWindow, 798 sdata: dict[str, Any], 799 width: float, 800 ): 801 self._store_window = store_window 802 self._width = width 803 store_data = cstore.get_store_layout() 804 self._tab = sdata['tab'] 805 self._sections = copy.deepcopy(store_data[sdata['tab']]) 806 self._height: float | None = None 807 808 assert bui.app.classic is not None 809 uiscale = bui.app.ui_v1.uiscale 810 811 # Pre-calc a few things and add them to store-data. 812 for section in self._sections: 813 if self._tab == 'characters': 814 dummy_name = 'characters.foo' 815 elif self._tab == 'extras': 816 dummy_name = 'pro' 817 elif self._tab == 'maps': 818 dummy_name = 'maps.foo' 819 elif self._tab == 'icons': 820 dummy_name = 'icons.foo' 821 else: 822 dummy_name = '' 823 section['button_size'] = ( 824 cstore.get_store_item_display_size(dummy_name) 825 ) 826 section['v_spacing'] = ( 827 -25 828 if ( 829 self._tab == 'extras' 830 and uiscale is bui.UIScale.SMALL 831 ) 832 else -17 if self._tab == 'characters' else 0 833 ) 834 if 'title' not in section: 835 section['title'] = '' 836 section['x_offs'] = ( 837 130 838 if self._tab == 'extras' 839 else 270 if self._tab == 'maps' else 0 840 ) 841 section['y_offs'] = ( 842 20 843 if ( 844 self._tab == 'extras' 845 and uiscale is bui.UIScale.SMALL 846 and bui.app.config.get('Merch Link') 847 ) 848 else ( 849 55 850 if ( 851 self._tab == 'extras' 852 and uiscale is bui.UIScale.SMALL 853 ) 854 else -20 if self._tab == 'icons' else 0 855 ) 856 ) 857 858 def instantiate( 859 self, scrollwidget: bui.Widget, tab_button: bui.Widget 860 ) -> None: 861 """Create the store.""" 862 # pylint: disable=too-many-locals 863 # pylint: disable=too-many-branches 864 # pylint: disable=too-many-nested-blocks 865 from bauiv1lib.store.item import ( 866 instantiate_store_item_display, 867 ) 868 869 title_spacing = 40 870 button_border = 20 871 button_spacing = 4 872 boffs_h = 40 873 self._height = 80.0 874 875 # Calc total height. 876 for i, section in enumerate(self._sections): 877 if section['title'] != '': 878 assert self._height is not None 879 self._height += title_spacing 880 b_width, b_height = section['button_size'] 881 b_column_count = int( 882 math.floor( 883 (self._width - boffs_h - 20) 884 / (b_width + button_spacing) 885 ) 886 ) 887 b_row_count = int( 888 math.ceil( 889 float(len(section['items'])) / b_column_count 890 ) 891 ) 892 b_height_total = ( 893 2 * button_border 894 + b_row_count * b_height 895 + (b_row_count - 1) * section['v_spacing'] 896 ) 897 self._height += b_height_total 898 899 assert self._height is not None 900 cnt2 = bui.containerwidget( 901 parent=scrollwidget, 902 scale=1.0, 903 size=(self._width, self._height), 904 background=False, 905 claims_left_right=True, 906 claims_tab=True, 907 selection_loops_to_parent=True, 908 ) 909 v = self._height - 20 910 911 if self._tab == 'characters': 912 txt = bui.Lstr( 913 resource='store.howToSwitchCharactersText', 914 subs=[ 915 ( 916 '${SETTINGS}', 917 bui.Lstr( 918 resource=( 919 'accountSettingsWindow.titleText' 920 ) 921 ), 922 ), 923 ( 924 '${PLAYER_PROFILES}', 925 bui.Lstr( 926 resource=( 927 'playerProfilesWindow.titleText' 928 ) 929 ), 930 ), 931 ], 932 ) 933 bui.textwidget( 934 parent=cnt2, 935 text=txt, 936 size=(0, 0), 937 position=(self._width * 0.5, self._height - 28), 938 h_align='center', 939 v_align='center', 940 color=(0.7, 1, 0.7, 0.4), 941 scale=0.7, 942 shadow=0, 943 flatness=1.0, 944 maxwidth=700, 945 transition_delay=0.4, 946 ) 947 elif self._tab == 'icons': 948 txt = bui.Lstr( 949 resource='store.howToUseIconsText', 950 subs=[ 951 ( 952 '${SETTINGS}', 953 bui.Lstr(resource='mainMenu.settingsText'), 954 ), 955 ( 956 '${PLAYER_PROFILES}', 957 bui.Lstr( 958 resource=( 959 'playerProfilesWindow.titleText' 960 ) 961 ), 962 ), 963 ], 964 ) 965 bui.textwidget( 966 parent=cnt2, 967 text=txt, 968 size=(0, 0), 969 position=(self._width * 0.5, self._height - 28), 970 h_align='center', 971 v_align='center', 972 color=(0.7, 1, 0.7, 0.4), 973 scale=0.7, 974 shadow=0, 975 flatness=1.0, 976 maxwidth=700, 977 transition_delay=0.4, 978 ) 979 elif self._tab == 'maps': 980 assert self._width is not None 981 assert self._height is not None 982 txt = bui.Lstr(resource='store.howToUseMapsText') 983 bui.textwidget( 984 parent=cnt2, 985 text=txt, 986 size=(0, 0), 987 position=(self._width * 0.5, self._height - 28), 988 h_align='center', 989 v_align='center', 990 color=(0.7, 1, 0.7, 0.4), 991 scale=0.7, 992 shadow=0, 993 flatness=1.0, 994 maxwidth=700, 995 transition_delay=0.4, 996 ) 997 998 prev_row_buttons: list | None = None 999 this_row_buttons = [] 1000 1001 delay = 0.3 1002 for section in self._sections: 1003 if section['title'] != '': 1004 bui.textwidget( 1005 parent=cnt2, 1006 position=(60, v - title_spacing * 0.8), 1007 size=(0, 0), 1008 scale=1.0, 1009 transition_delay=delay, 1010 color=(0.7, 0.9, 0.7, 1), 1011 h_align='left', 1012 v_align='center', 1013 text=bui.Lstr(resource=section['title']), 1014 maxwidth=self._width * 0.7, 1015 ) 1016 v -= title_spacing 1017 delay = max(0.100, delay - 0.100) 1018 v -= button_border 1019 b_width, b_height = section['button_size'] 1020 b_count = len(section['items']) 1021 b_column_count = int( 1022 math.floor( 1023 (self._width - boffs_h - 20) 1024 / (b_width + button_spacing) 1025 ) 1026 ) 1027 col = 0 1028 item: dict[str, Any] 1029 assert self._store_window.button_infos is not None 1030 for i, item_name in enumerate(section['items']): 1031 item = self._store_window.button_infos[ 1032 item_name 1033 ] = {} 1034 item['call'] = bui.WeakCall( 1035 self._store_window.buy, item_name 1036 ) 1037 if 'x_offs' in section: 1038 boffs_h2 = section['x_offs'] 1039 else: 1040 boffs_h2 = 0 1041 1042 if 'y_offs' in section: 1043 boffs_v2 = section['y_offs'] 1044 else: 1045 boffs_v2 = 0 1046 b_pos = ( 1047 boffs_h 1048 + boffs_h2 1049 + (b_width + button_spacing) * col, 1050 v - b_height + boffs_v2, 1051 ) 1052 instantiate_store_item_display( 1053 item_name, 1054 item, 1055 parent_widget=cnt2, 1056 b_pos=b_pos, 1057 boffs_h=boffs_h, 1058 b_width=b_width, 1059 b_height=b_height, 1060 boffs_h2=boffs_h2, 1061 boffs_v2=boffs_v2, 1062 delay=delay, 1063 ) 1064 btn = item['button'] 1065 delay = max(0.1, delay - 0.1) 1066 this_row_buttons.append(btn) 1067 1068 # Wire this button to the equivalent in the 1069 # previous row. 1070 if prev_row_buttons is not None: 1071 if len(prev_row_buttons) > col: 1072 bui.widget( 1073 edit=btn, 1074 up_widget=prev_row_buttons[col], 1075 ) 1076 bui.widget( 1077 edit=prev_row_buttons[col], 1078 down_widget=btn, 1079 ) 1080 1081 # If we're the last button in our row, 1082 # wire any in the previous row past 1083 # our position to go to us if down is 1084 # pressed. 1085 if ( 1086 col + 1 == b_column_count 1087 or i == b_count - 1 1088 ): 1089 for b_prev in prev_row_buttons[ 1090 col + 1 : 1091 ]: 1092 bui.widget( 1093 edit=b_prev, down_widget=btn 1094 ) 1095 else: 1096 bui.widget( 1097 edit=btn, up_widget=prev_row_buttons[-1] 1098 ) 1099 else: 1100 bui.widget(edit=btn, up_widget=tab_button) 1101 1102 col += 1 1103 if col == b_column_count or i == b_count - 1: 1104 prev_row_buttons = this_row_buttons 1105 this_row_buttons = [] 1106 col = 0 1107 v -= b_height 1108 if i < b_count - 1: 1109 v -= section['v_spacing'] 1110 1111 v -= button_border 1112 1113 # Set a timer to update these buttons periodically as long 1114 # as we're alive (so if we buy one it will grey out, etc). 1115 self._store_window.update_buttons_timer = bui.AppTimer( 1116 0.5, 1117 bui.WeakCall(self._store_window.update_buttons), 1118 repeat=True, 1119 ) 1120 1121 # Also update them immediately. 1122 self._store_window.update_buttons() 1123 1124 if self._current_tab in ( 1125 self.TabID.EXTRAS, 1126 self.TabID.MINIGAMES, 1127 self.TabID.CHARACTERS, 1128 self.TabID.MAPS, 1129 self.TabID.ICONS, 1130 ): 1131 store = _Store(self, data, self._scroll_width) 1132 assert self._scrollwidget is not None 1133 store.instantiate( 1134 scrollwidget=self._scrollwidget, 1135 tab_button=self._tab_row.tabs[self._current_tab].button, 1136 ) 1137 else: 1138 cnt = bui.containerwidget( 1139 parent=self._scrollwidget, 1140 scale=1.0, 1141 size=(self._scroll_width, self._scroll_height * 0.95), 1142 background=False, 1143 claims_left_right=True, 1144 claims_tab=True, 1145 selection_loops_to_parent=True, 1146 ) 1147 self._status_textwidget = bui.textwidget( 1148 parent=cnt, 1149 position=( 1150 self._scroll_width * 0.5, 1151 self._scroll_height * 0.5, 1152 ), 1153 size=(0, 0), 1154 scale=1.3, 1155 transition_delay=0.1, 1156 color=(1, 1, 0.3, 1.0), 1157 h_align='center', 1158 v_align='center', 1159 text=bui.Lstr(resource=f'{self._r}.comingSoonText'), 1160 maxwidth=self._scroll_width * 0.9, 1161 ) 1162 1163 @override 1164 def get_main_window_state(self) -> bui.MainWindowState: 1165 # Support recreating our window for back/refresh purposes. 1166 cls = type(self) 1167 return bui.BasicMainWindowState( 1168 create_call=lambda transition, origin_widget: cls( 1169 transition=transition, origin_widget=origin_widget 1170 ) 1171 ) 1172 1173 @override 1174 def on_main_window_close(self) -> None: 1175 self._save_state() 1176 1177 def _save_state(self) -> None: 1178 try: 1179 sel = self._root_widget.get_selected_child() 1180 selected_tab_ids = [ 1181 tab_id 1182 for tab_id, tab in self._tab_row.tabs.items() 1183 if sel == tab.button 1184 ] 1185 if sel == self._scrollwidget: 1186 sel_name = 'Scroll' 1187 elif sel == self._back_button: 1188 sel_name = 'Back' 1189 elif selected_tab_ids: 1190 assert len(selected_tab_ids) == 1 1191 sel_name = f'Tab:{selected_tab_ids[0].value}' 1192 else: 1193 raise ValueError(f'unrecognized selection \'{sel}\'') 1194 assert bui.app.classic is not None 1195 bui.app.ui_v1.window_states[type(self)] = { 1196 'sel_name': sel_name, 1197 } 1198 except Exception: 1199 logging.exception('Error saving state for %s.', self) 1200 1201 def _restore_state(self) -> None: 1202 1203 try: 1204 sel: bui.Widget | None 1205 assert bui.app.classic is not None 1206 sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get( 1207 'sel_name' 1208 ) 1209 assert isinstance(sel_name, (str, type(None))) 1210 1211 try: 1212 current_tab = self.TabID(bui.app.config.get('Store Tab')) 1213 except ValueError: 1214 current_tab = self.TabID.CHARACTERS 1215 1216 if self._show_tab is not None: 1217 current_tab = self._show_tab 1218 if sel_name == 'Back': 1219 sel = self._back_button 1220 elif sel_name == 'Scroll': 1221 sel = self._scrollwidget 1222 elif isinstance(sel_name, str) and sel_name.startswith('Tab:'): 1223 try: 1224 sel_tab_id = self.TabID(sel_name.split(':')[-1]) 1225 except ValueError: 1226 sel_tab_id = self.TabID.CHARACTERS 1227 sel = self._tab_row.tabs[sel_tab_id].button 1228 else: 1229 sel = self._tab_row.tabs[current_tab].button 1230 1231 # If we were requested to show a tab, select it too. 1232 if ( 1233 self._show_tab is not None 1234 and self._show_tab in self._tab_row.tabs 1235 ): 1236 sel = self._tab_row.tabs[self._show_tab].button 1237 self._set_tab(current_tab) 1238 if sel is not None: 1239 bui.containerwidget(edit=self._root_widget, selected_child=sel) 1240 except Exception: 1241 logging.exception('Error restoring state for %s.', self) 1242 1243 1244def _check_merch_availability_in_bg_thread() -> None: 1245 # pylint: disable=cell-var-from-loop 1246 1247 # Merch is available from some countries only. Make a reasonable 1248 # check to ask the master-server about this at launch and store the 1249 # results. 1250 plus = bui.app.plus 1251 assert plus is not None 1252 1253 for _i in range(15): 1254 try: 1255 if plus.cloud.is_connected(): 1256 response = plus.cloud.send_message( 1257 bacommon.cloud.MerchAvailabilityMessage() 1258 ) 1259 1260 def _store_in_logic_thread() -> None: 1261 cfg = bui.app.config 1262 current = cfg.get(MERCH_LINK_KEY) 1263 if not isinstance(current, str | None): 1264 current = None 1265 if current != response.url: 1266 cfg[MERCH_LINK_KEY] = response.url 1267 cfg.commit() 1268 1269 # If we successfully get a response, kick it over to the 1270 # logic thread to store and we're done. 1271 bui.pushcall(_store_in_logic_thread, from_other_thread=True) 1272 return 1273 except CommunicationError: 1274 pass 1275 except Exception: 1276 logging.warning( 1277 'Unexpected error in merch-availability-check.', exc_info=True 1278 ) 1279 time.sleep(1.1934) # A bit randomized to avoid aliasing. 1280 1281 1282# Slight hack; start checking merch availability in the bg (but only if 1283# it looks like we've been imported for use in a running app; don't want 1284# to do this during docs generation/etc.) 1285 1286# TODO: Should wire this up explicitly to app bootstrapping; not good to 1287# be kicking off work at module import time. 1288if ( 1289 os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') != '1' 1290 and bui.app.state is not bui.app.State.NOT_STARTED 1291): 1292 Thread(target=_check_merch_availability_in_bg_thread, daemon=True).start()
MERCH_LINK_KEY =
'Merch Link'
class
StoreBrowserWindow(bauiv1._uitypes.MainWindow):
30class StoreBrowserWindow(bui.MainWindow): 31 """Window for browsing the store.""" 32 33 class TabID(Enum): 34 """Our available tab types.""" 35 36 EXTRAS = 'extras' 37 MAPS = 'maps' 38 MINIGAMES = 'minigames' 39 CHARACTERS = 'characters' 40 ICONS = 'icons' 41 42 def __init__( 43 self, 44 transition: str | None = 'in_right', 45 origin_widget: bui.Widget | None = None, 46 show_tab: StoreBrowserWindow.TabID | None = None, 47 minimal_toolbars: bool = False, 48 ): 49 # pylint: disable=too-many-statements 50 # pylint: disable=too-many-locals 51 from bauiv1lib.tabs import TabRow 52 from bauiv1 import SpecialChar 53 54 app = bui.app 55 assert app.classic is not None 56 uiscale = app.ui_v1.uiscale 57 58 bui.set_analytics_screen('Store Window') 59 60 self.button_infos: dict[str, dict[str, Any]] | None = None 61 self.update_buttons_timer: bui.AppTimer | None = None 62 self._status_textwidget_update_timer = None 63 64 self._show_tab = show_tab 65 self._width = 1670 if uiscale is bui.UIScale.SMALL else 1040 66 self._x_inset = x_inset = 310 if uiscale is bui.UIScale.SMALL else 0 67 self._height = ( 68 538 69 if uiscale is bui.UIScale.SMALL 70 else 645 if uiscale is bui.UIScale.MEDIUM else 800 71 ) 72 self._current_tab: StoreBrowserWindow.TabID | None = None 73 extra_top = 30 if uiscale is bui.UIScale.SMALL else 0 74 75 self.request: Any = None 76 self._r = 'store' 77 self._last_buy_time: float | None = None 78 79 super().__init__( 80 root_widget=bui.containerwidget( 81 size=(self._width, self._height + extra_top), 82 toolbar_visibility=( 83 'menu_store' 84 if (uiscale is bui.UIScale.SMALL or minimal_toolbars) 85 else 'menu_full' 86 ), 87 scale=( 88 1.3 89 if uiscale is bui.UIScale.SMALL 90 else 0.9 if uiscale is bui.UIScale.MEDIUM else 0.8 91 ), 92 stack_offset=( 93 (0, 10) 94 if uiscale is bui.UIScale.SMALL 95 else (0, 0) if uiscale is bui.UIScale.MEDIUM else (0, 0) 96 ), 97 ), 98 transition=transition, 99 origin_widget=origin_widget, 100 ) 101 102 self._back_button = btn = bui.buttonwidget( 103 parent=self._root_widget, 104 position=(70 + x_inset, self._height - 74), 105 size=(140, 60), 106 scale=1.1, 107 autoselect=True, 108 label=bui.Lstr(resource='backText'), 109 button_type='back', 110 on_activate_call=self.main_window_back, 111 ) 112 113 if uiscale is bui.UIScale.SMALL: 114 self._back_button.delete() 115 bui.containerwidget( 116 edit=self._root_widget, on_cancel_call=self.main_window_back 117 ) 118 backbuttonspecial = True 119 else: 120 bui.containerwidget(edit=self._root_widget, cancel_button=btn) 121 backbuttonspecial = False 122 123 if ( 124 app.classic.platform in ['mac', 'ios'] 125 and app.classic.subplatform == 'appstore' 126 ): 127 bui.buttonwidget( 128 parent=self._root_widget, 129 position=(self._width * 0.5 - 70, 16), 130 size=(230, 50), 131 scale=0.65, 132 on_activate_call=bui.WeakCall(self._restore_purchases), 133 color=(0.35, 0.3, 0.4), 134 selectable=False, 135 textcolor=(0.55, 0.5, 0.6), 136 label=bui.Lstr( 137 resource='getTicketsWindow.restorePurchasesText' 138 ), 139 ) 140 141 bui.textwidget( 142 parent=self._root_widget, 143 position=( 144 self._width * 0.5, 145 self._height - (53 if uiscale is bui.UIScale.SMALL else 44), 146 ), 147 size=(0, 0), 148 color=app.ui_v1.title_color, 149 scale=1.5, 150 h_align='center', 151 v_align='center', 152 text=bui.Lstr(resource='storeText'), 153 maxwidth=290, 154 ) 155 156 if not backbuttonspecial: 157 bui.buttonwidget( 158 edit=self._back_button, 159 button_type='backSmall', 160 size=(60, 60), 161 label=bui.charstr(SpecialChar.BACK), 162 ) 163 164 scroll_buffer_h = 130 + 2 * x_inset 165 tab_buffer_h = 250 + 2 * x_inset 166 167 tabs_def = [ 168 (self.TabID.EXTRAS, bui.Lstr(resource=f'{self._r}.extrasText')), 169 (self.TabID.MAPS, bui.Lstr(resource=f'{self._r}.mapsText')), 170 ( 171 self.TabID.MINIGAMES, 172 bui.Lstr(resource=f'{self._r}.miniGamesText'), 173 ), 174 ( 175 self.TabID.CHARACTERS, 176 bui.Lstr(resource=f'{self._r}.charactersText'), 177 ), 178 (self.TabID.ICONS, bui.Lstr(resource=f'{self._r}.iconsText')), 179 ] 180 181 self._tab_row = TabRow( 182 self._root_widget, 183 tabs_def, 184 pos=(tab_buffer_h * 0.5, self._height - 130), 185 size=(self._width - tab_buffer_h, 50), 186 on_select_call=self._set_tab, 187 ) 188 189 self._purchasable_count_widgets: dict[ 190 StoreBrowserWindow.TabID, dict[str, Any] 191 ] = {} 192 193 # Create our purchasable-items tags and have them update over time. 194 for tab_id, tab in self._tab_row.tabs.items(): 195 pos = tab.position 196 size = tab.size 197 button = tab.button 198 rad = 10 199 center = (pos[0] + 0.1 * size[0], pos[1] + 0.9 * size[1]) 200 img = bui.imagewidget( 201 parent=self._root_widget, 202 position=(center[0] - rad * 1.04, center[1] - rad * 1.15), 203 size=(rad * 2.2, rad * 2.2), 204 texture=bui.gettexture('circleShadow'), 205 color=(1, 0, 0), 206 ) 207 txt = bui.textwidget( 208 parent=self._root_widget, 209 position=center, 210 size=(0, 0), 211 h_align='center', 212 v_align='center', 213 maxwidth=1.4 * rad, 214 scale=0.6, 215 shadow=1.0, 216 flatness=1.0, 217 ) 218 rad = 20 219 sale_img = bui.imagewidget( 220 parent=self._root_widget, 221 position=(center[0] - rad, center[1] - rad), 222 size=(rad * 2, rad * 2), 223 draw_controller=button, 224 texture=bui.gettexture('circleZigZag'), 225 color=(0.5, 0, 1.0), 226 ) 227 sale_title_text = bui.textwidget( 228 parent=self._root_widget, 229 position=(center[0], center[1] + 0.24 * rad), 230 size=(0, 0), 231 h_align='center', 232 v_align='center', 233 draw_controller=button, 234 maxwidth=1.4 * rad, 235 scale=0.6, 236 shadow=0.0, 237 flatness=1.0, 238 color=(0, 1, 0), 239 ) 240 sale_time_text = bui.textwidget( 241 parent=self._root_widget, 242 position=(center[0], center[1] - 0.29 * rad), 243 size=(0, 0), 244 h_align='center', 245 v_align='center', 246 draw_controller=button, 247 maxwidth=1.4 * rad, 248 scale=0.4, 249 shadow=0.0, 250 flatness=1.0, 251 color=(0, 1, 0), 252 ) 253 self._purchasable_count_widgets[tab_id] = { 254 'img': img, 255 'text': txt, 256 'sale_img': sale_img, 257 'sale_title_text': sale_title_text, 258 'sale_time_text': sale_time_text, 259 } 260 self._tab_update_timer = bui.AppTimer( 261 1.0, bui.WeakCall(self._update_tabs), repeat=True 262 ) 263 self._update_tabs() 264 265 if uiscale is bui.UIScale.SMALL: 266 first_tab_button = self._tab_row.tabs[tabs_def[0][0]].button 267 last_tab_button = self._tab_row.tabs[tabs_def[-1][0]].button 268 bui.widget( 269 edit=first_tab_button, 270 left_widget=bui.get_special_widget('back_button'), 271 ) 272 bui.widget( 273 edit=last_tab_button, 274 right_widget=bui.get_special_widget('squad_button'), 275 ) 276 277 self._scroll_width = self._width - scroll_buffer_h 278 self._scroll_height = self._height - 180 279 280 self._scrollwidget: bui.Widget | None = None 281 self._status_textwidget: bui.Widget | None = None 282 self._restore_state() 283 284 def _restore_purchases(self) -> None: 285 from bauiv1lib import account 286 287 plus = bui.app.plus 288 assert plus is not None 289 if plus.accounts.primary is None: 290 account.show_sign_in_prompt() 291 else: 292 plus.restore_purchases() 293 294 def _update_tabs(self) -> None: 295 assert bui.app.classic is not None 296 store = bui.app.classic.store 297 298 if not self._root_widget: 299 return 300 for tab_id, tab_data in list(self._purchasable_count_widgets.items()): 301 sale_time = store.get_available_sale_time(tab_id.value) 302 303 if sale_time is not None: 304 bui.textwidget( 305 edit=tab_data['sale_title_text'], 306 text=bui.Lstr(resource='store.saleText'), 307 ) 308 bui.textwidget( 309 edit=tab_data['sale_time_text'], 310 text=bui.timestring(sale_time / 1000.0, centi=False), 311 ) 312 bui.imagewidget(edit=tab_data['sale_img'], opacity=1.0) 313 count = 0 314 else: 315 bui.textwidget(edit=tab_data['sale_title_text'], text='') 316 bui.textwidget(edit=tab_data['sale_time_text'], text='') 317 bui.imagewidget(edit=tab_data['sale_img'], opacity=0.0) 318 count = store.get_available_purchase_count(tab_id.value) 319 320 if count > 0: 321 bui.textwidget(edit=tab_data['text'], text=str(count)) 322 bui.imagewidget(edit=tab_data['img'], opacity=1.0) 323 else: 324 bui.textwidget(edit=tab_data['text'], text='') 325 bui.imagewidget(edit=tab_data['img'], opacity=0.0) 326 327 def _set_tab(self, tab_id: TabID) -> None: 328 if self._current_tab is tab_id: 329 return 330 self._current_tab = tab_id 331 332 # We wanna preserve our current tab between runs. 333 cfg = bui.app.config 334 cfg['Store Tab'] = tab_id.value 335 cfg.commit() 336 337 # Update tab colors based on which is selected. 338 self._tab_row.update_appearance(tab_id) 339 340 # (Re)create scroll widget. 341 if self._scrollwidget: 342 self._scrollwidget.delete() 343 344 self._scrollwidget = bui.scrollwidget( 345 parent=self._root_widget, 346 highlight=False, 347 position=( 348 (self._width - self._scroll_width) * 0.5, 349 self._height - self._scroll_height - 79 - 48, 350 ), 351 size=(self._scroll_width, self._scroll_height), 352 claims_left_right=True, 353 claims_tab=True, 354 selection_loops_to_parent=True, 355 ) 356 357 # NOTE: this stuff is modified by the _Store class. 358 # Should maybe clean that up. 359 self.button_infos = {} 360 self.update_buttons_timer = None 361 362 # Show status over top. 363 if self._status_textwidget: 364 self._status_textwidget.delete() 365 self._status_textwidget = bui.textwidget( 366 parent=self._root_widget, 367 position=(self._width * 0.5, self._height * 0.5), 368 size=(0, 0), 369 color=(1, 0.7, 1, 0.5), 370 h_align='center', 371 v_align='center', 372 text=bui.Lstr(resource=f'{self._r}.loadingText'), 373 maxwidth=self._scroll_width * 0.9, 374 ) 375 376 class _Request: 377 def __init__(self, window: StoreBrowserWindow): 378 self._window = weakref.ref(window) 379 data = {'tab': tab_id.value} 380 bui.apptimer(0.1, bui.WeakCall(self._on_response, data)) 381 382 def _on_response(self, data: dict[str, Any] | None) -> None: 383 # FIXME: clean this up. 384 # pylint: disable=protected-access 385 window = self._window() 386 if window is not None and (window.request is self): 387 window.request = None 388 window._on_response(data) 389 390 # Kick off a server request. 391 self.request = _Request(self) 392 393 # Actually start the purchase locally. 394 def _purchase_check_result( 395 self, item: str, is_ticket_purchase: bool, result: dict[str, Any] | None 396 ) -> None: 397 plus = bui.app.plus 398 assert plus is not None 399 if result is None: 400 bui.getsound('error').play() 401 bui.screenmessage( 402 bui.Lstr(resource='internal.unavailableNoConnectionText'), 403 color=(1, 0, 0), 404 ) 405 else: 406 if is_ticket_purchase: 407 if result['allow']: 408 price = plus.get_v1_account_misc_read_val( 409 'price.' + item, None 410 ) 411 if ( 412 price is None 413 or not isinstance(price, int) 414 or price <= 0 415 ): 416 print( 417 'Error; got invalid local price of', 418 price, 419 'for item', 420 item, 421 ) 422 bui.getsound('error').play() 423 else: 424 bui.getsound('click01').play() 425 plus.in_game_purchase(item, price) 426 else: 427 if result['reason'] == 'versionTooOld': 428 bui.getsound('error').play() 429 bui.screenmessage( 430 bui.Lstr( 431 resource='getTicketsWindow.versionTooOldText' 432 ), 433 color=(1, 0, 0), 434 ) 435 else: 436 bui.getsound('error').play() 437 bui.screenmessage( 438 bui.Lstr( 439 resource='getTicketsWindow.unavailableText' 440 ), 441 color=(1, 0, 0), 442 ) 443 # Real in-app purchase. 444 else: 445 if result['allow']: 446 plus.purchase(item) 447 else: 448 if result['reason'] == 'versionTooOld': 449 bui.getsound('error').play() 450 bui.screenmessage( 451 bui.Lstr( 452 resource='getTicketsWindow.versionTooOldText' 453 ), 454 color=(1, 0, 0), 455 ) 456 else: 457 bui.getsound('error').play() 458 bui.screenmessage( 459 bui.Lstr( 460 resource='getTicketsWindow.unavailableText' 461 ), 462 color=(1, 0, 0), 463 ) 464 465 def _do_purchase_check( 466 self, item: str, is_ticket_purchase: bool = False 467 ) -> None: 468 app = bui.app 469 if app.classic is None: 470 logging.warning('_do_purchase_check() requires classic.') 471 return 472 473 # Here we ping the server to ask if it's valid for us to 474 # purchase this. Better to fail now than after we've 475 # paid locally. 476 477 app.classic.master_server_v1_get( 478 'bsAccountPurchaseCheck', 479 { 480 'item': item, 481 'platform': app.classic.platform, 482 'subplatform': app.classic.subplatform, 483 'version': app.env.engine_version, 484 'buildNumber': app.env.engine_build_number, 485 'purchaseType': 'ticket' if is_ticket_purchase else 'real', 486 }, 487 callback=bui.WeakCall( 488 self._purchase_check_result, item, is_ticket_purchase 489 ), 490 ) 491 492 def buy(self, item: str) -> None: 493 """Attempt to purchase the provided item.""" 494 from bauiv1lib import account 495 from bauiv1lib.confirm import ConfirmWindow 496 497 assert bui.app.classic is not None 498 store = bui.app.classic.store 499 500 plus = bui.app.plus 501 assert plus is not None 502 503 # Prevent pressing buy within a few seconds of the last press 504 # (gives the buttons time to disable themselves and whatnot). 505 curtime = bui.apptime() 506 if ( 507 self._last_buy_time is not None 508 and (curtime - self._last_buy_time) < 2.0 509 ): 510 bui.getsound('error').play() 511 else: 512 if plus.get_v1_account_state() != 'signed_in': 513 account.show_sign_in_prompt() 514 else: 515 self._last_buy_time = curtime 516 517 # Merch is a special case - just a link. 518 if item == 'merch': 519 url = bui.app.config.get('Merch Link') 520 if isinstance(url, str): 521 bui.open_url(url) 522 523 # Pro is an actual IAP, and the rest are ticket purchases. 524 elif item == 'pro': 525 bui.getsound('click01').play() 526 527 # Purchase either pro or pro_sale depending on whether 528 # there is a sale going on. 529 self._do_purchase_check( 530 'pro' 531 if store.get_available_sale_time('extras') is None 532 else 'pro_sale' 533 ) 534 else: 535 price = plus.get_v1_account_misc_read_val( 536 'price.' + item, None 537 ) 538 our_tickets = plus.get_v1_account_ticket_count() 539 if price is not None and our_tickets < price: 540 bui.getsound('error').play() 541 print('FIXME - show not-enough-tickets info.') 542 # gettickets.show_get_tickets_prompt() 543 else: 544 545 def do_it() -> None: 546 self._do_purchase_check( 547 item, is_ticket_purchase=True 548 ) 549 550 bui.getsound('swish').play() 551 ConfirmWindow( 552 bui.Lstr( 553 resource='store.purchaseConfirmText', 554 subs=[ 555 ( 556 '${ITEM}', 557 store.get_store_item_name_translated( 558 item 559 ), 560 ) 561 ], 562 ), 563 width=400, 564 height=120, 565 action=do_it, 566 ok_text=bui.Lstr( 567 resource='store.purchaseText', 568 fallback_resource='okText', 569 ), 570 ) 571 572 def _print_already_own(self, charname: str) -> None: 573 bui.screenmessage( 574 bui.Lstr( 575 resource=f'{self._r}.alreadyOwnText', 576 subs=[('${NAME}', charname)], 577 ), 578 color=(1, 0, 0), 579 ) 580 bui.getsound('error').play() 581 582 def update_buttons(self) -> None: 583 """Update our buttons.""" 584 # pylint: disable=too-many-statements 585 # pylint: disable=too-many-branches 586 # pylint: disable=too-many-locals 587 from bauiv1 import SpecialChar 588 589 assert bui.app.classic is not None 590 store = bui.app.classic.store 591 592 plus = bui.app.plus 593 assert plus is not None 594 595 if not self._root_widget: 596 return 597 598 sales_raw = plus.get_v1_account_misc_read_val('sales', {}) 599 sales = {} 600 try: 601 # Look at the current set of sales; filter any with time remaining. 602 for sale_item, sale_info in list(sales_raw.items()): 603 to_end = ( 604 datetime.datetime.fromtimestamp( 605 sale_info['e'], datetime.UTC 606 ) 607 - utc_now() 608 ).total_seconds() 609 if to_end > 0: 610 sales[sale_item] = { 611 'to_end': to_end, 612 'original_price': sale_info['op'], 613 } 614 except Exception: 615 logging.exception('Error parsing sales.') 616 617 assert self.button_infos is not None 618 for b_type, b_info in self.button_infos.items(): 619 if b_type == 'merch': 620 purchased = False 621 elif b_type in ['upgrades.pro', 'pro']: 622 assert bui.app.classic is not None 623 purchased = bui.app.classic.accounts.have_pro() 624 else: 625 purchased = plus.get_v1_account_product_purchased(b_type) 626 627 sale_opacity = 0.0 628 sale_title_text: str | bui.Lstr = '' 629 sale_time_text: str | bui.Lstr = '' 630 631 if purchased: 632 title_color = (0.8, 0.7, 0.9, 1.0) 633 color = (0.63, 0.55, 0.78) 634 extra_image_opacity = 0.5 635 call = bui.WeakCall(self._print_already_own, b_info['name']) 636 price_text = '' 637 price_text_left = '' 638 price_text_right = '' 639 show_purchase_check = True 640 description_color: Sequence[float] = (0.4, 1.0, 0.4, 0.4) 641 description_color2: Sequence[float] = (0.0, 0.0, 0.0, 0.0) 642 price_color = (0.5, 1, 0.5, 0.3) 643 else: 644 title_color = (0.7, 0.9, 0.7, 1.0) 645 color = (0.4, 0.8, 0.1) 646 extra_image_opacity = 1.0 647 call = b_info['call'] if 'call' in b_info else None 648 if b_type == 'merch': 649 price_text = '' 650 price_text_left = '' 651 price_text_right = '' 652 elif b_type in ['upgrades.pro', 'pro']: 653 sale_time = store.get_available_sale_time('extras') 654 if sale_time is not None: 655 priceraw = plus.get_price('pro') 656 price_text_left = ( 657 priceraw if priceraw is not None else '?' 658 ) 659 priceraw = plus.get_price('pro_sale') 660 price_text_right = ( 661 priceraw if priceraw is not None else '?' 662 ) 663 sale_opacity = 1.0 664 price_text = '' 665 sale_title_text = bui.Lstr(resource='store.saleText') 666 sale_time_text = bui.timestring( 667 sale_time / 1000.0, centi=False 668 ) 669 else: 670 priceraw = plus.get_price('pro') 671 price_text = priceraw if priceraw is not None else '?' 672 price_text_left = '' 673 price_text_right = '' 674 else: 675 price = plus.get_v1_account_misc_read_val( 676 'price.' + b_type, 0 677 ) 678 679 # Color the button differently if we cant afford this. 680 if plus.get_v1_account_state() == 'signed_in': 681 if plus.get_v1_account_ticket_count() < price: 682 color = (0.6, 0.61, 0.6) 683 price_text = bui.charstr(bui.SpecialChar.TICKET) + str( 684 plus.get_v1_account_misc_read_val( 685 'price.' + b_type, '?' 686 ) 687 ) 688 price_text_left = '' 689 price_text_right = '' 690 691 # TESTING: 692 if b_type in sales: 693 sale_opacity = 1.0 694 price_text_left = bui.charstr(SpecialChar.TICKET) + str( 695 sales[b_type]['original_price'] 696 ) 697 price_text_right = price_text 698 price_text = '' 699 sale_title_text = bui.Lstr(resource='store.saleText') 700 sale_time_text = bui.timestring( 701 sales[b_type]['to_end'], centi=False 702 ) 703 704 description_color = (0.5, 1.0, 0.5) 705 description_color2 = (0.3, 1.0, 1.0) 706 price_color = (0.2, 1, 0.2, 1.0) 707 show_purchase_check = False 708 709 if 'title_text' in b_info: 710 bui.textwidget(edit=b_info['title_text'], color=title_color) 711 if 'purchase_check' in b_info: 712 bui.imagewidget( 713 edit=b_info['purchase_check'], 714 opacity=1.0 if show_purchase_check else 0.0, 715 ) 716 if 'price_widget' in b_info: 717 bui.textwidget( 718 edit=b_info['price_widget'], 719 text=price_text, 720 color=price_color, 721 ) 722 if 'price_widget_left' in b_info: 723 bui.textwidget( 724 edit=b_info['price_widget_left'], text=price_text_left 725 ) 726 if 'price_widget_right' in b_info: 727 bui.textwidget( 728 edit=b_info['price_widget_right'], text=price_text_right 729 ) 730 if 'price_slash_widget' in b_info: 731 bui.imagewidget( 732 edit=b_info['price_slash_widget'], opacity=sale_opacity 733 ) 734 if 'sale_bg_widget' in b_info: 735 bui.imagewidget( 736 edit=b_info['sale_bg_widget'], opacity=sale_opacity 737 ) 738 if 'sale_title_widget' in b_info: 739 bui.textwidget( 740 edit=b_info['sale_title_widget'], text=sale_title_text 741 ) 742 if 'sale_time_widget' in b_info: 743 bui.textwidget( 744 edit=b_info['sale_time_widget'], text=sale_time_text 745 ) 746 if 'button' in b_info: 747 bui.buttonwidget( 748 edit=b_info['button'], color=color, on_activate_call=call 749 ) 750 if 'extra_backings' in b_info: 751 for bck in b_info['extra_backings']: 752 bui.imagewidget( 753 edit=bck, color=color, opacity=extra_image_opacity 754 ) 755 if 'extra_images' in b_info: 756 for img in b_info['extra_images']: 757 bui.imagewidget(edit=img, opacity=extra_image_opacity) 758 if 'extra_texts' in b_info: 759 for etxt in b_info['extra_texts']: 760 bui.textwidget(edit=etxt, color=description_color) 761 if 'extra_texts_2' in b_info: 762 for etxt in b_info['extra_texts_2']: 763 bui.textwidget(edit=etxt, color=description_color2) 764 if 'descriptionText' in b_info: 765 bui.textwidget( 766 edit=b_info['descriptionText'], color=description_color 767 ) 768 769 def _on_response(self, data: dict[str, Any] | None) -> None: 770 # pylint: disable=too-many-statements 771 772 assert bui.app.classic is not None 773 cstore = bui.app.classic.store 774 775 # clear status text.. 776 if self._status_textwidget: 777 self._status_textwidget.delete() 778 self._status_textwidget_update_timer = None 779 780 if data is None: 781 self._status_textwidget = bui.textwidget( 782 parent=self._root_widget, 783 position=(self._width * 0.5, self._height * 0.5), 784 size=(0, 0), 785 scale=1.3, 786 transition_delay=0.1, 787 color=(1, 0.3, 0.3, 1.0), 788 h_align='center', 789 v_align='center', 790 text=bui.Lstr(resource=f'{self._r}.loadErrorText'), 791 maxwidth=self._scroll_width * 0.9, 792 ) 793 else: 794 795 class _Store: 796 def __init__( 797 self, 798 store_window: StoreBrowserWindow, 799 sdata: dict[str, Any], 800 width: float, 801 ): 802 self._store_window = store_window 803 self._width = width 804 store_data = cstore.get_store_layout() 805 self._tab = sdata['tab'] 806 self._sections = copy.deepcopy(store_data[sdata['tab']]) 807 self._height: float | None = None 808 809 assert bui.app.classic is not None 810 uiscale = bui.app.ui_v1.uiscale 811 812 # Pre-calc a few things and add them to store-data. 813 for section in self._sections: 814 if self._tab == 'characters': 815 dummy_name = 'characters.foo' 816 elif self._tab == 'extras': 817 dummy_name = 'pro' 818 elif self._tab == 'maps': 819 dummy_name = 'maps.foo' 820 elif self._tab == 'icons': 821 dummy_name = 'icons.foo' 822 else: 823 dummy_name = '' 824 section['button_size'] = ( 825 cstore.get_store_item_display_size(dummy_name) 826 ) 827 section['v_spacing'] = ( 828 -25 829 if ( 830 self._tab == 'extras' 831 and uiscale is bui.UIScale.SMALL 832 ) 833 else -17 if self._tab == 'characters' else 0 834 ) 835 if 'title' not in section: 836 section['title'] = '' 837 section['x_offs'] = ( 838 130 839 if self._tab == 'extras' 840 else 270 if self._tab == 'maps' else 0 841 ) 842 section['y_offs'] = ( 843 20 844 if ( 845 self._tab == 'extras' 846 and uiscale is bui.UIScale.SMALL 847 and bui.app.config.get('Merch Link') 848 ) 849 else ( 850 55 851 if ( 852 self._tab == 'extras' 853 and uiscale is bui.UIScale.SMALL 854 ) 855 else -20 if self._tab == 'icons' else 0 856 ) 857 ) 858 859 def instantiate( 860 self, scrollwidget: bui.Widget, tab_button: bui.Widget 861 ) -> None: 862 """Create the store.""" 863 # pylint: disable=too-many-locals 864 # pylint: disable=too-many-branches 865 # pylint: disable=too-many-nested-blocks 866 from bauiv1lib.store.item import ( 867 instantiate_store_item_display, 868 ) 869 870 title_spacing = 40 871 button_border = 20 872 button_spacing = 4 873 boffs_h = 40 874 self._height = 80.0 875 876 # Calc total height. 877 for i, section in enumerate(self._sections): 878 if section['title'] != '': 879 assert self._height is not None 880 self._height += title_spacing 881 b_width, b_height = section['button_size'] 882 b_column_count = int( 883 math.floor( 884 (self._width - boffs_h - 20) 885 / (b_width + button_spacing) 886 ) 887 ) 888 b_row_count = int( 889 math.ceil( 890 float(len(section['items'])) / b_column_count 891 ) 892 ) 893 b_height_total = ( 894 2 * button_border 895 + b_row_count * b_height 896 + (b_row_count - 1) * section['v_spacing'] 897 ) 898 self._height += b_height_total 899 900 assert self._height is not None 901 cnt2 = bui.containerwidget( 902 parent=scrollwidget, 903 scale=1.0, 904 size=(self._width, self._height), 905 background=False, 906 claims_left_right=True, 907 claims_tab=True, 908 selection_loops_to_parent=True, 909 ) 910 v = self._height - 20 911 912 if self._tab == 'characters': 913 txt = bui.Lstr( 914 resource='store.howToSwitchCharactersText', 915 subs=[ 916 ( 917 '${SETTINGS}', 918 bui.Lstr( 919 resource=( 920 'accountSettingsWindow.titleText' 921 ) 922 ), 923 ), 924 ( 925 '${PLAYER_PROFILES}', 926 bui.Lstr( 927 resource=( 928 'playerProfilesWindow.titleText' 929 ) 930 ), 931 ), 932 ], 933 ) 934 bui.textwidget( 935 parent=cnt2, 936 text=txt, 937 size=(0, 0), 938 position=(self._width * 0.5, self._height - 28), 939 h_align='center', 940 v_align='center', 941 color=(0.7, 1, 0.7, 0.4), 942 scale=0.7, 943 shadow=0, 944 flatness=1.0, 945 maxwidth=700, 946 transition_delay=0.4, 947 ) 948 elif self._tab == 'icons': 949 txt = bui.Lstr( 950 resource='store.howToUseIconsText', 951 subs=[ 952 ( 953 '${SETTINGS}', 954 bui.Lstr(resource='mainMenu.settingsText'), 955 ), 956 ( 957 '${PLAYER_PROFILES}', 958 bui.Lstr( 959 resource=( 960 'playerProfilesWindow.titleText' 961 ) 962 ), 963 ), 964 ], 965 ) 966 bui.textwidget( 967 parent=cnt2, 968 text=txt, 969 size=(0, 0), 970 position=(self._width * 0.5, self._height - 28), 971 h_align='center', 972 v_align='center', 973 color=(0.7, 1, 0.7, 0.4), 974 scale=0.7, 975 shadow=0, 976 flatness=1.0, 977 maxwidth=700, 978 transition_delay=0.4, 979 ) 980 elif self._tab == 'maps': 981 assert self._width is not None 982 assert self._height is not None 983 txt = bui.Lstr(resource='store.howToUseMapsText') 984 bui.textwidget( 985 parent=cnt2, 986 text=txt, 987 size=(0, 0), 988 position=(self._width * 0.5, self._height - 28), 989 h_align='center', 990 v_align='center', 991 color=(0.7, 1, 0.7, 0.4), 992 scale=0.7, 993 shadow=0, 994 flatness=1.0, 995 maxwidth=700, 996 transition_delay=0.4, 997 ) 998 999 prev_row_buttons: list | None = None 1000 this_row_buttons = [] 1001 1002 delay = 0.3 1003 for section in self._sections: 1004 if section['title'] != '': 1005 bui.textwidget( 1006 parent=cnt2, 1007 position=(60, v - title_spacing * 0.8), 1008 size=(0, 0), 1009 scale=1.0, 1010 transition_delay=delay, 1011 color=(0.7, 0.9, 0.7, 1), 1012 h_align='left', 1013 v_align='center', 1014 text=bui.Lstr(resource=section['title']), 1015 maxwidth=self._width * 0.7, 1016 ) 1017 v -= title_spacing 1018 delay = max(0.100, delay - 0.100) 1019 v -= button_border 1020 b_width, b_height = section['button_size'] 1021 b_count = len(section['items']) 1022 b_column_count = int( 1023 math.floor( 1024 (self._width - boffs_h - 20) 1025 / (b_width + button_spacing) 1026 ) 1027 ) 1028 col = 0 1029 item: dict[str, Any] 1030 assert self._store_window.button_infos is not None 1031 for i, item_name in enumerate(section['items']): 1032 item = self._store_window.button_infos[ 1033 item_name 1034 ] = {} 1035 item['call'] = bui.WeakCall( 1036 self._store_window.buy, item_name 1037 ) 1038 if 'x_offs' in section: 1039 boffs_h2 = section['x_offs'] 1040 else: 1041 boffs_h2 = 0 1042 1043 if 'y_offs' in section: 1044 boffs_v2 = section['y_offs'] 1045 else: 1046 boffs_v2 = 0 1047 b_pos = ( 1048 boffs_h 1049 + boffs_h2 1050 + (b_width + button_spacing) * col, 1051 v - b_height + boffs_v2, 1052 ) 1053 instantiate_store_item_display( 1054 item_name, 1055 item, 1056 parent_widget=cnt2, 1057 b_pos=b_pos, 1058 boffs_h=boffs_h, 1059 b_width=b_width, 1060 b_height=b_height, 1061 boffs_h2=boffs_h2, 1062 boffs_v2=boffs_v2, 1063 delay=delay, 1064 ) 1065 btn = item['button'] 1066 delay = max(0.1, delay - 0.1) 1067 this_row_buttons.append(btn) 1068 1069 # Wire this button to the equivalent in the 1070 # previous row. 1071 if prev_row_buttons is not None: 1072 if len(prev_row_buttons) > col: 1073 bui.widget( 1074 edit=btn, 1075 up_widget=prev_row_buttons[col], 1076 ) 1077 bui.widget( 1078 edit=prev_row_buttons[col], 1079 down_widget=btn, 1080 ) 1081 1082 # If we're the last button in our row, 1083 # wire any in the previous row past 1084 # our position to go to us if down is 1085 # pressed. 1086 if ( 1087 col + 1 == b_column_count 1088 or i == b_count - 1 1089 ): 1090 for b_prev in prev_row_buttons[ 1091 col + 1 : 1092 ]: 1093 bui.widget( 1094 edit=b_prev, down_widget=btn 1095 ) 1096 else: 1097 bui.widget( 1098 edit=btn, up_widget=prev_row_buttons[-1] 1099 ) 1100 else: 1101 bui.widget(edit=btn, up_widget=tab_button) 1102 1103 col += 1 1104 if col == b_column_count or i == b_count - 1: 1105 prev_row_buttons = this_row_buttons 1106 this_row_buttons = [] 1107 col = 0 1108 v -= b_height 1109 if i < b_count - 1: 1110 v -= section['v_spacing'] 1111 1112 v -= button_border 1113 1114 # Set a timer to update these buttons periodically as long 1115 # as we're alive (so if we buy one it will grey out, etc). 1116 self._store_window.update_buttons_timer = bui.AppTimer( 1117 0.5, 1118 bui.WeakCall(self._store_window.update_buttons), 1119 repeat=True, 1120 ) 1121 1122 # Also update them immediately. 1123 self._store_window.update_buttons() 1124 1125 if self._current_tab in ( 1126 self.TabID.EXTRAS, 1127 self.TabID.MINIGAMES, 1128 self.TabID.CHARACTERS, 1129 self.TabID.MAPS, 1130 self.TabID.ICONS, 1131 ): 1132 store = _Store(self, data, self._scroll_width) 1133 assert self._scrollwidget is not None 1134 store.instantiate( 1135 scrollwidget=self._scrollwidget, 1136 tab_button=self._tab_row.tabs[self._current_tab].button, 1137 ) 1138 else: 1139 cnt = bui.containerwidget( 1140 parent=self._scrollwidget, 1141 scale=1.0, 1142 size=(self._scroll_width, self._scroll_height * 0.95), 1143 background=False, 1144 claims_left_right=True, 1145 claims_tab=True, 1146 selection_loops_to_parent=True, 1147 ) 1148 self._status_textwidget = bui.textwidget( 1149 parent=cnt, 1150 position=( 1151 self._scroll_width * 0.5, 1152 self._scroll_height * 0.5, 1153 ), 1154 size=(0, 0), 1155 scale=1.3, 1156 transition_delay=0.1, 1157 color=(1, 1, 0.3, 1.0), 1158 h_align='center', 1159 v_align='center', 1160 text=bui.Lstr(resource=f'{self._r}.comingSoonText'), 1161 maxwidth=self._scroll_width * 0.9, 1162 ) 1163 1164 @override 1165 def get_main_window_state(self) -> bui.MainWindowState: 1166 # Support recreating our window for back/refresh purposes. 1167 cls = type(self) 1168 return bui.BasicMainWindowState( 1169 create_call=lambda transition, origin_widget: cls( 1170 transition=transition, origin_widget=origin_widget 1171 ) 1172 ) 1173 1174 @override 1175 def on_main_window_close(self) -> None: 1176 self._save_state() 1177 1178 def _save_state(self) -> None: 1179 try: 1180 sel = self._root_widget.get_selected_child() 1181 selected_tab_ids = [ 1182 tab_id 1183 for tab_id, tab in self._tab_row.tabs.items() 1184 if sel == tab.button 1185 ] 1186 if sel == self._scrollwidget: 1187 sel_name = 'Scroll' 1188 elif sel == self._back_button: 1189 sel_name = 'Back' 1190 elif selected_tab_ids: 1191 assert len(selected_tab_ids) == 1 1192 sel_name = f'Tab:{selected_tab_ids[0].value}' 1193 else: 1194 raise ValueError(f'unrecognized selection \'{sel}\'') 1195 assert bui.app.classic is not None 1196 bui.app.ui_v1.window_states[type(self)] = { 1197 'sel_name': sel_name, 1198 } 1199 except Exception: 1200 logging.exception('Error saving state for %s.', self) 1201 1202 def _restore_state(self) -> None: 1203 1204 try: 1205 sel: bui.Widget | None 1206 assert bui.app.classic is not None 1207 sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get( 1208 'sel_name' 1209 ) 1210 assert isinstance(sel_name, (str, type(None))) 1211 1212 try: 1213 current_tab = self.TabID(bui.app.config.get('Store Tab')) 1214 except ValueError: 1215 current_tab = self.TabID.CHARACTERS 1216 1217 if self._show_tab is not None: 1218 current_tab = self._show_tab 1219 if sel_name == 'Back': 1220 sel = self._back_button 1221 elif sel_name == 'Scroll': 1222 sel = self._scrollwidget 1223 elif isinstance(sel_name, str) and sel_name.startswith('Tab:'): 1224 try: 1225 sel_tab_id = self.TabID(sel_name.split(':')[-1]) 1226 except ValueError: 1227 sel_tab_id = self.TabID.CHARACTERS 1228 sel = self._tab_row.tabs[sel_tab_id].button 1229 else: 1230 sel = self._tab_row.tabs[current_tab].button 1231 1232 # If we were requested to show a tab, select it too. 1233 if ( 1234 self._show_tab is not None 1235 and self._show_tab in self._tab_row.tabs 1236 ): 1237 sel = self._tab_row.tabs[self._show_tab].button 1238 self._set_tab(current_tab) 1239 if sel is not None: 1240 bui.containerwidget(edit=self._root_widget, selected_child=sel) 1241 except Exception: 1242 logging.exception('Error restoring state for %s.', self)
Window for browsing the store.
StoreBrowserWindow( transition: str | None = 'in_right', origin_widget: _bauiv1.Widget | None = None, show_tab: StoreBrowserWindow.TabID | None = None, minimal_toolbars: bool = False)
42 def __init__( 43 self, 44 transition: str | None = 'in_right', 45 origin_widget: bui.Widget | None = None, 46 show_tab: StoreBrowserWindow.TabID | None = None, 47 minimal_toolbars: bool = False, 48 ): 49 # pylint: disable=too-many-statements 50 # pylint: disable=too-many-locals 51 from bauiv1lib.tabs import TabRow 52 from bauiv1 import SpecialChar 53 54 app = bui.app 55 assert app.classic is not None 56 uiscale = app.ui_v1.uiscale 57 58 bui.set_analytics_screen('Store Window') 59 60 self.button_infos: dict[str, dict[str, Any]] | None = None 61 self.update_buttons_timer: bui.AppTimer | None = None 62 self._status_textwidget_update_timer = None 63 64 self._show_tab = show_tab 65 self._width = 1670 if uiscale is bui.UIScale.SMALL else 1040 66 self._x_inset = x_inset = 310 if uiscale is bui.UIScale.SMALL else 0 67 self._height = ( 68 538 69 if uiscale is bui.UIScale.SMALL 70 else 645 if uiscale is bui.UIScale.MEDIUM else 800 71 ) 72 self._current_tab: StoreBrowserWindow.TabID | None = None 73 extra_top = 30 if uiscale is bui.UIScale.SMALL else 0 74 75 self.request: Any = None 76 self._r = 'store' 77 self._last_buy_time: float | None = None 78 79 super().__init__( 80 root_widget=bui.containerwidget( 81 size=(self._width, self._height + extra_top), 82 toolbar_visibility=( 83 'menu_store' 84 if (uiscale is bui.UIScale.SMALL or minimal_toolbars) 85 else 'menu_full' 86 ), 87 scale=( 88 1.3 89 if uiscale is bui.UIScale.SMALL 90 else 0.9 if uiscale is bui.UIScale.MEDIUM else 0.8 91 ), 92 stack_offset=( 93 (0, 10) 94 if uiscale is bui.UIScale.SMALL 95 else (0, 0) if uiscale is bui.UIScale.MEDIUM else (0, 0) 96 ), 97 ), 98 transition=transition, 99 origin_widget=origin_widget, 100 ) 101 102 self._back_button = btn = bui.buttonwidget( 103 parent=self._root_widget, 104 position=(70 + x_inset, self._height - 74), 105 size=(140, 60), 106 scale=1.1, 107 autoselect=True, 108 label=bui.Lstr(resource='backText'), 109 button_type='back', 110 on_activate_call=self.main_window_back, 111 ) 112 113 if uiscale is bui.UIScale.SMALL: 114 self._back_button.delete() 115 bui.containerwidget( 116 edit=self._root_widget, on_cancel_call=self.main_window_back 117 ) 118 backbuttonspecial = True 119 else: 120 bui.containerwidget(edit=self._root_widget, cancel_button=btn) 121 backbuttonspecial = False 122 123 if ( 124 app.classic.platform in ['mac', 'ios'] 125 and app.classic.subplatform == 'appstore' 126 ): 127 bui.buttonwidget( 128 parent=self._root_widget, 129 position=(self._width * 0.5 - 70, 16), 130 size=(230, 50), 131 scale=0.65, 132 on_activate_call=bui.WeakCall(self._restore_purchases), 133 color=(0.35, 0.3, 0.4), 134 selectable=False, 135 textcolor=(0.55, 0.5, 0.6), 136 label=bui.Lstr( 137 resource='getTicketsWindow.restorePurchasesText' 138 ), 139 ) 140 141 bui.textwidget( 142 parent=self._root_widget, 143 position=( 144 self._width * 0.5, 145 self._height - (53 if uiscale is bui.UIScale.SMALL else 44), 146 ), 147 size=(0, 0), 148 color=app.ui_v1.title_color, 149 scale=1.5, 150 h_align='center', 151 v_align='center', 152 text=bui.Lstr(resource='storeText'), 153 maxwidth=290, 154 ) 155 156 if not backbuttonspecial: 157 bui.buttonwidget( 158 edit=self._back_button, 159 button_type='backSmall', 160 size=(60, 60), 161 label=bui.charstr(SpecialChar.BACK), 162 ) 163 164 scroll_buffer_h = 130 + 2 * x_inset 165 tab_buffer_h = 250 + 2 * x_inset 166 167 tabs_def = [ 168 (self.TabID.EXTRAS, bui.Lstr(resource=f'{self._r}.extrasText')), 169 (self.TabID.MAPS, bui.Lstr(resource=f'{self._r}.mapsText')), 170 ( 171 self.TabID.MINIGAMES, 172 bui.Lstr(resource=f'{self._r}.miniGamesText'), 173 ), 174 ( 175 self.TabID.CHARACTERS, 176 bui.Lstr(resource=f'{self._r}.charactersText'), 177 ), 178 (self.TabID.ICONS, bui.Lstr(resource=f'{self._r}.iconsText')), 179 ] 180 181 self._tab_row = TabRow( 182 self._root_widget, 183 tabs_def, 184 pos=(tab_buffer_h * 0.5, self._height - 130), 185 size=(self._width - tab_buffer_h, 50), 186 on_select_call=self._set_tab, 187 ) 188 189 self._purchasable_count_widgets: dict[ 190 StoreBrowserWindow.TabID, dict[str, Any] 191 ] = {} 192 193 # Create our purchasable-items tags and have them update over time. 194 for tab_id, tab in self._tab_row.tabs.items(): 195 pos = tab.position 196 size = tab.size 197 button = tab.button 198 rad = 10 199 center = (pos[0] + 0.1 * size[0], pos[1] + 0.9 * size[1]) 200 img = bui.imagewidget( 201 parent=self._root_widget, 202 position=(center[0] - rad * 1.04, center[1] - rad * 1.15), 203 size=(rad * 2.2, rad * 2.2), 204 texture=bui.gettexture('circleShadow'), 205 color=(1, 0, 0), 206 ) 207 txt = bui.textwidget( 208 parent=self._root_widget, 209 position=center, 210 size=(0, 0), 211 h_align='center', 212 v_align='center', 213 maxwidth=1.4 * rad, 214 scale=0.6, 215 shadow=1.0, 216 flatness=1.0, 217 ) 218 rad = 20 219 sale_img = bui.imagewidget( 220 parent=self._root_widget, 221 position=(center[0] - rad, center[1] - rad), 222 size=(rad * 2, rad * 2), 223 draw_controller=button, 224 texture=bui.gettexture('circleZigZag'), 225 color=(0.5, 0, 1.0), 226 ) 227 sale_title_text = bui.textwidget( 228 parent=self._root_widget, 229 position=(center[0], center[1] + 0.24 * rad), 230 size=(0, 0), 231 h_align='center', 232 v_align='center', 233 draw_controller=button, 234 maxwidth=1.4 * rad, 235 scale=0.6, 236 shadow=0.0, 237 flatness=1.0, 238 color=(0, 1, 0), 239 ) 240 sale_time_text = bui.textwidget( 241 parent=self._root_widget, 242 position=(center[0], center[1] - 0.29 * rad), 243 size=(0, 0), 244 h_align='center', 245 v_align='center', 246 draw_controller=button, 247 maxwidth=1.4 * rad, 248 scale=0.4, 249 shadow=0.0, 250 flatness=1.0, 251 color=(0, 1, 0), 252 ) 253 self._purchasable_count_widgets[tab_id] = { 254 'img': img, 255 'text': txt, 256 'sale_img': sale_img, 257 'sale_title_text': sale_title_text, 258 'sale_time_text': sale_time_text, 259 } 260 self._tab_update_timer = bui.AppTimer( 261 1.0, bui.WeakCall(self._update_tabs), repeat=True 262 ) 263 self._update_tabs() 264 265 if uiscale is bui.UIScale.SMALL: 266 first_tab_button = self._tab_row.tabs[tabs_def[0][0]].button 267 last_tab_button = self._tab_row.tabs[tabs_def[-1][0]].button 268 bui.widget( 269 edit=first_tab_button, 270 left_widget=bui.get_special_widget('back_button'), 271 ) 272 bui.widget( 273 edit=last_tab_button, 274 right_widget=bui.get_special_widget('squad_button'), 275 ) 276 277 self._scroll_width = self._width - scroll_buffer_h 278 self._scroll_height = self._height - 180 279 280 self._scrollwidget: bui.Widget | None = None 281 self._status_textwidget: bui.Widget | None = None 282 self._restore_state()
Create a MainWindow given a root widget and transition info.
Automatically handles in and out transitions on the provided widget, so there is no need to set transitions when creating it.
def
buy(self, item: str) -> None:
492 def buy(self, item: str) -> None: 493 """Attempt to purchase the provided item.""" 494 from bauiv1lib import account 495 from bauiv1lib.confirm import ConfirmWindow 496 497 assert bui.app.classic is not None 498 store = bui.app.classic.store 499 500 plus = bui.app.plus 501 assert plus is not None 502 503 # Prevent pressing buy within a few seconds of the last press 504 # (gives the buttons time to disable themselves and whatnot). 505 curtime = bui.apptime() 506 if ( 507 self._last_buy_time is not None 508 and (curtime - self._last_buy_time) < 2.0 509 ): 510 bui.getsound('error').play() 511 else: 512 if plus.get_v1_account_state() != 'signed_in': 513 account.show_sign_in_prompt() 514 else: 515 self._last_buy_time = curtime 516 517 # Merch is a special case - just a link. 518 if item == 'merch': 519 url = bui.app.config.get('Merch Link') 520 if isinstance(url, str): 521 bui.open_url(url) 522 523 # Pro is an actual IAP, and the rest are ticket purchases. 524 elif item == 'pro': 525 bui.getsound('click01').play() 526 527 # Purchase either pro or pro_sale depending on whether 528 # there is a sale going on. 529 self._do_purchase_check( 530 'pro' 531 if store.get_available_sale_time('extras') is None 532 else 'pro_sale' 533 ) 534 else: 535 price = plus.get_v1_account_misc_read_val( 536 'price.' + item, None 537 ) 538 our_tickets = plus.get_v1_account_ticket_count() 539 if price is not None and our_tickets < price: 540 bui.getsound('error').play() 541 print('FIXME - show not-enough-tickets info.') 542 # gettickets.show_get_tickets_prompt() 543 else: 544 545 def do_it() -> None: 546 self._do_purchase_check( 547 item, is_ticket_purchase=True 548 ) 549 550 bui.getsound('swish').play() 551 ConfirmWindow( 552 bui.Lstr( 553 resource='store.purchaseConfirmText', 554 subs=[ 555 ( 556 '${ITEM}', 557 store.get_store_item_name_translated( 558 item 559 ), 560 ) 561 ], 562 ), 563 width=400, 564 height=120, 565 action=do_it, 566 ok_text=bui.Lstr( 567 resource='store.purchaseText', 568 fallback_resource='okText', 569 ), 570 )
Attempt to purchase the provided item.
1164 @override 1165 def get_main_window_state(self) -> bui.MainWindowState: 1166 # Support recreating our window for back/refresh purposes. 1167 cls = type(self) 1168 return bui.BasicMainWindowState( 1169 create_call=lambda transition, origin_widget: cls( 1170 transition=transition, origin_widget=origin_widget 1171 ) 1172 )
Return a WindowState to recreate this window, if supported.
@override
def
on_main_window_close(self) -> None:
Called before transitioning out a main window.
A good opportunity to save window state/etc.
Inherited Members
- bauiv1._uitypes.MainWindow
- main_window_back_state
- main_window_is_top_level
- main_window_is_auxiliary
- main_window_close
- main_window_has_control
- main_window_back
- main_window_replace
- bauiv1._uitypes.Window
- get_root_widget
class
StoreBrowserWindow.TabID(enum.Enum):
33 class TabID(Enum): 34 """Our available tab types.""" 35 36 EXTRAS = 'extras' 37 MAPS = 'maps' 38 MINIGAMES = 'minigames' 39 CHARACTERS = 'characters' 40 ICONS = 'icons'
Our available tab types.
Inherited Members
- enum.Enum
- name
- value