bauiv1lib.store.browser

UI for browsing the store.

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

Window for browsing the store.

StoreBrowserWindow( transition: str | None = 'in_right', origin_widget: _bauiv1.Widget | None = None, show_tab: StoreBrowserWindow.TabID | None = None, minimal_toolbars: bool = False)
 42    def __init__(
 43        self,
 44        transition: str | None = 'in_right',
 45        origin_widget: bui.Widget | None = None,
 46        show_tab: StoreBrowserWindow.TabID | None = None,
 47        minimal_toolbars: bool = False,
 48    ):
 49        # pylint: disable=too-many-statements
 50        # pylint: disable=too-many-locals
 51        from bauiv1lib.tabs import TabRow
 52        from bauiv1 import SpecialChar
 53
 54        app = bui.app
 55        assert app.classic is not None
 56        uiscale = app.ui_v1.uiscale
 57
 58        bui.set_analytics_screen('Store Window')
 59
 60        self.button_infos: dict[str, dict[str, Any]] | None = None
 61        self.update_buttons_timer: bui.AppTimer | None = None
 62        self._status_textwidget_update_timer = None
 63
 64        self._show_tab = show_tab
 65        self._width = 1670 if uiscale is bui.UIScale.SMALL else 1040
 66        self._x_inset = x_inset = 310 if uiscale is bui.UIScale.SMALL else 0
 67        self._height = (
 68            538
 69            if uiscale is bui.UIScale.SMALL
 70            else 645 if uiscale is bui.UIScale.MEDIUM else 800
 71        )
 72        self._current_tab: StoreBrowserWindow.TabID | None = None
 73        extra_top = 30 if uiscale is bui.UIScale.SMALL else 0
 74
 75        self.request: Any = None
 76        self._r = 'store'
 77        self._last_buy_time: float | None = None
 78
 79        super().__init__(
 80            root_widget=bui.containerwidget(
 81                size=(self._width, self._height + extra_top),
 82                toolbar_visibility=(
 83                    'menu_store'
 84                    if (uiscale is bui.UIScale.SMALL or minimal_toolbars)
 85                    else 'menu_full'
 86                ),
 87                scale=(
 88                    1.3
 89                    if uiscale is bui.UIScale.SMALL
 90                    else 0.9 if uiscale is bui.UIScale.MEDIUM else 0.8
 91                ),
 92                stack_offset=(
 93                    (0, 10)
 94                    if uiscale is bui.UIScale.SMALL
 95                    else (0, 0) if uiscale is bui.UIScale.MEDIUM else (0, 0)
 96                ),
 97            ),
 98            transition=transition,
 99            origin_widget=origin_widget,
100        )
101
102        self._back_button = btn = bui.buttonwidget(
103            parent=self._root_widget,
104            position=(70 + x_inset, self._height - 74),
105            size=(140, 60),
106            scale=1.1,
107            autoselect=True,
108            label=bui.Lstr(resource='backText'),
109            button_type='back',
110            on_activate_call=self.main_window_back,
111        )
112
113        if uiscale is bui.UIScale.SMALL:
114            self._back_button.delete()
115            bui.containerwidget(
116                edit=self._root_widget, on_cancel_call=self.main_window_back
117            )
118            backbuttonspecial = True
119        else:
120            bui.containerwidget(edit=self._root_widget, cancel_button=btn)
121            backbuttonspecial = False
122
123        if (
124            app.classic.platform in ['mac', 'ios']
125            and app.classic.subplatform == 'appstore'
126        ):
127            bui.buttonwidget(
128                parent=self._root_widget,
129                position=(self._width * 0.5 - 70, 16),
130                size=(230, 50),
131                scale=0.65,
132                on_activate_call=bui.WeakCall(self._restore_purchases),
133                color=(0.35, 0.3, 0.4),
134                selectable=False,
135                textcolor=(0.55, 0.5, 0.6),
136                label=bui.Lstr(
137                    resource='getTicketsWindow.restorePurchasesText'
138                ),
139            )
140
141        bui.textwidget(
142            parent=self._root_widget,
143            position=(
144                self._width * 0.5,
145                self._height - (53 if uiscale is bui.UIScale.SMALL else 44),
146            ),
147            size=(0, 0),
148            color=app.ui_v1.title_color,
149            scale=1.5,
150            h_align='center',
151            v_align='center',
152            text=bui.Lstr(resource='storeText'),
153            maxwidth=290,
154        )
155
156        if not backbuttonspecial:
157            bui.buttonwidget(
158                edit=self._back_button,
159                button_type='backSmall',
160                size=(60, 60),
161                label=bui.charstr(SpecialChar.BACK),
162            )
163
164        scroll_buffer_h = 130 + 2 * x_inset
165        tab_buffer_h = 250 + 2 * x_inset
166
167        tabs_def = [
168            (self.TabID.EXTRAS, bui.Lstr(resource=f'{self._r}.extrasText')),
169            (self.TabID.MAPS, bui.Lstr(resource=f'{self._r}.mapsText')),
170            (
171                self.TabID.MINIGAMES,
172                bui.Lstr(resource=f'{self._r}.miniGamesText'),
173            ),
174            (
175                self.TabID.CHARACTERS,
176                bui.Lstr(resource=f'{self._r}.charactersText'),
177            ),
178            (self.TabID.ICONS, bui.Lstr(resource=f'{self._r}.iconsText')),
179        ]
180
181        self._tab_row = TabRow(
182            self._root_widget,
183            tabs_def,
184            pos=(tab_buffer_h * 0.5, self._height - 130),
185            size=(self._width - tab_buffer_h, 50),
186            on_select_call=self._set_tab,
187        )
188
189        self._purchasable_count_widgets: dict[
190            StoreBrowserWindow.TabID, dict[str, Any]
191        ] = {}
192
193        # Create our purchasable-items tags and have them update over time.
194        for tab_id, tab in self._tab_row.tabs.items():
195            pos = tab.position
196            size = tab.size
197            button = tab.button
198            rad = 10
199            center = (pos[0] + 0.1 * size[0], pos[1] + 0.9 * size[1])
200            img = bui.imagewidget(
201                parent=self._root_widget,
202                position=(center[0] - rad * 1.04, center[1] - rad * 1.15),
203                size=(rad * 2.2, rad * 2.2),
204                texture=bui.gettexture('circleShadow'),
205                color=(1, 0, 0),
206            )
207            txt = bui.textwidget(
208                parent=self._root_widget,
209                position=center,
210                size=(0, 0),
211                h_align='center',
212                v_align='center',
213                maxwidth=1.4 * rad,
214                scale=0.6,
215                shadow=1.0,
216                flatness=1.0,
217            )
218            rad = 20
219            sale_img = bui.imagewidget(
220                parent=self._root_widget,
221                position=(center[0] - rad, center[1] - rad),
222                size=(rad * 2, rad * 2),
223                draw_controller=button,
224                texture=bui.gettexture('circleZigZag'),
225                color=(0.5, 0, 1.0),
226            )
227            sale_title_text = bui.textwidget(
228                parent=self._root_widget,
229                position=(center[0], center[1] + 0.24 * rad),
230                size=(0, 0),
231                h_align='center',
232                v_align='center',
233                draw_controller=button,
234                maxwidth=1.4 * rad,
235                scale=0.6,
236                shadow=0.0,
237                flatness=1.0,
238                color=(0, 1, 0),
239            )
240            sale_time_text = bui.textwidget(
241                parent=self._root_widget,
242                position=(center[0], center[1] - 0.29 * rad),
243                size=(0, 0),
244                h_align='center',
245                v_align='center',
246                draw_controller=button,
247                maxwidth=1.4 * rad,
248                scale=0.4,
249                shadow=0.0,
250                flatness=1.0,
251                color=(0, 1, 0),
252            )
253            self._purchasable_count_widgets[tab_id] = {
254                'img': img,
255                'text': txt,
256                'sale_img': sale_img,
257                'sale_title_text': sale_title_text,
258                'sale_time_text': sale_time_text,
259            }
260        self._tab_update_timer = bui.AppTimer(
261            1.0, bui.WeakCall(self._update_tabs), repeat=True
262        )
263        self._update_tabs()
264
265        if uiscale is bui.UIScale.SMALL:
266            first_tab_button = self._tab_row.tabs[tabs_def[0][0]].button
267            last_tab_button = self._tab_row.tabs[tabs_def[-1][0]].button
268            bui.widget(
269                edit=first_tab_button,
270                left_widget=bui.get_special_widget('back_button'),
271            )
272            bui.widget(
273                edit=last_tab_button,
274                right_widget=bui.get_special_widget('squad_button'),
275            )
276
277        self._scroll_width = self._width - scroll_buffer_h
278        self._scroll_height = self._height - 180
279
280        self._scrollwidget: bui.Widget | None = None
281        self._status_textwidget: bui.Widget | None = None
282        self._restore_state()

