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

Attempt to purchase the provided item.

def update_buttons(self) -> None:
669    def update_buttons(self) -> None:
670        """Update our buttons."""
671        # pylint: disable=too-many-statements
672        # pylint: disable=too-many-branches
673        # pylint: disable=too-many-locals
674        from bauiv1 import SpecialChar
675
676        assert bui.app.classic is not None
677        store = bui.app.classic.store
678
679        plus = bui.app.plus
680        assert plus is not None
681
682        if not self._root_widget:
683            return
684
685        sales_raw = plus.get_v1_account_misc_read_val('sales', {})
686        sales = {}
687        try:
688            # Look at the current set of sales; filter any with time remaining.
689            for sale_item, sale_info in list(sales_raw.items()):
690                to_end = (
691                    datetime.datetime.utcfromtimestamp(sale_info['e'])
692                    - datetime.datetime.utcnow()
693                ).total_seconds()
694                if to_end > 0:
695                    sales[sale_item] = {
696                        'to_end': to_end,
697                        'original_price': sale_info['op'],
698                    }
699        except Exception:
700            logging.exception('Error parsing sales.')
701
702        assert self.button_infos is not None
703        for b_type, b_info in self.button_infos.items():
704            if b_type == 'merch':
705                purchased = False
706            elif b_type in ['upgrades.pro', 'pro']:
707                assert bui.app.classic is not None
708                purchased = bui.app.classic.accounts.have_pro()
709            else:
710                purchased = plus.get_purchased(b_type)
711
712            sale_opacity = 0.0
713            sale_title_text: str | bui.Lstr = ''
714            sale_time_text: str | bui.Lstr = ''
715
716            if purchased:
717                title_color = (0.8, 0.7, 0.9, 1.0)
718                color = (0.63, 0.55, 0.78)
719                extra_image_opacity = 0.5
720                call = bui.WeakCall(self._print_already_own, b_info['name'])
721                price_text = ''
722                price_text_left = ''
723                price_text_right = ''
724                show_purchase_check = True
725                description_color: Sequence[float] = (0.4, 1.0, 0.4, 0.4)
726                description_color2: Sequence[float] = (0.0, 0.0, 0.0, 0.0)
727                price_color = (0.5, 1, 0.5, 0.3)
728            else:
729                title_color = (0.7, 0.9, 0.7, 1.0)
730                color = (0.4, 0.8, 0.1)
731                extra_image_opacity = 1.0
732                call = b_info['call'] if 'call' in b_info else None
733                if b_type == 'merch':
734                    price_text = ''
735                    price_text_left = ''
736                    price_text_right = ''
737                elif b_type in ['upgrades.pro', 'pro']:
738                    sale_time = store.get_available_sale_time('extras')
739                    if sale_time is not None:
740                        priceraw = plus.get_price('pro')
741                        price_text_left = (
742                            priceraw if priceraw is not None else '?'
743                        )
744                        priceraw = plus.get_price('pro_sale')
745                        price_text_right = (
746                            priceraw if priceraw is not None else '?'
747                        )
748                        sale_opacity = 1.0
749                        price_text = ''
750                        sale_title_text = bui.Lstr(resource='store.saleText')
751                        sale_time_text = bui.timestring(
752                            sale_time / 1000.0, centi=False
753                        )
754                    else:
755                        priceraw = plus.get_price('pro')
756                        price_text = priceraw if priceraw is not None else '?'
757                        price_text_left = ''
758                        price_text_right = ''
759                else:
760                    price = plus.get_v1_account_misc_read_val(
761                        'price.' + b_type, 0
762                    )
763
764                    # Color the button differently if we cant afford this.
765                    if plus.get_v1_account_state() == 'signed_in':
766                        if plus.get_v1_account_ticket_count() < price:
767                            color = (0.6, 0.61, 0.6)
768                    price_text = bui.charstr(bui.SpecialChar.TICKET) + str(
769                        plus.get_v1_account_misc_read_val(
770                            'price.' + b_type, '?'
771                        )
772                    )
773                    price_text_left = ''
774                    price_text_right = ''
775
776                    # TESTING:
777                    if b_type in sales:
778                        sale_opacity = 1.0
779                        price_text_left = bui.charstr(SpecialChar.TICKET) + str(
780                            sales[b_type]['original_price']
781                        )
782                        price_text_right = price_text
783                        price_text = ''
784                        sale_title_text = bui.Lstr(resource='store.saleText')
785                        sale_time_text = bui.timestring(
786                            sales[b_type]['to_end'], centi=False
787                        )
788
789                description_color = (0.5, 1.0, 0.5)
790                description_color2 = (0.3, 1.0, 1.0)
791                price_color = (0.2, 1, 0.2, 1.0)
792                show_purchase_check = False
793
794            if 'title_text' in b_info:
795                bui.textwidget(edit=b_info['title_text'], color=title_color)
796            if 'purchase_check' in b_info:
797                bui.imagewidget(
798                    edit=b_info['purchase_check'],
799                    opacity=1.0 if show_purchase_check else 0.0,
800                )
801            if 'price_widget' in b_info:
802                bui.textwidget(
803                    edit=b_info['price_widget'],
804                    text=price_text,
805                    color=price_color,
806                )
807            if 'price_widget_left' in b_info:
808                bui.textwidget(
809                    edit=b_info['price_widget_left'], text=price_text_left
810                )
811            if 'price_widget_right' in b_info:
812                bui.textwidget(
813                    edit=b_info['price_widget_right'], text=price_text_right
814                )
815            if 'price_slash_widget' in b_info:
816                bui.imagewidget(
817                    edit=b_info['price_slash_widget'], opacity=sale_opacity
818                )
819            if 'sale_bg_widget' in b_info:
820                bui.imagewidget(
821                    edit=b_info['sale_bg_widget'], opacity=sale_opacity
822                )
823            if 'sale_title_widget' in b_info:
824                bui.textwidget(
825                    edit=b_info['sale_title_widget'], text=sale_title_text
826                )
827            if 'sale_time_widget' in b_info:
828                bui.textwidget(
829                    edit=b_info['sale_time_widget'], text=sale_time_text
830                )
831            if 'button' in b_info:
832                bui.buttonwidget(
833                    edit=b_info['button'], color=color, on_activate_call=call
834                )
835            if 'extra_backings' in b_info:
836                for bck in b_info['extra_backings']:
837                    bui.imagewidget(
838                        edit=bck, color=color, opacity=extra_image_opacity
839                    )
840            if 'extra_images' in b_info:
841                for img in b_info['extra_images']:
842                    bui.imagewidget(edit=img, opacity=extra_image_opacity)
843            if 'extra_texts' in b_info:
844                for etxt in b_info['extra_texts']:
845                    bui.textwidget(edit=etxt, color=description_color)
846            if 'extra_texts_2' in b_info:
847                for etxt in b_info['extra_texts_2']:
848                    bui.textwidget(edit=etxt, color=description_color2)
849            if 'descriptionText' in b_info:
850                bui.textwidget(
851                    edit=b_info['descriptionText'], color=description_color
852                )

Update our buttons.

Inherited Members
bauiv1._uitypes.Window
get_root_widget
class StoreBrowserWindow.TabID(enum.Enum):
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'

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