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()
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()
button_infos: dict[str, dict[str, typing.Any]] | None
update_buttons_timer: _babase.AppTimer | None
request: Any
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.

def update_buttons(self) -> None:
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                )

Update our buttons.

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.

EXTRAS = <TabID.EXTRAS: 'extras'>
MAPS = <TabID.MAPS: 'maps'>
MINIGAMES = <TabID.MINIGAMES: 'minigames'>
CHARACTERS = <TabID.CHARACTERS: 'characters'>
ICONS = <TabID.ICONS: 'icons'>
Inherited Members
enum.Enum
name
value