Create a MainWindow given a root widget and transition info.

Automatically handles in and out transitions on the provided widget, so there is no need to set transitions when creating it.

button_infos: dict[str, dict[str, typing.Any]] | None
update_buttons_timer: _babase.AppTimer | None
request: Any
def buy(self, item: str) -> None:
491    def buy(self, item: str) -> None:
492        """Attempt to purchase the provided item."""
493        from bauiv1lib.account.signin import show_sign_in_prompt
494        from bauiv1lib.confirm import ConfirmWindow
495
496        assert bui.app.classic is not None
497        store = bui.app.classic.store
498
499        plus = bui.app.plus
500        assert plus is not None
501
502        # Prevent pressing buy within a few seconds of the last press
503        # (gives the buttons time to disable themselves and whatnot).
504        curtime = bui.apptime()
505        if (
506            self._last_buy_time is not None
507            and (curtime - self._last_buy_time) < 2.0
508        ):
509            bui.getsound('error').play()
510        else:
511            if plus.get_v1_account_state() != 'signed_in':
512                show_sign_in_prompt()
513            else:
514                self._last_buy_time = curtime
515
516                # Merch is a special case - just a link.
517                if item == 'merch':
518                    url = bui.app.config.get('Merch Link')
519                    if isinstance(url, str):
520                        bui.open_url(url)
521
522                # Pro is an actual IAP, and the rest are ticket purchases.
523                elif item == 'pro':
524                    bui.getsound('click01').play()
525
526                    # Purchase either pro or pro_sale depending on whether
527                    # there is a sale going on.
528                    self._do_purchase_check(
529                        'pro'
530                        if store.get_available_sale_time('extras') is None
531                        else 'pro_sale'
532                    )
533                else:
534                    price = plus.get_v1_account_misc_read_val(
535                        'price.' + item, None
536                    )
537                    our_tickets = plus.get_v1_account_ticket_count()
538                    if price is not None and our_tickets < price:
539                        bui.getsound('error').play()
540                        print('FIXME - show not-enough-tickets info.')
541                        # gettickets.show_get_tickets_prompt()
542                    else:
543
544                        def do_it() -> None:
545                            self._do_purchase_check(
546                                item, is_ticket_purchase=True
547                            )
548
549                        bui.getsound('swish').play()
550                        ConfirmWindow(
551                            bui.Lstr(
552                                resource='store.purchaseConfirmText',
553                                subs=[
554                                    (
555                                        '${ITEM}',
556                                        store.get_store_item_name_translated(
557                                            item
558                                        ),
559                                    )
560                                ],
561                            ),
562                            width=400,
563                            height=120,
564                            action=do_it,
565                            ok_text=bui.Lstr(
566                                resource='store.purchaseText',
567                                fallback_resource='okText',
568                            ),
569                        )

