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 - (55 if uiscale is bui.UIScale.SMALL else 44), 145 ), 146 size=(0, 0), 147 color=app.ui_v1.title_color, 148 scale=1.1 if uiscale is bui.UIScale.SMALL else 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.account.signin import show_sign_in_prompt 285 286 plus = bui.app.plus 287 assert plus is not None 288 if plus.accounts.primary is None: 289 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 selection_loops_to_parent=True, 353 border_opacity=0.4, 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.account.signin import show_sign_in_prompt 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 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 bui.screenmessage( 541 bui.Lstr(resource='notEnoughTicketsText'), 542 color=(1, 0, 0), 543 ) 544 # gettickets.show_get_tickets_prompt() 545 else: 546 547 def do_it() -> None: 548 self._do_purchase_check( 549 item, is_ticket_purchase=True 550 ) 551 552 bui.getsound('swish').play() 553 ConfirmWindow( 554 bui.Lstr( 555 resource='store.purchaseConfirmText', 556 subs=[ 557 ( 558 '${ITEM}', 559 store.get_store_item_name_translated( 560 item 561 ), 562 ) 563 ], 564 ), 565 width=400, 566 height=120, 567 action=do_it, 568 ok_text=bui.Lstr( 569 resource='store.purchaseText', 570 fallback_resource='okText', 571 ), 572 ) 573 574 def _print_already_own(self, charname: str) -> None: 575 bui.screenmessage( 576 bui.Lstr( 577 resource=f'{self._r}.alreadyOwnText', 578 subs=[('${NAME}', charname)], 579 ), 580 color=(1, 0, 0), 581 ) 582 bui.getsound('error').play() 583 584 def update_buttons(self) -> None: 585 """Update our buttons.""" 586 # pylint: disable=too-many-statements 587 # pylint: disable=too-many-branches 588 # pylint: disable=too-many-locals 589 from bauiv1 import SpecialChar 590 591 assert bui.app.classic is not None 592 store = bui.app.classic.store 593 594 plus = bui.app.plus 595 assert plus is not None 596 597 if not self._root_widget: 598 return 599 600 sales_raw = plus.get_v1_account_misc_read_val('sales', {}) 601 sales = {} 602 try: 603 # Look at the current set of sales; filter any with time remaining. 604 for sale_item, sale_info in list(sales_raw.items()): 605 to_end = ( 606 datetime.datetime.fromtimestamp( 607 sale_info['e'], datetime.UTC 608 ) 609 - utc_now() 610 ).total_seconds() 611 if to_end > 0: 612 sales[sale_item] = { 613 'to_end': to_end, 614 'original_price': sale_info['op'], 615 } 616 except Exception: 617 logging.exception('Error parsing sales.') 618 619 assert self.button_infos is not None 620 for b_type, b_info in self.button_infos.items(): 621 if b_type == 'merch': 622 purchased = False 623 elif b_type in ['upgrades.pro', 'pro']: 624 assert bui.app.classic is not None 625 purchased = bui.app.classic.accounts.have_pro() 626 else: 627 purchased = plus.get_v1_account_product_purchased(b_type) 628 629 sale_opacity = 0.0 630 sale_title_text: str | bui.Lstr = '' 631 sale_time_text: str | bui.Lstr = '' 632 633 call: Callable | None 634 if purchased: 635 title_color = (0.8, 0.7, 0.9, 1.0) 636 color = (0.63, 0.55, 0.78) 637 extra_image_opacity = 0.5 638 call = bui.WeakCall(self._print_already_own, b_info['name']) 639 price_text = '' 640 price_text_left = '' 641 price_text_right = '' 642 show_purchase_check = True 643 description_color: Sequence[float] = (0.4, 1.0, 0.4, 0.4) 644 description_color2: Sequence[float] = (0.0, 0.0, 0.0, 0.0) 645 price_color = (0.5, 1, 0.5, 0.3) 646 else: 647 title_color = (0.7, 0.9, 0.7, 1.0) 648 color = (0.4, 0.8, 0.1) 649 extra_image_opacity = 1.0 650 call = b_info['call'] if 'call' in b_info else None 651 if b_type == 'merch': 652 price_text = '' 653 price_text_left = '' 654 price_text_right = '' 655 elif b_type in ['upgrades.pro', 'pro']: 656 sale_time = store.get_available_sale_time('extras') 657 if sale_time is not None: 658 priceraw = plus.get_price('pro') 659 price_text_left = ( 660 priceraw if priceraw is not None else '?' 661 ) 662 priceraw = plus.get_price('pro_sale') 663 price_text_right = ( 664 priceraw if priceraw is not None else '?' 665 ) 666 sale_opacity = 1.0 667 price_text = '' 668 sale_title_text = bui.Lstr(resource='store.saleText') 669 sale_time_text = bui.timestring( 670 sale_time / 1000.0, centi=False 671 ) 672 else: 673 priceraw = plus.get_price('pro') 674 price_text = priceraw if priceraw is not None else '?' 675 price_text_left = '' 676 price_text_right = '' 677 else: 678 price = plus.get_v1_account_misc_read_val( 679 'price.' + b_type, 0 680 ) 681 682 # Color the button differently if we cant afford this. 683 if plus.get_v1_account_state() == 'signed_in': 684 if plus.get_v1_account_ticket_count() < price: 685 color = (0.6, 0.61, 0.6) 686 price_text = bui.charstr(bui.SpecialChar.TICKET) + str( 687 plus.get_v1_account_misc_read_val( 688 'price.' + b_type, '?' 689 ) 690 ) 691 price_text_left = '' 692 price_text_right = '' 693 694 # TESTING: 695 if b_type in sales: 696 sale_opacity = 1.0 697 price_text_left = bui.charstr(SpecialChar.TICKET) + str( 698 sales[b_type]['original_price'] 699 ) 700 price_text_right = price_text 701 price_text = '' 702 sale_title_text = bui.Lstr(resource='store.saleText') 703 sale_time_text = bui.timestring( 704 sales[b_type]['to_end'], centi=False 705 ) 706 707 description_color = (0.5, 1.0, 0.5) 708 description_color2 = (0.3, 1.0, 1.0) 709 price_color = (0.2, 1, 0.2, 1.0) 710 show_purchase_check = False 711 712 if 'title_text' in b_info: 713 bui.textwidget(edit=b_info['title_text'], color=title_color) 714 if 'purchase_check' in b_info: 715 bui.imagewidget( 716 edit=b_info['purchase_check'], 717 opacity=1.0 if show_purchase_check else 0.0, 718 ) 719 if 'price_widget' in b_info: 720 bui.textwidget( 721 edit=b_info['price_widget'], 722 text=price_text, 723 color=price_color, 724 ) 725 if 'price_widget_left' in b_info: 726 bui.textwidget( 727 edit=b_info['price_widget_left'], text=price_text_left 728 ) 729 if 'price_widget_right' in b_info: 730 bui.textwidget( 731 edit=b_info['price_widget_right'], text=price_text_right 732 ) 733 if 'price_slash_widget' in b_info: 734 bui.imagewidget( 735 edit=b_info['price_slash_widget'], opacity=sale_opacity 736 ) 737 if 'sale_bg_widget' in b_info: 738 bui.imagewidget( 739 edit=b_info['sale_bg_widget'], opacity=sale_opacity 740 ) 741 if 'sale_title_widget' in b_info: 742 bui.textwidget( 743 edit=b_info['sale_title_widget'], text=sale_title_text 744 ) 745 if 'sale_time_widget' in b_info: 746 bui.textwidget( 747 edit=b_info['sale_time_widget'], text=sale_time_text 748 ) 749 if 'button' in b_info: 750 bui.buttonwidget( 751 edit=b_info['button'], color=color, on_activate_call=call 752 ) 753 if 'extra_backings' in b_info: 754 for bck in b_info['extra_backings']: 755 bui.imagewidget( 756 edit=bck, color=color, opacity=extra_image_opacity 757 ) 758 if 'extra_images' in b_info: 759 for img in b_info['extra_images']: 760 bui.imagewidget(edit=img, opacity=extra_image_opacity) 761 if 'extra_texts' in b_info: 762 for etxt in b_info['extra_texts']: 763 bui.textwidget(edit=etxt, color=description_color) 764 if 'extra_texts_2' in b_info: 765 for etxt in b_info['extra_texts_2']: 766 bui.textwidget(edit=etxt, color=description_color2) 767 if 'descriptionText' in b_info: 768 bui.textwidget( 769 edit=b_info['descriptionText'], color=description_color 770 ) 771 772 def _on_response(self, data: dict[str, Any] | None) -> None: 773 # pylint: disable=too-many-statements 774 775 assert bui.app.classic is not None 776 cstore = bui.app.classic.store 777 778 # clear status text.. 779 if self._status_textwidget: 780 self._status_textwidget.delete() 781 self._status_textwidget_update_timer = None 782 783 if data is None: 784 self._status_textwidget = bui.textwidget( 785 parent=self._root_widget, 786 position=(self._width * 0.5, self._height * 0.5), 787 size=(0, 0), 788 scale=1.3, 789 transition_delay=0.1, 790 color=(1, 0.3, 0.3, 1.0), 791 h_align='center', 792 v_align='center', 793 text=bui.Lstr(resource=f'{self._r}.loadErrorText'), 794 maxwidth=self._scroll_width * 0.9, 795 ) 796 else: 797 798 class _Store: 799 def __init__( 800 self, 801 store_window: StoreBrowserWindow, 802 sdata: dict[str, Any], 803 width: float, 804 ): 805 self._store_window = store_window 806 self._width = width 807 store_data = cstore.get_store_layout() 808 self._tab = sdata['tab'] 809 self._sections = copy.deepcopy(store_data[sdata['tab']]) 810 self._height: float | None = None 811 812 assert bui.app.classic is not None 813 uiscale = bui.app.ui_v1.uiscale 814 815 # Pre-calc a few things and add them to store-data. 816 for section in self._sections: 817 if self._tab == 'characters': 818 dummy_name = 'characters.foo' 819 elif self._tab == 'extras': 820 dummy_name = 'pro' 821 elif self._tab == 'maps': 822 dummy_name = 'maps.foo' 823 elif self._tab == 'icons': 824 dummy_name = 'icons.foo' 825 else: 826 dummy_name = '' 827 section['button_size'] = ( 828 cstore.get_store_item_display_size(dummy_name) 829 ) 830 section['v_spacing'] = ( 831 -25 832 if ( 833 self._tab == 'extras' 834 and uiscale is bui.UIScale.SMALL 835 ) 836 else -17 if self._tab == 'characters' else 0 837 ) 838 if 'title' not in section: 839 section['title'] = '' 840 section['x_offs'] = ( 841 130 842 if self._tab == 'extras' 843 else 270 if self._tab == 'maps' else 0 844 ) 845 section['y_offs'] = ( 846 20 847 if ( 848 self._tab == 'extras' 849 and uiscale is bui.UIScale.SMALL 850 and bui.app.config.get('Merch Link') 851 ) 852 else ( 853 55 854 if ( 855 self._tab == 'extras' 856 and uiscale is bui.UIScale.SMALL 857 ) 858 else -20 if self._tab == 'icons' else 0 859 ) 860 ) 861 862 def instantiate( 863 self, scrollwidget: bui.Widget, tab_button: bui.Widget 864 ) -> None: 865 """Create the store.""" 866 # pylint: disable=too-many-locals 867 # pylint: disable=too-many-branches 868 # pylint: disable=too-many-nested-blocks 869 from bauiv1lib.store.item import ( 870 instantiate_store_item_display, 871 ) 872 873 title_spacing = 40 874 button_border = 20 875 button_spacing = 4 876 boffs_h = 40 877 self._height = 80.0 878 879 # Calc total height. 880 for i, section in enumerate(self._sections): 881 if section['title'] != '': 882 assert self._height is not None 883 self._height += title_spacing 884 b_width, b_height = section['button_size'] 885 b_column_count = int( 886 math.floor( 887 (self._width - boffs_h - 20) 888 / (b_width + button_spacing) 889 ) 890 ) 891 b_row_count = int( 892 math.ceil( 893 float(len(section['items'])) / b_column_count 894 ) 895 ) 896 b_height_total = ( 897 2 * button_border 898 + b_row_count * b_height 899 + (b_row_count - 1) * section['v_spacing'] 900 ) 901 self._height += b_height_total 902 903 assert self._height is not None 904 cnt2 = bui.containerwidget( 905 parent=scrollwidget, 906 scale=1.0, 907 size=(self._width, self._height), 908 background=False, 909 claims_left_right=True, 910 selection_loops_to_parent=True, 911 ) 912 v = self._height - 20 913 914 if self._tab == 'characters': 915 txt = bui.Lstr( 916 resource='store.howToSwitchCharactersText', 917 subs=[ 918 ( 919 '${SETTINGS}', 920 bui.Lstr( 921 resource=( 922 'accountSettingsWindow.titleText' 923 ) 924 ), 925 ), 926 ( 927 '${PLAYER_PROFILES}', 928 bui.Lstr( 929 resource=( 930 'playerProfilesWindow.titleText' 931 ) 932 ), 933 ), 934 ], 935 ) 936 bui.textwidget( 937 parent=cnt2, 938 text=txt, 939 size=(0, 0), 940 position=(self._width * 0.5, self._height - 28), 941 h_align='center', 942 v_align='center', 943 color=(0.7, 1, 0.7, 0.4), 944 scale=0.7, 945 shadow=0, 946 flatness=1.0, 947 maxwidth=700, 948 transition_delay=0.4, 949 ) 950 elif self._tab == 'icons': 951 txt = bui.Lstr( 952 resource='store.howToUseIconsText', 953 subs=[ 954 ( 955 '${SETTINGS}', 956 bui.Lstr(resource='mainMenu.settingsText'), 957 ), 958 ( 959 '${PLAYER_PROFILES}', 960 bui.Lstr( 961 resource=( 962 'playerProfilesWindow.titleText' 963 ) 964 ), 965 ), 966 ], 967 ) 968 bui.textwidget( 969 parent=cnt2, 970 text=txt, 971 size=(0, 0), 972 position=(self._width * 0.5, self._height - 28), 973 h_align='center', 974 v_align='center', 975 color=(0.7, 1, 0.7, 0.4), 976 scale=0.7, 977 shadow=0, 978 flatness=1.0, 979 maxwidth=700, 980 transition_delay=0.4, 981 ) 982 elif self._tab == 'maps': 983 assert self._width is not None 984 assert self._height is not None 985 txt = bui.Lstr(resource='store.howToUseMapsText') 986 bui.textwidget( 987 parent=cnt2, 988 text=txt, 989 size=(0, 0), 990 position=(self._width * 0.5, self._height - 28), 991 h_align='center', 992 v_align='center', 993 color=(0.7, 1, 0.7, 0.4), 994 scale=0.7, 995 shadow=0, 996 flatness=1.0, 997 maxwidth=700, 998 transition_delay=0.4, 999 ) 1000 1001 prev_row_buttons: list | None = None 1002 this_row_buttons = [] 1003 1004 delay = 0.3 1005 for section in self._sections: 1006 if section['title'] != '': 1007 bui.textwidget( 1008 parent=cnt2, 1009 position=(60, v - title_spacing * 0.8), 1010 size=(0, 0), 1011 scale=1.0, 1012 transition_delay=delay, 1013 color=(0.7, 0.9, 0.7, 1), 1014 h_align='left', 1015 v_align='center', 1016 text=bui.Lstr(resource=section['title']), 1017 maxwidth=self._width * 0.7, 1018 ) 1019 v -= title_spacing 1020 delay = max(0.100, delay - 0.100) 1021 v -= button_border 1022 b_width, b_height = section['button_size'] 1023 b_count = len(section['items']) 1024 b_column_count = int( 1025 math.floor( 1026 (self._width - boffs_h - 20) 1027 / (b_width + button_spacing) 1028 ) 1029 ) 1030 col = 0 1031 item: dict[str, Any] 1032 assert self._store_window.button_infos is not None 1033 for i, item_name in enumerate(section['items']): 1034 item = self._store_window.button_infos[ 1035 item_name 1036 ] = {} 1037 item['call'] = bui.WeakCall( 1038 self._store_window.buy, item_name 1039 ) 1040 if 'x_offs' in section: 1041 boffs_h2 = section['x_offs'] 1042 else: 1043 boffs_h2 = 0 1044 1045 if 'y_offs' in section: 1046 boffs_v2 = section['y_offs'] 1047 else: 1048 boffs_v2 = 0 1049 b_pos = ( 1050 boffs_h 1051 + boffs_h2 1052 + (b_width + button_spacing) * col, 1053 v - b_height + boffs_v2, 1054 ) 1055 instantiate_store_item_display( 1056 item_name, 1057 item, 1058 parent_widget=cnt2, 1059 b_pos=b_pos, 1060 boffs_h=boffs_h, 1061 b_width=b_width, 1062 b_height=b_height, 1063 boffs_h2=boffs_h2, 1064 boffs_v2=boffs_v2, 1065 delay=delay, 1066 ) 1067 btn = item['button'] 1068 delay = max(0.1, delay - 0.1) 1069 this_row_buttons.append(btn) 1070 1071 # Wire this button to the equivalent in the 1072 # previous row. 1073 if prev_row_buttons is not None: 1074 if len(prev_row_buttons) > col: 1075 bui.widget( 1076 edit=btn, 1077 up_widget=prev_row_buttons[col], 1078 ) 1079 bui.widget( 1080 edit=prev_row_buttons[col], 1081 down_widget=btn, 1082 ) 1083 1084 # If we're the last button in our row, 1085 # wire any in the previous row past 1086 # our position to go to us if down is 1087 # pressed. 1088 if ( 1089 col + 1 == b_column_count 1090 or i == b_count - 1 1091 ): 1092 for b_prev in prev_row_buttons[ 1093 col + 1 : 1094 ]: 1095 bui.widget( 1096 edit=b_prev, down_widget=btn 1097 ) 1098 else: 1099 bui.widget( 1100 edit=btn, up_widget=prev_row_buttons[-1] 1101 ) 1102 else: 1103 bui.widget(edit=btn, up_widget=tab_button) 1104 1105 col += 1 1106 if col == b_column_count or i == b_count - 1: 1107 prev_row_buttons = this_row_buttons 1108 this_row_buttons = [] 1109 col = 0 1110 v -= b_height 1111 if i < b_count - 1: 1112 v -= section['v_spacing'] 1113 1114 v -= button_border 1115 1116 # Set a timer to update these buttons periodically as long 1117 # as we're alive (so if we buy one it will grey out, etc). 1118 self._store_window.update_buttons_timer = bui.AppTimer( 1119 0.5, 1120 bui.WeakCall(self._store_window.update_buttons), 1121 repeat=True, 1122 ) 1123 1124 # Also update them immediately. 1125 self._store_window.update_buttons() 1126 1127 if self._current_tab in ( 1128 self.TabID.EXTRAS, 1129 self.TabID.MINIGAMES, 1130 self.TabID.CHARACTERS, 1131 self.TabID.MAPS, 1132 self.TabID.ICONS, 1133 ): 1134 store = _Store(self, data, self._scroll_width) 1135 assert self._scrollwidget is not None 1136 store.instantiate( 1137 scrollwidget=self._scrollwidget, 1138 tab_button=self._tab_row.tabs[self._current_tab].button, 1139 ) 1140 else: 1141 cnt = bui.containerwidget( 1142 parent=self._scrollwidget, 1143 scale=1.0, 1144 size=(self._scroll_width, self._scroll_height * 0.95), 1145 background=False, 1146 claims_left_right=True, 1147 selection_loops_to_parent=True, 1148 ) 1149 self._status_textwidget = bui.textwidget( 1150 parent=cnt, 1151 position=( 1152 self._scroll_width * 0.5, 1153 self._scroll_height * 0.5, 1154 ), 1155 size=(0, 0), 1156 scale=1.3, 1157 transition_delay=0.1, 1158 color=(1, 1, 0.3, 1.0), 1159 h_align='center', 1160 v_align='center', 1161 text=bui.Lstr(resource=f'{self._r}.comingSoonText'), 1162 maxwidth=self._scroll_width * 0.9, 1163 ) 1164 1165 @override 1166 def get_main_window_state(self) -> bui.MainWindowState: 1167 # Support recreating our window for back/refresh purposes. 1168 cls = type(self) 1169 return bui.BasicMainWindowState( 1170 create_call=lambda transition, origin_widget: cls( 1171 transition=transition, origin_widget=origin_widget 1172 ) 1173 ) 1174 1175 @override 1176 def on_main_window_close(self) -> None: 1177 self._save_state() 1178 1179 def _save_state(self) -> None: 1180 try: 1181 sel = self._root_widget.get_selected_child() 1182 selected_tab_ids = [ 1183 tab_id 1184 for tab_id, tab in self._tab_row.tabs.items() 1185 if sel == tab.button 1186 ] 1187 if sel == self._scrollwidget: 1188 sel_name = 'Scroll' 1189 elif sel == self._back_button: 1190 sel_name = 'Back' 1191 elif selected_tab_ids: 1192 assert len(selected_tab_ids) == 1 1193 sel_name = f'Tab:{selected_tab_ids[0].value}' 1194 else: 1195 raise ValueError(f'unrecognized selection \'{sel}\'') 1196 assert bui.app.classic is not None 1197 bui.app.ui_v1.window_states[type(self)] = { 1198 'sel_name': sel_name, 1199 } 1200 except Exception: 1201 logging.exception('Error saving state for %s.', self) 1202 1203 def _restore_state(self) -> None: 1204 1205 try: 1206 sel: bui.Widget | None 1207 assert bui.app.classic is not None 1208 sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get( 1209 'sel_name' 1210 ) 1211 assert isinstance(sel_name, (str, type(None))) 1212 1213 try: 1214 current_tab = self.TabID(bui.app.config.get('Store Tab')) 1215 except ValueError: 1216 current_tab = self.TabID.CHARACTERS 1217 1218 if self._show_tab is not None: 1219 current_tab = self._show_tab 1220 if sel_name == 'Back': 1221 sel = self._back_button 1222 elif sel_name == 'Scroll': 1223 sel = self._scrollwidget 1224 elif isinstance(sel_name, str) and sel_name.startswith('Tab:'): 1225 try: 1226 sel_tab_id = self.TabID(sel_name.split(':')[-1]) 1227 except ValueError: 1228 sel_tab_id = self.TabID.CHARACTERS 1229 sel = self._tab_row.tabs[sel_tab_id].button 1230 else: 1231 sel = self._tab_row.tabs[current_tab].button 1232 1233 # If we were requested to show a tab, select it too. 1234 if ( 1235 self._show_tab is not None 1236 and self._show_tab in self._tab_row.tabs 1237 ): 1238 sel = self._tab_row.tabs[self._show_tab].button 1239 self._set_tab(current_tab) 1240 if sel is not None: 1241 bui.containerwidget(edit=self._root_widget, selected_child=sel) 1242 except Exception: 1243 logging.exception('Error restoring state for %s.', self) 1244 1245 1246def _check_merch_availability_in_bg_thread() -> None: 1247 # pylint: disable=cell-var-from-loop 1248 1249 # Merch is available from some countries only. Make a reasonable 1250 # check to ask the master-server about this at launch and store the 1251 # results. 1252 plus = bui.app.plus 1253 assert plus is not None 1254 1255 for _i in range(15): 1256 try: 1257 if plus.cloud.is_connected(): 1258 response = plus.cloud.send_message( 1259 bacommon.cloud.MerchAvailabilityMessage() 1260 ) 1261 1262 def _store_in_logic_thread() -> None: 1263 cfg = bui.app.config 1264 current = cfg.get(MERCH_LINK_KEY) 1265 if not isinstance(current, str | None): 1266 current = None 1267 if current != response.url: 1268 cfg[MERCH_LINK_KEY] = response.url 1269 cfg.commit() 1270 1271 # If we successfully get a response, kick it over to the 1272 # logic thread to store and we're done. 1273 bui.pushcall(_store_in_logic_thread, from_other_thread=True) 1274 return 1275 except CommunicationError: 1276 pass 1277 except Exception: 1278 logging.warning( 1279 'Unexpected error in merch-availability-check.', exc_info=True 1280 ) 1281 time.sleep(1.1934) # A bit randomized to avoid aliasing. 1282 1283 1284# Slight hack; start checking merch availability in the bg (but only if 1285# it looks like we've been imported for use in a running app; don't want 1286# to do this during docs generation/etc.) 1287 1288# TODO: Should wire this up explicitly to app bootstrapping; not good to 1289# be kicking off work at module import time. 1290if ( 1291 os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') != '1' 1292 and bui.app.state is not bui.app.State.NOT_STARTED 1293): 1294 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 - (55 if uiscale is bui.UIScale.SMALL else 44), 146 ), 147 size=(0, 0), 148 color=app.ui_v1.title_color, 149 scale=1.1 if uiscale is bui.UIScale.SMALL else 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.account.signin import show_sign_in_prompt 286 287 plus = bui.app.plus 288 assert plus is not None 289 if plus.accounts.primary is None: 290 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 selection_loops_to_parent=True, 354 border_opacity=0.4, 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.account.signin import show_sign_in_prompt 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 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 bui.screenmessage( 542 bui.Lstr(resource='notEnoughTicketsText'), 543 color=(1, 0, 0), 544 ) 545 # gettickets.show_get_tickets_prompt() 546 else: 547 548 def do_it() -> None: 549 self._do_purchase_check( 550 item, is_ticket_purchase=True 551 ) 552 553 bui.getsound('swish').play() 554 ConfirmWindow( 555 bui.Lstr( 556 resource='store.purchaseConfirmText', 557 subs=[ 558 ( 559 '${ITEM}', 560 store.get_store_item_name_translated( 561 item 562 ), 563 ) 564 ], 565 ), 566 width=400, 567 height=120, 568 action=do_it, 569 ok_text=bui.Lstr( 570 resource='store.purchaseText', 571 fallback_resource='okText', 572 ), 573 ) 574 575 def _print_already_own(self, charname: str) -> None: 576 bui.screenmessage( 577 bui.Lstr( 578 resource=f'{self._r}.alreadyOwnText', 579 subs=[('${NAME}', charname)], 580 ), 581 color=(1, 0, 0), 582 ) 583 bui.getsound('error').play() 584 585 def update_buttons(self) -> None: 586 """Update our buttons.""" 587 # pylint: disable=too-many-statements 588 # pylint: disable=too-many-branches 589 # pylint: disable=too-many-locals 590 from bauiv1 import SpecialChar 591 592 assert bui.app.classic is not None 593 store = bui.app.classic.store 594 595 plus = bui.app.plus 596 assert plus is not None 597 598 if not self._root_widget: 599 return 600 601 sales_raw = plus.get_v1_account_misc_read_val('sales', {}) 602 sales = {} 603 try: 604 # Look at the current set of sales; filter any with time remaining. 605 for sale_item, sale_info in list(sales_raw.items()): 606 to_end = ( 607 datetime.datetime.fromtimestamp( 608 sale_info['e'], datetime.UTC 609 ) 610 - utc_now() 611 ).total_seconds() 612 if to_end > 0: 613 sales[sale_item] = { 614 'to_end': to_end, 615 'original_price': sale_info['op'], 616 } 617 except Exception: 618 logging.exception('Error parsing sales.') 619 620 assert self.button_infos is not None 621 for b_type, b_info in self.button_infos.items(): 622 if b_type == 'merch': 623 purchased = False 624 elif b_type in ['upgrades.pro', 'pro']: 625 assert bui.app.classic is not None 626 purchased = bui.app.classic.accounts.have_pro() 627 else: 628 purchased = plus.get_v1_account_product_purchased(b_type) 629 630 sale_opacity = 0.0 631 sale_title_text: str | bui.Lstr = '' 632 sale_time_text: str | bui.Lstr = '' 633 634 call: Callable | None 635 if purchased: 636 title_color = (0.8, 0.7, 0.9, 1.0) 637 color = (0.63, 0.55, 0.78) 638 extra_image_opacity = 0.5 639 call = bui.WeakCall(self._print_already_own, b_info['name']) 640 price_text = '' 641 price_text_left = '' 642 price_text_right = '' 643 show_purchase_check = True 644 description_color: Sequence[float] = (0.4, 1.0, 0.4, 0.4) 645 description_color2: Sequence[float] = (0.0, 0.0, 0.0, 0.0) 646 price_color = (0.5, 1, 0.5, 0.3) 647 else: 648 title_color = (0.7, 0.9, 0.7, 1.0) 649 color = (0.4, 0.8, 0.1) 650 extra_image_opacity = 1.0 651 call = b_info['call'] if 'call' in b_info else None 652 if b_type == 'merch': 653 price_text = '' 654 price_text_left = '' 655 price_text_right = '' 656 elif b_type in ['upgrades.pro', 'pro']: 657 sale_time = store.get_available_sale_time('extras') 658 if sale_time is not None: 659 priceraw = plus.get_price('pro') 660 price_text_left = ( 661 priceraw if priceraw is not None else '?' 662 ) 663 priceraw = plus.get_price('pro_sale') 664 price_text_right = ( 665 priceraw if priceraw is not None else '?' 666 ) 667 sale_opacity = 1.0 668 price_text = '' 669 sale_title_text = bui.Lstr(resource='store.saleText') 670 sale_time_text = bui.timestring( 671 sale_time / 1000.0, centi=False 672 ) 673 else: 674 priceraw = plus.get_price('pro') 675 price_text = priceraw if priceraw is not None else '?' 676 price_text_left = '' 677 price_text_right = '' 678 else: 679 price = plus.get_v1_account_misc_read_val( 680 'price.' + b_type, 0 681 ) 682 683 # Color the button differently if we cant afford this. 684 if plus.get_v1_account_state() == 'signed_in': 685 if plus.get_v1_account_ticket_count() < price: 686 color = (0.6, 0.61, 0.6) 687 price_text = bui.charstr(bui.SpecialChar.TICKET) + str( 688 plus.get_v1_account_misc_read_val( 689 'price.' + b_type, '?' 690 ) 691 ) 692 price_text_left = '' 693 price_text_right = '' 694 695 # TESTING: 696 if b_type in sales: 697 sale_opacity = 1.0 698 price_text_left = bui.charstr(SpecialChar.TICKET) + str( 699 sales[b_type]['original_price'] 700 ) 701 price_text_right = price_text 702 price_text = '' 703 sale_title_text = bui.Lstr(resource='store.saleText') 704 sale_time_text = bui.timestring( 705 sales[b_type]['to_end'], centi=False 706 ) 707 708 description_color = (0.5, 1.0, 0.5) 709 description_color2 = (0.3, 1.0, 1.0) 710 price_color = (0.2, 1, 0.2, 1.0) 711 show_purchase_check = False 712 713 if 'title_text' in b_info: 714 bui.textwidget(edit=b_info['title_text'], color=title_color) 715 if 'purchase_check' in b_info: 716 bui.imagewidget( 717 edit=b_info['purchase_check'], 718 opacity=1.0 if show_purchase_check else 0.0, 719 ) 720 if 'price_widget' in b_info: 721 bui.textwidget( 722 edit=b_info['price_widget'], 723 text=price_text, 724 color=price_color, 725 ) 726 if 'price_widget_left' in b_info: 727 bui.textwidget( 728 edit=b_info['price_widget_left'], text=price_text_left 729 ) 730 if 'price_widget_right' in b_info: 731 bui.textwidget( 732 edit=b_info['price_widget_right'], text=price_text_right 733 ) 734 if 'price_slash_widget' in b_info: 735 bui.imagewidget( 736 edit=b_info['price_slash_widget'], opacity=sale_opacity 737 ) 738 if 'sale_bg_widget' in b_info: 739 bui.imagewidget( 740 edit=b_info['sale_bg_widget'], opacity=sale_opacity 741 ) 742 if 'sale_title_widget' in b_info: 743 bui.textwidget( 744 edit=b_info['sale_title_widget'], text=sale_title_text 745 ) 746 if 'sale_time_widget' in b_info: 747 bui.textwidget( 748 edit=b_info['sale_time_widget'], text=sale_time_text 749 ) 750 if 'button' in b_info: 751 bui.buttonwidget( 752 edit=b_info['button'], color=color, on_activate_call=call 753 ) 754 if 'extra_backings' in b_info: 755 for bck in b_info['extra_backings']: 756 bui.imagewidget( 757 edit=bck, color=color, opacity=extra_image_opacity 758 ) 759 if 'extra_images' in b_info: 760 for img in b_info['extra_images']: 761 bui.imagewidget(edit=img, opacity=extra_image_opacity) 762 if 'extra_texts' in b_info: 763 for etxt in b_info['extra_texts']: 764 bui.textwidget(edit=etxt, color=description_color) 765 if 'extra_texts_2' in b_info: 766 for etxt in b_info['extra_texts_2']: 767 bui.textwidget(edit=etxt, color=description_color2) 768 if 'descriptionText' in b_info: 769 bui.textwidget( 770 edit=b_info['descriptionText'], color=description_color 771 ) 772 773 def _on_response(self, data: dict[str, Any] | None) -> None: 774 # pylint: disable=too-many-statements 775 776 assert bui.app.classic is not None 777 cstore = bui.app.classic.store 778 779 # clear status text.. 780 if self._status_textwidget: 781 self._status_textwidget.delete() 782 self._status_textwidget_update_timer = None 783 784 if data is None: 785 self._status_textwidget = bui.textwidget( 786 parent=self._root_widget, 787 position=(self._width * 0.5, self._height * 0.5), 788 size=(0, 0), 789 scale=1.3, 790 transition_delay=0.1, 791 color=(1, 0.3, 0.3, 1.0), 792 h_align='center', 793 v_align='center', 794 text=bui.Lstr(resource=f'{self._r}.loadErrorText'), 795 maxwidth=self._scroll_width * 0.9, 796 ) 797 else: 798 799 class _Store: 800 def __init__( 801 self, 802 store_window: StoreBrowserWindow, 803 sdata: dict[str, Any], 804 width: float, 805 ): 806 self._store_window = store_window 807 self._width = width 808 store_data = cstore.get_store_layout() 809 self._tab = sdata['tab'] 810 self._sections = copy.deepcopy(store_data[sdata['tab']]) 811 self._height: float | None = None 812 813 assert bui.app.classic is not None 814 uiscale = bui.app.ui_v1.uiscale 815 816 # Pre-calc a few things and add them to store-data. 817 for section in self._sections: 818 if self._tab == 'characters': 819 dummy_name = 'characters.foo' 820 elif self._tab == 'extras': 821 dummy_name = 'pro' 822 elif self._tab == 'maps': 823 dummy_name = 'maps.foo' 824 elif self._tab == 'icons': 825 dummy_name = 'icons.foo' 826 else: 827 dummy_name = '' 828 section['button_size'] = ( 829 cstore.get_store_item_display_size(dummy_name) 830 ) 831 section['v_spacing'] = ( 832 -25 833 if ( 834 self._tab == 'extras' 835 and uiscale is bui.UIScale.SMALL 836 ) 837 else -17 if self._tab == 'characters' else 0 838 ) 839 if 'title' not in section: 840 section['title'] = '' 841 section['x_offs'] = ( 842 130 843 if self._tab == 'extras' 844 else 270 if self._tab == 'maps' else 0 845 ) 846 section['y_offs'] = ( 847 20 848 if ( 849 self._tab == 'extras' 850 and uiscale is bui.UIScale.SMALL 851 and bui.app.config.get('Merch Link') 852 ) 853 else ( 854 55 855 if ( 856 self._tab == 'extras' 857 and uiscale is bui.UIScale.SMALL 858 ) 859 else -20 if self._tab == 'icons' else 0 860 ) 861 ) 862 863 def instantiate( 864 self, scrollwidget: bui.Widget, tab_button: bui.Widget 865 ) -> None: 866 """Create the store.""" 867 # pylint: disable=too-many-locals 868 # pylint: disable=too-many-branches 869 # pylint: disable=too-many-nested-blocks 870 from bauiv1lib.store.item import ( 871 instantiate_store_item_display, 872 ) 873 874 title_spacing = 40 875 button_border = 20 876 button_spacing = 4 877 boffs_h = 40 878 self._height = 80.0 879 880 # Calc total height. 881 for i, section in enumerate(self._sections): 882 if section['title'] != '': 883 assert self._height is not None 884 self._height += title_spacing 885 b_width, b_height = section['button_size'] 886 b_column_count = int( 887 math.floor( 888 (self._width - boffs_h - 20) 889 / (b_width + button_spacing) 890 ) 891 ) 892 b_row_count = int( 893 math.ceil( 894 float(len(section['items'])) / b_column_count 895 ) 896 ) 897 b_height_total = ( 898 2 * button_border 899 + b_row_count * b_height 900 + (b_row_count - 1) * section['v_spacing'] 901 ) 902 self._height += b_height_total 903 904 assert self._height is not None 905 cnt2 = bui.containerwidget( 906 parent=scrollwidget, 907 scale=1.0, 908 size=(self._width, self._height), 909 background=False, 910 claims_left_right=True, 911 selection_loops_to_parent=True, 912 ) 913 v = self._height - 20 914 915 if self._tab == 'characters': 916 txt = bui.Lstr( 917 resource='store.howToSwitchCharactersText', 918 subs=[ 919 ( 920 '${SETTINGS}', 921 bui.Lstr( 922 resource=( 923 'accountSettingsWindow.titleText' 924 ) 925 ), 926 ), 927 ( 928 '${PLAYER_PROFILES}', 929 bui.Lstr( 930 resource=( 931 'playerProfilesWindow.titleText' 932 ) 933 ), 934 ), 935 ], 936 ) 937 bui.textwidget( 938 parent=cnt2, 939 text=txt, 940 size=(0, 0), 941 position=(self._width * 0.5, self._height - 28), 942 h_align='center', 943 v_align='center', 944 color=(0.7, 1, 0.7, 0.4), 945 scale=0.7, 946 shadow=0, 947 flatness=1.0, 948 maxwidth=700, 949 transition_delay=0.4, 950 ) 951 elif self._tab == 'icons': 952 txt = bui.Lstr( 953 resource='store.howToUseIconsText', 954 subs=[ 955 ( 956 '${SETTINGS}', 957 bui.Lstr(resource='mainMenu.settingsText'), 958 ), 959 ( 960 '${PLAYER_PROFILES}', 961 bui.Lstr( 962 resource=( 963 'playerProfilesWindow.titleText' 964 ) 965 ), 966 ), 967 ], 968 ) 969 bui.textwidget( 970 parent=cnt2, 971 text=txt, 972 size=(0, 0), 973 position=(self._width * 0.5, self._height - 28), 974 h_align='center', 975 v_align='center', 976 color=(0.7, 1, 0.7, 0.4), 977 scale=0.7, 978 shadow=0, 979 flatness=1.0, 980 maxwidth=700, 981 transition_delay=0.4, 982 ) 983 elif self._tab == 'maps': 984 assert self._width is not None 985 assert self._height is not None 986 txt = bui.Lstr(resource='store.howToUseMapsText') 987 bui.textwidget( 988 parent=cnt2, 989 text=txt, 990 size=(0, 0), 991 position=(self._width * 0.5, self._height - 28), 992 h_align='center', 993 v_align='center', 994 color=(0.7, 1, 0.7, 0.4), 995 scale=0.7, 996 shadow=0, 997 flatness=1.0, 998 maxwidth=700, 999 transition_delay=0.4, 1000 ) 1001 1002 prev_row_buttons: list | None = None 1003 this_row_buttons = [] 1004 1005 delay = 0.3 1006 for section in self._sections: 1007 if section['title'] != '': 1008 bui.textwidget( 1009 parent=cnt2, 1010 position=(60, v - title_spacing * 0.8), 1011 size=(0, 0), 1012 scale=1.0, 1013 transition_delay=delay, 1014 color=(0.7, 0.9, 0.7, 1), 1015 h_align='left', 1016 v_align='center', 1017 text=bui.Lstr(resource=section['title']), 1018 maxwidth=self._width * 0.7, 1019 ) 1020 v -= title_spacing 1021 delay = max(0.100, delay - 0.100) 1022 v -= button_border 1023 b_width, b_height = section['button_size'] 1024 b_count = len(section['items']) 1025 b_column_count = int( 1026 math.floor( 1027 (self._width - boffs_h - 20) 1028 / (b_width + button_spacing) 1029 ) 1030 ) 1031 col = 0 1032 item: dict[str, Any] 1033 assert self._store_window.button_infos is not None 1034 for i, item_name in enumerate(section['items']): 1035 item = self._store_window.button_infos[ 1036 item_name 1037 ] = {} 1038 item['call'] = bui.WeakCall( 1039 self._store_window.buy, item_name 1040 ) 1041 if 'x_offs' in section: 1042 boffs_h2 = section['x_offs'] 1043 else: 1044 boffs_h2 = 0 1045 1046 if 'y_offs' in section: 1047 boffs_v2 = section['y_offs'] 1048 else: 1049 boffs_v2 = 0 1050 b_pos = ( 1051 boffs_h 1052 + boffs_h2 1053 + (b_width + button_spacing) * col, 1054 v - b_height + boffs_v2, 1055 ) 1056 instantiate_store_item_display( 1057 item_name, 1058 item, 1059 parent_widget=cnt2, 1060 b_pos=b_pos, 1061 boffs_h=boffs_h, 1062 b_width=b_width, 1063 b_height=b_height, 1064 boffs_h2=boffs_h2, 1065 boffs_v2=boffs_v2, 1066 delay=delay, 1067 ) 1068 btn = item['button'] 1069 delay = max(0.1, delay - 0.1) 1070 this_row_buttons.append(btn) 1071 1072 # Wire this button to the equivalent in the 1073 # previous row. 1074 if prev_row_buttons is not None: 1075 if len(prev_row_buttons) > col: 1076 bui.widget( 1077 edit=btn, 1078 up_widget=prev_row_buttons[col], 1079 ) 1080 bui.widget( 1081 edit=prev_row_buttons[col], 1082 down_widget=btn, 1083 ) 1084 1085 # If we're the last button in our row, 1086 # wire any in the previous row past 1087 # our position to go to us if down is 1088 # pressed. 1089 if ( 1090 col + 1 == b_column_count 1091 or i == b_count - 1 1092 ): 1093 for b_prev in prev_row_buttons[ 1094 col + 1 : 1095 ]: 1096 bui.widget( 1097 edit=b_prev, down_widget=btn 1098 ) 1099 else: 1100 bui.widget( 1101 edit=btn, up_widget=prev_row_buttons[-1] 1102 ) 1103 else: 1104 bui.widget(edit=btn, up_widget=tab_button) 1105 1106 col += 1 1107 if col == b_column_count or i == b_count - 1: 1108 prev_row_buttons = this_row_buttons 1109 this_row_buttons = [] 1110 col = 0 1111 v -= b_height 1112 if i < b_count - 1: 1113 v -= section['v_spacing'] 1114 1115 v -= button_border 1116 1117 # Set a timer to update these buttons periodically as long 1118 # as we're alive (so if we buy one it will grey out, etc). 1119 self._store_window.update_buttons_timer = bui.AppTimer( 1120 0.5, 1121 bui.WeakCall(self._store_window.update_buttons), 1122 repeat=True, 1123 ) 1124 1125 # Also update them immediately. 1126 self._store_window.update_buttons() 1127 1128 if self._current_tab in ( 1129 self.TabID.EXTRAS, 1130 self.TabID.MINIGAMES, 1131 self.TabID.CHARACTERS, 1132 self.TabID.MAPS, 1133 self.TabID.ICONS, 1134 ): 1135 store = _Store(self, data, self._scroll_width) 1136 assert self._scrollwidget is not None 1137 store.instantiate( 1138 scrollwidget=self._scrollwidget, 1139 tab_button=self._tab_row.tabs[self._current_tab].button, 1140 ) 1141 else: 1142 cnt = bui.containerwidget( 1143 parent=self._scrollwidget, 1144 scale=1.0, 1145 size=(self._scroll_width, self._scroll_height * 0.95), 1146 background=False, 1147 claims_left_right=True, 1148 selection_loops_to_parent=True, 1149 ) 1150 self._status_textwidget = bui.textwidget( 1151 parent=cnt, 1152 position=( 1153 self._scroll_width * 0.5, 1154 self._scroll_height * 0.5, 1155 ), 1156 size=(0, 0), 1157 scale=1.3, 1158 transition_delay=0.1, 1159 color=(1, 1, 0.3, 1.0), 1160 h_align='center', 1161 v_align='center', 1162 text=bui.Lstr(resource=f'{self._r}.comingSoonText'), 1163 maxwidth=self._scroll_width * 0.9, 1164 ) 1165 1166 @override 1167 def get_main_window_state(self) -> bui.MainWindowState: 1168 # Support recreating our window for back/refresh purposes. 1169 cls = type(self) 1170 return bui.BasicMainWindowState( 1171 create_call=lambda transition, origin_widget: cls( 1172 transition=transition, origin_widget=origin_widget 1173 ) 1174 ) 1175 1176 @override 1177 def on_main_window_close(self) -> None: 1178 self._save_state() 1179 1180 def _save_state(self) -> None: 1181 try: 1182 sel = self._root_widget.get_selected_child() 1183 selected_tab_ids = [ 1184 tab_id 1185 for tab_id, tab in self._tab_row.tabs.items() 1186 if sel == tab.button 1187 ] 1188 if sel == self._scrollwidget: 1189 sel_name = 'Scroll' 1190 elif sel == self._back_button: 1191 sel_name = 'Back' 1192 elif selected_tab_ids: 1193 assert len(selected_tab_ids) == 1 1194 sel_name = f'Tab:{selected_tab_ids[0].value}' 1195 else: 1196 raise ValueError(f'unrecognized selection \'{sel}\'') 1197 assert bui.app.classic is not None 1198 bui.app.ui_v1.window_states[type(self)] = { 1199 'sel_name': sel_name, 1200 } 1201 except Exception: 1202 logging.exception('Error saving state for %s.', self) 1203 1204 def _restore_state(self) -> None: 1205 1206 try: 1207 sel: bui.Widget | None 1208 assert bui.app.classic is not None 1209 sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get( 1210 'sel_name' 1211 ) 1212 assert isinstance(sel_name, (str, type(None))) 1213 1214 try: 1215 current_tab = self.TabID(bui.app.config.get('Store Tab')) 1216 except ValueError: 1217 current_tab = self.TabID.CHARACTERS 1218 1219 if self._show_tab is not None: 1220 current_tab = self._show_tab 1221 if sel_name == 'Back': 1222 sel = self._back_button 1223 elif sel_name == 'Scroll': 1224 sel = self._scrollwidget 1225 elif isinstance(sel_name, str) and sel_name.startswith('Tab:'): 1226 try: 1227 sel_tab_id = self.TabID(sel_name.split(':')[-1]) 1228 except ValueError: 1229 sel_tab_id = self.TabID.CHARACTERS 1230 sel = self._tab_row.tabs[sel_tab_id].button 1231 else: 1232 sel = self._tab_row.tabs[current_tab].button 1233 1234 # If we were requested to show a tab, select it too. 1235 if ( 1236 self._show_tab is not None 1237 and self._show_tab in self._tab_row.tabs 1238 ): 1239 sel = self._tab_row.tabs[self._show_tab].button 1240 self._set_tab(current_tab) 1241 if sel is not None: 1242 bui.containerwidget(edit=self._root_widget, selected_child=sel) 1243 except Exception: 1244 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 - (55 if uiscale is bui.UIScale.SMALL else 44), 146 ), 147 size=(0, 0), 148 color=app.ui_v1.title_color, 149 scale=1.1 if uiscale is bui.UIScale.SMALL else 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.account.signin import show_sign_in_prompt 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 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 bui.screenmessage( 542 bui.Lstr(resource='notEnoughTicketsText'), 543 color=(1, 0, 0), 544 ) 545 # gettickets.show_get_tickets_prompt() 546 else: 547 548 def do_it() -> None: 549 self._do_purchase_check( 550 item, is_ticket_purchase=True 551 ) 552 553 bui.getsound('swish').play() 554 ConfirmWindow( 555 bui.Lstr( 556 resource='store.purchaseConfirmText', 557 subs=[ 558 ( 559 '${ITEM}', 560 store.get_store_item_name_translated( 561 item 562 ), 563 ) 564 ], 565 ), 566 width=400, 567 height=120, 568 action=do_it, 569 ok_text=bui.Lstr( 570 resource='store.purchaseText', 571 fallback_resource='okText', 572 ), 573 )
Attempt to purchase the provided item.
1166 @override 1167 def get_main_window_state(self) -> bui.MainWindowState: 1168 # Support recreating our window for back/refresh purposes. 1169 cls = type(self) 1170 return bui.BasicMainWindowState( 1171 create_call=lambda transition, origin_widget: cls( 1172 transition=transition, origin_widget=origin_widget 1173 ) 1174 )
Return a WindowState to recreate this window, if supported.
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.