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