Attempt to purchase the provided item.

def update_buttons(self) -> None:
581    def update_buttons(self) -> None:
582        """Update our buttons."""
583        # pylint: disable=too-many-statements
584        # pylint: disable=too-many-branches
585        # pylint: disable=too-many-locals
586        from bauiv1 import SpecialChar
587
588        assert bui.app.classic is not None
589        store = bui.app.classic.store
590
591        plus = bui.app.plus
592        assert plus is not None
593
594        if not self._root_widget:
595            return
596
597        sales_raw = plus.get_v1_account_misc_read_val('sales', {})
598        sales = {}
599        try:
600            # Look at the current set of sales; filter any with time remaining.
601            for sale_item, sale_info in list(sales_raw.items()):
602                to_end = (
603                    datetime.datetime.fromtimestamp(
604                        sale_info['e'], datetime.UTC
605                    )
606                    - utc_now()
607                ).total_seconds()
608                if to_end > 0:
609                    sales[sale_item] = {
610                        'to_end': to_end,
611                        'original_price': sale_info['op'],
612                    }
613        except Exception:
614            logging.exception('Error parsing sales.')
615
616        assert self.button_infos is not None
617        for b_type, b_info in self.button_infos.items():
618            if b_type == 'merch':
619                purchased = False
620            elif b_type in ['upgrades.pro', 'pro']:
621                assert bui.app.classic is not None
622                purchased = bui.app.classic.accounts.have_pro()
623            else:
624                purchased = plus.get_v1_account_product_purchased(b_type)
625
626            sale_opacity = 0.0
627            sale_title_text: str | bui.Lstr = ''
628            sale_time_text: str | bui.Lstr = ''
629
630            call: Callable | None
631            if purchased:
632                title_color = (0.8, 0.7, 0.9, 1.0)
633                color = (0.63, 0.55, 0.78)
634                extra_image_opacity = 0.5
635                call = bui.WeakCall(self._print_already_own, b_info['name'])
636                price_text = ''
637                price_text_left = ''
638                price_text_right = ''
639                show_purchase_check = True
640                description_color: Sequence[float] = (0.4, 1.0, 0.4, 0.4)
641                description_color2: Sequence[float] = (0.0, 0.0, 0.0, 0.0)
642                price_color = (0.5, 1, 0.5, 0.3)
643            else:
644                title_color = (0.7, 0.9, 0.7, 1.0)
645                color = (0.4, 0.8, 0.1)
646                extra_image_opacity = 1.0
647                call = b_info['call'] if 'call' in b_info else None
648                if b_type == 'merch':
649                    price_text = ''
650                    price_text_left = ''
651                    price_text_right = ''
652                elif b_type in ['upgrades.pro', 'pro']:
653                    sale_time = store.get_available_sale_time('extras')
654                    if sale_time is not None:
655                        priceraw = plus.get_price('pro')
656                        price_text_left = (
657                            priceraw if priceraw is not None else '?'
658                        )
659                        priceraw = plus.get_price('pro_sale')
660                        price_text_right = (
661                            priceraw if priceraw is not None else '?'
662                        )
663                        sale_opacity = 1.0
664                        price_text = ''
665                        sale_title_text = bui.Lstr(resource='store.saleText')
666                        sale_time_text = bui.timestring(
667                            sale_time / 1000.0, centi=False
668                        )
669                    else:
670                        priceraw = plus.get_price('pro')
671                        price_text = priceraw if priceraw is not None else '?'
672                        price_text_left = ''
673                        price_text_right = ''
674                else:
675                    price = plus.get_v1_account_misc_read_val(
676                        'price.' + b_type, 0
677                    )
678
679                    # Color the button differently if we cant afford this.
680                    if plus.get_v1_account_state() == 'signed_in':
681                        if plus.get_v1_account_ticket_count() < price:
682                            color = (0.6, 0.61, 0.6)
683                    price_text = bui.charstr(bui.SpecialChar.TICKET) + str(
684                        plus.get_v1_account_misc_read_val(
685                            'price.' + b_type, '?'
686                        )
687                    )
688                    price_text_left = ''
689                    price_text_right = ''
690
691                    # TESTING:
692                    if b_type in sales:
693                        sale_opacity = 1.0
694                        price_text_left = bui.charstr(SpecialChar.TICKET) + str(
695                            sales[b_type]['original_price']
696                        )
697                        price_text_right = price_text
698                        price_text = ''
699                        sale_title_text = bui.Lstr(resource='store.saleText')
700                        sale_time_text = bui.timestring(
701                            sales[b_type]['to_end'], centi=False
702                        )
703
704                description_color = (0.5, 1.0, 0.5)
705                description_color2 = (0.3, 1.0, 1.0)
706                price_color = (0.2, 1, 0.2, 1.0)
707                show_purchase_check = False
708
709            if 'title_text' in b_info:
710                bui.textwidget(edit=b_info['title_text'], color=title_color)
711            if 'purchase_check' in b_info:
712                bui.imagewidget(
713                    edit=b_info['purchase_check'],
714                    opacity=1.0 if show_purchase_check else 0.0,
715                )
716            if 'price_widget' in b_info:
717                bui.textwidget(
718                    edit=b_info['price_widget'],
719                    text=price_text,
720                    color=price_color,
721                )
722            if 'price_widget_left' in b_info:
723                bui.textwidget(
724                    edit=b_info['price_widget_left'], text=price_text_left
725                )
726            if 'price_widget_right' in b_info:
727                bui.textwidget(
728                    edit=b_info['price_widget_right'], text=price_text_right
729                )
730            if 'price_slash_widget' in b_info:
731                bui.imagewidget(
732                    edit=b_info['price_slash_widget'], opacity=sale_opacity
733                )
734            if 'sale_bg_widget' in b_info:
735                bui.imagewidget(
736                    edit=b_info['sale_bg_widget'], opacity=sale_opacity
737                )
738            if 'sale_title_widget' in b_info:
739                bui.textwidget(
740                    edit=b_info['sale_title_widget'], text=sale_title_text
741                )
742            if 'sale_time_widget' in b_info:
743                bui.textwidget(
744                    edit=b_info['sale_time_widget'], text=sale_time_text
745                )
746            if 'button' in b_info:
747                bui.buttonwidget(
748                    edit=b_info['button'], color=color, on_activate_call=call
749                )
750            if 'extra_backings' in b_info:
751                for bck in b_info['extra_backings']:
752                    bui.imagewidget(
753                        edit=bck, color=color, opacity=extra_image_opacity
754                    )
755            if 'extra_images' in b_info:
756                for img in b_info['extra_images']:
757                    bui.imagewidget(edit=img, opacity=extra_image_opacity)
758            if 'extra_texts' in b_info:
759                for etxt in b_info['extra_texts']:
760                    bui.textwidget(edit=etxt, color=description_color)
761            if 'extra_texts_2' in b_info:
762                for etxt in b_info['extra_texts_2']:
763                    bui.textwidget(edit=etxt, color=description_color2)
764            if 'descriptionText' in b_info:
765                bui.textwidget(
766                    edit=b_info['descriptionText'], color=description_color
767                )

