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 call: Callable | None 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) 1243 1244 1245def _check_merch_availability_in_bg_thread() -> None: 1246 # pylint: disable=cell-var-from-loop 1247 1248 # Merch is available from some countries only. Make a reasonable 1249 # check to ask the master-server about this at launch and store the 1250 # results. 1251 plus = bui.app.plus 1252 assert plus is not None 1253 1254 for _i in range(15): 1255 try: 1256 if plus.cloud.is_connected(): 1257 response = plus.cloud.send_message( 1258 bacommon.cloud.MerchAvailabilityMessage() 1259 ) 1260 1261 def _store_in_logic_thread() -> None: 1262 cfg = bui.app.config 1263 current = cfg.get(MERCH_LINK_KEY) 1264 if not isinstance(current, str | None): 1265 current = None 1266 if current != response.url: 1267 cfg[MERCH_LINK_KEY] = response.url 1268 cfg.commit() 1269 1270 # If we successfully get a response, kick it over to the 1271 # logic thread to store and we're done. 1272 bui.pushcall(_store_in_logic_thread, from_other_thread=True) 1273 return 1274 except CommunicationError: 1275 pass 1276 except Exception: 1277 logging.warning( 1278 'Unexpected error in merch-availability-check.', exc_info=True 1279 ) 1280 time.sleep(1.1934) # A bit randomized to avoid aliasing. 1281 1282 1283# Slight hack; start checking merch availability in the bg (but only if 1284# it looks like we've been imported for use in a running app; don't want 1285# to do this during docs generation/etc.) 1286 1287# TODO: Should wire this up explicitly to app bootstrapping; not good to 1288# be kicking off work at module import time. 1289if ( 1290 os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') != '1' 1291 and bui.app.state is not bui.app.State.NOT_STARTED 1292): 1293 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 call: Callable | None 632 if purchased: 633 title_color = (0.8, 0.7, 0.9, 1.0) 634 color = (0.63, 0.55, 0.78) 635 extra_image_opacity = 0.5 636 call = bui.WeakCall(self._print_already_own, b_info['name']) 637 price_text = '' 638 price_text_left = '' 639 price_text_right = '' 640 show_purchase_check = True 641 description_color: Sequence[float] = (0.4, 1.0, 0.4, 0.4) 642 description_color2: Sequence[float] = (0.0, 0.0, 0.0, 0.0) 643 price_color = (0.5, 1, 0.5, 0.3) 644 else: 645 title_color = (0.7, 0.9, 0.7, 1.0) 646 color = (0.4, 0.8, 0.1) 647 extra_image_opacity = 1.0 648 call = b_info['call'] if 'call' in b_info else None 649 if b_type == 'merch': 650 price_text = '' 651 price_text_left = '' 652 price_text_right = '' 653 elif b_type in ['upgrades.pro', 'pro']: 654 sale_time = store.get_available_sale_time('extras') 655 if sale_time is not None: 656 priceraw = plus.get_price('pro') 657 price_text_left = ( 658 priceraw if priceraw is not None else '?' 659 ) 660 priceraw = plus.get_price('pro_sale') 661 price_text_right = ( 662 priceraw if priceraw is not None else '?' 663 ) 664 sale_opacity = 1.0 665 price_text = '' 666 sale_title_text = bui.Lstr(resource='store.saleText') 667 sale_time_text = bui.timestring( 668 sale_time / 1000.0, centi=False 669 ) 670 else: 671 priceraw = plus.get_price('pro') 672 price_text = priceraw if priceraw is not None else '?' 673 price_text_left = '' 674 price_text_right = '' 675 else: 676 price = plus.get_v1_account_misc_read_val( 677 'price.' + b_type, 0 678 ) 679 680 # Color the button differently if we cant afford this. 681 if plus.get_v1_account_state() == 'signed_in': 682 if plus.get_v1_account_ticket_count() < price: 683 color = (0.6, 0.61, 0.6) 684 price_text = bui.charstr(bui.SpecialChar.TICKET) + str( 685 plus.get_v1_account_misc_read_val( 686 'price.' + b_type, '?' 687 ) 688 ) 689 price_text_left = '' 690 price_text_right = '' 691 692 # TESTING: 693 if b_type in sales: 694 sale_opacity = 1.0 695 price_text_left = bui.charstr(SpecialChar.TICKET) + str( 696 sales[b_type]['original_price'] 697 ) 698 price_text_right = price_text 699 price_text = '' 700 sale_title_text = bui.Lstr(resource='store.saleText') 701 sale_time_text = bui.timestring( 702 sales[b_type]['to_end'], centi=False 703 ) 704 705 description_color = (0.5, 1.0, 0.5) 706 description_color2 = (0.3, 1.0, 1.0) 707 price_color = (0.2, 1, 0.2, 1.0) 708 show_purchase_check = False 709 710 if 'title_text' in b_info: 711 bui.textwidget(edit=b_info['title_text'], color=title_color) 712 if 'purchase_check' in b_info: 713 bui.imagewidget( 714 edit=b_info['purchase_check'], 715 opacity=1.0 if show_purchase_check else 0.0, 716 ) 717 if 'price_widget' in b_info: 718 bui.textwidget( 719 edit=b_info['price_widget'], 720 text=price_text, 721 color=price_color, 722 ) 723 if 'price_widget_left' in b_info: 724 bui.textwidget( 725 edit=b_info['price_widget_left'], text=price_text_left 726 ) 727 if 'price_widget_right' in b_info: 728 bui.textwidget( 729 edit=b_info['price_widget_right'], text=price_text_right 730 ) 731 if 'price_slash_widget' in b_info: 732 bui.imagewidget( 733 edit=b_info['price_slash_widget'], opacity=sale_opacity 734 ) 735 if 'sale_bg_widget' in b_info: 736 bui.imagewidget( 737 edit=b_info['sale_bg_widget'], opacity=sale_opacity 738 ) 739 if 'sale_title_widget' in b_info: 740 bui.textwidget( 741 edit=b_info['sale_title_widget'], text=sale_title_text 742 ) 743 if 'sale_time_widget' in b_info: 744 bui.textwidget( 745 edit=b_info['sale_time_widget'], text=sale_time_text 746 ) 747 if 'button' in b_info: 748 bui.buttonwidget( 749 edit=b_info['button'], color=color, on_activate_call=call 750 ) 751 if 'extra_backings' in b_info: 752 for bck in b_info['extra_backings']: 753 bui.imagewidget( 754 edit=bck, color=color, opacity=extra_image_opacity 755 ) 756 if 'extra_images' in b_info: 757 for img in b_info['extra_images']: 758 bui.imagewidget(edit=img, opacity=extra_image_opacity) 759 if 'extra_texts' in b_info: 760 for etxt in b_info['extra_texts']: 761 bui.textwidget(edit=etxt, color=description_color) 762 if 'extra_texts_2' in b_info: 763 for etxt in b_info['extra_texts_2']: 764 bui.textwidget(edit=etxt, color=description_color2) 765 if 'descriptionText' in b_info: 766 bui.textwidget( 767 edit=b_info['descriptionText'], color=description_color 768 ) 769 770 def _on_response(self, data: dict[str, Any] | None) -> None: 771 # pylint: disable=too-many-statements 772 773 assert bui.app.classic is not None 774 cstore = bui.app.classic.store 775 776 # clear status text.. 777 if self._status_textwidget: 778 self._status_textwidget.delete() 779 self._status_textwidget_update_timer = None 780 781 if data is None: 782 self._status_textwidget = bui.textwidget( 783 parent=self._root_widget, 784 position=(self._width * 0.5, self._height * 0.5), 785 size=(0, 0), 786 scale=1.3, 787 transition_delay=0.1, 788 color=(1, 0.3, 0.3, 1.0), 789 h_align='center', 790 v_align='center', 791 text=bui.Lstr(resource=f'{self._r}.loadErrorText'), 792 maxwidth=self._scroll_width * 0.9, 793 ) 794 else: 795 796 class _Store: 797 def __init__( 798 self, 799 store_window: StoreBrowserWindow, 800 sdata: dict[str, Any], 801 width: float, 802 ): 803 self._store_window = store_window 804 self._width = width 805 store_data = cstore.get_store_layout() 806 self._tab = sdata['tab'] 807 self._sections = copy.deepcopy(store_data[sdata['tab']]) 808 self._height: float | None = None 809 810 assert bui.app.classic is not None 811 uiscale = bui.app.ui_v1.uiscale 812 813 # Pre-calc a few things and add them to store-data. 814 for section in self._sections: 815 if self._tab == 'characters': 816 dummy_name = 'characters.foo' 817 elif self._tab == 'extras': 818 dummy_name = 'pro' 819 elif self._tab == 'maps': 820 dummy_name = 'maps.foo' 821 elif self._tab == 'icons': 822 dummy_name = 'icons.foo' 823 else: 824 dummy_name = '' 825 section['button_size'] = ( 826 cstore.get_store_item_display_size(dummy_name) 827 ) 828 section['v_spacing'] = ( 829 -25 830 if ( 831 self._tab == 'extras' 832 and uiscale is bui.UIScale.SMALL 833 ) 834 else -17 if self._tab == 'characters' else 0 835 ) 836 if 'title' not in section: 837 section['title'] = '' 838 section['x_offs'] = ( 839 130 840 if self._tab == 'extras' 841 else 270 if self._tab == 'maps' else 0 842 ) 843 section['y_offs'] = ( 844 20 845 if ( 846 self._tab == 'extras' 847 and uiscale is bui.UIScale.SMALL 848 and bui.app.config.get('Merch Link') 849 ) 850 else ( 851 55 852 if ( 853 self._tab == 'extras' 854 and uiscale is bui.UIScale.SMALL 855 ) 856 else -20 if self._tab == 'icons' else 0 857 ) 858 ) 859 860 def instantiate( 861 self, scrollwidget: bui.Widget, tab_button: bui.Widget 862 ) -> None: 863 """Create the store.""" 864 # pylint: disable=too-many-locals 865 # pylint: disable=too-many-branches 866 # pylint: disable=too-many-nested-blocks 867 from bauiv1lib.store.item import ( 868 instantiate_store_item_display, 869 ) 870 871 title_spacing = 40 872 button_border = 20 873 button_spacing = 4 874 boffs_h = 40 875 self._height = 80.0 876 877 # Calc total height. 878 for i, section in enumerate(self._sections): 879 if section['title'] != '': 880 assert self._height is not None 881 self._height += title_spacing 882 b_width, b_height = section['button_size'] 883 b_column_count = int( 884 math.floor( 885 (self._width - boffs_h - 20) 886 / (b_width + button_spacing) 887 ) 888 ) 889 b_row_count = int( 890 math.ceil( 891 float(len(section['items'])) / b_column_count 892 ) 893 ) 894 b_height_total = ( 895 2 * button_border 896 + b_row_count * b_height 897 + (b_row_count - 1) * section['v_spacing'] 898 ) 899 self._height += b_height_total 900 901 assert self._height is not None 902 cnt2 = bui.containerwidget( 903 parent=scrollwidget, 904 scale=1.0, 905 size=(self._width, self._height), 906 background=False, 907 claims_left_right=True, 908 claims_tab=True, 909 selection_loops_to_parent=True, 910 ) 911 v = self._height - 20 912 913 if self._tab == 'characters': 914 txt = bui.Lstr( 915 resource='store.howToSwitchCharactersText', 916 subs=[ 917 ( 918 '${SETTINGS}', 919 bui.Lstr( 920 resource=( 921 'accountSettingsWindow.titleText' 922 ) 923 ), 924 ), 925 ( 926 '${PLAYER_PROFILES}', 927 bui.Lstr( 928 resource=( 929 'playerProfilesWindow.titleText' 930 ) 931 ), 932 ), 933 ], 934 ) 935 bui.textwidget( 936 parent=cnt2, 937 text=txt, 938 size=(0, 0), 939 position=(self._width * 0.5, self._height - 28), 940 h_align='center', 941 v_align='center', 942 color=(0.7, 1, 0.7, 0.4), 943 scale=0.7, 944 shadow=0, 945 flatness=1.0, 946 maxwidth=700, 947 transition_delay=0.4, 948 ) 949 elif self._tab == 'icons': 950 txt = bui.Lstr( 951 resource='store.howToUseIconsText', 952 subs=[ 953 ( 954 '${SETTINGS}', 955 bui.Lstr(resource='mainMenu.settingsText'), 956 ), 957 ( 958 '${PLAYER_PROFILES}', 959 bui.Lstr( 960 resource=( 961 'playerProfilesWindow.titleText' 962 ) 963 ), 964 ), 965 ], 966 ) 967 bui.textwidget( 968 parent=cnt2, 969 text=txt, 970 size=(0, 0), 971 position=(self._width * 0.5, self._height - 28), 972 h_align='center', 973 v_align='center', 974 color=(0.7, 1, 0.7, 0.4), 975 scale=0.7, 976 shadow=0, 977 flatness=1.0, 978 maxwidth=700, 979 transition_delay=0.4, 980 ) 981 elif self._tab == 'maps': 982 assert self._width is not None 983 assert self._height is not None 984 txt = bui.Lstr(resource='store.howToUseMapsText') 985 bui.textwidget( 986 parent=cnt2, 987 text=txt, 988 size=(0, 0), 989 position=(self._width * 0.5, self._height - 28), 990 h_align='center', 991 v_align='center', 992 color=(0.7, 1, 0.7, 0.4), 993 scale=0.7, 994 shadow=0, 995 flatness=1.0, 996 maxwidth=700, 997 transition_delay=0.4, 998 ) 999 1000 prev_row_buttons: list | None = None 1001 this_row_buttons = [] 1002 1003 delay = 0.3 1004 for section in self._sections: 1005 if section['title'] != '': 1006 bui.textwidget( 1007 parent=cnt2, 1008 position=(60, v - title_spacing * 0.8), 1009 size=(0, 0), 1010 scale=1.0, 1011 transition_delay=delay, 1012 color=(0.7, 0.9, 0.7, 1), 1013 h_align='left', 1014 v_align='center', 1015 text=bui.Lstr(resource=section['title']), 1016 maxwidth=self._width * 0.7, 1017 ) 1018 v -= title_spacing 1019 delay = max(0.100, delay - 0.100) 1020 v -= button_border 1021 b_width, b_height = section['button_size'] 1022 b_count = len(section['items']) 1023 b_column_count = int( 1024 math.floor( 1025 (self._width - boffs_h - 20) 1026 / (b_width + button_spacing) 1027 ) 1028 ) 1029 col = 0 1030 item: dict[str, Any] 1031 assert self._store_window.button_infos is not None 1032 for i, item_name in enumerate(section['items']): 1033 item = self._store_window.button_infos[ 1034 item_name 1035 ] = {} 1036 item['call'] = bui.WeakCall( 1037 self._store_window.buy, item_name 1038 ) 1039 if 'x_offs' in section: 1040 boffs_h2 = section['x_offs'] 1041 else: 1042 boffs_h2 = 0 1043 1044 if 'y_offs' in section: 1045 boffs_v2 = section['y_offs'] 1046 else: 1047 boffs_v2 = 0 1048 b_pos = ( 1049 boffs_h 1050 + boffs_h2 1051 + (b_width + button_spacing) * col, 1052 v - b_height + boffs_v2, 1053 ) 1054 instantiate_store_item_display( 1055 item_name, 1056 item, 1057 parent_widget=cnt2, 1058 b_pos=b_pos, 1059 boffs_h=boffs_h, 1060 b_width=b_width, 1061 b_height=b_height, 1062 boffs_h2=boffs_h2, 1063 boffs_v2=boffs_v2, 1064 delay=delay, 1065 ) 1066 btn = item['button'] 1067 delay = max(0.1, delay - 0.1) 1068 this_row_buttons.append(btn) 1069 1070 # Wire this button to the equivalent in the 1071 # previous row. 1072 if prev_row_buttons is not None: 1073 if len(prev_row_buttons) > col: 1074 bui.widget( 1075 edit=btn, 1076 up_widget=prev_row_buttons[col], 1077 ) 1078 bui.widget( 1079 edit=prev_row_buttons[col], 1080 down_widget=btn, 1081 ) 1082 1083 # If we're the last button in our row, 1084 # wire any in the previous row past 1085 # our position to go to us if down is 1086 # pressed. 1087 if ( 1088 col + 1 == b_column_count 1089 or i == b_count - 1 1090 ): 1091 for b_prev in prev_row_buttons[ 1092 col + 1 : 1093 ]: 1094 bui.widget( 1095 edit=b_prev, down_widget=btn 1096 ) 1097 else: 1098 bui.widget( 1099 edit=btn, up_widget=prev_row_buttons[-1] 1100 ) 1101 else: 1102 bui.widget(edit=btn, up_widget=tab_button) 1103 1104 col += 1 1105 if col == b_column_count or i == b_count - 1: 1106 prev_row_buttons = this_row_buttons 1107 this_row_buttons = [] 1108 col = 0 1109 v -= b_height 1110 if i < b_count - 1: 1111 v -= section['v_spacing'] 1112 1113 v -= button_border 1114 1115 # Set a timer to update these buttons periodically as long 1116 # as we're alive (so if we buy one it will grey out, etc). 1117 self._store_window.update_buttons_timer = bui.AppTimer( 1118 0.5, 1119 bui.WeakCall(self._store_window.update_buttons), 1120 repeat=True, 1121 ) 1122 1123 # Also update them immediately. 1124 self._store_window.update_buttons() 1125 1126 if self._current_tab in ( 1127 self.TabID.EXTRAS, 1128 self.TabID.MINIGAMES, 1129 self.TabID.CHARACTERS, 1130 self.TabID.MAPS, 1131 self.TabID.ICONS, 1132 ): 1133 store = _Store(self, data, self._scroll_width) 1134 assert self._scrollwidget is not None 1135 store.instantiate( 1136 scrollwidget=self._scrollwidget, 1137 tab_button=self._tab_row.tabs[self._current_tab].button, 1138 ) 1139 else: 1140 cnt = bui.containerwidget( 1141 parent=self._scrollwidget, 1142 scale=1.0, 1143 size=(self._scroll_width, self._scroll_height * 0.95), 1144 background=False, 1145 claims_left_right=True, 1146 claims_tab=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)
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.
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 )
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.