Update our buttons.

@override
def get_main_window_state(self) -> bauiv1.MainWindowState:
1162    @override
1163    def get_main_window_state(self) -> bui.MainWindowState:
1164        # Support recreating our window for back/refresh purposes.
1165        cls = type(self)
1166        return bui.BasicMainWindowState(
1167            create_call=lambda transition, origin_widget: cls(
1168                transition=transition, origin_widget=origin_widget
1169            )
1170        )

Return a WindowState to recreate this window, if supported.

@override
def on_main_window_close(self) -> None:
1172    @override
1173    def on_main_window_close(self) -> None:
1174        self._save_state()

Called before transitioning out a main window.

A good opportunity to save window state/etc.

class StoreBrowserWindow.TabID(enum.Enum):
33    class TabID(Enum):
34        """Our available tab types."""
35
36        EXTRAS = 'extras'
37        MAPS = 'maps'
38        MINIGAMES = 'minigames'
39        CHARACTERS = 'characters'
40        ICONS = 'icons'

Our available tab types.

EXTRAS = <TabID.EXTRAS: 'extras'>
MAPS = <TabID.MAPS: 'maps'>
MINIGAMES = <TabID.MINIGAMES: 'minigames'>
CHARACTERS = <TabID.CHARACTERS: 'characters'>
ICONS = <TabID.ICONS: 'icons'>